CryptoSidebar.tsx 6.8 KB

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