feat: implement pwa push notifications

This commit is contained in:
2026-01-13 09:03:46 +01:00
parent e7291951d8
commit 14430b275e
16 changed files with 547 additions and 50 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Database
DATABASE_URL="file:./dev.db"
# Web Push Notifications
# Generate these with: npx web-push generate-vapid-keys
NEXT_PUBLIC_VAPID_PUBLIC_KEY="<your_public_key>"
VAPID_PRIVATE_KEY="<your_private_key>"
VAPID_SUBJECT="mailto:admin@example.com"

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@@ -3,7 +3,7 @@
import prisma from "@/lib/prisma"
import { revalidatePath } from "next/cache"
import { headers } from "next/headers"
import { sendNotification } from "@/lib/notifications"
import { sendPlanNotification } from "@/lib/notifications"
import { getDictionary } from "@/get-dictionary"
export async function createBooking(planId: string, date: Date, name: string, type: "SITTER" | "OWNER_HOME" = "SITTER", lang: string = "en") {
@@ -34,7 +34,7 @@ export async function createBooking(planId: string, date: Date, name: string, ty
}
})
if (plan?.webhookUrl && plan.notifyAll) {
if (plan?.notifyAll) {
const host = (await headers()).get("host")
const protocol = host?.includes("localhost") ? "http" : "https"
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
@@ -44,7 +44,7 @@ export async function createBooking(planId: string, date: Date, name: string, ty
? dict.notifications.ownerHome.replace("{date}", dateStr).replace("{url}", planUrl)
: dict.notifications.newBooking.replace("{name}", name).replace("{date}", dateStr).replace("{url}", planUrl)
await sendNotification(plan.webhookUrl, message)
await sendPlanNotification(planId, message, plan.webhookUrl)
}
revalidatePath(`/${lang}/dashboard/${planId}`)
@@ -64,7 +64,7 @@ export async function deleteBooking(bookingId: number, planId: string, lang: str
where: { id: bookingId }
})
if (booking.plan.webhookUrl) {
if (booking.plan.notifyAll) {
const host = (await headers()).get("host")
const protocol = host?.includes("localhost") ? "http" : "https"
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
@@ -77,7 +77,7 @@ export async function deleteBooking(bookingId: number, planId: string, lang: str
.replace("{url}", planUrl)
.replace("{message}", messageDisplay)
await sendNotification(booking.plan.webhookUrl, message)
await sendPlanNotification(planId, message, booking.plan.webhookUrl)
}
revalidatePath(`/${lang}/dashboard/${planId}`)
@@ -98,7 +98,7 @@ export async function completeBooking(bookingId: number, planId: string, lang: s
data: { completedAt: new Date() }
})
if (booking.plan.webhookUrl) {
if (booking.plan.notifyAll) {
const host = (await headers()).get("host")
const protocol = host?.includes("localhost") ? "http" : "https"
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
@@ -109,7 +109,7 @@ export async function completeBooking(bookingId: number, planId: string, lang: s
.replace("{date}", dateStr)
.replace("{url}", planUrl)
await sendNotification(booking.plan.webhookUrl, message)
await sendPlanNotification(planId, message, booking.plan.webhookUrl)
}
revalidatePath(`/${lang}/dashboard/${planId}`)

View File

@@ -3,7 +3,7 @@
import prisma from "@/lib/prisma"
import { revalidatePath } from "next/cache"
import { headers } from "next/headers"
import { sendNotification } from "@/lib/notifications"
import { sendPlanNotification } from "@/lib/notifications"
import { getDictionary } from "@/get-dictionary"
export async function updatePlan(
@@ -34,14 +34,15 @@ export async function updatePlan(
}
})
if (data.instructions && plan.webhookUrl && plan.notifyAll) {
if (data.instructions && plan.notifyAll) {
const host = (await headers()).get("host")
const protocol = host?.includes("localhost") ? "http" : "https"
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
await sendNotification(
plan.webhookUrl,
dict.notifications.instructionsUpdated.replace("{url}", planUrl)
await sendPlanNotification(
planId,
dict.notifications.instructionsUpdated.replace("{url}", planUrl),
plan.webhookUrl
)
}

View File

@@ -0,0 +1,45 @@
"use server";
import prisma from "@/lib/prisma";
export async function subscribeUser(planId: string, subscription: any) {
if (!subscription || !subscription.endpoint || !subscription.keys) {
throw new Error("Invalid subscription object");
}
const { endpoint, keys: { p256dh, auth } } = subscription;
// Use upsert to handle updates gracefully
// If endpoint exists, update the planId. A device follows the last plan logged into (or explicitly subscribed to).
// Check existence first to decide logic (or just upsert)
// Actually, if we want to allow multiple plans per device, we need a different model.
// For now, simple model: One device -> One Plan.
await prisma.pushSubscription.upsert({
where: { endpoint },
create: {
planId,
endpoint,
p256dh,
auth,
},
update: {
planId,
p256dh,
auth,
}
});
return { success: true };
}
export async function unsubscribeUser(endpoint: string) {
if (!endpoint) return;
await prisma.pushSubscription.deleteMany({
where: { endpoint },
});
return { success: true };
}

View File

@@ -4,6 +4,7 @@ import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { PushSubscriptionSettings } from "@/components/push-manager"
import { Settings } from "lucide-react"
import { toast } from "sonner"
@@ -112,6 +113,9 @@ export function PlanSettings({
{dict.description}
</DialogDescription>
</DialogHeader>
<PushSubscriptionSettings planId={planId} />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField

105
components/push-manager.tsx Normal file
View File

@@ -0,0 +1,105 @@
"use client"
import { useState, useEffect } from "react"
import { Bell, BellOff, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { subscribeUser, unsubscribeUser } from "@/app/actions/subscription"
import { Button } from "@/components/ui/button"
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
export function PushSubscriptionSettings({ planId }: { planId: string }) {
const [isSupported, setIsSupported] = useState(false)
const [subscription, setSubscription] = useState<PushSubscription | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window && process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) {
setIsSupported(true)
registerServiceWorker()
}
}, [])
async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.getSubscription()
setSubscription(sub)
} catch (e) {
console.error("SW registration error", e)
}
}
async function subscribe() {
setLoading(true)
try {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!)
})
setSubscription(sub)
await subscribeUser(planId, sub.toJSON())
toast.success("Push Notifications enabled")
} catch (error) {
console.error(error)
toast.error("Failed to enable notifications. check permissions.")
} finally {
setLoading(false)
}
}
async function unsubscribe() {
setLoading(true)
try {
if (subscription) {
await subscription.unsubscribe()
await unsubscribeUser(subscription.endpoint)
setSubscription(null)
toast.success("Notifications disabled")
}
} catch (error) {
console.error(error)
toast.error("Failed to disable.")
} finally {
setLoading(false)
}
}
if (!isSupported) return null
return (
<div className="flex items-center justify-between p-4 border rounded-md mb-4 bg-muted/50">
<div className="space-y-0.5">
<h3 className="font-medium text-sm">Device Notifications</h3>
<p className="text-xs text-muted-foreground">
Receive updates on this device
</p>
</div>
{subscription ? (
<Button variant="outline" size="sm" onClick={unsubscribe} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <BellOff className="w-4 h-4 mr-2" />}
Disable
</Button>
) : (
<Button size="sm" onClick={subscribe} disabled={loading} variant="secondary">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Bell className="w-4 h-4 mr-2" />}
Enable
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
# Implementation Plan - PWA Push Notifications
This plan outlines the steps to implement Push Notifications for the Cat Sitting Planner PWA.
## Goals
- Allow users to subscribe to Push Notifications from the browser.
- Send Push Notifications for existing events (New Booking, Cancellation, Completion, Instructions Update).
- Ensure integration with the Service Worker.
## User Actions Required
- [ ] Generate VAPID Keys:
Run `npx web-push generate-vapid-keys` in your terminal.
Add the output to your `.env` (create if needed) or `.env.local`:
```env
NEXT_PUBLIC_VAPID_PUBLIC_KEY="<your_public_key>"
VAPID_PRIVATE_KEY="<your_private_key>"
VAPID_SUBJECT="mailto:your-email@example.com"
```
*Note: `NEXT_PUBLIC_` prefix is needed for the frontend to access the public key.*
## Steps
### 1. Dependencies and Configuration
- [ ] Install `web-push`: `npm install web-push && npm install -D @types/web-push`
- [ ] Install `serwist` or ensure `next-pwa` can handle custom service workers. (We will use `importScripts` with `next-pwa`).
### 2. Database Schema
- [ ] Update `prisma/schema.prisma`:
Add `PushSubscription` model linked to `Plan`.
```prisma
model PushSubscription {
id String @id @default(cuid())
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
endpoint String @unique
p256dh String
auth String
createdAt DateTime @default(now())
}
```
- [ ] Run `npx prisma migrate dev --name add_push_subscription`
### 3. Service Worker
- [ ] Create `public/push-sw.js`:
Implement the `push` event listener to show notifications.
Implement the `notificationclick` event listener to focus/open the app.
- [ ] Update `next.config.mjs`:
Configure `next-pwa` (Workbox) to import `push-sw.js` via `importScripts`.
### 4. Server Actions & Library
- [ ] Create `lib/push.ts`:
Helper functions to initialize `web-push` and send notifications.
- [ ] Create `app/actions/subscription.ts`:
`subscribeUser(planId, subscription)`: Saves subscription to DB.
`unsubscribeUser(endpoint)`: Removes subscription.
- [ ] Update `lib/notifications.ts`:
Add `sendPlanNotification(planId, message, webhookUrl?)`.
This function will:
1. Send to Webhook (if exists).
2. Fetch all subscriptions for `planId`.
3. Send Web Push to all.
4. Handle 410 (Gone) by deleting sub.
### 5. Backend Integration
- [ ] Update `app/actions/booking.ts`:
Replace `sendNotification` with `sendPlanNotification`.
- [ ] Update `app/actions/plan.ts`:
Replace `sendNotification` with `sendPlanNotification`.
### 6. Frontend Integration
- [ ] Create `components/push-notification-manager.tsx`:
UI component to check permission, register SW (if not handled), and subscribe.
Show "Enable Notifications" button.
- [ ] Add `PushNotificationManager` to `Dashboard` or `Settings`.
## Verification
- [ ] Test on Localhost (requires HTTPS or localhost exception).
- [ ] Verify notifications appear for:
- Booking creation
- Booking completion
- Cancellation
- Instructions update

View File

@@ -21,3 +21,51 @@ export async function sendNotification(webhookUrl: string | null, message: strin
console.error("Failed to send notification:", error);
}
}
import { sendPushNotification } from "./push";
import prisma from "@/lib/prisma";
export async function sendPlanNotification(planId: string, message: string, webhookUrl?: string | null) {
// Parallelize sending
const promises: Promise<any>[] = [];
if (webhookUrl) {
promises.push(sendNotification(webhookUrl, message));
}
try {
const subscriptions = await prisma.pushSubscription.findMany({
where: { planId }
});
if (subscriptions.length > 0) {
const payload = {
title: "Cat Sitting Planner",
body: message,
url: `/`
// We could pass specific URL if needed, but for now root is okay or dashboard?
// The service worker opens the URL.
// Ideally, we want to open `/dashboard/[planId]`.
};
// Refine URL in payload
// We need 'lang'. We don't have it here easily unless passed.
// But we can guess or just link to root and let redirect handle it?
// Or just link to context.
// Let's rely on SW opening `/`.
subscriptions.forEach(sub => {
promises.push((async () => {
const res = await sendPushNotification(sub, payload);
if (!res.success && (res.statusCode === 410 || res.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: sub.id } });
}
})());
});
}
} catch (e) {
console.error("Failed to fetch/send push subscriptions", e);
}
await Promise.allSettled(promises);
}

37
lib/push.ts Normal file
View File

@@ -0,0 +1,37 @@
import webpush from 'web-push';
if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) {
console.warn("VAPID keys are missing. Push notifications will not work.");
} else {
webpush.setVapidDetails(
process.env.VAPID_SUBJECT || 'mailto:admin@localhost',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
}
interface PushSubscriptionData {
endpoint: string;
p256dh: string;
auth: string;
}
export async function sendPushNotification(subscription: PushSubscriptionData, payload: any) {
try {
await webpush.sendNotification({
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
}
}, JSON.stringify(payload));
return { success: true, statusCode: 201 };
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription is gone
return { success: false, statusCode: error.statusCode };
}
console.error("Error sending push:", error);
return { success: false, statusCode: error.statusCode || 500 };
}
}

View File

@@ -5,6 +5,9 @@ const withPWA = withPWAInit({
disable: process.env.NODE_ENV === "development",
register: true,
skipWaiting: true,
workboxOptions: {
importScripts: ["/push-sw.js"],
},
});
/** @type {import('next').NextConfig} */

164
package-lock.json generated
View File

@@ -23,12 +23,14 @@
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
"prisma": "^6.19.1",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"web-push": "^3.6.7",
"zod": "^4.3.5"
},
"devDependencies": {
@@ -36,10 +38,10 @@
"@types/node": "^20.19.28",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"prisma": "^6.19.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
@@ -2584,7 +2586,6 @@
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.1.tgz",
"integrity": "sha512-bUL/aYkGXLwxVGhJmQMtslLT7KPEfUqmRa919fKI4wQFX4bIFUKiY8Jmio/2waAjjPYrtuDHa7EsNCnJTXxiOw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
@@ -2597,14 +2598,12 @@
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.1.tgz",
"integrity": "sha512-h1JImhlAd/s5nhY/e9qkAzausWldbeT+e4nZF7A4zjDYBF4BZmKDt4y0jK7EZapqOm1kW7V0e9agV/iFDy3fWw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.1.tgz",
"integrity": "sha512-xy95dNJ7DiPf9IJ3oaVfX785nbFl7oNDzclUF+DIiJw6WdWCvPl0LPU0YqQLsrwv8N64uOQkH391ujo3wSo+Nw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2618,14 +2617,12 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.1.tgz",
"integrity": "sha512-mmgcotdaq4VtAHO6keov3db+hqlBzQS6X7tR7dFCbvXjLVTxBYdSJFRWz+dq7F9p6dvWyy1X0v8BlfRixyQK6g==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.1",
@@ -2637,7 +2634,6 @@
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.1.tgz",
"integrity": "sha512-zsg44QUiQAnFUyh6Fbt7c9HjMXHwFTqtrgcX7DAZmRgnkPyYT7Sh8Mn8D5PuuDYNtMOYcpLGg576MLfIORsBYw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.1"
@@ -3896,7 +3892,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
@@ -4299,6 +4294,16 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
@@ -5047,6 +5052,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -5309,6 +5323,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -5439,6 +5465,12 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -5494,6 +5526,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -5504,7 +5542,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
@@ -5627,7 +5664,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -5653,7 +5689,6 @@
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
@@ -5731,14 +5766,12 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@@ -5905,7 +5938,6 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -5949,14 +5981,12 @@
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
@@ -5992,7 +6022,6 @@
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -6015,11 +6044,19 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -6058,7 +6095,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -6718,14 +6754,12 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -7103,7 +7137,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@@ -7309,6 +7342,28 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -7892,7 +7947,6 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -8012,6 +8066,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -8483,6 +8558,12 @@
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -8499,7 +8580,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -8654,7 +8734,6 @@
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/node-releases": {
@@ -8667,7 +8746,6 @@
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@@ -8807,7 +8885,6 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/once": {
@@ -8938,14 +9015,12 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/picocolors": {
@@ -8970,7 +9045,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
@@ -9042,7 +9116,6 @@
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.1.tgz",
"integrity": "sha512-XRfmGzh6gtkc/Vq3LqZJcS2884dQQW3UhPo6jNRoiTW95FFQkXFg8vkYEy6og+Pyv0aY7zRQ7Wn1Cvr56XjhQQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -9089,7 +9162,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -9135,7 +9207,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@@ -9280,7 +9351,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -9554,6 +9624,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -10228,7 +10304,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -10722,6 +10797,25 @@
"node": ">=10.13.0"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",

View File

@@ -24,20 +24,22 @@
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
"prisma": "^6.19.1",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.5",
"prisma": "^6.19.1"
"web-push": "^3.6.7",
"zod": "^4.3.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20.19.28",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.1",

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL PRIMARY KEY,
"planId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PushSubscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");

View File

@@ -21,6 +21,7 @@ model Plan {
litterInterval Int @default(2)
createdAt DateTime @default(now())
bookings Booking[]
pushSubscriptions PushSubscription[]
}
model Booking {
@@ -35,3 +36,13 @@ model Booking {
@@unique([planId, date, sitterName])
}
model PushSubscription {
id String @id @default(cuid())
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
endpoint String @unique
p256dh String
auth String
createdAt DateTime @default(now())
}

43
public/push-sw.js Normal file
View File

@@ -0,0 +1,43 @@
self.addEventListener('push', function (event) {
if (!event.data) return;
try {
const data = event.data.json();
const title = data.title || 'Cat Sitting Planner';
const options = {
body: data.body || '',
icon: '/icon.png',
badge: '/icon.png',
data: {
url: data.url || '/'
}
};
event.waitUntil(
self.registration.showNotification(title, options)
);
} catch (err) {
console.error('Error processing push event:', err);
}
});
self.addEventListener('notificationclick', function (event) {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
const urlToOpen = event.notification.data.url;
// Check if there is already a window/tab open with the target URL
for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i];
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// If not, open a new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});