245 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|