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.
143 lines
5.0 KiB
TypeScript
143 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
// 종목 페이지의 가격 알람 패널.
|
|
// 현재 종목의 활성 알람 리스트 + 새 알람 추가 폼 (≥ / ≤ + 가격).
|
|
// 발사 자체는 AlertsToaster 가 글로벌로 처리. 여기는 입력/표시만.
|
|
// Collapsible 본문은 hidden 으로만 가리므로 접어도 op/target partial 입력 유지.
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { alerts, type Alert, type AlertOp } from "../lib/alerts";
|
|
import { Collapsible } from "./Collapsible";
|
|
|
|
type Props = {
|
|
code: string;
|
|
name?: string | null;
|
|
// 현재가 — 폼 placeholder 와 기본값으로 사용.
|
|
current?: number | null;
|
|
};
|
|
|
|
export function AlertsPanel({ code, name, current }: Props) {
|
|
const [items, setItems] = useState<Alert[]>([]);
|
|
const [op, setOp] = useState<AlertOp>("gte");
|
|
const [target, setTarget] = useState<string>("");
|
|
|
|
useEffect(() => {
|
|
const refresh = () => setItems(alerts.listForCode(code));
|
|
refresh();
|
|
return alerts.subscribe(refresh);
|
|
}, [code]);
|
|
|
|
const onAdd = () => {
|
|
const n = Number(target.replace(/[, ]/g, ""));
|
|
if (!Number.isFinite(n) || n <= 0) return;
|
|
alerts.add({ code, name: name ?? undefined, op, target: n });
|
|
setTarget("");
|
|
};
|
|
|
|
const subtitle =
|
|
current != null ? `현재가 ${current.toLocaleString()}원` : undefined;
|
|
|
|
return (
|
|
<Collapsible title="가격 알람" subtitle={subtitle} storageKey="alerts">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-[11px]">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOp("gte")}
|
|
className={`px-2 py-1 ${
|
|
op === "gte"
|
|
? "bg-zinc-800 text-rose-300"
|
|
: "bg-transparent text-zinc-500 hover:text-zinc-300"
|
|
}`}
|
|
>
|
|
≥ 도달 시
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOp("lte")}
|
|
className={`px-2 py-1 ${
|
|
op === "lte"
|
|
? "bg-zinc-800 text-sky-300"
|
|
: "bg-transparent text-zinc-500 hover:text-zinc-300"
|
|
}`}
|
|
>
|
|
≤ 하락 시
|
|
</button>
|
|
</div>
|
|
<input
|
|
inputMode="numeric"
|
|
value={target}
|
|
onChange={(e) => setTarget(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") onAdd();
|
|
}}
|
|
placeholder={current != null ? String(Math.round(current)) : "목표가"}
|
|
className="w-28 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-zinc-500"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={onAdd}
|
|
disabled={!target.trim()}
|
|
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-[11px] text-amber-300 hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
추가
|
|
</button>
|
|
</div>
|
|
|
|
{items.length === 0 ? (
|
|
<div className="mt-3 text-[11px] text-zinc-500">
|
|
설정된 알람이 없습니다. 임계값을 추가하세요.
|
|
</div>
|
|
) : (
|
|
<ul className="mt-3 divide-y divide-zinc-800">
|
|
{items.map((a) => (
|
|
<li
|
|
key={a.id}
|
|
className="flex items-center justify-between gap-2 py-1.5 text-xs"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span
|
|
className={`shrink-0 rounded-sm px-1 py-0.5 text-[10px] font-semibold ${
|
|
a.op === "gte"
|
|
? "bg-rose-900/50 text-rose-300"
|
|
: "bg-sky-900/50 text-sky-300"
|
|
}`}
|
|
>
|
|
{a.op === "gte" ? "≥" : "≤"}
|
|
</span>
|
|
<span className="tabular-nums text-zinc-100">
|
|
{a.target.toLocaleString()}
|
|
</span>
|
|
{a.triggered && (
|
|
<span className="ml-1 rounded-sm bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-400">
|
|
발사됨
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{a.triggered && (
|
|
<button
|
|
type="button"
|
|
onClick={() => alerts.reset(a.id)}
|
|
className="rounded-sm border border-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:text-zinc-200"
|
|
>
|
|
재무장
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => alerts.remove(a.id)}
|
|
className="rounded-sm px-1 text-[10px] text-zinc-500 hover:text-rose-300"
|
|
aria-label="알람 삭제"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Collapsible>
|
|
);
|
|
}
|