Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -0,0 +1,153 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user