Files
stock_chart_site/web/components/PeerComparePanel.tsx
claude-owner 959e9afa80 refactor(card): extract shared CardHeader for detail-page body cards
상세 페이지 본문 카드 8개가 character-by-character 로 동일한 헤더
markup 을 중복 정의하고 있었음 — ReturnBucket 분리와 같은 future
drift 리스크. 단일 source of truth 로 묶음.

신규 web/components/CardHeader.tsx:
- 표준 패턴 캡슐화: mb-3 flex items-baseline justify-between 컨테이너
  + text-sm font-medium text-zinc-200 title + 옵셔널 text-[11px]
  text-zinc-500 subtitle.
- subtitle 은 옵셔널 — MetricsPanel 처럼 제목만 있는 카드도 같은
  컨테이너로 정렬돼 본문 카드 라인 높이 통일.

적용한 카드 8개:
- IntradayReturns / PeriodReturns / CompositeScoreCard /
  PeerComparePanel / TradingValuePanel / DisclosuresPanel / NewsList
  — 기존 markup 과 시각 결과 완전 동일.
- MetricsPanel — 기존 mb-2 standalone div → CardHeader 로 흡수,
  마진이 mb-2 → mb-3 으로 정렬됨. 다른 본문 카드들과 헤더 아래 공간
  통일.

적용 제외:
- PredictionPanel: 우측 슬롯이 button (예측 실행), 좌측이 title+
  subtitle 두 줄 중첩. 단순 text subtitle 모델에 안 맞음.
- InvestorCumulative: 우측 슬롯이 window 선택 button 그룹 (1M/3M/...)
  + aria-pressed. 위와 같은 이유.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:52:32 +09:00

163 lines
4.6 KiB
TypeScript

"use client";
// 동종업종(테마) 비교 패널 — 토스의 "동일업종 비교" 톤.
// /api/themes/peer/{code} 한 번 호출로 (테마 라벨, 본인 수익률, 평균, 상/하위 N) 모두 받음.
// 테마 무소속 종목은 패널을 통째로 숨김.
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type PeerCompareItem, type PeerCompareResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function PeerComparePanel({ code }: { code: string }) {
const [data, setData] = useState<PeerCompareResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
api
.peerCompare(code, 21, 5)
.then((r) => {
if (alive) setData(r);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, [code]);
if (err) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
: {err}
</div>
);
}
if (!data) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
);
}
// 테마 무소속 → 패널 숨김
if (data.themes.length === 0) return null;
const self = data.self_pct;
const avg = data.peer_avg_pct;
const diff = self != null && avg != null ? self - avg : null;
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader
title="동종업종 비교"
subtitle={`최근 ${data.window}거래일 · peer ${data.peer_count ?? 0}종목`}
/>
<div className="mb-3 flex flex-wrap gap-1.5">
{data.themes.map((t) => (
<Link
key={t.slug}
href={`/themes/${t.slug}`}
className="rounded-full border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[11px] text-zinc-300 hover:border-zinc-500"
>
#{t.name}
</Link>
))}
</div>
<div className="mb-4 grid grid-cols-3 gap-2 text-center">
<CompareTile label="이 종목" pct={self} bold />
<CompareTile label="업종 평균" pct={avg} />
<CompareTile label="격차" pct={diff} sign />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<PeerList title="상위 5종목" items={data.top} />
<PeerList title="하위 5종목" items={data.bottom} />
</div>
</div>
);
}
function CompareTile({
label,
pct,
bold = false,
sign = false,
}: {
label: string;
pct: number | null;
bold?: boolean;
sign?: boolean;
}) {
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
const text =
pct == null
? "—"
: sign
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%p`
: `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2">
<div className="text-[11px] text-zinc-500">{label}</div>
<div
className={`mt-1 tabular-nums ${tone} ${
bold ? "text-base font-semibold" : "text-sm font-medium"
}`}
>
{text}
</div>
</div>
);
}
function PeerList({
title,
items,
}: {
title: string;
items: PeerCompareItem[];
}) {
return (
<div>
<div className="mb-1.5 text-[11px] text-zinc-500">{title}</div>
<ul className="divide-y divide-zinc-800 rounded-md border border-zinc-800">
{items.length === 0 && (
<li className="px-2 py-2 text-xs text-zinc-500"> </li>
)}
{items.map((it) => {
const tone =
it.pct_change == null || it.pct_change === 0
? "text-zinc-400"
: it.pct_change > 0
? "text-rose-400"
: "text-sky-400";
return (
<li key={it.code}>
<Link
href={`/${it.code}`}
className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs hover:bg-zinc-800/50"
>
<span className="truncate text-zinc-200">{it.name}</span>
<span className={`shrink-0 tabular-nums ${tone}`}>
{it.pct_change == null
? "—"
: `${it.pct_change >= 0 ? "+" : ""}${it.pct_change.toFixed(2)}%`}
</span>
</Link>
</li>
);
})}
</ul>
</div>
);
}