mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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".
241 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|