154 lines
5.2 KiB
TypeScript
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'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>
|
|
);
|
|
}
|