import React, { useState, useEffect, useCallback, useRef } from 'react'; import { setMarketNames } from '../utils/marketNameCache'; import { getFavorites, toggleFavorite, FAVORITES_CHANGED_EVENT, } from '../utils/marketStorage'; import { API_BASE } from '../utils/backendApi'; import { UPBIT_API } from '../utils/upbitApi'; import { fetchTickersThrottled } from '../utils/upbitTickerFetch'; import { useIsCompactDevice } from '../hooks/useMediaQuery'; import { safeToFixed } from '../utils/safeFormat'; // ─── Types ─────────────────────────────────────────────────────────────────── interface MarketInfo { market: string; // "KRW-BTC" korean_name: string; // "비트코인" english_name: string; // "Bitcoin" symbol: string; // "BTC" } interface TickerInfo { trade_price: number; signed_change_rate: number; // 전일 대비 변화율 (소수, e.g. 0.015 = +1.5%) acc_trade_price_24h: number; // 24h 거래대금 (KRW) } // ─── Constants ─────────────────────────────────────────────────────────────── const TICKER_TTL_DESKTOP = 5000; const TICKER_TTL_COMPACT = 25_000; function formatVol(vol: number | string | null | undefined): string { const v = Number(vol); if (!Number.isFinite(v)) return '-'; if (v >= 1e12) return `${(v / 1e12).toFixed(1)}조`; if (v >= 1e8) return `${(v / 1e8).toFixed(0)}억`; if (v >= 1e4) return `${(v / 1e4).toFixed(0)}만`; return v.toLocaleString(); } function formatChangeRate(raw: number | string | null | undefined): number | null { const rate = Number(raw); if (!Number.isFinite(rate)) return null; return rate * 100; } // ─── Component ─────────────────────────────────────────────────────────────── interface MarketSearchPanelProps { currentMarket: string; onSelect: (market: string) => void; onClose: () => void; /** * 슬롯/버튼 rect. anchorPlacement 에 따라 패널 위치가 결정된다. * 없으면 기본(전체화면 좌측 고정) 위치로 표시된다. */ anchorRect?: DOMRect | null; /** slot=슬롯 영역 채움, dropdown=클릭 지점 아래 드롭다운 */ anchorPlacement?: 'slot' | 'dropdown'; /** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */ variant?: 'overlay' | 'embedded'; /** 외부 입력과 검색어 동기화 */ initialQuery?: string; /** 제어형 검색어 (embedded 등 외부 input 사용 시) */ query?: string; onQueryChange?: (query: string) => void; /** favorite=별표, add=투자대상 추가(+) */ actionMode?: 'favorite' | 'add'; /** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */ onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void; /** actionMode=add 일 때 이미 추가된 종목 클릭 → 투자대상에서 제거 */ onRemove?: (market: string) => void; /** 이미 추가된 종목 (add 모드에서 체크 표시·클릭 시 제거) */ addedMarkets?: Set; /** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */ maxTargetsReached?: boolean; } export const MarketSearchPanel: React.FC = ({ currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '', variant = 'overlay', query: controlledQuery, onQueryChange, actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false, }) => { const embedded = variant === 'embedded'; const isCompact = useIsCompactDevice(); const tickerTtl = isCompact ? TICKER_TTL_COMPACT : TICKER_TTL_DESKTOP; const [markets, setMarkets] = useState([]); const [tickers, setTickers] = useState>({}); const [internalQuery, setInternalQuery] = useState(initialQuery); const query = controlledQuery ?? internalQuery; const setQuery = useCallback((next: string) => { if (controlledQuery === undefined) setInternalQuery(next); onQueryChange?.(next); }, [controlledQuery, onQueryChange]); const [favs, setFavs] = useState>(() => new Set(getFavorites())); const [loading, setLoading] = useState(true); const refreshFavs = useCallback(() => { setFavs(new Set(getFavorites())); }, []); const inputRef = useRef(null); const tickerTimer = useRef | null>(null); const marketCodesRef = useRef(''); // ── 1. 마켓 목록 로드 ───────────────────────────────────────────────────── useEffect(() => { setLoading(true); fetch(`${API_BASE}/markets/all?isDetails=false`) .then(r => { if (!r.ok) return fetch(`${UPBIT_API}/market/all?isDetails=false`); return r; }) .then(r => { if (!r.ok) throw new Error(`market/all ${r.status}`); return r.json(); }) .then((data: Array<{ market: string; korean_name: string; english_name: string }>) => { const krw = data .filter(m => m.market.startsWith('KRW-')) .map(m => ({ ...m, symbol: m.market.replace('KRW-', '') })); setMarkets(krw); marketCodesRef.current = krw.map(m => m.market).join(','); // 한글명 캐시 저장 (Toolbar 등에서 사용) const nameMap: Record = {}; for (const m of krw) nameMap[m.market] = m.korean_name; setMarketNames(nameMap); setLoading(false); }) .catch(() => setLoading(false)); if (!embedded) setTimeout(() => inputRef.current?.focus(), 50); refreshFavs(); }, [refreshFavs, embedded]); useEffect(() => { if (controlledQuery === undefined) setInternalQuery(initialQuery); }, [initialQuery, controlledQuery]); // 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화 useEffect(() => { const onChanged = () => refreshFavs(); window.addEventListener(FAVORITES_CHANGED_EVENT, onChanged); return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChanged); }, [refreshFavs]); // ── 2. 시세 데이터 폴링 (100개씩 배치 요청) ───────────────────────────── const fetchTickers = useCallback(async () => { const codes = marketCodesRef.current; if (!codes) return; const allCodes = codes.split(',').filter(Boolean); const rawList = await fetchTickersThrottled(allCodes); setTickers(prev => { const map: Record = { ...prev }; for (const t of rawList) { const market = (t.market ?? t.code) as string; if (!market) continue; map[market] = { trade_price: t.trade_price, signed_change_rate: t.signed_change_rate, acc_trade_price_24h: t.acc_trade_price_24h, }; } return map; }); }, []); useEffect(() => { if (markets.length === 0) return; fetchTickers(); tickerTimer.current = setInterval(fetchTickers, tickerTtl); return () => { if (tickerTimer.current) clearInterval(tickerTimer.current); }; }, [markets, fetchTickers, tickerTtl]); // ── 3. 즐겨찾기 토글 ───────────────────────────────────────────────────── const toggleFav = useCallback(async (market: string, e: React.MouseEvent) => { e.stopPropagation(); const row = markets.find(m => m.market === market); try { const next = await toggleFavorite(market, { koreanName: row?.korean_name, englishName: row?.english_name, }); setFavs(new Set(next)); } catch (err) { console.warn('[MarketSearchPanel] watchlist toggle failed', err); } }, [markets]); const tryAddMarket = useCallback((market: string) => { if (actionMode === 'add') { onSelect(market); if (addedMarkets?.has(market)) { if (embedded) onClose(); return; } if (maxTargetsReached) return; const row = markets.find(m => m.market === market); onAdd?.(market, { koreanName: row?.korean_name ?? market, englishName: row?.english_name ?? market, }); if (embedded) onClose(); return; } onSelect(market); if (!embedded) onClose(); }, [actionMode, onSelect, onAdd, addedMarkets, maxTargetsReached, markets, embedded, onClose]); const handleAddClick = useCallback((market: string, e: React.MouseEvent) => { e.stopPropagation(); tryAddMarket(market); }, [tryAddMarket]); // ── 4. 필터 + 정렬 ─────────────────────────────────────────────────────── const q = query.trim(); const filtered = markets.filter(m => { if (!q) return true; return ( m.korean_name.includes(q) || m.symbol.toUpperCase().includes(q.toUpperCase()) || m.english_name.toLowerCase().includes(q.toLowerCase()) || m.market.toUpperCase().includes(q.toUpperCase()) ); }); const sorted = [...filtered].sort((a, b) => { const fa = favs.has(a.market) ? 1 : 0; const fb = favs.has(b.market) ? 1 : 0; if (fb !== fa) return fb - fa; // 즐겨찾기 동순위: 24h 거래대금 내림차순 const va = tickers[a.market]?.acc_trade_price_24h ?? 0; const vb = tickers[b.market]?.acc_trade_price_24h ?? 0; return vb - va; }); // ── Render ──────────────────────────────────────────────────────────────── // anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다. const HEADER_H = 30; // 슬롯 헤더 높이 (약 30px) const MIN_W = 260; // LWC v5 canvas 레이어가 내부적으로 높은 z-index를 사용하므로 // 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다. const PANEL_Z = 99999; const DROPDOWN_H = 480; const isCompactDropdown = anchorPlacement === 'dropdown' && !embedded; const panelStyle: React.CSSProperties | undefined = embedded ? undefined : anchorRect ? anchorPlacement === 'dropdown' ? { position: 'fixed', top: anchorRect.bottom + 4, left: anchorRect.left, width: Math.max(420, anchorRect.width, MIN_W), height: Math.min(DROPDOWN_H, window.innerHeight - anchorRect.bottom - 12), maxWidth: Math.min(520, window.innerWidth - anchorRect.left - 8), minHeight: 320, zIndex: PANEL_Z, background: 'var(--bg2)', color: 'var(--text)', border: '1px solid var(--border)', } : { position: 'fixed', top: anchorRect.top + HEADER_H, left: anchorRect.left, width: Math.max(anchorRect.width, MIN_W), height: anchorRect.height - HEADER_H, maxWidth: anchorRect.width, zIndex: PANEL_Z, background: 'var(--bg2)', color: 'var(--text)', border: '1px solid var(--border)', } : undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용 // backdrop: dropdown=전체 화면, slot=해당 슬롯 영역만 const backdropStyle: React.CSSProperties | undefined = anchorRect ? anchorPlacement === 'dropdown' ? { position: 'fixed', inset: 0, zIndex: PANEL_Z - 1, background: 'rgba(0,0,0,0.28)', } : { position: 'fixed', top: anchorRect.top, left: anchorRect.left, width: anchorRect.width, height: anchorRect.height, zIndex: PANEL_Z - 1, background: 'rgba(0,0,0,0.45)', backdropFilter: 'none', } : undefined; const handleRowSelect = useCallback((market: string) => { tryAddMarket(market); }, [tryAddMarket]); return ( <> {!embedded && (
)}
{!embedded && (
setQuery(e.target.value)} onKeyDown={e => { if (e.key === 'Escape') onClose(); if (e.key === 'Enter' && sorted.length > 0) handleRowSelect(sorted[0].market); }} /> {query && ( )}
)}
{!isCompactDropdown && ( {embedded ? '추가' : ''} )} {isCompactDropdown ? '종목명' : '한글명'} {!embedded && ( <> 현재가 전일대비 {!isCompactDropdown && ( 거래대금 )} )}
{/* 목록 */}
{loading && (
마켓 목록 로딩 중...
)} {!loading && sorted.length === 0 && (
검색 결과가 없습니다
)} {sorted.map(m => { const tk = tickers[m.market]; const isFav = favs.has(m.market); const isAdded = addedMarkets?.has(m.market) ?? false; const addBlocked = maxTargetsReached && !isAdded; const isActive = m.market === currentMarket; const rate = tk ? formatChangeRate(tk.signed_change_rate) : null; const isUp = rate !== null && rate >= 0; return (
handleRowSelect(m.market)} > {!isCompactDropdown && ( actionMode === 'add' ? ( ) : ( toggleFav(m.market, e)} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > {isFav ? '★' : '☆'} ) )} {m.korean_name} {!isCompactDropdown && {m.symbol}} {!embedded && ( <> {tk && Number.isFinite(Number(tk.trade_price)) ? Number(tk.trade_price).toLocaleString() : '-'} {rate === null ? '-' : `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`} {!isCompactDropdown && ( {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} )} )}
); })}
); }; export default MarketSearchPanel;