Files
NextChair/src/features/notifications/components/push-subscribe-toggle.tsx
T
2026-08-03 21:15:24 +01:00

154 lines
5.2 KiB
TypeScript

"use client";
import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { subscribeToPushAction, unsubscribeFromPushAction } from "../actions/push-subscriptions";
import { urlBase64ToUint8Array } from "../lib/vapid";
type Status = "checking" | "unsupported" | "subscribed" | "not-subscribed" | "denied";
/**
* The one control that ever asks for push permission — deliberately not
* rendered anywhere that loads automatically on first visit (see the
* Phase 7 brief's "never request permission on first load"). Placed in
* Settings, a screen someone only reaches by deliberately navigating
* there, with the benefit explained in the paragraph above the button
* before anything is requested.
*/
function isPushSupported(): boolean {
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window;
}
export function PushSubscribeToggle() {
// Always starts as "checking" (renders nothing) on both server and
// client — `isPushSupported`/`Notification.permission` are
// client-only reads, so resolving them in a useState initializer would
// make the client's hydration render disagree with the server's and
// trigger a hydration error. The real status is only ever resolved
// client-side, post-mount, in the effect below.
const [status, setStatus] = useState<Status>("checking");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (status !== "checking") return;
let cancelled = false;
Promise.resolve()
.then(async () => {
if (!isPushSupported()) return "unsupported" as const;
if (Notification.permission === "denied") return "denied" as const;
const reg = await navigator.serviceWorker.getRegistration();
const sub = await reg?.pushManager.getSubscription();
return sub ? ("subscribed" as const) : ("not-subscribed" as const);
})
.then((resolved) => {
if (!cancelled) setStatus(resolved);
})
.catch(() => {
if (!cancelled) setStatus("not-subscribed");
});
return () => {
cancelled = true;
};
}, [status]);
const subscribe = () => {
setError(null);
startTransition(async () => {
try {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
setError("Push notifications aren't configured for this business yet.");
return;
}
const registration = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setStatus("denied");
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
});
const json = subscription.toJSON();
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
setError("Couldn't complete the push subscription. Please try again.");
return;
}
const result = await subscribeToPushAction({
endpoint: json.endpoint,
p256dhKey: json.keys.p256dh,
authKey: json.keys.auth,
deviceLabel: navigator.userAgent.slice(0, 100),
});
if (!result.success) {
setError(result.error ?? "Couldn't save your subscription.");
return;
}
setStatus("subscribed");
} catch {
setError("Couldn't enable push notifications. Please try again.");
}
});
};
const unsubscribe = () => {
setError(null);
startTransition(async () => {
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (subscription) {
await unsubscribeFromPushAction(subscription.endpoint);
await subscription.unsubscribe();
}
setStatus("not-subscribed");
});
};
if (status === "checking") return null;
if (status === "unsupported") {
return <p className="text-muted text-sm">Push notifications aren&apos;t supported in this browser.</p>;
}
if (status === "denied") {
return (
<p className="text-muted text-sm">
Push notifications are blocked for this site in your browser settings. Allow notifications for this site to
turn them back on.
</p>
);
}
return (
<div className="space-y-2">
{status === "not-subscribed" && (
<p className="text-muted text-sm">
Get a push notification on this device for reminders and important updates, in addition to email.
</p>
)}
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button
variant={status === "subscribed" ? "secondary" : "primary"}
size="sm"
isLoading={isPending}
onClick={status === "subscribed" ? unsubscribe : subscribe}
>
{status === "subscribed" ? "Disable Push Notifications" : "Enable Push Notifications"}
</Button>
</div>
);
}