539 lines
21 KiB
TypeScript
539 lines
21 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef, useMemo } 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';
|
|
import { VirtualScroll } from './common/VirtualScroll';
|
|
|
|
// ─── 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;
|
|
}
|
|
|
|
type SortKey = 'name' | 'price' | 'change' | 'volume';
|
|
type SortDir = 'asc' | 'desc';
|
|
|
|
// ─── 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<string>;
|
|
/** 투자대상 최대 개수에 도달 — 미등록 종목 추가 불가 */
|
|
maxTargetsReached?: boolean;
|
|
}
|
|
|
|
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|
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<MarketInfo[]>([]);
|
|
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
|
|
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<Set<string>>(() => new Set(getFavorites()));
|
|
const [loading, setLoading] = useState(true);
|
|
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
|
|
|
const refreshFavs = useCallback(() => {
|
|
setFavs(new Set(getFavorites()));
|
|
}, []);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const tickerTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const marketCodesRef = useRef<string>('');
|
|
|
|
// ── 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<string, string> = {};
|
|
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<string, TickerInfo> = { ...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]);
|
|
|
|
const handleSort = useCallback((key: SortKey) => {
|
|
setSortKey(prev => {
|
|
if (prev === key) {
|
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
return key;
|
|
}
|
|
setSortDir('desc');
|
|
return key;
|
|
});
|
|
}, []);
|
|
|
|
const renderSortHeader = (key: SortKey, label: string, className: string) => {
|
|
const active = sortKey === key;
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`msp-col ${className} msp-h-sort-btn ${active ? 'msp-h-sort-btn--active' : ''}`}
|
|
onClick={() => handleSort(key)}
|
|
title={active ? (sortDir === 'asc' ? '내림차순 정렬' : '오름차순 정렬') : `${label} 정렬`}
|
|
>
|
|
{label}
|
|
<span className="msp-sort-icon">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// ── 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 = useMemo(() => {
|
|
if (!sortKey) {
|
|
return [...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;
|
|
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
|
|
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
|
|
return vb - va;
|
|
});
|
|
}
|
|
const mul = sortDir === 'asc' ? 1 : -1;
|
|
return [...filtered].sort((a, b) => {
|
|
switch (sortKey) {
|
|
case 'name':
|
|
return mul * a.korean_name.localeCompare(b.korean_name, 'ko');
|
|
case 'price': {
|
|
const pa = tickers[a.market]?.trade_price ?? 0;
|
|
const pb = tickers[b.market]?.trade_price ?? 0;
|
|
return mul * (pa - pb);
|
|
}
|
|
case 'change': {
|
|
const ca = tickers[a.market]?.signed_change_rate ?? 0;
|
|
const cb = tickers[b.market]?.signed_change_rate ?? 0;
|
|
return mul * (ca - cb);
|
|
}
|
|
case 'volume': {
|
|
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
|
|
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
|
|
return mul * (va - vb);
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
}, [filtered, sortKey, sortDir, favs, tickers]);
|
|
|
|
// ── 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 && (
|
|
<div
|
|
className="msp-backdrop"
|
|
style={backdropStyle}
|
|
onClick={onClose}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
className={[
|
|
'msp-panel',
|
|
anchorRect && anchorPlacement === 'dropdown' ? 'msp-panel--dropdown' : '',
|
|
anchorRect && anchorPlacement === 'slot' ? 'msp-panel--slot' : '',
|
|
embedded ? 'msp-panel--embedded' : '',
|
|
].filter(Boolean).join(' ')}
|
|
style={panelStyle}
|
|
>
|
|
|
|
{!embedded && (
|
|
<div className="msp-search-row">
|
|
<span className="msp-search-icon">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
</span>
|
|
<input
|
|
ref={inputRef}
|
|
className="msp-input"
|
|
placeholder="종목명 또는 심볼 검색"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Escape') onClose();
|
|
if (e.key === 'Enter' && sorted.length > 0) handleRowSelect(sorted[0].market);
|
|
}}
|
|
/>
|
|
{query && (
|
|
<button className="msp-btn-icon" title="초기화" onClick={() => setQuery('')}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<button className="msp-btn-icon" title="닫기" onClick={onClose}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<polyline points="18 15 12 9 6 15"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}${isCompactDropdown ? ' msp-header--compact' : ''}`}>
|
|
{!isCompactDropdown && (
|
|
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
|
|
)}
|
|
{renderSortHeader('name', isCompactDropdown ? '종목명' : '한글명', 'msp-col-name')}
|
|
{!embedded && (
|
|
<>
|
|
{renderSortHeader('price', '현재가', 'msp-col-price')}
|
|
{renderSortHeader('change', '전일대비', 'msp-col-change')}
|
|
{!isCompactDropdown && renderSortHeader('volume', '거래대금', 'msp-col-vol')}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 목록 — 가상 스크롤 (업비트 전 종목) */}
|
|
{loading ? (
|
|
<div className="msp-list">
|
|
<div className="msp-empty">마켓 목록 로딩 중...</div>
|
|
</div>
|
|
) : (
|
|
<VirtualScroll
|
|
className="msp-list"
|
|
items={sorted}
|
|
estimateSize={embedded ? 34 : isCompactDropdown ? 36 : 38}
|
|
measureDynamic
|
|
getItemKey={m => m.market}
|
|
empty={<div className="msp-empty">검색 결과가 없습니다</div>}
|
|
aria-label="종목 검색 결과"
|
|
>
|
|
{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 (
|
|
<div
|
|
className={[
|
|
'msp-item',
|
|
embedded ? 'msp-item--embedded' : '',
|
|
isCompactDropdown ? 'msp-item--compact' : '',
|
|
isActive ? 'active' : '',
|
|
].filter(Boolean).join(' ')}
|
|
onClick={() => handleRowSelect(m.market)}
|
|
>
|
|
{!isCompactDropdown && (
|
|
actionMode === 'add' ? (
|
|
<button
|
|
type="button"
|
|
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`}
|
|
onClick={e => handleAddClick(m.market, e)}
|
|
disabled={addBlocked}
|
|
title={
|
|
isAdded
|
|
? '투자대상에서 제거'
|
|
: addBlocked
|
|
? '투자대상 최대 개수에 도달했습니다'
|
|
: '투자대상에 추가'
|
|
}
|
|
>
|
|
{isAdded ? (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
) : (
|
|
<span
|
|
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
|
onClick={e => toggleFav(m.market, e)}
|
|
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
|
>
|
|
{isFav ? '★' : '☆'}
|
|
</span>
|
|
)
|
|
)}
|
|
|
|
<span className="msp-col msp-col-name">
|
|
<span className="msp-kor">{m.korean_name}</span>
|
|
{!isCompactDropdown && <span className="msp-sym">{m.symbol}</span>}
|
|
</span>
|
|
|
|
{!embedded && (
|
|
<>
|
|
<span className="msp-col msp-col-price">
|
|
{tk && Number.isFinite(Number(tk.trade_price))
|
|
? Number(tk.trade_price).toLocaleString()
|
|
: '-'}
|
|
</span>
|
|
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
|
|
{rate === null
|
|
? '-'
|
|
: `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`}
|
|
</span>
|
|
{!isCompactDropdown && (
|
|
<span className="msp-col msp-col-vol">
|
|
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}}
|
|
</VirtualScroll>
|
|
)}
|
|
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default MarketSearchPanel;
|