Three lower-sidebar panels are now wrapped in a shared Collapsible so mobile readers can fold away sections they're not actively using. New shared pieces: - lib/collapsible.ts — readOpen/writeOpen helpers backed by localStorage under 'panel-open:<name>'. Per-panel-name (not per-code) because the preference is a panel-level habit; users don't want different fold state for every symbol. - components/Collapsible.tsx — button-as-header + chevron + body. aria-expanded for AT. Body uses 'hidden' instead of conditional unmount so textarea text, partial alert form input, and RelatedStocks fetch results all survive collapse cycles. SSR-safe: useState(defaultOpen) then useEffect-sync to avoid React hydration mismatch when localStorage diverges from default. Panel refactors: - RelatedStocks: outer rounded div + header replaced with Collapsible; loading/error/empty/data states all rendered inside body (header stays visible so user can still toggle/recognize the section even while loading). - InvestmentNote: same swap. saved.updatedAt drives the subtitle. - AlertsPanel: same swap. current price drives the subtitle. Defaults are open=true everywhere; the surface area is the toggle itself, not a forced collapse. User's first interaction sets the sticky preference.
88 lines
2.9 KiB
TypeScript
88 lines
2.9 KiB
TypeScript
"use client";
|
|
|
|
// 같은 시장 내 거래량/유동성 기준 관련 종목 8개.
|
|
// /api/markets/related/{code} 응답을 좁은 list 카드로 렌더.
|
|
// Collapsible 로 감싸 — 모바일에서 사이드바 길이가 부담되면 사용자가 접어 둠.
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { api, type RelatedResponse } from "../lib/api";
|
|
import { Collapsible } from "./Collapsible";
|
|
|
|
export function RelatedStocks({ code }: { code: string }) {
|
|
const [data, setData] = useState<RelatedResponse | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setErr(null);
|
|
setData(null);
|
|
api
|
|
.related(code, 8)
|
|
.then((r) => {
|
|
if (alive) setData(r);
|
|
})
|
|
.catch((e) => {
|
|
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [code]);
|
|
|
|
const subtitle = data?.market ? `${data.market} · 거래량 기준` : undefined;
|
|
|
|
return (
|
|
<Collapsible title="관련 종목" subtitle={subtitle} storageKey="related">
|
|
{err && (
|
|
<div className="text-xs text-zinc-500">관련 종목 로딩 실패: {err}</div>
|
|
)}
|
|
{!err && !data && (
|
|
<div className="text-xs text-zinc-500">관련 종목 로딩 중…</div>
|
|
)}
|
|
{!err && data && !data.items.length && (
|
|
<div className="text-xs text-zinc-500">
|
|
같은 시장({data.market}) 데이터가 아직 부족합니다.
|
|
</div>
|
|
)}
|
|
{!err && data && data.items.length > 0 && (
|
|
<ul className="divide-y divide-zinc-800">
|
|
{data.items.map((m) => {
|
|
const pct = m.pct_change;
|
|
const tone =
|
|
pct == null || pct === 0
|
|
? "text-zinc-400"
|
|
: pct > 0
|
|
? "text-rose-400"
|
|
: "text-sky-400";
|
|
return (
|
|
<li key={m.code}>
|
|
<Link
|
|
href={`/${m.code}`}
|
|
className="flex items-center justify-between gap-2 py-1.5 text-xs hover:bg-zinc-800/40"
|
|
>
|
|
<span className="min-w-0 truncate text-zinc-100">{m.name}</span>
|
|
<span className="shrink-0 text-right tabular-nums">
|
|
<span className="text-zinc-100">
|
|
{m.close != null
|
|
? m.close.toLocaleString(undefined, {
|
|
maximumFractionDigits: 0,
|
|
})
|
|
: "—"}
|
|
</span>
|
|
<span className={`ml-2 ${tone}`}>
|
|
{pct != null
|
|
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
|
|
: ""}
|
|
</span>
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</Collapsible>
|
|
);
|
|
}
|