"use client"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { AlertsPanel } from "../../components/AlertsPanel"; import { CompositeScoreCard } from "../../components/CompositeScoreCard"; import { DisclosuresPanel } from "../../components/DisclosuresPanel"; import { IntradayReturns } from "../../components/IntradayReturns"; import { InvestmentNote } from "../../components/InvestmentNote"; import { InvestorCumulative } from "../../components/InvestorCumulative"; import { MetricsPanel } from "../../components/MetricsPanel"; import { PeerComparePanel } from "../../components/PeerComparePanel"; import { NewsList } from "../../components/NewsList"; import { OrderbookPanel } from "../../components/OrderbookPanel"; import { PeriodReturns } from "../../components/PeriodReturns"; import { PeriodTabs, periodNeighbor, periodSpec, type Period } from "../../components/PeriodTabs"; import { PredictionPanel } from "../../components/PredictionPanel"; import { PriceHero } from "../../components/PriceHero"; import { PriceTargets } from "../../components/PriceTargets"; import { RelatedStocks } from "../../components/RelatedStocks"; import { ShareButton } from "../../components/ShareButton"; import { StarButton } from "../../components/StarButton"; import { StockChart } from "../../components/StockChart"; import { SymbolSidebar } from "../../components/SymbolSidebar"; import { TradingValuePanel } from "../../components/TradingValuePanel"; import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api"; import { recent } from "../../lib/recent"; import { readTargets, type Targets } from "../../lib/targets"; import { watchlist } from "../../lib/watchlist"; import { markRecorded, wasRecordedToday } from "../../lib/views"; export default function CodePage({ params }: { params: { code: string } }) { const { code } = params; const [chart, setChart] = useState(null); const [prediction, setPrediction] = useState(null); const [err, setErr] = useState(null); const [period, setPeriod] = useState("3M"); const [viewsToday, setViewsToday] = useState(null); const [targets, setTargets] = useState({}); const spec = periodSpec(period); const isIntraday = spec.interval === "10m"; // 종목 페이지 방문 시 최근 본 종목에 push. useEffect(() => { recent.push(code); }, [code]); // 조회 카운터. // - 같은 (code, KST today) 는 localStorage 마크로 dedupe → POST 1회. // - dedupe 실패 (스토리지 잠금 등) 해도 GET 으로 today_views 는 조회. // - POST 응답이 today_views 를 주므로 별도 GET 안 해도 됨 (한 라운드트립으로 끝). // - 이미 봤던 종목이면 GET 만. useEffect(() => { let alive = true; const apply = (n: number) => { if (alive) setViewsToday(n); }; if (!wasRecordedToday(code)) { api.recordView(code) .then((r) => { // POST 성공 후에만 mark — 실패 시 다음 방문에 다시 시도 가능. markRecorded(code); apply(r.today_views); }) .catch(() => { // POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌). api.views(code).then((r) => apply(r.today_views)).catch(() => {}); }); } else { api.views(code).then((r) => apply(r.today_views)).catch(() => {}); } return () => { alive = false; }; }, [code]); // 저장된 목표가/손절가 — 마운트 + 종목 전환 시 localStorage 에서 로드. useEffect(() => { setTargets(readTargets(code)); }, [code]); // 종목 페이지 키보드 단축키: // ←/→ : 기간 탭 이전/다음 (양끝 clamp — wrap 안 함, 사용자가 끝 인지) // S : 관심종목 토글 // 입력 필드(검색창, 메모 등) 포커스 시 무시 — 텍스트 편집 방해 금지. // modifier(Ctrl/Cmd/Alt) 가 눌리면 브라우저/OS 단축키 양보. useEffect(() => { if (typeof window === "undefined") return; const isEditableTarget = (t: EventTarget | null): boolean => { if (!(t instanceof HTMLElement)) return false; const tag = t.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; if (t.isContentEditable) return true; return false; }; const onKey = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey || e.altKey) return; if (isEditableTarget(e.target)) return; // 모달/대화상자가 열려 있으면 뒤의 페이지 단축키를 가로채지 않음. // ShortcutsHelp 처럼 close 버튼이 포커스 잡혀 있어도 editable 가드는 통과되므로, // 페이지 단축키가 뒤에서 작동하는 것을 막기 위해 aria-modal 으로 추가 확인. if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; if (e.key === "ArrowLeft") { e.preventDefault(); setPeriod((cur) => periodNeighbor(cur, -1)); } else if (e.key === "ArrowRight") { e.preventDefault(); setPeriod((cur) => periodNeighbor(cur, 1)); } else if (e.key === "s" || e.key === "S") { e.preventDefault(); watchlist.toggle(code); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [code]); useEffect(() => { let alive = true; setErr(null); setChart(null); const load = () => { api .getChart(code, spec.days, spec.interval) .then((c) => { if (alive) setChart(c); }) .catch((e) => { if (alive) setErr(e instanceof Error ? e.message : String(e)); }); }; load(); // 1일(10분봉) 모드만 60s 폴링 — 백엔드가 10분 내면 DB 만 읽음. if (isIntraday) { const h = window.setInterval(load, 60_000); return () => { alive = false; window.clearInterval(h); }; } return () => { alive = false; }; }, [code, spec.days, spec.interval, isIntraday]); useEffect(() => { let alive = true; api .latestPrediction(code) .then((r) => { if (alive && r.found) setPrediction(r); }) .catch(() => { // 예측 이력 없음 — 무시 }); return () => { alive = false; }; }, [code]); // 현재가/전일가 + 헤더 sparkline 시리즈 — ohlcv 끝부분에서 산출. const { current, prev, asOf, sparkSeries } = useMemo(() => { if (!chart || !chart.ohlcv.length) return { current: null, prev: null, asOf: null, sparkSeries: [] as number[] }; const valid = chart.ohlcv.filter((p) => p.close != null); if (!valid.length) return { current: null, prev: null, asOf: null, sparkSeries: [] as number[] }; const last = valid[valid.length - 1]; const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null; // 최근 30개 종가 (없으면 가용 전체). const recent = valid.slice(-30).map((p) => p.close as number); return { current: last.close, prev: prevPt?.close ?? null, asOf: last.date, sparkSeries: recent, }; }, [chart]); return (
{/* 헤더 액션 바 — 2 행 구조: 1행) 좌: 페이지 nav 링크 (← 검색 / 관심종목), 우: 종목 액션 (비교/공유/Star) 2행) PeriodTabs (7 탭) 단독 — 항상 우측 정렬 한 줄에 nav+액션+7탭 을 모두 넣으면 모바일에서 wrap 되며 PeriodTabs 가 줄 중간에 끼어 어색해짐. 2행 분리 + flex-wrap + overflow-x-auto 로 모든 폭에서 일관. */}
← 검색 관심종목
⇄ 비교
{/* PeriodTabs 가 좁은 폭에서 컨테이너보다 넓어질 때: 부모에 `justify-end` 를 직접 걸면 음수 방향으로 첫 탭이 잘려서 스크롤로도 접근이 어려움. wrapper-of-wrapper 패턴 — 바깥은 overflow-x-auto 스크롤만, 안쪽 inline flex 가 폭에 맞춰 넓으면 좌측 정렬(스크롤 노출), 좁으면 우측 정렬(공간 차지 안 함). w-max + min-w-full 이 핵심. */}
setPeriod(id)} />
{err &&
차트 로딩 실패: {err}
} {chart && ( <>
{isIntraday && chart.intraday_status && (
실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}]
)}
{/* 1D(10분봉) 모드에선 PeriodReturns 의 거래일 라벨이 거짓이 되므로 단기 카드로 교체 */} {isIntraday ? ( ) : ( )}
)}
); }