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:
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user