From d6032edc00aca59225f2ca3bad77ecf81795d20b Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 08:11:05 +0200 Subject: [PATCH] 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. --- apps/web/app/api/v1/push/subscribe/route.ts | 71 +++++++++++++++ apps/web/app/offline/page.tsx | 18 ++++ .../components/pwa/push-subscribe-button.tsx | 86 +++++++++++++++++++ apps/web/components/pwa/sw-register.tsx | 13 +++ apps/web/lib/push.ts | 33 +++++++ apps/web/public/sw.js | 44 ++++++++++ 6 files changed, 265 insertions(+) create mode 100644 apps/web/app/api/v1/push/subscribe/route.ts create mode 100644 apps/web/app/offline/page.tsx create mode 100644 apps/web/components/pwa/push-subscribe-button.tsx create mode 100644 apps/web/components/pwa/sw-register.tsx create mode 100644 apps/web/lib/push.ts create mode 100644 apps/web/public/sw.js diff --git a/apps/web/app/api/v1/push/subscribe/route.ts b/apps/web/app/api/v1/push/subscribe/route.ts new file mode 100644 index 0000000..7ac69ca --- /dev/null +++ b/apps/web/app/api/v1/push/subscribe/route.ts @@ -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 }); +} diff --git a/apps/web/app/offline/page.tsx b/apps/web/app/offline/page.tsx new file mode 100644 index 0000000..de55900 --- /dev/null +++ b/apps/web/app/offline/page.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; + +export default function OfflinePage() { + return ( +
+

You're offline

+

+ Your recently visited recipes are available. Connect to internet to load new content. +

+ + Back to my recipes + +
+ ); +} diff --git a/apps/web/components/pwa/push-subscribe-button.tsx b/apps/web/components/pwa/push-subscribe-button.tsx new file mode 100644 index 0000000..aece2d6 --- /dev/null +++ b/apps/web/components/pwa/push-subscribe-button.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/pwa/sw-register.tsx b/apps/web/components/pwa/sw-register.tsx new file mode 100644 index 0000000..b5abee7 --- /dev/null +++ b/apps/web/components/pwa/sw-register.tsx @@ -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; +} diff --git a/apps/web/lib/push.ts b/apps/web/lib/push.ts new file mode 100644 index 0000000..8e9319d --- /dev/null +++ b/apps/web/lib/push.ts @@ -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 { + 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 + ) + ) + ); +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..2e8a4f8 --- /dev/null +++ b/apps/web/public/sw.js @@ -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")) + ) + ); +});