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