CryptoSidebar.tsx 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. 'use client';
  2. import { useState, useMemo, useEffect, useRef } from 'react';
  3. import { useCryptoContext } from '@/contexts/cryptoProvider';
  4. import useTickers from '@/hooks/useTickers';
  5. import type { TickerRestData } from '@/types/crypto';
  6. import './sidebar.scss';
  7. const QUOTE_TABS = ['KRW', 'BTC', 'USDT'] as const;
  8. type Props = {
  9. initialTickers?: TickerRestData[];
  10. };
  11. function formatPrice(price: number): string {
  12. if (price >= 1000) {
  13. return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
  14. }
  15. if (price >= 1) {
  16. return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
  17. }
  18. return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
  19. }
  20. function formatChangeRate(rate: number): string {
  21. const pct = (rate * 100).toFixed(2);
  22. return rate >= 0 ? `+${pct}%` : `${pct}%`;
  23. }
  24. function getChangeClass(change: string): string {
  25. if (change === 'RISE') return 'up';
  26. if (change === 'FALL') return 'down';
  27. return 'neutral';
  28. }
  29. export default function CryptoSidebar({ initialTickers }: Props) {
  30. const { selectedMarket, setSelectedMarket, quoteMarket, setQuoteMarket, setTickers: setContextTickers, setTickerMeta: setContextMeta } = useCryptoContext();
  31. const { tickers, meta } = useTickers(quoteMarket, quoteMarket === 'KRW' ? initialTickers : undefined);
  32. const [search, setSearch] = useState('');
  33. const prevPricesRef = useRef<Map<string, number>>(new Map());
  34. // tickers를 context에 공유
  35. useEffect(() => {
  36. setContextTickers(tickers);
  37. }, [tickers, setContextTickers]);
  38. useEffect(() => {
  39. setContextMeta(meta);
  40. }, [meta, setContextMeta]);
  41. const sortedTickers = useMemo(() => {
  42. const arr = Array.from(tickers.values());
  43. const keyword = search.toLowerCase().trim();
  44. const filtered = keyword
  45. ? arr.filter((t) => {
  46. const m = meta.get(t.market);
  47. return t.symbol.toLowerCase().includes(keyword) ||
  48. t.market.toLowerCase().includes(keyword) ||
  49. (m?.korName && m.korName.includes(keyword));
  50. })
  51. : arr;
  52. return filtered.sort((a, b) => b.accTradePrice24h - a.accTradePrice24h);
  53. }, [tickers, search, meta]);
  54. // 시세 변동 보더 플래시 애니메이션
  55. useEffect(() => {
  56. for (const [market, ticker] of tickers) {
  57. const prev = prevPricesRef.current.get(market);
  58. if (prev !== undefined && prev !== ticker.tradePrice) {
  59. const direction = ticker.tradePrice > prev ? 'up' : 'down';
  60. const el = document.querySelector(`[data-market="${market}"]`) as HTMLElement | null;
  61. if (el) {
  62. el.classList.remove('flash-up', 'flash-down');
  63. void el.offsetWidth; // force reflow
  64. el.classList.add(`flash-${direction}`);
  65. }
  66. }
  67. prevPricesRef.current.set(market, ticker.tradePrice);
  68. }
  69. }, [tickers]);
  70. return (
  71. <div className='crypto-sidebar'>
  72. <div className='sidebar-tabs'>
  73. {QUOTE_TABS.map((tab) => (
  74. <button
  75. key={tab}
  76. type='button'
  77. className={`tab ${quoteMarket === tab ? 'active' : ''}`}
  78. onClick={() => setQuoteMarket(tab)}
  79. >
  80. {tab}
  81. </button>
  82. ))}
  83. </div>
  84. <div className='sidebar-search'>
  85. <input
  86. type='text'
  87. placeholder='코인 검색...'
  88. value={search}
  89. onChange={(e) => setSearch(e.target.value)}
  90. />
  91. </div>
  92. <div className='sidebar-list'>
  93. {sortedTickers.map((ticker) => {
  94. const tickerMeta = meta.get(ticker.market);
  95. return (
  96. <button
  97. key={ticker.market}
  98. type='button'
  99. className={`sidebar-item ${selectedMarket === ticker.market ? 'active' : ''}`}
  100. data-market={ticker.market}
  101. onClick={() => setSelectedMarket(ticker.market)}
  102. >
  103. <div className='item-info'>
  104. <span className='item-kor-name'>{tickerMeta?.korName ?? ticker.symbol}</span>
  105. <span className='item-symbol'>{ticker.symbol}</span>
  106. </div>
  107. <div className='item-price'>
  108. <span className={`price ${getChangeClass(ticker.change)}`}>
  109. {formatPrice(ticker.tradePrice)}
  110. </span>
  111. <span className={`change ${getChangeClass(ticker.change)}`}>
  112. {formatChangeRate(ticker.signedChangeRate)}
  113. </span>
  114. </div>
  115. </button>
  116. );
  117. })}
  118. {sortedTickers.length === 0 && (
  119. <div className='sidebar-empty'>검색 결과가 없습니다.</div>
  120. )}
  121. </div>
  122. </div>
  123. );
  124. }