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,71 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db, pushSubscriptions, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const subscribeSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
});
const unsubscribeSchema = z.object({
endpoint: z.string(),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (response) return response;
const json = await request.json();
const parsed = subscribeSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const body = parsed.data;
await db
.insert(pushSubscriptions)
.values({
id: crypto.randomUUID(),
userId: session!.user.id,
endpoint: body.endpoint,
p256dh: body.keys.p256dh,
auth: body.keys.auth,
})
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: {
userId: session!.user.id,
p256dh: body.keys.p256dh,
auth: body.keys.auth,
},
});
return NextResponse.json({ ok: true });
}
export async function DELETE(request: Request) {
const { session, response } = await requireSession();
if (response) return response;
const json = await request.json();
const parsed = unsubscribeSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
await db
.delete(pushSubscriptions)
.where(
and(
eq(pushSubscriptions.endpoint, parsed.data.endpoint),
eq(pushSubscriptions.userId, session!.user.id)
)
);
return NextResponse.json({ ok: true });
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
export default function OfflinePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">You're offline</h1>
<p className="max-w-sm text-muted-foreground">
Your recently visited recipes are available. Connect to internet to load new content.
</p>
<Link
href="/recipes"
className="mt-2 underline underline-offset-4 hover:text-primary"
>
Back to my recipes
</Link>
</div>
);
}
@@ -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>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { useEffect } from "react";
export function SwRegister() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(console.error);
}
}, []);
return null;
}
+33
View File
@@ -0,0 +1,33 @@
import webpush from "web-push";
import { db, pushSubscriptions, eq } from "@epicure/db";
webpush.setVapidDetails(
"mailto:contact@epicure.app",
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function sendPushNotification(
userId: string,
notification: { title: string; body: string; url?: string }
): Promise<void> {
const subs = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.userId, userId));
const payload = JSON.stringify({
title: notification.title,
body: notification.body,
url: notification.url ?? "/",
});
await Promise.allSettled(
subs.map((sub) =>
webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload
)
)
);
}
+44
View File
@@ -0,0 +1,44 @@
const CACHE_NAME = "epicure-v1";
const SHELL_ASSETS = ["/", "/recipes", "/offline"];
self.addEventListener("install", event => {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(SHELL_ASSETS)));
self.skipWaiting();
});
self.addEventListener("activate", event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", event => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
// Skip API routes
if (url.pathname.startsWith("/api/")) return;
// Cache-first for cook mode pages
if (url.pathname.includes("/cook")) {
event.respondWith(
caches.match(request).then(cached =>
cached ??
fetch(request).then(res => {
const clone = res.clone();
caches.open(CACHE_NAME).then(c => c.put(request, clone));
return res;
})
)
);
return;
}
// Network-first for other pages
event.respondWith(
fetch(request).catch(() =>
caches.match(request).then(cached => cached ?? caches.match("/offline"))
)
);
});