Files
HealthLog/src/components/settings/web-push-card.tsx
T
Marc-André Bombeck e31f9690e2 refactor(settings): compose single-card sections from SettingsCard
Swap the hand-rolled bg-card border-border rounded-xl border p-4 sm:p-6
container for SettingsCard across the single-card sections (integration
cards, notification channel cards, coach prefs, cycle, modules, mood
reminder, thresholds, dashboard-layout, about-me, low-stock, and the
health-record export panel). Drops the sm: padding split and the
primitive bypass; landmark sections keep their element via as="section".
2026-06-19 21:14:57 +02:00

241 lines
7.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const buffer = new ArrayBuffer(raw.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < raw.length; ++i) {
view[i] = raw.charCodeAt(i);
}
return buffer;
}
export function WebPushCard() {
const { t } = useTranslations();
const [isSubscribed, setIsSubscribed] = useState(false);
const [isSupported, setIsSupported] = useState(true);
const [isDenied, setIsDenied] = useState(false);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
useEffect(() => {
// Async I/O (browser API + service worker registration). The lint rule
// allows setState inside async callbacks — only synchronous setState in
// the effect body is rejected.
async function checkSubscription() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setIsSupported(false);
setLoading(false);
return;
}
if (Notification.permission === "denied") {
setIsDenied(true);
setLoading(false);
return;
}
try {
const registration =
await navigator.serviceWorker.getRegistration("/sw.js");
if (registration) {
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
}
} catch {
// Ignore errors during check
}
setLoading(false);
}
void checkSubscription();
}, []);
async function handleSubscribe() {
setActionLoading(true);
setMsg(null);
setMsgType(null);
try {
const vapidRes = await apiFetchRaw("/api/notifications/vapid");
if (!vapidRes.ok) {
setMsg(t("settings.webPushNotConfigured"));
setMsgType("error");
setActionLoading(false);
return;
}
const vapidData = await vapidRes.json();
const vapidPublicKey = vapidData.data.publicKey;
// Defer to the app-wide registration (`<ServiceWorkerRegistrar>` in
// the root providers) rather than re-registering here — same scope,
// so a second `register("/sw.js")` would only return the existing
// worker. `ready` resolves with the active registration once it is
// controlling the page.
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
});
const subJson = subscription.toJSON();
const res = await apiFetchRaw("/api/notifications/web-push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: {
p256dh: subJson.keys?.p256dh ?? "",
auth: subJson.keys?.auth ?? "",
},
}),
});
if (res.ok) {
setIsSubscribed(true);
setMsg(t("settings.webPushSubscribed"));
setMsgType("success");
} else {
setMsg(t("settings.webPushSubscribeFailed"));
setMsgType("error");
}
} catch {
if (Notification.permission === "denied") {
setIsDenied(true);
setMsg(t("settings.webPushDenied"));
} else {
setMsg(t("settings.webPushSubscribeFailed"));
}
setMsgType("error");
} finally {
setActionLoading(false);
}
}
async function handleUnsubscribe() {
setActionLoading(true);
setMsg(null);
setMsgType(null);
try {
const registration =
await navigator.serviceWorker.getRegistration("/sw.js");
if (registration) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await apiFetchRaw("/api/notifications/web-push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
}
setIsSubscribed(false);
setMsg(t("settings.webPushUnsubscribed"));
setMsgType("success");
} catch {
setMsg(t("settings.webPushSubscribeFailed"));
setMsgType("error");
} finally {
setActionLoading(false);
}
}
// The primary action sits in the header row (right of the title) rather than
// bottom-left, so an inactive card does not waste a wide empty band before
// its enable button. The loading spinner rides the same slot; the
// unsupported / denied notices stay in the body.
const headerAction = loading ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
) : !isSupported || isDenied ? null : (
<div className="flex flex-wrap items-center justify-end gap-2">
{isSubscribed ? (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleUnsubscribe}
disabled={actionLoading}
>
{actionLoading && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.webPushUnsubscribe")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSubscribe}
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<BellRing className="h-3.5 w-3.5" />
)}
{t("settings.webPushSubscribe")}
</Button>
)}
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
</div>
);
return (
<SettingsCard>
<SettingsCardHeader
icon={BellRing}
title={t("settings.webPush")}
description={t("settings.webPushDescription")}
status={headerAction}
/>
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
<div className="mt-4 space-y-4 pl-7">
{!loading && !isSupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
</p>
) : !loading && isDenied ? (
<p className="text-destructive text-sm">
{t("settings.webPushDenied")}
</p>
) : null}
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
) : null}
</SettingsCard>
);
}