goldenChat base source add
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 좌측 마켓 패널
|
||||
*
|
||||
* 탭 구성:
|
||||
* 원화 - 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';
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────
|
||||
type Tab = 'krw' | 'btc' | 'hold' | 'fav';
|
||||
type SortKey = 'name' | 'price' | 'change';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
export interface MarketPanelProps {
|
||||
open: boolean;
|
||||
tickers: Map<string, TickerData>;
|
||||
loading: boolean;
|
||||
usdRate: number; // KRW per 1 USD (from KRW-USDT)
|
||||
activeSymbol: string;
|
||||
onSymbolSelect: (symbol: string) => void;
|
||||
}
|
||||
|
||||
// ─── 가격 포맷 헬퍼 ────────────────────────────────────────────────────────
|
||||
function fmtKrw(price: number | null | undefined): string {
|
||||
if (price == null || !isFinite(price)) return '-';
|
||||
if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
if (price >= 0.01) return price.toFixed(4);
|
||||
return price.toFixed(8);
|
||||
}
|
||||
|
||||
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 <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;
|
||||
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<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">{ticker?.koreanName ?? code}</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, loading, usdRate, activeSymbol, onSymbolSelect,
|
||||
}) => {
|
||||
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(''); }, []);
|
||||
|
||||
// 컬럼 정렬 토글
|
||||
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<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>{tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')}</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}
|
||||
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;
|
||||
Reference in New Issue
Block a user