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