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>
);
}