452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
/**
|
||
* 좌측 마켓 패널
|
||
*
|
||
* 탭 구성:
|
||
* 원화 - KRW 마켓 목록, 현재가(KRW)
|
||
* BTC - KRW 마켓 목록, 현재가(USD 환산)
|
||
* 보유 - 보유 종목 목록 (localStorage 관리)
|
||
* 관심 - 즐겨찾기 목록 (localStorage 관리)
|
||
*/
|
||
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
|
||
import type { TickerData, ChangeDir, MarketInfo } 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<string, TickerData>;
|
||
/** 업비트 KRW 전 종목 (종목검색 드롭다운과 동일 소스) */
|
||
marketInfos: MarketInfo[];
|
||
loading: boolean;
|
||
usdRate: number; // KRW per 1 USD (from KRW-USDT)
|
||
activeSymbol: string;
|
||
onSymbolSelect: (symbol: string) => void;
|
||
/** 정렬 전 전 종목 시세 재조회 (useMarketTicker.refreshAllTickers) */
|
||
refreshAllTickers?: () => Promise<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)}%`;
|
||
}
|
||
|
||
function compareNullableNum(
|
||
a: number | null | undefined,
|
||
b: number | null | undefined,
|
||
mul: number,
|
||
): number {
|
||
if (a == null && b == null) return 0;
|
||
if (a == null) return 1;
|
||
if (b == null) return -1;
|
||
return mul * (a - b);
|
||
}
|
||
|
||
// ─── 방향 지시자 삼각형 ────────────────────────────────────────────────────
|
||
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {
|
||
if (change === 'RISE') return <span className="mp-arrow mp-arrow--rise">▲</span>;
|
||
if (change === 'FALL') return <span className="mp-arrow mp-arrow--fall">▼</span>;
|
||
return <span className="mp-arrow mp-arrow--even">─</span>;
|
||
});
|
||
|
||
// ─── 개별 행 ───────────────────────────────────────────────────────────────
|
||
interface RowProps {
|
||
ticker: TickerData | undefined;
|
||
market: string;
|
||
koreanName: 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, koreanName, 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<number | null>(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 (
|
||
<div
|
||
className={`mp-row ${isActive ? 'mp-row--active' : ''} ${flashClass}`}
|
||
onClick={onSelect}
|
||
>
|
||
{/* 즐겨찾기 별 */}
|
||
<button
|
||
className={`mp-star ${isFav ? 'mp-star--on' : ''}`}
|
||
onClick={onToggleFav}
|
||
title={isFav ? '관심 해제' : '관심 등록'}
|
||
>
|
||
{isFav ? '★' : '☆'}
|
||
</button>
|
||
|
||
{/* 이름 + 방향 지시자 */}
|
||
<div className="mp-name">
|
||
<span className="mp-korean-row">
|
||
<ChangeArrow change={change} />
|
||
<span className="mp-korean">{koreanName}</span>
|
||
</span>
|
||
<span className="mp-code">{code}/KRW</span>
|
||
</div>
|
||
|
||
{/* 현재가 */}
|
||
<span className={`mp-price ${colorCls}`}>
|
||
{ticker
|
||
? (showUsd ? fmtUsd(ticker.tradePrice, usdRate) : fmtKrw(ticker.tradePrice))
|
||
: '-'}
|
||
</span>
|
||
|
||
{/* 전일대비 */}
|
||
<span className={`mp-change ${colorCls}`}>
|
||
{ticker ? fmtPct(ticker.changeRate) : '-'}
|
||
</span>
|
||
|
||
{/* 보유 토글 / 제거 버튼 */}
|
||
{onRemoveHold ? (
|
||
<button className="mp-hold-rm" onClick={onRemoveHold} title="보유 종목 제거">×</button>
|
||
) : showHoldBtn ? (
|
||
<button
|
||
className={`mp-hold-btn ${isHold ? 'mp-hold-btn--on' : ''}`}
|
||
onClick={onToggleHold}
|
||
title={isHold ? '보유 해제' : '보유 등록'}
|
||
>
|
||
{isHold ? '₩' : '+₩'}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
// ─── 메인 패널 ─────────────────────────────────────────────────────────────
|
||
export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||
open, tickers, marketInfos, loading, usdRate, activeSymbol, onSymbolSelect,
|
||
refreshAllTickers,
|
||
}) => {
|
||
const [tab, setTab] = useState<Tab>('krw');
|
||
const [search, setSearch] = useState('');
|
||
const [favorites, setFavs] = useState<string[]>(getFavorites);
|
||
const [holdings, setHolds] = useState<string[]>(getHoldings);
|
||
const [holdSearch, setHoldSrch] = useState('');
|
||
const [showHoldAdd, setShowHoldAdd] = useState(false);
|
||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||
const searchRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 패널 열릴 때 검색창 포커스
|
||
useEffect(() => { if (open) setTimeout(() => searchRef.current?.focus(), 250); }, [open]);
|
||
|
||
// 검색 초기화 (탭 전환 시)
|
||
const handleTab = useCallback((t: Tab) => { setTab(t); setSearch(''); }, []);
|
||
|
||
// 컬럼 정렬 토글 (시세 기반 정렬 전 전 종목 REST 재조회)
|
||
const applySortState = useCallback((key: SortKey) => {
|
||
setSortKey(prev => {
|
||
if (prev === key) {
|
||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||
return key;
|
||
}
|
||
setSortDir('desc');
|
||
return key;
|
||
});
|
||
}, []);
|
||
|
||
const handleSort = useCallback((key: SortKey) => {
|
||
const needsRefresh = key === 'change' || key === 'price';
|
||
if (needsRefresh && refreshAllTickers) {
|
||
void refreshAllTickers().then(() => applySortState(key));
|
||
return;
|
||
}
|
||
applySortState(key);
|
||
}, [refreshAllTickers, applySortState]);
|
||
|
||
// 즐겨찾기 토글
|
||
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 koreanNameByMarket = useMemo(() => {
|
||
const map = new Map<string, string>();
|
||
marketInfos.forEach(m => map.set(m.market, m.koreanName));
|
||
return map;
|
||
}, [marketInfos]);
|
||
|
||
const resolveKoreanName = useCallback((market: string) =>
|
||
tickers.get(market)?.koreanName
|
||
?? koreanNameByMarket.get(market)
|
||
?? market.replace(/^KRW-/, ''),
|
||
[tickers, koreanNameByMarket]);
|
||
|
||
// 탭별 마켓 목록 — market/all 전 종목 (종목검색 드롭다운과 동일)
|
||
const allKrwMarkets = useMemo(() => {
|
||
const keys = marketInfos.length > 0
|
||
? marketInfos.map(m => m.market)
|
||
: Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
|
||
return keys.sort((a, b) =>
|
||
(tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0)
|
||
);
|
||
}, [marketInfos, 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 = resolveKoreanName(m);
|
||
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 = resolveKoreanName(a);
|
||
const nb = resolveKoreanName(b);
|
||
return mul * na.localeCompare(nb, 'ko');
|
||
}
|
||
case 'price':
|
||
return compareNullableNum(ta?.tradePrice, tb?.tradePrice, mul);
|
||
case 'change':
|
||
return compareNullableNum(ta?.changeRate, tb?.changeRate, mul);
|
||
default:
|
||
return 0;
|
||
}
|
||
});
|
||
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir, resolveKoreanName]);
|
||
|
||
// 보유 탭 "추가" 검색 결과
|
||
const holdAddResults = useMemo(() => {
|
||
if (!holdSearch) return [];
|
||
const q = holdSearch.toUpperCase();
|
||
return allKrwMarkets
|
||
.filter(m => {
|
||
const code = m.replace(/^KRW-/, '');
|
||
const name = resolveKoreanName(m);
|
||
return code.includes(q) || name.includes(holdSearch);
|
||
})
|
||
.slice(0, 10);
|
||
}, [holdSearch, allKrwMarkets, resolveKoreanName]);
|
||
|
||
const tabLabels: Record<Tab, string> = {
|
||
krw: '원화', btc: 'BTC', hold: '보유', fav: '관심',
|
||
};
|
||
|
||
return (
|
||
<div className={`market-panel ${open ? 'market-panel--open' : ''}`}>
|
||
{/* ── 패널 내용 ─────────────────────────────────────────────── */}
|
||
<div className="mp-body mp-body--full">
|
||
{/* 탭 헤더 */}
|
||
<div className="mp-tabs">
|
||
{(['krw', 'btc', 'hold', 'fav'] as Tab[]).map(t => (
|
||
<button
|
||
key={t}
|
||
className={`mp-tab ${tab === t ? 'mp-tab--active' : ''}`}
|
||
onClick={() => handleTab(t)}
|
||
>
|
||
{tabLabels[t]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 검색 */}
|
||
<div className="mp-search-row">
|
||
<div className="mp-search-box">
|
||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" opacity={0.5}>
|
||
<circle cx="5.5" cy="5.5" r="4"/><line x1="9" y1="9" x2="12.5" y2="12.5"/>
|
||
</svg>
|
||
<input
|
||
ref={searchRef}
|
||
className="mp-search-input"
|
||
type="text"
|
||
placeholder="코인명/심볼검색"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
/>
|
||
{search && (
|
||
<button className="mp-search-clear" onClick={() => setSearch('')}>✕</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 컬럼 헤더 */}
|
||
<div className="mp-list-header">
|
||
{(['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 (
|
||
<button
|
||
key={key}
|
||
className={`${cls} mp-h-sort-btn ${active ? 'mp-h-sort-btn--active' : ''}`}
|
||
onClick={() => handleSort(key)}
|
||
title={active ? (sortDir === 'asc' ? '내림차순 정렬' : '오름차순 정렬') : `${label} 정렬`}
|
||
>
|
||
{label}
|
||
<span className="mp-sort-icon">
|
||
{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 보유 탭: 추가 버튼 */}
|
||
{tab === 'hold' && (
|
||
<div className="mp-hold-add-area">
|
||
{showHoldAdd ? (
|
||
<div className="mp-hold-add-form">
|
||
<input
|
||
className="mp-hold-add-input"
|
||
placeholder="종목 검색..."
|
||
value={holdSearch}
|
||
onChange={e => setHoldSrch(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
<button className="mp-hold-add-cancel" onClick={() => { setShowHoldAdd(false); setHoldSrch(''); }}>취소</button>
|
||
{holdAddResults.length > 0 && (
|
||
<div className="mp-hold-add-dropdown">
|
||
{holdAddResults.map(m => (
|
||
<div key={m} className="mp-hold-add-item" onClick={() => handleAddHolding(m)}>
|
||
<span>{resolveKoreanName(m)}</span>
|
||
<span className="mp-hold-add-code">{m.replace(/^KRW-/, '')}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<button className="mp-hold-add-btn" onClick={() => setShowHoldAdd(true)}>
|
||
+ 보유 종목 추가
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 마켓 목록 */}
|
||
<div className="mp-list">
|
||
{loading && (
|
||
<div className="mp-loading">
|
||
<div className="loading-spinner" style={{ width: 18, height: 18 }} />
|
||
<span>마켓 정보 로딩 중...</span>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && filtered.length === 0 && (
|
||
<div className="mp-empty">
|
||
{tab === 'hold' ? '보유 종목이 없습니다' :
|
||
tab === 'fav' ? '관심 종목이 없습니다' :
|
||
'검색 결과 없음'}
|
||
</div>
|
||
)}
|
||
|
||
{!loading && filtered.map(market => (
|
||
<MarketRow
|
||
key={market}
|
||
market={market}
|
||
koreanName={resolveKoreanName(market)}
|
||
ticker={tickers.get(market)}
|
||
isActive={market === activeSymbol}
|
||
isFav={favorites.includes(market)}
|
||
isHold={holdings.includes(market)}
|
||
showUsd={tab === 'btc'}
|
||
showHoldBtn={tab === 'krw' || tab === 'btc' || tab === 'fav'}
|
||
usdRate={usdRate}
|
||
onSelect={() => onSymbolSelect(market)}
|
||
onToggleFav={e => handleToggleFav(market, e)}
|
||
onToggleHold={e => handleToggleHold(market, e)}
|
||
onRemoveHold={tab === 'hold' ? e => handleRemoveHold(market, e) : undefined}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MarketPanel;
|