CryptoSidebar.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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 } from '@/lib/utils/crypto';
  8. import type { TickerRestData } from '@/types/crypto';
  9. import './sidebar.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. };
  17. export default function CryptoSidebar({ initialTickers }: Props) {
  18. const { selectedMarket, setSelectedMarket, quoteMarket, setQuoteMarket, setTickers: setContextTickers, setTickerMeta: setContextMeta } = useCryptoContext();
  19. const { tickers, meta } = useTickers(quoteMarket, quoteMarket === 'KRW' ? initialTickers : undefined);
  20. const [search, setSearch] = useState('');
  21. const [sortKey, setSortKey] = useState<SortKey | null>(null);
  22. const [sortDir, setSortDir] = useState<SortDir>('desc');
  23. const [nameMode, setNameMode] = useState<NameMode>('kor');
  24. const prevPricesRef = useRef<Map<string, number>>(new Map());
  25. const handleSort = useCallback((key: SortKey) => {
  26. if (key === 'name' && sortKey === 'name') {
  27. setNameMode((prev) => prev === 'kor' ? 'eng' : 'kor');
  28. } else if (sortKey === key) {
  29. setSortDir((prev) => prev === 'desc' ? 'asc' : 'desc');
  30. } else {
  31. setSortKey(key);
  32. setSortDir(key === 'name' ? 'asc' : 'desc');
  33. }
  34. }, [sortKey]);
  35. // tickers를 context에 공유
  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-market="${market}"]`) as HTMLElement | null;
  79. if (el) {
  80. el.classList.remove('flash-up', 'flash-down');
  81. void el.offsetWidth; // force reflow
  82. el.classList.add(`flash-${direction}`);
  83. }
  84. }
  85. prevPricesRef.current.set(market, ticker.tradePrice);
  86. }
  87. }, [tickers]);
  88. return (
  89. <div className='crypto-sidebar'>
  90. <div className='sidebar-tabs'>
  91. {QUOTE_TABS.map((tab) => (
  92. <button
  93. key={tab}
  94. type='button'
  95. className={`tab ${quoteMarket === tab ? 'active' : ''}`}
  96. onClick={() => setQuoteMarket(tab)}
  97. >
  98. {tab}
  99. </button>
  100. ))}
  101. </div>
  102. <div className='sidebar-search'>
  103. <input
  104. type='text'
  105. placeholder='코인 검색...'
  106. value={search}
  107. onChange={(e) => setSearch(e.target.value)}
  108. />
  109. </div>
  110. <div className='sidebar-sort'>
  111. <button
  112. type='button'
  113. className={sortKey === 'name' ? 'active' : ''}
  114. onClick={() => handleSort('name')}
  115. >
  116. {nameMode === 'kor' ? '한글명' : '영문명'}
  117. </button>
  118. <button
  119. type='button'
  120. className={sortKey === 'price' ? 'active' : ''}
  121. onClick={() => handleSort('price')}
  122. >
  123. 현재가
  124. <FontAwesomeIcon icon={sortKey === 'price' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
  125. </button>
  126. <button
  127. type='button'
  128. className={sortKey === 'change' ? 'active' : ''}
  129. onClick={() => handleSort('change')}
  130. >
  131. 전일대비
  132. <FontAwesomeIcon icon={sortKey === 'change' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
  133. </button>
  134. <button
  135. type='button'
  136. className={sortKey === 'volume' ? 'active' : ''}
  137. onClick={() => handleSort('volume')}
  138. >
  139. 거래대금
  140. <FontAwesomeIcon icon={sortKey === 'volume' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
  141. </button>
  142. </div>
  143. <div className='sidebar-list'>
  144. {sortedTickers.map((ticker) => {
  145. const tickerMeta = meta.get(ticker.market);
  146. return (
  147. <button
  148. key={ticker.market}
  149. type='button'
  150. className={`sidebar-item ${selectedMarket === ticker.market ? 'active' : ''}`}
  151. data-market={ticker.market}
  152. onClick={() => setSelectedMarket(ticker.market)}
  153. >
  154. <div className='item-info'>
  155. <span className='item-name'>{nameMode === 'kor' ? (tickerMeta?.korName ?? ticker.symbol) : ticker.symbol}</span>
  156. <span className='item-symbol'>{nameMode === 'kor' ? ticker.symbol : (tickerMeta?.korName ?? '')}</span>
  157. </div>
  158. <div className='item-price'>
  159. <span className={`price ${getChangeClass(ticker.change)}`}>
  160. {formatPrice(ticker.tradePrice)}
  161. </span>
  162. <span className={`change ${getChangeClass(ticker.change)}`}>
  163. {formatChangeRate(ticker.signedChangeRate)}
  164. </span>
  165. </div>
  166. </button>
  167. );
  168. })}
  169. {sortedTickers.length === 0 && (
  170. <div className='sidebar-empty'>검색 결과가 없습니다.</div>
  171. )}
  172. </div>
  173. </div>
  174. );
  175. }