@ -1,251 +1,499 @@
import { use Ref, useState } from 'react' ;
import { use Effect, use Ref, useState } from 'react' ;
import { useQueryClient } from '@tanstack/react-query' ;
import { toast } from 'sonner' ;
import { Globe , X , AlertCircle , Loader2 , Cpu , RefreshCw } from 'lucide-react' ;
import { Clock , X } from 'lucide-react' ;
import { cn } from '@/lib/utils' ;
import { showToast } from '@/lib/notify' ;
import { Typography } from '@/components/ui/typography' ;
import { Button } from '@/components/ui/button' ;
import { Typography } from '@/components/ui/typography' ;
import { useScrollLock } from '@/lib/useScrollLock' ;
import { triggerLowestPrice , getLowestPrice } from '@/api/generated/item/item' ;
import type { LowestPriceEntryByMall } from '@/api/generated/model/lowestPriceEntryByMall' ;
import type { Product } from '../types' ;
type PriceUpdateModalProps = {
open : boolean ;
products : Product [ ] ;
selectedIds : string [ ] ;
onDone : ( ) = > void ; // 완료 시 선택 해제
onDone : ( ) = > void ; // 선택 해제 — 결과를 계속 보여줘야 해서 창을 닫을 때 호출한다
onClose : ( ) = > void ;
} ;
const POLL_INTERVAL_MS = 5 _000 ; // GET lowest-price 폴링 간격(서버가 조회 시 lps_db 증분 동기화를 겸함)
const POLL_TIMEOUT_MS = 300 _000 ; // 상품당 수십 초 × 순차 처리 감안한 전체 상한(5분)
// 인터넷 최저가 실시간 수집 모달 — LPS 연동.
// 강조색 — 채도 낮춘 인디고 한 톤만 쓴다(절감액·주 버튼). 대비: #3730a3 on white 9.93:1.
// ⚠️ 앱 전역 --primary(#5e6ad2)보다 어두운 계열이라 이 모달에서만 국소 적용한다.
const ACCENT_TEXT = 'text-indigo-800 dark:text-indigo-300' ;
const ACCENT_BTN =
'bg-indigo-800 text-white hover:bg-indigo-900 dark:bg-indigo-300 dark:text-indigo-950 dark:hover:bg-indigo-200' ;
// 표에 열로 세우는 검색 소스. by_mall 에는 매칭된 몰만 담겨 오므로, 여기 없는 소스는 그 회차에 빈손이었다는 뜻.
// (오픈마켓 폴백은 기본 OFF — 켜지면 이 목록과 아래 ROW_GRID 열 개수를 함께 넓힌다.)
const SOURCES = [
{ key : 'naver' , label : '네이버' } ,
{ key : 'coupang' , label : '쿠팡' } ,
] as const ;
// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. 값이 없으면 그 몰은 못 찾은 것.
type MallPrices = Record < string , number > ;
const toMallPrices = ( byMall : LowestPriceEntryByMall ) : MallPrices = > {
const out : MallPrices = { } ;
for ( const entry of byMall ? ? [ ] ) {
const source = String ( entry . source ? ? '' ) . toLowerCase ( ) ;
const price = Number ( entry . price ) ;
if ( ! source || ! Number . isFinite ( price ) ) continue ;
if ( out [ source ] === undefined || price < out [ source ] ) out [ source ] = price ;
}
return out ;
} ;
// 상품 1건의 처리 상태(화면 전체 상태와 구분해 ItemState 로 둔다).
type ItemState =
| { kind : 'pending' } // 접수됨 — 결과 대기
| { kind : 'done' ; price : number ; malls : MallPrices }
| { kind : 'notfound' ; malls : MallPrices } // 동일 상품 판정 실패(기존 값 유지)
| { kind : 'failed' ; reason? : string } // 요청 자체가 접수되지 않음 — 재시도 대상
| { kind : 'timeout' } // 폴링 상한 초과 — 서버는 계속 검색 중
| { kind : 'stopped' } ; // 사용자가 지켜보기를 중단 — 서버는 계속 검색 중
// 표 정렬 — 헤더와 본문 행이 같은 그리드를 써야 열이 어긋나지 않는다.
// [상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과]
// ⚠️ Tailwind JIT 는 소스에 리터럴로 적힌 클래스만 생성한다 — SOURCES 를 늘리면
// 여기 열 개수(5rem 반복)도 함께 손으로 맞춰야 한다(템플릿 문자열 금지).
const ROW_GRID = 'grid grid-cols-[1.5rem_minmax(0,1fr)_5.5rem_5rem_5rem_6.5rem] gap-x-3 px-3' ;
const NUM = 'text-right tabular-nums' ; // 금액 열은 등폭 숫자로 자릿수를 맞춘다
// 표 체크박스 — DataTable 과 같은 규격을 쓴다(앱 전역 일관성)
const CHECKBOX = 'h-3.5 w-3.5 rounded border-border text-primary focus:ring-primary cursor-pointer accent-primary' ;
// 경과 시간 mm:ss — 등폭 숫자와 맞물려 자릿수가 흔들리지 않는다(1분 넘어가도 폭 유지).
const fmtClock = ( sec : number ) = >
` ${ String ( Math . floor ( sec / 60 ) ) . padStart ( 2 , '0' ) } : ${ String ( sec % 60 ) . padStart ( 2 , '0' ) } ` ;
const fmtPrice = ( v : number | undefined ) = > ( v && v > 0 ? v . toLocaleString ( ) : '– ' ) ;
// 수집 시각을 epoch ms 로. 파싱 불가/누락이면 0(= 어떤 기준선도 넘지 못함).
const crawlMs = ( iso : string | null | undefined ) = > {
const t = iso ? Date . parse ( iso ) : NaN ;
return Number . isNaN ( t ) ? 0 : t ;
} ;
const latestCrawlMs = ( entries : { crawl_end_time? : string | null } [ ] | undefined ) = >
( entries ? ? [ ] ) . reduce ( ( max , e ) = > Math . max ( max , crawlMs ( e . crawl_end_time ) ) , 0 ) ;
// 인터넷 최저가 검색 모달 — LPS 연동.
// 흐름: 선택 상품마다 POST(수집 요청, 큐 접수) → GET 폴링(요청 시각 이후의 수집 이력이 생기면 완료).
// 폴링이 시간을 초과해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다.
// 폴링이 시간을 초과 하거나 사용자가 중단 해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다.
export function PriceUpdateModal ( { open , products , selectedIds , onDone , onClose } : PriceUpdateModalProps ) {
useScrollLock ( open ) ; // 모달 열린 동안 배경(부모) 스크롤 잠금
const queryClient = useQueryClient ( ) ;
const [ isCrawling , setIsCrawling ] = useState ( false ) ;
const [ crawlingProgress , setCrawlingProgress ] = useState ( 0 ) ;
const [ crawlerLogs , setCrawlerLogs ] = useState < string [ ] > ( [ ] ) ;
const cancelledRef = useRef ( false ) ; // 닫기 시 폴링 루프 중단(서버 검색은 계속)
const [ isSearching , setIsSearching ] = useState ( false ) ;
const [ items , setItems ] = useState < Record < string , ItemState > > ( { } ) ;
const [ elapsedSec , setElapsedSec ] = useState ( 0 ) ; // 경과 시간 — "멈춘 건지 오래 걸리는 건지" 판단 근거
// 검색 시작 시점의 대상 스냅샷. 결과를 계속 띄워둬야 하는데 부모의 selectedIds 는
// 창을 닫을 때 비워지므로, 표는 이 스냅샷을 그린다.
const [ targetIds , setTargetIds ] = useState < string [ ] > ( [ ] ) ;
// 재검색 대상 선택. null = 아직 손대지 않음(전체 선택으로 간주).
// 검색이 끝나면 '성공하지 못한 행'만 남겨 두어, 이미 갱신된 상품에 크롤 비용을 다시 쓰지 않게 한다.
const [ checkedIds , setCheckedIds ] = useState < Set < string > | null > ( null ) ;
const cancelledRef = useRef ( false ) ; // 중단/닫기 시 폴링 루프 탈출(서버 검색은 계속)
const closeRef = useRef < ( ) = > void > ( ( ) = > { } ) ; // ESC 핸들러가 최신 닫기 로직을 보게 하는 통로
useEffect ( ( ) = > {
if ( ! isSearching ) return ;
const timer = setInterval ( ( ) = > setElapsedSec ( ( s ) = > s + 1 ) , 1 _000 ) ;
return ( ) = > clearInterval ( timer ) ;
} , [ isSearching ] ) ;
// ESC 로 닫기 — X 버튼/푸터 닫기와 함께 탈출구 3중 확보
useEffect ( ( ) = > {
if ( ! open ) return ;
const onKey = ( e : KeyboardEvent ) = > {
if ( e . key === 'Escape' ) closeRef . current ( ) ;
} ;
window . addEventListener ( 'keydown' , onKey ) ;
return ( ) = > window . removeEventListener ( 'keydown' , onKey ) ;
} , [ open ] ) ;
if ( ! open ) return null ;
const pushLog = ( line : string ) = > setCrawlerLogs ( ( prev ) = > [ . . . prev , line ] ) ;
const nameOf = ( id : string ) = > products . find ( ( p ) = > p . item_id === id ) ? . name || id ;
const rows = targetIds . length > 0 ? targetIds : selectedIds ;
// 화면 전체 상태 — 한 화면에는 한 상태만 선언한다(검색 중에 완료 결론을 섞지 않는다).
const status : 'confirm' | 'searching' | 'done' =
targetIds . length === 0 ? 'confirm' : isSearching ? 'searching' : 'done' ;
const handleStartCrawling = async ( ) = > {
if ( selectedIds . length === 0 ) {
toast . error ( '업데이트할 상품을 1개 이상 선택해 주십시오.' ) ;
return ;
}
const checked = checkedIds ? ? new Set ( rows ) ;
const allChecked = rows . length > 0 && rows . every ( ( id ) = > checked . has ( id ) ) ;
const toggleOne = ( id : string , on : boolean ) = >
setCheckedIds ( ( ) = > {
const next = new Set ( checked ) ;
if ( on ) next . add ( id ) ;
else next . delete ( id ) ;
return next ;
} ) ;
const toggleAll = ( on : boolean ) = > setCheckedIds ( on ? new Set ( rows ) : new Set ( ) ) ;
const setItem = ( id : string , s : ItemState ) = > setItems ( ( prev ) = > ( { . . . prev , [ id ] : s } ) ) ;
const productOf = ( id : string ) = > products . find ( ( p ) = > p . item_id === id ) ;
const nameOf = ( id : string ) = > productOf ( id ) ? . name || id ;
const prevPriceOf = ( id : string ) = > Number ( productOf ( id ) ? . internet_lowest_price ? ? 0 ) ;
const settled = rows . filter ( ( id ) = > items [ id ] && items [ id ] . kind !== 'pending' ) . length ;
const failedIds = rows . filter ( ( id ) = > items [ id ] ? . kind === 'failed' ) ;
const failReason = failedIds
. map ( ( id ) = > ( items [ id ] as { kind : 'failed' ; reason? : string } ) . reason )
. find ( Boolean ) ;
// 한 행의 절감액 — 기존 최저가보다 싸게 찾았을 때만 값이 생긴다.
const savingOf = ( id : string ) = > {
const s = items [ id ] ;
if ( s ? . kind !== 'done' ) return 0 ;
const prev = prevPriceOf ( id ) ;
return prev > 0 && s . price < prev ? prev - s.price : 0 ;
} ;
const updatedIds = rows . filter ( ( id ) = > savingOf ( id ) > 0 ) ;
const totalSaved = updatedIds . reduce ( ( sum , id ) = > sum + savingOf ( id ) , 0 ) ;
const runningIds = rows . filter ( ( id ) = > [ 'timeout' , 'stopped' ] . includes ( items [ id ] ? . kind ? ? '' ) ) ;
// 상태 블록·보조 안내는 내용이 있을 때만 그린다 — 빈 요소가 여백만 차지하지 않도록.
const hasStatusBlock =
status === 'searching' || ( status === 'done' && ( totalSaved > 0 || failedIds . length > 0 ) ) ;
const note =
status === 'confirm'
? '상품당 수십 초가 소요됩니다. 미발견 시 기존 최저가가 유지됩니다.'
: status === 'searching'
? '창을 닫아도 검색은 계속됩니다.'
: failReason
? ` 탐색 실패 사유: ${ failReason } `
: runningIds . length > 0
? ` ${ runningIds . length } 개는 서버에서 검색 중입니다. 완료되면 자동 반영됩니다. `
: '' ;
const handleClose = ( ) = > {
cancelledRef . current = true ; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영)
if ( targetIds . length > 0 ) onDone ( ) ; // 한 번이라도 돌렸으면 선택 해제
onClose ( ) ;
} ;
closeRef . current = handleClose ;
// 지켜보기 중단 — 서버 검색까지 취소하는 API 는 없으므로, 화면 감시만 멈추고 그 사실을 문구로 밝힌다.
const handleStop = ( ) = > {
cancelledRef . current = true ;
setIsSearching ( false ) ;
setItems ( ( prev ) = > {
const next = { . . . prev } ;
rows . forEach ( ( id ) = > {
if ( next [ id ] ? . kind === 'pending' ) next [ id ] = { kind : 'stopped' } ;
} ) ;
return next ;
} ) ;
} ;
// 주어진 상품들만 검색한다. 재시도에서도 그대로 쓰므로 나머지 행의 결과는 건드리지 않는다.
// force=true 면 LPS 네거티브 캐시(24h not_found)를 무시하고 실제로 다시 크롤한다.
const runSearch = async ( ids : string [ ] , force = false ) = > {
cancelledRef . current = false ;
setIsCrawling ( true ) ;
setCrawlingProgress ( 5 ) ;
setCrawlerLogs ( [
'[System] 인터넷 최저가 검색(LPS) 요청 접수 중...' ,
` [Target] 선택된 ${ selectedIds . length } 개 상품 ` ,
] ) ;
const startedAt = new Date ( ) . toISOString ( ) ; // 이 시각 이후의 수집 이력만 "이번 요청 결과"로 인정
setIsSearching ( true ) ;
setElapsedSec ( 0 ) ;
setItems ( ( prev ) = > {
const next = { . . . prev } ;
ids . forEach ( ( id ) = > delete next [ id ] ) ;
return next ;
} ) ;
// "이번 요청 결과"의 판정 기준선 — 상품별로 '요청 직전의 최신 수집 시각'을 서버 값에서 읽어 둔다.
// 클라이언트 시각(new Date())을 기준으로 쓰면 브라우저 시계가 서버보다 조금만 앞서도
// 어떤 결과도 기준을 넘지 못해 영영 대기한다. 서버 값끼리 비교해 시계 의존을 없앤다.
const baselineOf = new Map < string , number > ( ) ;
// 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행
// 1) 상품별 검색 요청(POST) — 실패/중복은 상태 로 구분하고 계속 진행
const pending = new Set < string > ( ) ;
for ( const id of selectedIds ) {
for ( const id of i ds) {
try {
const r = await triggerLowestPrice ( id ) ;
if ( r . status === 'queued' ) {
const before = await getLowestPrice ( id ) ;
baselineOf . set ( id , latestCrawlMs ( before . results ) ) ;
} catch {
baselineOf . set ( id , Date . now ( ) ) ; // 기준선을 못 읽으면 과거 이력을 결과로 오인하지 않도록 보수적으로
}
try {
const r = await triggerLowestPrice ( id , force ? { force : true } : undefined ) ;
// 'duplicated' = 이미 진행 중 → 결과는 폴링으로 같이 받는다
if ( r . status === 'queued' || r . status === 'duplicated' ) {
pending . add ( id ) ;
pushLog ( ` [접수] ' ${ nameOf ( id ) } ' 검색 큐 등록 ` ) ;
} else if ( r . status === 'duplicated' ) {
pending . add ( id ) ; // 이미 진행 중 → 결과는 폴링으로 같이 받는다
pushLog ( ` [진행중] ' ${ nameOf ( id ) } ' 이미 검색이 진행 중 — 결과 대기에 합류 ` ) ;
setItem ( id , { kind : 'pending' } ) ;
} else {
pushLog ( ` [불가] ' ${ nameOf ( id ) } ' ${ r . message || '검색 서비스 연결 불가' } ` ) ;
// 서버가 준 사유를 버리지 않는다 — 실패 원인을 화면에서 읽을 수 있어야 한다
setItem ( id , { kind : 'failed' , reason : r.message || '검색 서비스에 연결하지 못했습니다' } ) ;
}
} catch {
pushLog ( ` [오류] ' ${ nameOf ( id ) } ' 요청 실패 ` ) ;
setItem( id , { kind : 'failed' , reason : '요청을 보내지 못했습니다(네트워크 오류)' } ) ;
}
}
if ( pending . size === 0 ) {
showToast ( '접수된 상품이 없습니다. 검색 서비스 상태를 확인해 주세요.' , 'error' ) ;
setIsCrawling ( false ) ;
setCrawlingProgress ( 0 ) ;
setIsSearching ( false ) ;
return ;
}
setCrawlingProgress ( 15 ) ;
pushLog ( ` [Search] ${ pending . size } 개 상품 크롤링 진행 — 네이버·쿠팡 수집 및 AI 동일상품 판정... ` ) ;
// 2) 폴링 — GET 이 서버측 증분 동기화를 겸함. 요청 시각 이후 이력이 생긴 상품부터 완료 처리.
const total = pending . size ;
const succeeded = new Set < string > ( ) ; // 실제로 값이 내려간 행 — 끝나면 선택에서 빼 준다
let found = 0 ;
let notFound = 0 ;
const deadline = Date . now ( ) + POLL_TIMEOUT_MS ;
while ( pending . size > 0 && Date . now ( ) < deadline && ! cancelledRef . current ) {
await new Promise ( ( r ) = > setTimeout ( r , POLL_INTERVAL_MS ) ) ;
for ( const id of [ . . . pending ] ) {
try {
const r = await getLowestPrice ( id ) ;
const fresh = ( r . results ? ? [ ] ) . find ( ( e ) = > ( e . crawl_end_time ? ? '' ) >= startedAt ) ;
// 문자열 비교 금지 — 서버는 마이크로초(.755705Z), JS 는 밀리초(.755Z)라 자릿수가 달라
// 사전순 비교가 시간순과 어긋난다('.' < 'Z'). 반드시 epoch ms 로 환산해 비교한다.
const baseline = baselineOf . get ( id ) ? ? 0 ;
const fresh = ( r . results ? ? [ ] ) . find ( ( e ) = > crawlMs ( e . crawl_end_time ) > baseline ) ;
if ( ! fresh ) continue ;
pending . delete ( id ) ;
const malls = toMallPrices ( fresh . by_mall ? ? null ) ;
if ( fresh . success_yn && fresh . lp_price != null ) {
found += 1 ;
pushLog ( ` [완료] ' ${ nameOf ( id ) } ' 최저가 ${ fresh . lp_price . toLocaleString ( ) } 원 반영 ` ) ;
const prev = prevPriceOf ( id ) ;
if ( prev <= 0 || fresh . lp_price < prev ) succeeded . add ( id ) ; // 값이 실제로 갱신된 행
// by_mall 이 비어 오는 옛 이력 대비 — 최소한 대표 최저가는 보이도록 폴백을 채운다.
if ( Object . keys ( malls ) . length === 0 ) malls . etc = fresh . lp_price ;
setItem ( id , { kind : 'done' , price : fresh.lp_price , malls } ) ;
} else {
notFound += 1 ;
pushLog ( ` [미발견] ' ${ nameOf ( id ) } ' 동일 상품을 찾지 못함(기존 값 유지) ` ) ;
setItem ( id , { kind : 'notfound' , malls } ) ;
}
} catch {
/* 일시 오류는 다음 tick 재시도 */
}
setCrawlingProgress ( 15 + Math . round ( ( ( total - pending . size ) / total ) * 85 ) ) ;
}
}
// 3) 마무리 — 목록 갱신(테이블 인터넷 최저가 컬럼 반영) 후 종료
const timedOut = pending . size > 0 && ! cancelledRef . current ;
if ( timedOut ) {
pushLog ( ` [대기초과] ${ pending . size } 개 상품은 아직 검색 중 — 완료되면 주기 동기화로 자동 반영됩니다. ` ) ;
}
// 3) 마무리 — 목록만 갱신하고 창은 그대로 둔다(결과를 바로 확인할 수 있어야 하므로 자동으로 닫지 않는다).
if ( pending . size > 0 && ! cancelledRef . current ) pending . forEach ( ( id ) = > setItem ( id , { kind : 'timeout' } ) ) ;
await queryClient . invalidateQueries ( { queryKey : [ '/v1/item/list' ] } ) ;
setCrawlingProgress ( 100 ) ;
// 다음 '다시 검색'의 기본 대상 = 이번에 갱신되지 않은 행. 이미 갱신된 상품엔 크롤 비용을 다시 쓰지 않는다.
setCheckedIds ( new Set ( ids . filter ( ( id ) = > ! succeeded . has ( id ) ) ) ) ;
if ( ! cancelledRef . current ) {
showToast (
` 최저가 수집 완료: 반영 ${ found } · 미발견 ${ notFound } ${ timedOut ? ` · 검색중 ${ pending . size } (자동 반영 예정) ` : '' } ` ,
found > 0 ? 'success' : 'info' ,
setIsSearching ( false ) ;
if ( found === 0 ) showToast ( '더 낮은 가격을 찾지 못했습니다.' , 'info' ) ;
}
} ;
const handleStart = ( ) = > {
if ( checked . size === 0 ) {
toast . error ( '검색할 상품을 1개 이상 선택해 주십시오.' ) ;
return ;
}
setTargetIds ( [ . . . rows ] ) ; // 표는 대상 전체를 계속 보여주고, 검색은 체크된 행만 돈다
setItems ( { } ) ;
void runSearch ( [ . . . checked ] ) ;
} ;
// 몰 셀 — 검색 중에는 셀 단위 스피너로 진행 위치를 보여주고, 끝나면 금액을 찍는다.
const renderMallCell = ( id : string , source : string ) = > {
const s = items [ id ] ;
if ( ! s ) return < span className = "text-muted-foreground" > – < / span > ;
if ( s . kind === 'pending' ) {
// 작은 회전 아이콘은 표 안에서 어수선하게 읽힌다. 값이 들어올 자리를 잡아 두는
// 스켈레톤 막대로 대신한다(레이아웃 이동도 없음).
return (
< span className = "inline-flex w-full justify-end" >
< span className = "h-3 w-10 animate-pulse rounded-[3px] bg-muted" aria - hidden / >
< span className = "sr-only" > 검 색 중 < / span >
< / span >
) ;
}
onDone ( ) ;
setIsCrawling ( false ) ;
onClose ( ) ;
setCrawlingProgress ( 0 ) ;
setCrawlerLogs ( [ ] ) ;
const malls = s . kind === 'done' || s . kind === 'notfound' ? s . malls : { } ;
const prices = Object . values ( malls ) ;
const best = prices . length > 0 ? Math . min ( . . . prices ) : null ;
const price = malls [ source ] ;
if ( price === undefined ) return < span className = "text-muted-foreground" > – < / span > ;
const isBest = price === best ;
return (
< span className = { isBest ? 'font-semibold text-foreground' : 'text-muted-foreground' } >
{ price . toLocaleString ( ) }
{ isBest && < span className = "sr-only" > ( 최 저 가 ) < / span > }
< / span >
) ;
} ;
// 결과 열 — 완료 상태에서만 각 행의 '처리 결과 상태'를 적는다(검색 중에는 비워 둔다).
// 금액이 아니라 상태어를 쓴다: 가격 자체는 왼쪽 몰 열에 이미 있고, 이 열은 "그래서 어떻게 됐나"를 답한다.
const renderResultCell = ( id : string ) = > {
const s = items [ id ] ;
if ( status !== 'done' || ! s ) return < span className = "text-muted-foreground" > – < / span > ;
if ( savingOf ( id ) > 0 ) return < span className = { cn ( 'font-semibold' , ACCENT_TEXT ) } > 탐 색 성 공 < / span > ;
switch ( s . kind ) {
case 'done' :
case 'notfound' :
// 검색은 정상 수행됐으나 기존보다 낮은 가격이 없었음(미발견 포함) — 값이 안 바뀐 상태.
return < span className = "text-muted-foreground" > 변 동 없 음 < / span > ;
case 'failed' :
return < span className = "text-foreground" > 탐 색 실 패 < / span > ;
case 'timeout' :
case 'stopped' :
return < span className = "text-foreground" > 탐 색 중 < / span > ;
default :
return < span className = "text-muted-foreground" > – < / span > ;
}
} ;
return (
< div className = "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55 backdrop-blur-xs" >
< div className = "w-full max-w-lg bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto animate-scale-up font-mono text-xs" >
< div
className = "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40"
onMouseDown = { ( e ) = > {
if ( e . target === e . currentTarget ) handleClose ( ) ; // 오버레이 클릭으로도 닫기
} }
>
< div className = "w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-[8px] border border-border bg-card shadow-sm" >
{ /* Modal Title */ }
< div className = "flex items-center justify-between pb-4 border-b border-border" >
< div className = "flex items-center gap-2 text-foreground" >
< Globe className = "text-rose-500 animate-pulse" size = { 18 } / >
< Typography variant = "h3" > 인 터 넷 최 저 가 실 시 간 수 집 및 동 기 화 < / Typography >
< / div >
{ ! isCrawling && (
< button
onClick = { onClose }
className = "p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer"
>
< X size = { 18 } / >
< / button >
) }
< / div >
{ /* Modal Body */ }
< div className = "my-6 space-y-4" >
< div className = "p-3 bg-rose-500/5 border border-rose-500/20 rounded-md" >
< Typography variant = "small" className = "text-[11.5px] leading-relaxed text-left flex items-start gap-2" >
< AlertCircle size = { 15 } className = "text-rose-500 shrink-0 mt-0.5" / >
< span >
총 < span className = "font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2" > { selectedIds . length } 개 < / span > 품 목 에 대 하 여 네 이 버 쇼 핑 · 쿠 팡 의 최 저 가 를 수 집 하 고 AI 가 동 일 상 품 을 판 정 하 여 인 터 넷 최 저 가 필 드 로 동 기 화 합 니 다 . 상 품 당 수 십 초 가 소 요 될 수 있 습 니 다 .
< / span >
< / Typography >
< / div >
{ /* Targeted Items List */ }
< div className = "border border-border rounded p-2.5 bg-muted/20 space-y-1.5" >
< span className = "text-[10px] text-muted-foreground font-bold block" > 수 집 대 상 상 품 ( { selectedIds . length } ) < / span >
< div className = "max-h-24 overflow-y-auto space-y-1 pr-1" >
{ selectedIds . map ( ( id ) = > {
const prod = products . find ( ( p ) = > p . item_id === id ) ;
return prod ? (
< div key = { id } className = "flex justify-between items-center text-[11px] py-1 border-b border-border/30 last:border-b-0" >
< span className = "text-foreground truncate max-w-[200px] font-semibold" > { prod . name } < / span >
< div className = "flex items-center gap-1.5 font-mono" >
< span className = "text-muted-foreground" > { ( prod . price ? ? 0 ) . toLocaleString ( ) } 원 < / span >
< span className = "text-muted-foreground" > → < / span >
< span className = "text-rose-500 font-bold" >
{ prod . internet_lowest_price != null ? ` ${ Number ( prod . internet_lowest_price ) . toLocaleString ( ) } 원 갱신 ` : '신규 수집' }
< / span >
< / div >
< / div >
) : null ;
} ) }
< / div >
< / div >
{ /* Crawling Progress View */ }
{ isCrawling ? (
< div className = "space-y-3 pt-2" >
< div className = "flex justify-between text-[11px] text-muted-foreground" >
< span className = "flex items-center gap-1.5 font-bold text-foreground" >
< Loader2 size = { 13 } className = "animate-spin text-rose-500" / >
최 저 가 검 색 · 수 집 진 행 중 . . . ( 닫 아 도 검 색 은 계 속 됩 니 다 )
< / span >
< span className = "font-bold text-rose-500" > { crawlingProgress } % < / span >
< / div >
{ /* Progress Bar Container */ }
< div className = "w-full bg-muted rounded-full h-2.5 overflow-hidden border border-border" >
< div
className = "bg-gradient-to-r from-rose-500 to-rose-600 h-full transition-all duration-300 rounded-full"
style = { { width : ` ${ crawlingProgress } % ` } }
/ >
< / div >
{ /* Terminal Log Output */ }
< div className = "bg-zinc-950 dark:bg-black text-rose-300 p-3 rounded font-mono text-[10px] border border-zinc-800 space-y-1.5 max-h-36 overflow-y-auto shadow-inner" >
{ crawlerLogs . map ( ( log , idx ) = > (
< div key = { idx } className = "flex items-start gap-1" >
< span className = "text-zinc-600 select-none shrink-0" > & gt ; < / span >
< span className = { ` text-left break-all ${ log . includes ( '[System]' ) || log . includes ( '[Search]' ) ? 'text-zinc-400 font-bold' : log . includes ( '[완료]' ) ? 'text-emerald-400' : log . includes ( '[미발견]' ) || log . includes ( '[불가]' ) || log . includes ( '[오류]' ) || log . includes ( '[대기초과]' ) ? 'text-amber-400' : 'text-rose-300' } ` } >
{ log }
< / span >
< / div >
) ) }
< / div >
< / div >
) : (
< div className = "text-center py-4 border border-dashed border-border rounded bg-muted/10 space-y-2" >
< Cpu size = { 24 } className = "mx-auto text-muted-foreground/60" / >
< Typography variant = "muted" className = "text-[11px]" >
"인터넷 최저가 가동" 버 튼 을 누 르 시 면 실 시 간 수 집 이 시 작 됩 니 다 .
< / Typography >
< / div >
) }
< / div >
{ /* Modal Footer */ }
< div className = "flex justify-end gap-2 pt-4 border-t border-border" >
{ /* 다이얼로그 헤더 — X 는 푸터 버튼과 같은 높이(size-7)로 맞춘다 */ }
< div className = "flex items-center justify-between gap-3 px-5 py-4" >
< Typography variant = "h4" as = "h2" > 인 터 넷 최 저 가 검 색 < / Typography >
< Button
type = "button"
variant = "outline"
size = "sm"
onClick = { ( ) = > {
cancelledRef . current = true ; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영)
onClose ( ) ;
} }
size = "icon-sm"
className = "rounded-[4px]"
aria - label = "닫기"
onClick = { handleClose }
>
< X / >
< / Button >
< / div >
< div className = "px-5 pb-2" >
{ /* 결과 표 — 데이터가 주인공이라 맨 위에 둔다 */ }
< div className = "overflow-x-auto rounded-[6px] border border-border" >
< div className = "min-w-[36rem]" >
< div className = { cn ( ROW_GRID , 'items-center border-b border-border bg-muted/60 py-1.5 text-xs text-muted-foreground' ) } >
< input
type = "checkbox"
className = { CHECKBOX }
checked = { allChecked }
disabled = { status === 'searching' || rows . length === 0 }
onChange = { ( e ) = > toggleAll ( e . target . checked ) }
aria - label = "전체 선택"
/ >
< span > 상 품 < / span >
< span className = { NUM } > 기 존 최 저 가 < / span >
{ SOURCES . map ( ( { key , label } ) = > (
< span key = { key } className = { NUM } > { label } < / span >
) ) }
< span className = { NUM } > 결 과 < / span >
< / div >
< div className = "max-h-72 overflow-y-auto" >
{ rows . length === 0 && (
< div className = "px-3 py-6 text-center text-[13px] text-muted-foreground" >
선 택 된 상 품 이 없 습 니 다 . 목 록 에 서 상 품 을 선 택 해 주 십 시 오 .
< / div >
) }
{ rows . map ( ( id ) = > (
< label
key = { id }
className = { cn (
ROW_GRID ,
'items-center py-1.5 text-[13px] border-b border-border/60 last:border-b-0' ,
status !== 'searching' && 'cursor-pointer hover:bg-muted/40' ,
) }
>
< input
type = "checkbox"
className = { CHECKBOX }
checked = { checked . has ( id ) }
disabled = { status === 'searching' }
onChange = { ( e ) = > toggleOne ( id , e . target . checked ) }
aria - label = { ` ${ nameOf ( id ) } 선택 ` }
/ >
< span className = "truncate text-foreground" > { nameOf ( id ) } < / span >
< span className = { cn ( NUM , 'text-muted-foreground' ) } > { fmtPrice ( prevPriceOf ( id ) ) } < / span >
{ SOURCES . map ( ( { key } ) = > (
< span key = { key } className = { NUM } > { renderMallCell ( id , key ) } < / span >
) ) }
< span className = { NUM } > { renderResultCell ( id ) } < / span >
< / label >
) ) }
< / div >
< / div >
< / div >
{ / * 상 태 요 약 — 한 화 면 에 한 상 태 만 . 표 ( 데 이 터 ) 가 먼 저 오 고 진 행 / 결 과 는 그 아 래 .
confirm 단 계 는 별 도 문 구 없 이 표 와 하 단 안 내 만 으 로 충 분 하 다 . * / }
< div role = "status" aria - live = "polite" className = { hasStatusBlock ? 'mt-4' : undefined } >
{ status === 'searching' && (
< div className = "rounded-[6px] border border-border px-4 py-3" >
< div className = "flex items-center justify-between gap-3" >
< span className = "flex items-center gap-2 text-[13px] font-medium text-foreground" >
{ /* 동작 중임을 알리는 표시등 — 의미는 옆 문구가 진다(색 단독 의존 방지) */ }
< span
className = "size-1.5 shrink-0 animate-pulse rounded-full bg-indigo-800 dark:bg-indigo-300"
aria - hidden
/ >
더 낮 은 가 격 검 색 중
< / span >
< span className = "flex shrink-0 items-center gap-1.5 text-xs tabular-nums text-muted-foreground" >
< Clock size = { 13 } aria - hidden / >
< span className = "sr-only" > 경 과 시 간 < / span >
{ fmtClock ( elapsedSec ) }
< / span >
< / div >
< div className = "mt-3 flex items-center gap-3" >
< span className = "h-1.5 flex-1 overflow-hidden rounded-full bg-muted" >
< span
className = "block h-full rounded-full bg-indigo-800 transition-all duration-500 dark:bg-indigo-300"
style = { { width : ` ${ rows . length ? ( settled / rows . length ) * 100 : 0 } % ` } }
/ >
< / span >
< span className = "shrink-0 text-xs font-semibold tabular-nums text-foreground" >
{ settled } / { rows . length }
< / span >
< / div >
< / div >
) }
{ / * 완 료 — 행 별 결 과 는 표 의 ' 결 과 ' 열 이 말 해 주 므 로 , 여 기 엔 절 감 액 만 남 긴 다 .
절 감 이 없 으 면 시 각 적 으 로 는 비 우 고 완 료 사 실 만 스 크 린 리 더 에 알 린 다 . * / }
{ status === 'done' && (
< div className = "flex items-baseline justify-between gap-3" >
{ totalSaved > 0 ? (
< span className = { cn ( 'text-[22px] font-bold tabular-nums tracking-tight' , ACCENT_TEXT ) } >
총 { totalSaved . toLocaleString ( ) } 원 절 감
< / span >
) : (
< span className = "sr-only" > 검 색 이 완 료 되 었 습 니 다 . 갱 신 된 가 격 은 없 습 니 다 . < / span >
) }
{ failedIds . length > 0 && (
< span className = "shrink-0 text-xs tabular-nums text-muted-foreground" >
탐 색 실 패 { failedIds . length } 개
< / span >
) }
< / div >
) }
< / div >
{ /* 보조 안내 — 할 말이 없으면 아예 그리지 않는다(빈 줄이 여백만 잡는 걸 막는다) */ }
{ note && < p className = "mt-4 text-xs text-muted-foreground" > { note } < / p > }
< / div >
{ /* 액션 — 풀와이드 금지. 보조는 좌측 텍스트, 주 액션은 우측 */ }
< div className = "flex items-center justify-between gap-3 px-5 pb-5 pt-4" >
< Button type = "button" variant = "outline" size = "sm" className = "rounded-[4px]" onClick = { handleClose } >
닫 기
< / Button >
< Button type = "button" variant = "destructive" size = "sm" disabled = { isCrawling } onClick = { handleStartCrawling } >
{ isCrawling ? (
< >
< RefreshCw className = "animate-spin" / >
실 시 간 수 집 분 석 중 . . .
< / >
) : (
< >
< Globe / >
인 터 넷 최 저 가 가 동
< / >
) }
< / Button >
{ status === 'searching' ? (
< Button type = "button" variant = "secondary" size = "sm" className = "rounded-[4px]" onClick = { handleStop } >
탐 색 중 단
< / Button >
) : (
< Button
type = "button"
size = "sm"
className = { cn ( 'rounded-[4px]' , ACCENT_BTN ) }
disabled = { checked . size === 0 }
// 완료 후 재검색은 force — 미발견은 24h 네거티브 캐시에 걸려 그냥 누르면 실제로 안 돈다.
onClick = { status === 'done' ? ( ) = > void runSearch ( [ . . . checked ] , true ) : handleStart }
>
{ status === 'confirm' ? ` 선택 ${ checked . size } 개 검색 ` : ` 선택 ${ checked . size } 개 다시 검색 ` }
< / Button >
) }
< / div >
< / div >
< / div >