From 16aeba1a20c913d42c00b6c9de55aee357487d0e Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 01:21:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EC=9A=B0=EC=B8=A1=20=EC=8B=9C?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EC=B6=94=EA=B0=80=20(=EC=8B=9C=EA=B0=80/=EA=B3=A0?= =?UTF-8?q?=EA=B0=80/=EC=A0=80=EA=B0=80/=EA=B1=B0=EB=9E=98=EB=9F=89/52?= =?UTF-8?q?=EC=A3=BC=EB=B2=94=EC=9C=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토스 종목 페이지 우측 카드와 동일 위치/구성. ChartPayload(1Y, 1d) 한 번 받아 오늘 OHLC + 1년 high/low 만 계산. 백엔드가 PER/EPS/시총 미제공이라 제외. lg 브레이크포인트에서 chart(1fr) + sidebar(280px) 그리드. mobile 에서는 사이드바가 chart 아래로 스택. verify: tsc --noEmit clean, next lint clean. --- web/app/[code]/page.tsx | 20 ++++-- web/components/SymbolSidebar.tsx | 117 +++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 web/components/SymbolSidebar.tsx diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 74a1b64..f40863f 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -8,6 +8,7 @@ import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs import { PredictionPanel } from "../../components/PredictionPanel"; import { PriceHero } from "../../components/PriceHero"; import { StockChart } from "../../components/StockChart"; +import { SymbolSidebar } from "../../components/SymbolSidebar"; import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api"; export default function CodePage({ params }: { params: { code: string } }) { @@ -100,14 +101,19 @@ export default function CodePage({ params }: { params: { code: string } }) { prev={prev} asOf={asOf} /> - - {isIntraday && chart.intraday_status && ( -
- 실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}] +
+
+ + {isIntraday && chart.intraday_status && ( +
+ 실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}] +
+ )} +
+ +
- )} -
- +
diff --git a/web/components/SymbolSidebar.tsx b/web/components/SymbolSidebar.tsx new file mode 100644 index 0000000..d6d112e --- /dev/null +++ b/web/components/SymbolSidebar.tsx @@ -0,0 +1,117 @@ +"use client"; + +// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음. +// +// 표시 항목 (백엔드가 안정적으로 제공하는 값으로 한정): +// - 오늘: 시가 / 고가 / 저가 / 거래량 +// - 52주: 최고 / 최저 (1Y 일봉에서 계산) +// +// 백엔드에 PER/EPS/시총 데이터가 아직 없으므로 그 항목들은 의도적으로 제외. +// 차트 폴링과 독립적으로 1Y 일봉을 한 번만 받아 52주 범위만 계산한다. + +import { useEffect, useMemo, useState } from "react"; +import { api, type ChartPayload } from "../lib/api"; + +export function SymbolSidebar({ code }: { code: string }) { + const [chart, setChart] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + setErr(null); + setChart(null); + api + .getChart(code, 365, "1d") + .then((c) => { + if (alive) setChart(c); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + return () => { + alive = false; + }; + }, [code]); + + const stats = useMemo(() => { + if (!chart) return null; + const points = chart.ohlcv.filter((p) => p.close != null); + if (!points.length) return null; + const last = points[points.length - 1]; + + let yearHigh = -Infinity; + let yearLow = Infinity; + for (const p of points) { + if (p.high != null && p.high > yearHigh) yearHigh = p.high; + if (p.low != null && p.low < yearLow) yearLow = p.low; + } + return { + open: last.open, + high: last.high, + low: last.low, + volume: last.volume, + yearHigh: yearHigh === -Infinity ? null : yearHigh, + yearLow: yearLow === Infinity ? null : yearLow, + }; + }, [chart]); + + if (err) { + return ( +
+ 시세 정보 로딩 실패: {err} +
+ ); + } + if (!stats) { + return ( +
+ 시세 정보 로딩 중… +
+ ); + } + + return ( +
+
시세 정보
+
+ + + + + + +
+
+ ); +} + +function Row({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "up" | "down"; +}) { + const toneCls = + tone === "up" ? "text-rose-300" : tone === "down" ? "text-sky-300" : "text-zinc-100"; + return ( + <> +
{label}
+
{value}
+ + ); +} + +function fmt(n: number | null | undefined): string { + if (n == null || !Number.isFinite(n)) return "-"; + return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); +} + +function fmtCompact(n: number | null | undefined): string { + if (n == null || !Number.isFinite(n)) return "-"; + if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`; + if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`; + return n.toLocaleString(); +}