245 lines
10 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/app-shell";
import { apiFetch } from "@/lib/api";
type NotificationPreference = {
emailEnabled: boolean;
pushEnabled: boolean;
minSeverity: "info" | "warning" | "critical";
};
type LedgerNotification = {
id: string;
type: string;
severity: "info" | "warning" | "critical";
title: string;
body: string;
channels: string[];
readAt?: string | null;
createdAt: string;
};
type VapidStatus = {
enabled: boolean;
publicKey: string | null;
};
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<LedgerNotification[]>([]);
const [preferences, setPreferences] = useState<NotificationPreference>({
emailEnabled: true,
pushEnabled: false,
minSeverity: "info",
});
const [vapid, setVapid] = useState<VapidStatus>({ enabled: false, publicKey: null });
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(true);
const unreadCount = useMemo(() => notifications.filter((item) => !item.readAt).length, [notifications]);
const load = async () => {
setLoading(true);
const [listRes, prefRes, vapidRes] = await Promise.all([
apiFetch<LedgerNotification[]>("/api/notifications"),
apiFetch<NotificationPreference>("/api/notifications/preferences"),
apiFetch<VapidStatus>("/api/notifications/vapid-public-key"),
]);
if (!listRes.error) setNotifications(listRes.data ?? []);
if (!prefRes.error && prefRes.data) setPreferences(prefRes.data);
if (!vapidRes.error && vapidRes.data) setVapid(vapidRes.data);
setLoading(false);
};
useEffect(() => {
load();
}, []);
const updatePreferences = async (patch: Partial<NotificationPreference>) => {
const next = { ...preferences, ...patch };
setPreferences(next);
const res = await apiFetch<NotificationPreference>("/api/notifications/preferences", {
method: "PATCH",
body: JSON.stringify(patch),
});
if (res.error) {
setStatus(res.error.message ?? "Could not update notification preferences.");
return;
}
if (res.data) setPreferences(res.data);
setStatus("Notification preferences updated.");
};
const enablePush = async () => {
setStatus("");
if (!vapid.enabled || !vapid.publicKey) {
setStatus("Push notifications need VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY on the backend.");
return;
}
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setStatus("This browser does not support web push notifications.");
return;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setStatus("Browser notification permission was not granted.");
return;
}
const registration = await navigator.serviceWorker.register("/sw.js");
const existing = await registration.pushManager.getSubscription();
const subscription = existing ?? await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapid.publicKey),
});
const res = await apiFetch("/api/notifications/push-subscriptions", {
method: "POST",
body: JSON.stringify(subscription.toJSON()),
});
if (res.error) {
setStatus(res.error.message ?? "Could not save push subscription.");
return;
}
setPreferences((prev) => ({ ...prev, pushEnabled: true }));
setStatus("Push notifications enabled for this browser.");
};
const sendTest = async () => {
setStatus("Sending test notification...");
const res = await apiFetch("/api/notifications/test", { method: "POST" });
if (res.error) {
setStatus(res.error.message ?? "Test notification failed.");
return;
}
setStatus("Test notification sent.");
await load();
};
const markRead = async (id: string) => {
const res = await apiFetch(`/api/notifications/${id}/read`, { method: "PATCH" });
if (!res.error) {
setNotifications((items) => items.map((item) => item.id === id ? { ...item, readAt: new Date().toISOString() } : item));
}
};
const markAllRead = async () => {
const res = await apiFetch("/api/notifications/read-all", { method: "POST" });
if (!res.error) {
const now = new Date().toISOString();
setNotifications((items) => items.map((item) => ({ ...item, readAt: item.readAt ?? now })));
}
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
return (
<AppShell title="Notifications" subtitle="Manage in-app, SMTP email, and browser push alerts.">
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-3">
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">SMTP Email</p>
<p className="mt-1 text-xs text-muted-foreground">Send important LedgerOne alerts through the configured SMTP transport.</p>
<label className="mt-4 flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={preferences.emailEnabled}
onChange={(event) => updatePreferences({ emailEnabled: event.target.checked })}
className="rounded border-border text-primary focus:ring-primary"
/>
Email alerts enabled
</label>
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Browser Push</p>
<p className="mt-1 text-xs text-muted-foreground">
{vapid.enabled ? "Register this browser for web push alerts." : "Backend VAPID keys are not configured."}
</p>
<button
onClick={enablePush}
disabled={!vapid.enabled}
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
>
{preferences.pushEnabled ? "Refresh Push Registration" : "Enable Push"}
</button>
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Delivery Threshold</p>
<p className="mt-1 text-xs text-muted-foreground">Email and push delivery only run at or above this severity.</p>
<select
value={preferences.minSeverity}
onChange={(event) => updatePreferences({ minSeverity: event.target.value as NotificationPreference["minSeverity"] })}
className={inputCls}
>
<option value="info">Info and above</option>
<option value="warning">Warning and above</option>
<option value="critical">Critical only</option>
</select>
</section>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-bold text-foreground">Notification Center</p>
<p className="mt-1 text-xs text-muted-foreground">{unreadCount} unread alert{unreadCount === 1 ? "" : "s"}</p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={sendTest} className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90">
Send Test
</button>
<button onClick={markAllRead} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary/40">
Mark All Read
</button>
</div>
</div>
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
<div className="mt-6 divide-y divide-border">
{loading && <p className="py-8 text-sm text-muted-foreground">Loading notifications...</p>}
{!loading && notifications.length === 0 && <p className="py-8 text-sm text-muted-foreground">No notifications yet.</p>}
{!loading && notifications.map((item) => (
<article key={item.id} className="py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
{!item.readAt && <span className="h-2 w-2 rounded-full bg-primary" />}
<p className="text-sm font-bold text-foreground">{item.title}</p>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{item.severity}</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{item.body}</p>
<p className="mt-2 text-xs text-muted-foreground">
{new Date(item.createdAt).toLocaleString()} · {item.channels.join(", ") || "in_app"}
</p>
</div>
{!item.readAt && (
<button onClick={() => markRead(item.id)} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-secondary/40">
Mark Read
</button>
)}
</div>
</article>
))}
</div>
</div>
</div>
</AppShell>
);
}