page.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import './style.scss';
  2. import '@/app/styles/stock-common.scss';
  3. import { Metadata } from 'next';
  4. import Link from 'next/link';
  5. import { fetchStockList } from '@/lib/api/stock';
  6. import { StockMarketName } from '@/types/stock';
  7. import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
  8. import Pager from './_components/Pager';
  9. export const metadata: Metadata = {
  10. title: '종목 — 개미투자',
  11. description: '국내 상장 전 종목 목록 — 전일 종가·등락률·시가총액 (KOSPI/KOSDAQ/KONEX)'
  12. };
  13. const MARKET_TABS: { value: ''|StockMarketName; label: string }[] = [
  14. { value: '', label: '전체' },
  15. { value: 'KOSPI', label: 'KOSPI' },
  16. { value: 'KOSDAQ', label: 'KOSDAQ' },
  17. { value: 'KONEX', label: 'KONEX' }
  18. ];
  19. const PER_PAGE = 30;
  20. type Props = {
  21. searchParams: Promise<{
  22. market?: string;
  23. q?: string;
  24. page?: string;
  25. }>;
  26. };
  27. export default async function StockListPage({ searchParams }: Props)
  28. {
  29. const query = await searchParams;
  30. const market = MARKET_TABS.some(c => c.value === query.market) ? (query.market as ''|StockMarketName) : '';
  31. const q = (query.q ?? '').trim();
  32. const page = Math.max(Number(query.page) || 1, 1);
  33. // 백엔드 미기동 시에도 페이지 자체는 렌더 — 테이블 영역만 에러 표시 (request-time fetch)
  34. const res = await fetchStockList({ q, market, page, perPage: PER_PAGE });
  35. const data = res.success ? res.data : null;
  36. const buildHref = (nextMarket: ''|StockMarketName) => {
  37. const params = new URLSearchParams();
  38. if (nextMarket) {
  39. params.set('market', nextMarket);
  40. }
  41. if (q) {
  42. params.set('q', q);
  43. }
  44. const qs = params.toString();
  45. return qs ? `/stock?${qs}` : '/stock';
  46. };
  47. return (
  48. <div id='stock'>
  49. <div className='stock__head'>
  50. <h1>종목</h1>
  51. <span className='stock__basis'>전일 종가 기준 (T+1)</span>
  52. </div>
  53. <div className='stock__controls'>
  54. {/* 시장 탭 */}
  55. <nav className='stock__tabs' aria-label='시장 구분'>
  56. {MARKET_TABS.map(tab => (
  57. <Link
  58. key={tab.label}
  59. href={buildHref(tab.value)}
  60. {...(market === tab.value ? { 'aria-current': 'page' as const } : {})}
  61. >
  62. {tab.label}
  63. </Link>
  64. ))}
  65. </nav>
  66. {/* 종목명/코드 검색 (GET — SSR 재조회) */}
  67. <form className='stock__search' action='/stock' method='get' role='search'>
  68. {market && <input type='hidden' name='market' value={market} />}
  69. <input
  70. type='search'
  71. name='q'
  72. defaultValue={q}
  73. placeholder='종목명 또는 코드'
  74. aria-label='종목 검색'
  75. autoComplete='off'
  76. />
  77. <button type='submit'>검색</button>
  78. </form>
  79. </div>
  80. {!res.success ? (
  81. <div className='stock__error' role='alert'>
  82. <strong>종목 목록을 불러오지 못했습니다.</strong>
  83. 잠시 후 다시 시도해 주세요.
  84. </div>
  85. ) : !data || data.list.length === 0 ? (
  86. <div className='stock__empty'>
  87. {q ? `'${q}' 검색 결과가 없습니다.` : '표시할 종목이 없습니다.'}
  88. </div>
  89. ) : (
  90. <>
  91. <p className='stock__total'>전체 {formatNumber(data.total)}종목</p>
  92. <table className='stock__table'>
  93. <caption>종목 목록 — 종목명, 현재가(전일 종가), 등락률, 시가총액, 시장</caption>
  94. <thead>
  95. <tr>
  96. <th scope='col'>종목명</th>
  97. <th scope='col'>현재가 <small>(전일종가)</small></th>
  98. <th scope='col'>등락률</th>
  99. <th scope='col' className='stock__col--cap'>시가총액</th>
  100. <th scope='col' className='stock__col--market'>시장</th>
  101. </tr>
  102. </thead>
  103. <tbody>
  104. {data.list.map(row => {
  105. const direction = changeDirection(row.changeRate);
  106. const statusLabel = tradingStatusLabel(row.tradingStatus);
  107. return (
  108. <tr key={row.code}>
  109. <td className='stock__name'>
  110. <Link href={`/stock/${row.code}`}>
  111. <span className='stock__name-text'>{row.name}</span>
  112. <span className='stock__code'>{row.code}</span>
  113. {statusLabel && (
  114. <span className={`stock-badge stock-badge--${row.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
  115. )}
  116. </Link>
  117. </td>
  118. <td>{formatNumber(row.closePrice)}</td>
  119. <td className={`stock-change stock-change--${direction}`}>
  120. {changeSymbol(direction)} {formatChangeRate(row.changeRate)}
  121. </td>
  122. <td className='stock__col--cap'>{formatKrwCompact(row.marketCap)}</td>
  123. <td className='stock__col--market'>
  124. <span className={`stock-badge stock-badge--${row.market.toLowerCase()}`}>{row.market}</span>
  125. </td>
  126. </tr>
  127. );
  128. })}
  129. </tbody>
  130. </table>
  131. <Pager
  132. total={data.total}
  133. page={page}
  134. perPage={PER_PAGE}
  135. basePath='/stock'
  136. query={{ ...(market ? { market } : {}), ...(q ? { q } : {}) }}
  137. />
  138. </>
  139. )}
  140. </div>
  141. );
  142. }