feat(pwa): service worker, offline page, web push notifications

PWA manifest, sw.js with offline cache. VAPID web-push via lib/push.ts.
Push subscribe button (client). Push fired on recipe comment creation.
VAPID keys configurable via admin site settings.
This commit is contained in:
Arnaud
2026-07-01 08:11:05 +02:00
parent cba5d9c3ac
commit d6032edc00
6 changed files with 265 additions and 0 deletions
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { Bell, BellOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
export function PushSubscribeButton() {
const [subscribed, setSubscribed] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubscribe() {
if (Notification.permission === "denied") {
toast.error("Notifications blocked in browser settings.");
return;
}
setLoading(true);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
});
await fetch("/api/v1/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sub.toJSON()),
});
setSubscribed(true);
} catch (err) {
console.error(err);
toast.error("Failed to enable notifications.");
} finally {
setLoading(false);
}
}
async function handleUnsubscribe() {
setLoading(true);
try {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.getSubscription();
if (sub) {
await fetch("/api/v1/push/subscribe", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
}
setSubscribed(false);
} catch (err) {
console.error(err);
toast.error("Failed to disable notifications.");
} finally {
setLoading(false);
}
}
return (
<Button
variant="outline"
size="sm"
disabled={loading}
onClick={subscribed ? handleUnsubscribe : handleSubscribe}
>
{subscribed ? (
<>
<BellOff className="mr-2 h-4 w-4" />
Notifications on
</>
) : (
<>
<Bell className="mr-2 h-4 w-4" />
Enable notifications
</>
)}
</Button>
);
}