리뷰어 지적: 기존 문구가 '탭이 닫혀 있어도 알람'으로 오해되어 실제 AlertsToaster 의 60초 폴링 동작(탭 열려 있을 때만)과 불일치. - 메인 설명: '웹앱이 열려 있는 동안 60초마다 체크' + '탭 닫히면 체크 X' 명시 - NotificationHint: '다른 창을 보고 있을 때도 OS 알림 (탭 열려 있어야)'로 정정 Service Worker + Push API 미구현 — 로컬 localStorage 기반 앱이라 VAPID/푸시 백엔드는 과한 인프라. 문구로 한계를 정확히 표시하는 쪽으로 정정.
213 lines
7.3 KiB
TypeScript
213 lines
7.3 KiB
TypeScript
"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<Alert[]>([]);
|
|
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<string, { name?: string; rows: Alert[] }>();
|
|
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 (
|
|
<main className="mx-auto max-w-5xl px-6 py-8">
|
|
<div className="mb-4 flex items-center justify-between gap-3">
|
|
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
|
← 홈
|
|
</Link>
|
|
<span className="text-[11px] text-zinc-500">
|
|
활성 {activeCount} · 발사됨 {firedCount}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-end justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-zinc-50">가격 알람</h1>
|
|
<p className="mt-1 text-sm text-zinc-400">
|
|
브라우저에 저장됩니다. <b className="text-zinc-300">웹앱이 열려 있는 동안</b>{" "}
|
|
활성 알람을 60초마다 체크해 임계값 도달 시 토스트 + (권한 허용 시) 시스템 알림을
|
|
띄웁니다. 탭이 닫혀 있으면 체크하지 않습니다.
|
|
</p>
|
|
</div>
|
|
{items.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (confirm("모든 알람을 삭제하시겠어요?")) alerts.clear();
|
|
}}
|
|
className="shrink-0 rounded-md border border-zinc-700 px-2 py-1 text-[11px] text-zinc-400 hover:border-rose-500/40 hover:text-rose-300"
|
|
>
|
|
전체 삭제
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6">
|
|
{!hydrated ? (
|
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
|
로딩 중…
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-6 text-center text-sm text-zinc-500">
|
|
아직 설정된 알람이 없습니다.
|
|
<br />
|
|
종목 페이지의 <b className="text-amber-300">가격 알람</b> 카드에서 추가하세요.
|
|
</div>
|
|
) : (
|
|
<NotificationHint />
|
|
)}
|
|
|
|
{hydrated && grouped.length > 0 && (
|
|
<div className="mt-4 space-y-3">
|
|
{grouped.map((g) => (
|
|
<Group key={g.code} code={g.code} name={g.name} rows={g.rows} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// 알림 권한이 default 면 안내 + 요청 버튼.
|
|
function NotificationHint() {
|
|
const [perm, setPerm] = useState<NotificationPermission | "unsupported">(
|
|
"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 (
|
|
<div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-200">
|
|
<span>시스템 알림 권한이 없습니다. 허용하면 다른 창을 보고 있을 때도 OS 알림이 뜹니다 (탭이 열려 있어야 함).</span>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
const p = await Notification.requestPermission();
|
|
setPerm(p);
|
|
} catch {
|
|
// 무시
|
|
}
|
|
}}
|
|
className="shrink-0 rounded-md border border-amber-400/50 bg-amber-500/10 px-2 py-1 text-[11px] hover:bg-amber-500/20"
|
|
>
|
|
권한 허용
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Group({
|
|
code,
|
|
name,
|
|
rows,
|
|
}: {
|
|
code: string;
|
|
name?: string;
|
|
rows: Alert[];
|
|
}) {
|
|
return (
|
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-3">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<Link
|
|
href={`/${code}`}
|
|
className="flex items-baseline gap-2 text-sm text-zinc-100 hover:text-white"
|
|
>
|
|
<span className="font-medium">{name ?? code}</span>
|
|
<span className="text-[10px] text-zinc-500">{code}</span>
|
|
</Link>
|
|
<span className="text-[10px] text-zinc-500">{rows.length}개</span>
|
|
</div>
|
|
<ul className="divide-y divide-zinc-800">
|
|
{rows.map((a) => (
|
|
<li key={a.id} className="flex items-center justify-between gap-2 py-1.5">
|
|
<div className="flex min-w-0 items-center gap-1.5 text-xs">
|
|
<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>
|
|
<span className="ml-1 text-[10px] text-zinc-500">
|
|
{new Date(a.createdAt).toLocaleString("ko-KR", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</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>
|
|
);
|
|
}
|