/** * 좌측 마켓 패널 * * 탭 구성: * 원화 - KRW 마켓 목록, 현재가(KRW) * BTC - KRW 마켓 목록, 현재가(USD 환산) * 보유 - 보유 종목 목록 (localStorage 관리) * 관심 - 즐겨찾기 목록 (localStorage 관리) */ import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react'; import type { TickerData, ChangeDir } from '../hooks/useMarketTicker'; import { getFavorites, saveFavorites, toggleFavorite, getHoldings, addHolding, removeHolding, } from '../utils/marketStorage'; import { formatUpbitKrwPrice } from '../utils/safeFormat'; // ─── 타입 ────────────────────────────────────────────────────────────────── type Tab = 'krw' | 'btc' | 'hold' | 'fav'; type SortKey = 'name' | 'price' | 'change'; type SortDir = 'asc' | 'desc'; export interface MarketPanelProps { open: boolean; tickers: Map; loading: boolean; usdRate: number; // KRW per 1 USD (from KRW-USDT) activeSymbol: string; onSymbolSelect: (symbol: string) => void; } // ─── 가격 포맷 헬퍼 ──────────────────────────────────────────────────────── function fmtKrw(price: number | null | undefined): string { return formatUpbitKrwPrice(price, '-'); } function fmtUsd(krwPrice: number | null | undefined, rate: number): string { if (krwPrice == null || !isFinite(krwPrice) || !rate) return '-'; const usd = krwPrice / rate; if (usd >= 10_000) return '$' + usd.toLocaleString('en-US', { maximumFractionDigits: 0 }); if (usd >= 1) return '$' + usd.toFixed(2); if (usd >= 0.001) return '$' + usd.toFixed(4); return '$' + usd.toFixed(6); } function fmtPct(rate: number | null | undefined): string { if (rate == null || !isFinite(rate)) return '-'; const sign = rate >= 0 ? '+' : ''; return `${sign}${(rate * 100).toFixed(2)}%`; } // ─── 방향 지시자 삼각형 ──────────────────────────────────────────────────── const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) { if (change === 'RISE') return ; if (change === 'FALL') return ; return ; }); // ─── 개별 행 ─────────────────────────────────────────────────────────────── interface RowProps { ticker: TickerData | undefined; market: string; isActive: boolean; isFav: boolean; isHold: boolean; showUsd: boolean; showHoldBtn: boolean; usdRate: number; onSelect: () => void; onToggleFav: (e: React.MouseEvent) => void; onToggleHold: (e: React.MouseEvent) => void; onRemoveHold?: (e: React.MouseEvent) => void; } const MarketRow = memo(function MarketRow({ ticker, market, isActive, isFav, isHold, showUsd, showHoldBtn, usdRate, onSelect, onToggleFav, onToggleHold, onRemoveHold, }: RowProps) { const change: ChangeDir = ticker?.change ?? 'EVEN'; const code = market.replace(/^KRW-/, ''); // ── 가격 변동 플래시 (frontend_golden TickerItem 로직) ───────────────── const [flashClass, setFlashClass] = useState(''); const prevPriceRef = useRef(null); useEffect(() => { const price = ticker?.tradePrice ?? null; if (price === null) return; const prev = prevPriceRef.current; if (prev !== null && prev !== price) { const cls = price > prev ? 'mp-row--flash-rise' : 'mp-row--flash-fall'; setFlashClass(cls); const t = setTimeout(() => setFlashClass(''), 700); prevPriceRef.current = price; return () => clearTimeout(t); } prevPriceRef.current = price; }, [ticker?.tradePrice]); const colorCls = change === 'RISE' ? 'mp-up' : change === 'FALL' ? 'mp-dn' : 'mp-even'; return (
{/* 즐겨찾기 별 */} {/* 이름 + 방향 지시자 */}
{ticker?.koreanName ?? code} {code}/KRW
{/* 현재가 */} {ticker ? (showUsd ? fmtUsd(ticker.tradePrice, usdRate) : fmtKrw(ticker.tradePrice)) : '-'} {/* 전일대비 */} {ticker ? fmtPct(ticker.changeRate) : '-'} {/* 보유 토글 / 제거 버튼 */} {onRemoveHold ? ( ) : showHoldBtn ? ( ) : null}
); }); // ─── 메인 패널 ───────────────────────────────────────────────────────────── export const MarketPanel: React.FC = ({ open, tickers, loading, usdRate, activeSymbol, onSymbolSelect, }) => { const [tab, setTab] = useState('krw'); const [search, setSearch] = useState(''); const [favorites, setFavs] = useState(getFavorites); const [holdings, setHolds] = useState(getHoldings); const [holdSearch, setHoldSrch] = useState(''); const [showHoldAdd, setShowHoldAdd] = useState(false); const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState('desc'); const searchRef = useRef(null); // 패널 열릴 때 검색창 포커스 useEffect(() => { if (open) setTimeout(() => searchRef.current?.focus(), 250); }, [open]); // 검색 초기화 (탭 전환 시) const handleTab = useCallback((t: Tab) => { setTab(t); setSearch(''); }, []); // 컬럼 정렬 토글 const handleSort = useCallback((key: SortKey) => { setSortKey(prev => { if (prev === key) { setSortDir(d => d === 'asc' ? 'desc' : 'asc'); return key; } setSortDir('desc'); return key; }); }, []); // 즐겨찾기 토글 const handleToggleFav = useCallback(async (market: string, e: React.MouseEvent) => { e.stopPropagation(); const row = tickers.get(market); try { const next = await toggleFavorite(market, { koreanName: row?.koreanName }); setFavs(next); } catch (err) { console.warn('[MarketPanel] watchlist toggle failed', err); } }, [tickers]); // 보유 토글 const handleToggleHold = useCallback((market: string, e: React.MouseEvent) => { e.stopPropagation(); setHolds(prev => prev.includes(market) ? removeHolding(market) : addHolding(market) ); }, []); // 보유 제거 (보유 탭) const handleRemoveHold = useCallback((market: string, e: React.MouseEvent) => { e.stopPropagation(); setHolds(removeHolding(market)); }, []); // 보유 추가 검색에서 선택 const handleAddHolding = useCallback((market: string) => { setHolds(addHolding(market)); setHoldSrch(''); setShowHoldAdd(false); }, []); // 탭별 마켓 목록 계산 const allKrwMarkets = useMemo(() => { const keys = Array.from(tickers.keys()).filter(m => m.startsWith('KRW-')); // 24h 거래대금 내림차순 정렬 return keys.sort((a, b) => (tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0) ); }, [tickers]); const filtered = useMemo(() => { let base: string[] = []; switch (tab) { case 'krw': case 'btc': base = allKrwMarkets; break; case 'hold': base = holdings.filter(m => tickers.has(m) || true); break; case 'fav': base = favorites; break; } if (search) { const q = search.toUpperCase(); base = base.filter(m => { const code = m.replace(/^KRW-/, ''); const name = tickers.get(m)?.koreanName ?? ''; return code.includes(q) || name.includes(search); }); } if (!sortKey) return base; const mul = sortDir === 'asc' ? 1 : -1; return [...base].sort((a, b) => { const ta = tickers.get(a); const tb = tickers.get(b); switch (sortKey) { case 'name': { const na = ta?.koreanName ?? a.replace(/^KRW-/, ''); const nb = tb?.koreanName ?? b.replace(/^KRW-/, ''); return mul * na.localeCompare(nb, 'ko'); } case 'price': return mul * ((ta?.tradePrice ?? 0) - (tb?.tradePrice ?? 0)); case 'change': return mul * ((ta?.changeRate ?? 0) - (tb?.changeRate ?? 0)); default: return 0; } }); }, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir]); // 보유 탭 "추가" 검색 결과 const holdAddResults = useMemo(() => { if (!holdSearch) return []; const q = holdSearch.toUpperCase(); return allKrwMarkets .filter(m => { const code = m.replace(/^KRW-/, ''); const name = tickers.get(m)?.koreanName ?? ''; return code.includes(q) || name.includes(holdSearch); }) .slice(0, 10); }, [holdSearch, allKrwMarkets, tickers]); const tabLabels: Record = { krw: '원화', btc: 'BTC', hold: '보유', fav: '관심', }; return (
{/* ── 패널 내용 ─────────────────────────────────────────────── */}
{/* 탭 헤더 */}
{(['krw', 'btc', 'hold', 'fav'] as Tab[]).map(t => ( ))}
{/* 검색 */}
setSearch(e.target.value)} /> {search && ( )}
{/* 컬럼 헤더 */}
{(['name', 'price', 'change'] as SortKey[]).map(key => { const label = key === 'name' ? '한글명' : key === 'price' ? '현재가' : '전일대비'; const cls = key === 'name' ? 'mp-h-name' : key === 'price' ? 'mp-h-price' : 'mp-h-change'; const active = sortKey === key; return ( ); })}
{/* 보유 탭: 추가 버튼 */} {tab === 'hold' && (
{showHoldAdd ? (
setHoldSrch(e.target.value)} autoFocus /> {holdAddResults.length > 0 && (
{holdAddResults.map(m => (
handleAddHolding(m)}> {tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')} {m.replace(/^KRW-/, '')}
))}
)}
) : ( )}
)} {/* 마켓 목록 */}
{loading && (
마켓 정보 로딩 중...
)} {!loading && filtered.length === 0 && (
{tab === 'hold' ? '보유 종목이 없습니다' : tab === 'fav' ? '관심 종목이 없습니다' : '검색 결과 없음'}
)} {!loading && filtered.map(market => ( onSymbolSelect(market)} onToggleFav={e => handleToggleFav(market, e)} onToggleHold={e => handleToggleHold(market, e)} onRemoveHold={tab === 'hold' ? e => handleRemoveHold(market, e) : undefined} /> ))}
); }; export default MarketPanel;