diff --git a/web/app/alerts/page.tsx b/web/app/alerts/page.tsx new file mode 100644 index 0000000..97f5749 --- /dev/null +++ b/web/app/alerts/page.tsx @@ -0,0 +1,211 @@ +"use client"; + +// /alerts — 모든 가격 알람을 한 화면에서 관리. +// 종목별로 그룹핑, 활성/발사됨 분리, 재무장·삭제·일괄 지우기. +// AlertsToaster 가 글로벌로 폴링하므로 이 페이지는 표시/조작만 담당. + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { alerts, type Alert } from "../../lib/alerts"; + +export default function AlertsPage() { + const [items, setItems] = useState([]); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + const refresh = () => setItems(alerts.list()); + refresh(); + return alerts.subscribe(refresh); + }, []); + + // 종목별 그룹. 표시는 최신 등록순(prepend 된 순서) 유지. + const grouped = useMemo(() => { + const m = new Map(); + for (const a of items) { + const g = m.get(a.code) ?? { name: a.name, rows: [] }; + if (!g.name && a.name) g.name = a.name; + g.rows.push(a); + m.set(a.code, g); + } + return Array.from(m.entries()).map(([code, g]) => ({ + code, + name: g.name, + rows: g.rows, + })); + }, [items]); + + const activeCount = items.filter((a) => !a.triggered).length; + const firedCount = items.length - activeCount; + + return ( +
+
+ + ← 홈 + + + 활성 {activeCount} · 발사됨 {firedCount} + +
+ +
+
+

가격 알람

+

+ 브라우저에 저장됩니다. 활성 알람은 60초마다 체크되어 임계값 도달 시 토스트 + + (권한 허용 시) 시스템 알림. +

+
+ {items.length > 0 && ( + + )} +
+ +
+ {!hydrated ? ( +
+ 로딩 중… +
+ ) : items.length === 0 ? ( +
+ 아직 설정된 알람이 없습니다. +
+ 종목 페이지의 가격 알람 카드에서 추가하세요. +
+ ) : ( + + )} + + {hydrated && grouped.length > 0 && ( +
+ {grouped.map((g) => ( + + ))} +
+ )} +
+
+ ); +} + +// 알림 권한이 default 면 안내 + 요청 버튼. +function NotificationHint() { + const [perm, setPerm] = useState( + "unsupported", + ); + useEffect(() => { + if (typeof window === "undefined") return; + if (!("Notification" in window)) { + setPerm("unsupported"); + return; + } + setPerm(Notification.permission); + }, []); + + if (perm === "granted" || perm === "denied" || perm === "unsupported") return null; + return ( +
+ 시스템 알림 권한이 없습니다. 허용하면 브라우저 탭이 닫혀 있어도 알람을 받을 수 있습니다. + +
+ ); +} + +function Group({ + code, + name, + rows, +}: { + code: string; + name?: string; + rows: Alert[]; +}) { + return ( +
+
+ + {name ?? code} + {code} + + {rows.length}개 +
+
    + {rows.map((a) => ( +
  • +
    + + {a.op === "gte" ? "≥" : "≤"} + + + {a.target.toLocaleString()} + + + {new Date(a.createdAt).toLocaleString("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + + {a.triggered && ( + + 발사됨 + + )} +
    +
    + {a.triggered && ( + + )} + +
    +
  • + ))} +
+
+ ); +} diff --git a/web/components/TopNav.tsx b/web/components/TopNav.tsx index 061a90f..9b10fbb 100644 --- a/web/components/TopNav.tsx +++ b/web/components/TopNav.tsx @@ -151,6 +151,13 @@ export function TopNav() { )} + + 🔔 +