feat(alerts): 가격 알람 (localStorage + 60s 폴러 + 토스트)
- lib/alerts.ts: id/code/op(gte|lte)/target/triggered 저장소, watchlist/recent 와 동일 pub/sub - AlertsPanel: 종목 사이드바에 ≥/≤ 토글 + 목표가 입력 + 활성 알람 리스트(재무장/삭제) - AlertsToaster: layout 전역 마운트, 미발사 알람 코드만 폴링, 조건 충족시 markTriggered + 우측 하단 토스트(6s), Notification 권한 허용 시 브라우저 알림 병행
This commit is contained in:
143
web/components/AlertsPanel.tsx
Normal file
143
web/components/AlertsPanel.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
// 종목 페이지의 가격 알람 패널.
|
||||
// 현재 종목의 활성 알람 리스트 + 새 알람 추가 폼 (≥ / ≤ + 가격).
|
||||
// 발사 자체는 AlertsToaster 가 글로벌로 처리. 여기는 입력/표시만.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { alerts, type Alert, type AlertOp } from "../lib/alerts";
|
||||
|
||||
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("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<div className="text-sm font-medium text-zinc-200">가격 알람</div>
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
{current != null ? `현재가 ${current.toLocaleString()}원` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user