|
@@ -0,0 +1,202 @@
|
|
|
|
|
+import './style.scss';
|
|
|
|
|
+import '@/app/styles/stock-common.scss';
|
|
|
|
|
+import { cache } from 'react';
|
|
|
|
|
+import { Metadata } from 'next';
|
|
|
|
|
+import Link from 'next/link';
|
|
|
|
|
+import { notFound } from 'next/navigation';
|
|
|
|
|
+import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
|
|
|
|
|
+import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
|
|
|
|
|
+import Pager from '../_components/Pager';
|
|
|
|
|
+
|
|
|
|
|
+const TABS = [
|
|
|
|
|
+ { value: 'price', label: '시세' },
|
|
|
|
|
+ { value: 'news', label: '뉴스' },
|
|
|
|
|
+ { value: 'disclosure', label: '공시' },
|
|
|
|
|
+ { value: 'discussion', label: '토론' }
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+const HISTORY_PER_PAGE = 30;
|
|
|
|
|
+
|
|
|
|
|
+type Props = {
|
|
|
|
|
+ params: Promise<{
|
|
|
|
|
+ code: string;
|
|
|
|
|
+ }>;
|
|
|
|
|
+ searchParams: Promise<{
|
|
|
|
|
+ tab?: string;
|
|
|
|
|
+ page?: string;
|
|
|
|
|
+ }>;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
|
|
|
|
|
+const getDetail = cache(async (code: string) => {
|
|
|
|
|
+ return await fetchStockDetail(code);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export async function generateMetadata({ params }: Props): Promise<Metadata>
|
|
|
|
|
+{
|
|
|
|
|
+ const { code } = await params;
|
|
|
|
|
+ const res = await getDetail(code);
|
|
|
|
|
+
|
|
|
|
|
+ if (!res.success || !res.data) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ title: '종목 상세 — 개미투자'
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ title: `${res.data.name}(${res.data.code}) 주가·시세 — 개미투자`,
|
|
|
|
|
+ description: `${res.data.name}(${res.data.code}) 전일 종가·등락률·거래량·일별 시세 — 전일 종가 기준(T+1)`
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default async function StockDetailPage({ params, searchParams }: Props)
|
|
|
|
|
+{
|
|
|
|
|
+ const { code } = await params;
|
|
|
|
|
+ const query = await searchParams;
|
|
|
|
|
+ const tab = TABS.some(c => c.value === query.tab) ? (query.tab as string) : 'price';
|
|
|
|
|
+ const page = Math.max(Number(query.page) || 1, 1);
|
|
|
|
|
+
|
|
|
|
|
+ const res = await getDetail(code);
|
|
|
|
|
+
|
|
|
|
|
+ // 없는 코드(형식 불일치 포함)는 404
|
|
|
|
|
+ if (!res.success && res.status === 404) {
|
|
|
|
|
+ return notFound();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 백엔드 미기동 등 그 외 실패 — 에러 UI 로 우아하게 처리
|
|
|
|
|
+ if (!res.success || !res.data) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div id='stock-detail'>
|
|
|
|
|
+ <div className='stock-detail__error' role='alert'>
|
|
|
|
|
+ <strong>종목 정보를 불러오지 못했습니다.</strong>
|
|
|
|
|
+ 잠시 후 다시 시도해 주세요.
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const detail = res.data;
|
|
|
|
|
+ const latest = detail.recentPrices.length > 0 ? detail.recentPrices[0] : null;
|
|
|
|
|
+ const direction = changeDirection(detail.changeRate);
|
|
|
|
|
+ const statusLabel = tradingStatusLabel(detail.tradingStatus);
|
|
|
|
|
+
|
|
|
|
|
+ // 시세 탭만 히스토리 조회 (request-time)
|
|
|
|
|
+ const history = tab === 'price' ? await fetchStockHistory(code, { page, perPage: HISTORY_PER_PAGE }) : null;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div id='stock-detail'>
|
|
|
|
|
+ {/* 상단 요약 — T+1 데이터임을 명시 */}
|
|
|
|
|
+ <header className='stock-detail__summary'>
|
|
|
|
|
+ <div className='stock-detail__title'>
|
|
|
|
|
+ <h1>{detail.name}</h1>
|
|
|
|
|
+ <span className='stock-detail__code'>{detail.code}</span>
|
|
|
|
|
+ <span className={`stock-badge stock-badge--${detail.market.toLowerCase()}`}>{detail.market}</span>
|
|
|
|
|
+ {statusLabel && (
|
|
|
|
|
+ <span className={`stock-badge stock-badge--${detail.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {detail.sectorName && <span className='stock-detail__sector'>{detail.sectorName}</span>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className='stock-detail__price'>
|
|
|
|
|
+ <strong className={`stock-detail__close stock-change stock-change--${direction}`}>
|
|
|
|
|
+ {formatNumber(detail.closePrice)}
|
|
|
|
|
+ </strong>
|
|
|
|
|
+ <span className={`stock-detail__diff stock-change stock-change--${direction}`}>
|
|
|
|
|
+ {changeSymbol(direction)} {latest ? formatNumber(Math.abs(latest.changeAmount)) : '—'} ({formatChangeRate(detail.changeRate)})
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <dl className='stock-detail__meta'>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <dt>거래량</dt>
|
|
|
|
|
+ <dd>{formatNumber(latest?.volume)}</dd>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <dt>거래대금</dt>
|
|
|
|
|
+ <dd>{formatKrwCompact(latest?.tradingValue)}</dd>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <dt>시가총액</dt>
|
|
|
|
|
+ <dd>{formatKrwCompact(detail.marketCap)}</dd>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </dl>
|
|
|
|
|
+
|
|
|
|
|
+ <p className='stock-detail__basis'>
|
|
|
|
|
+ 전일 기준 · {detail.lastTradingDate ?? '데이터 준비 중'} 종가 (T+1 반영)
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 탭 */}
|
|
|
|
|
+ <nav className='stock-detail__tabs' aria-label='종목 상세 탭'>
|
|
|
|
|
+ {TABS.map(item => (
|
|
|
|
|
+ <Link
|
|
|
|
|
+ key={item.value}
|
|
|
|
|
+ href={item.value === 'price' ? `/stock/${detail.code}` : `/stock/${detail.code}?tab=${item.value}`}
|
|
|
|
|
+ {...(tab === item.value ? { 'aria-current': 'page' as const } : {})}
|
|
|
|
|
+ >
|
|
|
|
|
+ {item.label}
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </nav>
|
|
|
|
|
+
|
|
|
|
|
+ <section className='stock-detail__panel'>
|
|
|
|
|
+ {tab !== 'price' ? (
|
|
|
|
|
+ <p className='stock-detail__placeholder'>준비 중입니다.</p>
|
|
|
|
|
+ ) : !history || !history.success || !history.data ? (
|
|
|
|
|
+ <div className='stock-detail__error' role='alert'>
|
|
|
|
|
+ <strong>일별 시세를 불러오지 못했습니다.</strong>
|
|
|
|
|
+ 잠시 후 다시 시도해 주세요.
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : history.data.list.length === 0 ? (
|
|
|
|
|
+ <p className='stock-detail__empty'>수집된 일별 시세가 없습니다.</p>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <table className='stock-detail__table'>
|
|
|
|
|
+ <caption>{detail.name} 일별 시세 — 날짜, 종가, 등락, 시가, 고가, 저가, 거래량, 거래대금</caption>
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th scope='col'>날짜</th>
|
|
|
|
|
+ <th scope='col'>종가</th>
|
|
|
|
|
+ <th scope='col'>등락률</th>
|
|
|
|
|
+ <th scope='col' className='stock-detail__col--sub'>시가</th>
|
|
|
|
|
+ <th scope='col' className='stock-detail__col--sub'>고가</th>
|
|
|
|
|
+ <th scope='col' className='stock-detail__col--sub'>저가</th>
|
|
|
|
|
+ <th scope='col'>거래량</th>
|
|
|
|
|
+ <th scope='col' className='stock-detail__col--sub'>거래대금</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {history.data.list.map(row => {
|
|
|
|
|
+ const rowDirection = changeDirection(row.changeRate);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <tr key={row.tradingDate}>
|
|
|
|
|
+ <td>{row.tradingDate}</td>
|
|
|
|
|
+ <td>{formatNumber(row.close)}</td>
|
|
|
|
|
+ <td className={`stock-change stock-change--${rowDirection}`}>
|
|
|
|
|
+ {changeSymbol(rowDirection)} {formatChangeRate(row.changeRate)}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className='stock-detail__col--sub'>{formatNumber(row.open)}</td>
|
|
|
|
|
+ <td className='stock-detail__col--sub'>{formatNumber(row.high)}</td>
|
|
|
|
|
+ <td className='stock-detail__col--sub'>{formatNumber(row.low)}</td>
|
|
|
|
|
+ <td>{formatNumber(row.volume)}</td>
|
|
|
|
|
+ <td className='stock-detail__col--sub'>{formatKrwCompact(row.tradingValue)}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+
|
|
|
|
|
+ <Pager
|
|
|
|
|
+ total={history.data.total}
|
|
|
|
|
+ page={page}
|
|
|
|
|
+ perPage={HISTORY_PER_PAGE}
|
|
|
|
|
+ basePath={`/stock/${detail.code}`}
|
|
|
|
|
+ query={{}}
|
|
|
|
|
+ />
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|