| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- 'use client';
- import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
- import { useCryptoContext } from '@/contexts/cryptoProvider';
- import useTickers from '@/hooks/useTickers';
- import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
- import { faCaretUp, faCaretDown } from '@fortawesome/free-solid-svg-icons';
- import { formatPrice, formatChangeRate, getChangeClass, formatVolumeMillions } from '@/lib/utils/crypto';
- import type { TickerRestData } from '@/types/crypto';
- import './mobile-coin-list.scss';
- const QUOTE_TABS = ['KRW', 'BTC', 'USDT'] as const;
- type SortKey = 'name' | 'price' | 'change' | 'volume';
- type SortDir = 'asc' | 'desc';
- type NameMode = 'kor' | 'eng';
- type Props = {
- initialTickers?: TickerRestData[];
- onSelectCoin: (market: string) => void;
- };
- export default function MobileCoinList({ initialTickers, onSelectCoin }: Props) {
- const { selectedMarket, setSelectedMarket, quoteMarket, setQuoteMarket, setTickers: setContextTickers, setTickerMeta: setContextMeta } = useCryptoContext();
- const { tickers, meta } = useTickers(quoteMarket, quoteMarket === 'KRW' ? initialTickers : undefined);
- const [search, setSearch] = useState('');
- const [sortKey, setSortKey] = useState<SortKey | null>(null);
- const [sortDir, setSortDir] = useState<SortDir>('desc');
- const [nameMode, setNameMode] = useState<NameMode>('kor');
- const prevPricesRef = useRef<Map<string, number>>(new Map());
- const handleSort = useCallback((key: SortKey) => {
- if (key === 'name' && sortKey === 'name') {
- setNameMode((prev) => prev === 'kor' ? 'eng' : 'kor');
- } else if (sortKey === key) {
- setSortDir((prev) => prev === 'desc' ? 'asc' : 'desc');
- } else {
- setSortKey(key);
- setSortDir(key === 'name' ? 'asc' : 'desc');
- }
- }, [sortKey]);
- useEffect(() => {
- setContextTickers(tickers);
- }, [tickers, setContextTickers]);
- useEffect(() => {
- setContextMeta(meta);
- }, [meta, setContextMeta]);
- const sortedTickers = useMemo(() => {
- const arr = Array.from(tickers.values());
- const keyword = search.toLowerCase().trim();
- const filtered = keyword
- ? arr.filter((t) => {
- const m = meta.get(t.market);
- return t.symbol.toLowerCase().includes(keyword) ||
- t.market.toLowerCase().includes(keyword) ||
- (m?.korName && m.korName.includes(keyword));
- })
- : arr;
- if (!sortKey) {
- return filtered.sort((a, b) => b.accTradePrice24h - a.accTradePrice24h);
- }
- const dir = sortDir === 'asc' ? 1 : -1;
- return filtered.sort((a, b) => {
- if (sortKey === 'name') {
- const aName = nameMode === 'kor' ? (meta.get(a.market)?.korName ?? a.symbol) : a.symbol;
- const bName = nameMode === 'kor' ? (meta.get(b.market)?.korName ?? b.symbol) : b.symbol;
- return dir * aName.localeCompare(bName, nameMode === 'kor' ? 'ko' : 'en');
- }
- if (sortKey === 'price') {
- return dir * (a.tradePrice - b.tradePrice);
- }
- if (sortKey === 'change') {
- return dir * (a.signedChangeRate - b.signedChangeRate);
- }
- return dir * (a.accTradePrice24h - b.accTradePrice24h);
- });
- }, [tickers, search, meta, sortKey, sortDir, nameMode]);
- // 시세 변동 보더 플래시 애니메이션
- useEffect(() => {
- for (const [market, ticker] of tickers) {
- const prev = prevPricesRef.current.get(market);
- if (prev !== undefined && prev !== ticker.tradePrice) {
- const direction = ticker.tradePrice > prev ? 'up' : 'down';
- const el = document.querySelector(`[data-mcl-market="${market}"]`) as HTMLElement | null;
- if (el) {
- el.classList.remove('flash-up', 'flash-down');
- void el.offsetWidth;
- el.classList.add(`flash-${direction}`);
- }
- }
- prevPricesRef.current.set(market, ticker.tradePrice);
- }
- }, [tickers]);
- const handleCoinClick = useCallback((market: string) => {
- setSelectedMarket(market);
- onSelectCoin(market);
- }, [setSelectedMarket, onSelectCoin]);
- return (
- <div className='mobile-coin-list'>
- <div className='mcl-tabs'>
- {QUOTE_TABS.map((tab) => (
- <button
- key={tab}
- type='button'
- className={`tab ${quoteMarket === tab ? 'active' : ''}`}
- onClick={() => setQuoteMarket(tab)}
- >
- {tab}
- </button>
- ))}
- </div>
- <div className='mcl-search'>
- <input
- type='text'
- placeholder='코인명/심볼 검색'
- value={search}
- onChange={(e) => setSearch(e.target.value)}
- />
- </div>
- <div className='mcl-sort'>
- <button
- type='button'
- className={`sort-name ${sortKey === 'name' ? 'active' : ''}`}
- onClick={() => handleSort('name')}
- >
- {nameMode === 'kor' ? '한글명' : '영문명'}
- </button>
- <button
- type='button'
- className={`sort-price ${sortKey === 'price' ? 'active' : ''}`}
- onClick={() => handleSort('price')}
- >
- 현재가
- <FontAwesomeIcon icon={sortKey === 'price' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
- </button>
- <button
- type='button'
- className={`sort-change ${sortKey === 'change' ? 'active' : ''}`}
- onClick={() => handleSort('change')}
- >
- 전일대비
- <FontAwesomeIcon icon={sortKey === 'change' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
- </button>
- <button
- type='button'
- className={`sort-volume ${sortKey === 'volume' ? 'active' : ''}`}
- onClick={() => handleSort('volume')}
- >
- 거래대금
- <FontAwesomeIcon icon={sortKey === 'volume' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
- </button>
- </div>
- <div className='mcl-list'>
- {sortedTickers.map((ticker) => {
- const tickerMeta = meta.get(ticker.market);
- return (
- <button
- key={ticker.market}
- type='button'
- className={`mcl-item ${selectedMarket === ticker.market ? 'active' : ''}`}
- data-mcl-market={ticker.market}
- onClick={() => handleCoinClick(ticker.market)}
- >
- <div className='col-name'>
- <span className='name'>{nameMode === 'kor' ? (tickerMeta?.korName ?? ticker.symbol) : ticker.symbol}</span>
- <span className='symbol'>{ticker.symbol}/{quoteMarket}</span>
- </div>
- <div className={`col-price ${getChangeClass(ticker.change)}`}>
- {formatPrice(ticker.tradePrice)}
- </div>
- <div className={`col-change ${getChangeClass(ticker.change)}`}>
- {formatChangeRate(ticker.signedChangeRate)}
- </div>
- <div className='col-volume'>
- {formatVolumeMillions(ticker.accTradePrice24h)}
- </div>
- </button>
- );
- })}
- {sortedTickers.length === 0 && (
- <div className='mcl-empty'>검색 결과가 없습니다.</div>
- )}
- </div>
- </div>
- );
- }
|