MobileCoinList.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. 'use client';
  2. import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
  3. import { useCryptoContext } from '@/contexts/cryptoProvider';
  4. import useTickers from '@/hooks/useTickers';
  5. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  6. import { faCaretUp, faCaretDown } from '@fortawesome/free-solid-svg-icons';
  7. import { formatPrice, formatChangeRate, getChangeClass, formatVolumeMillions } from '@/lib/utils/crypto';
  8. import type { TickerRestData } from '@/types/crypto';
  9. import './mobile-coin-list.scss';
  10. const QUOTE_TABS = ['KRW', 'BTC', 'USDT'] as const;
  11. type SortKey = 'name' | 'price' | 'change' | 'volume';
  12. type SortDir = 'asc' | 'desc';
  13. type NameMode = 'kor' | 'eng';
  14. type Props = {
  15. initialTickers?: TickerRestData[];
  16. onSelectCoin: (market: string) => void;
  17. };
  18. export default function MobileCoinList({ initialTickers, onSelectCoin }: Props) {
  19. const { selectedMarket, setSelectedMarket, quoteMarket, setQuoteMarket, setTickers: setContextTickers, setTickerMeta: setContextMeta } = useCryptoContext();
  20. const { tickers, meta } = useTickers(quoteMarket, quoteMarket === 'KRW' ? initialTickers : undefined);
  21. const [search, setSearch] = useState('');
  22. const [sortKey, setSortKey] = useState<SortKey | null>(null);
  23. const [sortDir, setSortDir] = useState<SortDir>('desc');
  24. const [nameMode, setNameMode] = useState<NameMode>('kor');
  25. const prevPricesRef = useRef<Map<string, number>>(new Map());
  26. const handleSort = useCallback((key: SortKey) => {
  27. if (key === 'name' && sortKey === 'name') {
  28. setNameMode((prev) => prev === 'kor' ? 'eng' : 'kor');
  29. } else if (sortKey === key) {
  30. setSortDir((prev) => prev === 'desc' ? 'asc' : 'desc');
  31. } else {
  32. setSortKey(key);
  33. setSortDir(key === 'name' ? 'asc' : 'desc');
  34. }
  35. }, [sortKey]);
  36. useEffect(() => {
  37. setContextTickers(tickers);
  38. }, [tickers, setContextTickers]);
  39. useEffect(() => {
  40. setContextMeta(meta);
  41. }, [meta, setContextMeta]);
  42. const sortedTickers = useMemo(() => {
  43. const arr = Array.from(tickers.values());
  44. const keyword = search.toLowerCase().trim();
  45. const filtered = keyword
  46. ? arr.filter((t) => {
  47. const m = meta.get(t.market);
  48. return t.symbol.toLowerCase().includes(keyword) ||
  49. t.market.toLowerCase().includes(keyword) ||
  50. (m?.korName && m.korName.includes(keyword));
  51. })
  52. : arr;
  53. if (!sortKey) {
  54. return filtered.sort((a, b) => b.accTradePrice24h - a.accTradePrice24h);
  55. }
  56. const dir = sortDir === 'asc' ? 1 : -1;
  57. return filtered.sort((a, b) => {
  58. if (sortKey === 'name') {
  59. const aName = nameMode === 'kor' ? (meta.get(a.market)?.korName ?? a.symbol) : a.symbol;
  60. const bName = nameMode === 'kor' ? (meta.get(b.market)?.korName ?? b.symbol) : b.symbol;
  61. return dir * aName.localeCompare(bName, nameMode === 'kor' ? 'ko' : 'en');
  62. }
  63. if (sortKey === 'price') {
  64. return dir * (a.tradePrice - b.tradePrice);
  65. }
  66. if (sortKey === 'change') {
  67. return dir * (a.signedChangeRate - b.signedChangeRate);
  68. }
  69. return dir * (a.accTradePrice24h - b.accTradePrice24h);
  70. });
  71. }, [tickers, search, meta, sortKey, sortDir, nameMode]);
  72. // 시세 변동 보더 플래시 애니메이션
  73. useEffect(() => {
  74. for (const [market, ticker] of tickers) {
  75. const prev = prevPricesRef.current.get(market);
  76. if (prev !== undefined && prev !== ticker.tradePrice) {
  77. const direction = ticker.tradePrice > prev ? 'up' : 'down';
  78. const el = document.querySelector(`[data-mcl-market="${market}"]`) as HTMLElement | null;
  79. if (el) {
  80. el.classList.remove('flash-up', 'flash-down');
  81. void el.offsetWidth;
  82. el.classList.add(`flash-${direction}`);
  83. }
  84. }
  85. prevPricesRef.current.set(market, ticker.tradePrice);
  86. }
  87. }, [tickers]);
  88. const handleCoinClick = useCallback((market: string) => {
  89. setSelectedMarket(market);
  90. onSelectCoin(market);
  91. }, [setSelectedMarket, onSelectCoin]);
  92. return (
  93. <div className='mobile-coin-list'>
  94. <div className='mcl-tabs'>
  95. {QUOTE_TABS.map((tab) => (
  96. <button
  97. key={tab}
  98. type='button'
  99. className={`tab ${quoteMarket === tab ? 'active' : ''}`}
  100. onClick={() => setQuoteMarket(tab)}
  101. >
  102. {tab}
  103. </button>
  104. ))}
  105. </div>
  106. <div className='mcl-search'>
  107. <input
  108. type='text'
  109. placeholder='코인명/심볼 검색'
  110. value={search}
  111. onChange={(e) => setSearch(e.target.value)}
  112. />
  113. </div>
  114. <div className='mcl-sort'>
  115. <button
  116. type='button'
  117. className={`sort-name ${sortKey === 'name' ? 'active' : ''}`}
  118. onClick={() => handleSort('name')}
  119. >
  120. {nameMode === 'kor' ? '한글명' : '영문명'}
  121. </button>
  122. <button
  123. type='button'
  124. className={`sort-price ${sortKey === 'price' ? 'active' : ''}`}
  125. onClick={() => handleSort('price')}
  126. >
  127. 현재가
  128. <FontAwesomeIcon icon={sortKey === 'price' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
  129. </button>
  130. <button
  131. type='button'
  132. className={`sort-change ${sortKey === 'change' ? 'active' : ''}`}
  133. onClick={() => handleSort('change')}
  134. >
  135. 전일대비
  136. <FontAwesomeIcon icon={sortKey === 'change' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
  137. </button>
  138. <button
  139. type='button'
  140. className={`sort-volume ${sortKey === 'volume' ? 'active' : ''}`}
  141. onClick={() => handleSort('volume')}
  142. >
  143. 거래대금
  144. <FontAwesomeIcon icon={sortKey === 'volume' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
  145. </button>
  146. </div>
  147. <div className='mcl-list'>
  148. {sortedTickers.map((ticker) => {
  149. const tickerMeta = meta.get(ticker.market);
  150. return (
  151. <button
  152. key={ticker.market}
  153. type='button'
  154. className={`mcl-item ${selectedMarket === ticker.market ? 'active' : ''}`}
  155. data-mcl-market={ticker.market}
  156. onClick={() => handleCoinClick(ticker.market)}
  157. >
  158. <div className='col-name'>
  159. <span className='name'>{nameMode === 'kor' ? (tickerMeta?.korName ?? ticker.symbol) : ticker.symbol}</span>
  160. <span className='symbol'>{ticker.symbol}/{quoteMarket}</span>
  161. </div>
  162. <div className={`col-price ${getChangeClass(ticker.change)}`}>
  163. {formatPrice(ticker.tradePrice)}
  164. </div>
  165. <div className={`col-change ${getChangeClass(ticker.change)}`}>
  166. {formatChangeRate(ticker.signedChangeRate)}
  167. </div>
  168. <div className='col-volume'>
  169. {formatVolumeMillions(ticker.accTradePrice24h)}
  170. </div>
  171. </button>
  172. );
  173. })}
  174. {sortedTickers.length === 0 && (
  175. <div className='mcl-empty'>검색 결과가 없습니다.</div>
  176. )}
  177. </div>
  178. </div>
  179. );
  180. }