Compare commits
19 Commits
21a2d05d3a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b28fadb684 | |||
| bf2d939efe | |||
| 352212c0e0 | |||
| 5ffbc2e3d3 | |||
| 88ff0131b7 | |||
| af8c442bd1 | |||
| b305c4563f | |||
| 57003532be | |||
| 32225127ab | |||
| 3a16705614 | |||
| 3a50bb5299 | |||
| 97d8f12fc0 | |||
| e104a9d377 | |||
| 0ebe2172f4 | |||
| 8e87c5741e | |||
| 7267515a87 | |||
| de7bc516ff | |||
| 14430b275e | |||
| e7291951d8 |
8
.env.example
Normal file
8
.env.example
Normal 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
1
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
# Use a temporary DB for generation
|
# Use a temporary DB for generation
|
||||||
ENV DATABASE_URL="file:./temp.db"
|
ENV DATABASE_URL="file:./temp.db"
|
||||||
|
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -61,6 +61,31 @@ Am einfachsten via **Intergram**:
|
|||||||
2. Sende `/start`, um deine **Chat ID** zu erhalten.
|
2. Sende `/start`, um deine **Chat ID** zu erhalten.
|
||||||
3. Webhook-URL: `https://www.intergram.xyz/msg/DEINE_CHAT_ID`
|
3. Webhook-URL: `https://www.intergram.xyz/msg/DEINE_CHAT_ID`
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📲 Push Notifications (PWA)
|
||||||
|
|
||||||
|
Die App funktioniert als **Progressive Web App (PWA)** und unterstützt **Push-Benachrichtigungen** direkt auf dem Smartphone oder Desktop.
|
||||||
|
|
||||||
|
### Setup für Selbsthoster (VAPID Keys)
|
||||||
|
Damit Push funktioniert, müssen VAPID Keys in der Umgebung hinterlegt werden.
|
||||||
|
1. Generiere Keys: `npx web-push generate-vapid-keys`
|
||||||
|
2. Setze die Environment Variables (z.B. in `.env` oder Docker):
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Public Key (Wird vom Browser benötigt)
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY="<Dein Public Key>"
|
||||||
|
|
||||||
|
# Private Key (Bleibt auf dem Server!)
|
||||||
|
VAPID_PRIVATE_KEY="<Dein Private Key>"
|
||||||
|
|
||||||
|
# Kontakt-Email für den Push-Service
|
||||||
|
VAPID_SUBJECT="mailto:admin@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Benutzer kann Push-Benachrichtigungen dann direkt im Dashboard über die **Einstellungen** aktivieren.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐳 Deployment (Docker)
|
## 🐳 Deployment (Docker)
|
||||||
@@ -76,23 +101,38 @@ Mit **Docker CLI**:
|
|||||||
# Image bauen
|
# Image bauen
|
||||||
docker build -t cat-sitting-planner .
|
docker build -t cat-sitting-planner .
|
||||||
|
|
||||||
|
# Container starten
|
||||||
# Container starten
|
# Container starten
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name cat-sitting-planner \
|
--name cat-sitting-planner \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v /pfad/zum/host/data:/app/data \
|
-v /pfad/zum/host/data:/app/data \
|
||||||
|
-v /pfad/zum/host/uploads:/app/public/uploads \
|
||||||
--restart always \
|
--restart always \
|
||||||
cat-sitting-planner
|
cat-sitting-planner
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Datenpersistenz (Wichtig)
|
### 2. Datenpersistenz (Wichtig)
|
||||||
Die Daten liegen in `/app/data/dev.db`.
|
Die Daten liegen in `/app/data/dev.db`.
|
||||||
Mappe diesen Ordner unbedingt auf ein lokales Volume:
|
Bilder werden in `/app/public/uploads` gespeichert.
|
||||||
|
Mappe diese Ordner unbedingt auf lokale Volumes:
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
- /pfad/zum/host/data:/app/data
|
- /pfad/zum/host/data:/app/data
|
||||||
|
- /pfad/zum/host/uploads:/app/public/uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏥 System Status
|
||||||
|
|
||||||
|
Die App stellt einen einfachen Healthcheck-Endpoint bereit, der von Docker oder externen Monitoring-Tools genutzt werden kann:
|
||||||
|
|
||||||
|
- **Endpoint**: `/health`
|
||||||
|
- **Method**: `GET`
|
||||||
|
- **Response**: `200 OK` `{"status":"ok"}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Erstellt mit ❤️ für alle Dosenöffner.*
|
*Erstellt mit ❤️ für alle Dosenöffner.*
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { format, eachDayOfInterval, isSameDay } from "date-fns"
|
import { format, eachDayOfInterval, isSameDay } from "date-fns"
|
||||||
import { de, enUS } from "date-fns/locale"
|
import { de, enUS } from "date-fns/locale"
|
||||||
import { CalendarIcon, User, Home, X, Info, Utensils, Trash2, Check } from "lucide-react"
|
import { CalendarIcon, User, Home, X, Info, Utensils, Trash2, Check, Share2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { uploadImage } from "@/app/actions/upload-image"
|
||||||
|
import { Camera, Upload } from "lucide-react"
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
@@ -61,12 +65,56 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
|
|||||||
const [cancelReason, setCancelReason] = useState("")
|
const [cancelReason, setCancelReason] = useState("")
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const handleComplete = async (bookingId: number) => {
|
// Completion Dialog State
|
||||||
|
const [isCompleteDialogOpen, setIsCompleteDialogOpen] = useState(false)
|
||||||
|
const [bookingToComplete, setBookingToComplete] = useState<number | null>(null)
|
||||||
|
const [completionMessage, setCompletionMessage] = useState("")
|
||||||
|
const [completionImage, setCompletionImage] = useState<File | null>(null)
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleCompleteClick = (bookingId: number) => {
|
||||||
|
setBookingToComplete(bookingId)
|
||||||
|
setCompletionMessage("")
|
||||||
|
setCompletionImage(null)
|
||||||
|
setImagePreview(null)
|
||||||
|
setIsCompleteDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
setCompletionImage(file)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreview(reader.result as string)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmComplete = async () => {
|
||||||
|
if (!bookingToComplete) return
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await completeBooking(bookingId, plan.id, lang)
|
let imageUrl: string | undefined = undefined
|
||||||
toast.success(dict.bookedSuccess) // reuse for now or add new toast
|
|
||||||
|
if (completionImage) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", completionImage)
|
||||||
|
formData.append("planId", plan.id)
|
||||||
|
imageUrl = await uploadImage(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
await completeBooking(bookingToComplete, plan.id, lang, completionMessage, imageUrl)
|
||||||
|
toast.success(dict.jobDone)
|
||||||
|
setIsCompleteDialogOpen(false)
|
||||||
|
setBookingToComplete(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
toast.error(dict.bookError)
|
toast.error(dict.bookError)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +220,25 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
|
|||||||
dict={settingsDict}
|
dict={settingsDict}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
/>
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => {
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: plan.title,
|
||||||
|
text: dict.shareTitle,
|
||||||
|
url: window.location.href,
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback if share fails / is cancelled
|
||||||
|
navigator.clipboard.writeText(window.location.href)
|
||||||
|
toast.success(dict.copySuccess)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(window.location.href)
|
||||||
|
toast.success(dict.copySuccess)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Share2 className="w-4 h-4 mr-2" />
|
||||||
|
{dict.share}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -238,7 +305,7 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full flex gap-2 items-center border-green-200 hover:bg-green-100/50 text-green-700 dark:border-green-800 dark:hover:bg-green-900/40 font-semibold"
|
className="w-full flex gap-2 items-center border-green-200 hover:bg-green-100/50 text-green-700 dark:border-green-800 dark:hover:bg-green-900/40 font-semibold"
|
||||||
onClick={() => handleComplete(booking.id)}
|
onClick={() => handleCompleteClick(booking.id)}
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4" />
|
<Check className="w-4 h-4" />
|
||||||
{dict.markDone}
|
{dict.markDone}
|
||||||
@@ -352,6 +419,72 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Completion Dialog */}
|
||||||
|
<Dialog open={isCompleteDialogOpen} onOpenChange={setIsCompleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{dict.completeTitle}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.completeDesc}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="message">{dict.completeMessage}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
value={completionMessage}
|
||||||
|
onChange={(e) => setCompletionMessage(e.target.value)}
|
||||||
|
placeholder={dict.completeMessagePlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="photo">{dict.completePhoto}</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="outline" size="icon" className="w-12 h-12" onClick={() => document.getElementById('photo-upload')?.click()}>
|
||||||
|
<Camera className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
id="photo-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
/>
|
||||||
|
{imagePreview && (
|
||||||
|
<div className="relative w-20 h-20 rounded-md overflow-hidden border">
|
||||||
|
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-0 right-0 h-5 w-5 bg-background/50 hover:bg-background"
|
||||||
|
onClick={() => {
|
||||||
|
setCompletionImage(null)
|
||||||
|
setImagePreview(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsCompleteDialogOpen(false)} disabled={isSubmitting}>
|
||||||
|
{dict.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirmComplete} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? dict.completing : dict.completeSubmit}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { PlanLoginForm } from "@/components/plan-login-form"
|
import { PlanLoginForm } from "@/components/plan-login-form"
|
||||||
import { PlanDashboard } from "./_components/plan-dashboard"
|
import { PlanDashboard } from "./_components/plan-dashboard"
|
||||||
import { getDictionary } from "@/get-dictionary"
|
import { getDictionary } from "@/get-dictionary"
|
||||||
|
import { PlanTracker } from "@/components/plan-tracker"
|
||||||
|
|
||||||
export default async function DashboardPage({
|
export default async function DashboardPage({
|
||||||
params
|
params
|
||||||
@@ -39,6 +40,7 @@ export default async function DashboardPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center p-4">
|
<main className="flex min-h-screen flex-col items-center p-4">
|
||||||
|
<PlanTracker planId={plan.id} title={plan.title} />
|
||||||
<div className="w-full max-w-4xl space-y-6">
|
<div className="w-full max-w-4xl space-y-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Button variant="ghost" size="sm" asChild className="w-fit -ml-2 text-muted-foreground">
|
<Button variant="ghost" size="sm" asChild className="w-fit -ml-2 text-muted-foreground">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
|
|||||||
return {
|
return {
|
||||||
title: dict.home.title,
|
title: dict.home.title,
|
||||||
description: dict.home.description,
|
description: dict.home.description,
|
||||||
|
manifest: "/manifest.json",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Image from "next/image";
|
|||||||
import { CreatePlanForm } from "@/components/create-plan-form";
|
import { CreatePlanForm } from "@/components/create-plan-form";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { getDictionary } from "@/get-dictionary";
|
import { getDictionary } from "@/get-dictionary";
|
||||||
|
import { RecentPlans } from "@/components/recent-plans";
|
||||||
|
|
||||||
export default async function Home({
|
export default async function Home({
|
||||||
params,
|
params,
|
||||||
@@ -32,6 +33,8 @@ export default async function Home({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RecentPlans lang={lang} dict={dict.home.recentPlans} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{dict.home.createPlan}</CardTitle>
|
<CardTitle>{dict.home.createPlan}</CardTitle>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { headers } from "next/headers"
|
import { headers } from "next/headers"
|
||||||
import { sendNotification } from "@/lib/notifications"
|
import { sendPlanNotification } from "@/lib/notifications"
|
||||||
import { getDictionary } from "@/get-dictionary"
|
import { getDictionary } from "@/get-dictionary"
|
||||||
|
|
||||||
export async function createBooking(planId: string, date: Date, name: string, type: "SITTER" | "OWNER_HOME" = "SITTER", lang: string = "en") {
|
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 host = (await headers()).get("host")
|
||||||
const protocol = host?.includes("localhost") ? "http" : "https"
|
const protocol = host?.includes("localhost") ? "http" : "https"
|
||||||
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
|
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.ownerHome.replace("{date}", dateStr).replace("{url}", planUrl)
|
||||||
: dict.notifications.newBooking.replace("{name}", name).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}`)
|
revalidatePath(`/${lang}/dashboard/${planId}`)
|
||||||
@@ -64,7 +64,7 @@ export async function deleteBooking(bookingId: number, planId: string, lang: str
|
|||||||
where: { id: bookingId }
|
where: { id: bookingId }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (booking.plan.webhookUrl) {
|
if (booking.plan.notifyAll) {
|
||||||
const host = (await headers()).get("host")
|
const host = (await headers()).get("host")
|
||||||
const protocol = host?.includes("localhost") ? "http" : "https"
|
const protocol = host?.includes("localhost") ? "http" : "https"
|
||||||
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
|
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
|
||||||
@@ -77,13 +77,13 @@ export async function deleteBooking(bookingId: number, planId: string, lang: str
|
|||||||
.replace("{url}", planUrl)
|
.replace("{url}", planUrl)
|
||||||
.replace("{message}", messageDisplay)
|
.replace("{message}", messageDisplay)
|
||||||
|
|
||||||
await sendNotification(booking.plan.webhookUrl, message)
|
await sendPlanNotification(planId, message, booking.plan.webhookUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath(`/${lang}/dashboard/${planId}`)
|
revalidatePath(`/${lang}/dashboard/${planId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeBooking(bookingId: number, planId: string, lang: string = "en") {
|
export async function completeBooking(bookingId: number, planId: string, lang: string = "en", message?: string, imageUrl?: string) {
|
||||||
const dict = await getDictionary(lang as any)
|
const dict = await getDictionary(lang as any)
|
||||||
|
|
||||||
const booking = await prisma.booking.findUnique({
|
const booking = await prisma.booking.findUnique({
|
||||||
@@ -95,21 +95,32 @@ export async function completeBooking(bookingId: number, planId: string, lang: s
|
|||||||
|
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
data: { completedAt: new Date() }
|
data: {
|
||||||
|
completedAt: new Date(),
|
||||||
|
completionMessage: message,
|
||||||
|
completionImage: imageUrl
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (booking.plan.webhookUrl) {
|
if (booking.plan.notifyAll) {
|
||||||
const host = (await headers()).get("host")
|
const host = (await headers()).get("host")
|
||||||
const protocol = host?.includes("localhost") ? "http" : "https"
|
const protocol = host?.includes("localhost") ? "http" : "https"
|
||||||
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
|
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
|
||||||
|
|
||||||
const dateStr = booking.date.toLocaleDateString(lang)
|
const dateStr = booking.date.toLocaleDateString(lang)
|
||||||
const message = dict.notifications.completed
|
let notificationMessage = dict.notifications.completed
|
||||||
.replace("{name}", booking.sitterName || "Someone")
|
.replace("{name}", booking.sitterName || "Someone")
|
||||||
.replace("{date}", dateStr)
|
.replace("{date}", dateStr)
|
||||||
.replace("{url}", planUrl)
|
.replace("{url}", planUrl)
|
||||||
|
|
||||||
await sendNotification(booking.plan.webhookUrl, message)
|
if (message) {
|
||||||
|
notificationMessage += `\n"${message}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to pass the full URL for the image
|
||||||
|
const fullImageUrl = imageUrl ? `${protocol}://${host}${imageUrl}` : undefined
|
||||||
|
|
||||||
|
await sendPlanNotification(planId, notificationMessage, booking.plan.webhookUrl, fullImageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath(`/${lang}/dashboard/${planId}`)
|
revalidatePath(`/${lang}/dashboard/${planId}`)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { headers } from "next/headers"
|
import { headers } from "next/headers"
|
||||||
import { sendNotification } from "@/lib/notifications"
|
import { sendPlanNotification } from "@/lib/notifications"
|
||||||
import { getDictionary } from "@/get-dictionary"
|
import { getDictionary } from "@/get-dictionary"
|
||||||
|
|
||||||
export async function updatePlan(
|
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 host = (await headers()).get("host")
|
||||||
const protocol = host?.includes("localhost") ? "http" : "https"
|
const protocol = host?.includes("localhost") ? "http" : "https"
|
||||||
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
|
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
|
||||||
|
|
||||||
await sendNotification(
|
await sendPlanNotification(
|
||||||
plan.webhookUrl,
|
planId,
|
||||||
dict.notifications.instructionsUpdated.replace("{url}", planUrl)
|
dict.notifications.instructionsUpdated.replace("{url}", planUrl),
|
||||||
|
plan.webhookUrl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
49
app/actions/subscription.ts
Normal file
49
app/actions/subscription.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVapidPublicKey() {
|
||||||
|
return process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||||
|
}
|
||||||
50
app/actions/upload-image.ts
Normal file
50
app/actions/upload-image.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { writeFile, mkdir } from "fs/promises"
|
||||||
|
import { join } from "path"
|
||||||
|
import { v4 as uuidv4 } from "uuid"
|
||||||
|
|
||||||
|
export async function uploadImage(data: FormData) {
|
||||||
|
const file: File | null = data.get("file") as unknown as File
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw new Error("No file uploaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await file.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(bytes)
|
||||||
|
|
||||||
|
// Validate mime type (basic check)
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
throw new Error("Only images are allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create uploads directory if it doesn't exist
|
||||||
|
// We organize by generic uploads folder for now, or per plan?
|
||||||
|
// Let's optimize for simplicity: /public/uploads/YYYY/MM
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const params = data.get("planId") ? String(data.get("planId")) : "misc"
|
||||||
|
|
||||||
|
// Use /uploads/planId/ to keep it somewhat organized or cleanup-able
|
||||||
|
const relativeDir = join("uploads", params)
|
||||||
|
const uploadDir = join(process.cwd(), "public", relativeDir)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(uploadDir, { recursive: true })
|
||||||
|
} catch (e) {
|
||||||
|
// ignore if exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const ext = file.name.split('.').pop() || "jpg"
|
||||||
|
const filename = `${uuidv4()}.${ext}`
|
||||||
|
const filepath = join(uploadDir, filename)
|
||||||
|
|
||||||
|
await writeFile(filepath, buffer)
|
||||||
|
|
||||||
|
// Return the public URL
|
||||||
|
return `/${relativeDir}/${filename}`
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export async function GET(
|
|||||||
icsContent = icsContent.replace(`DTEND;VALUE=DATE:${dateStr}\n`, `DTEND;VALUE=DATE:${nextDateStr}\n`)
|
icsContent = icsContent.replace(`DTEND;VALUE=DATE:${dateStr}\n`, `DTEND;VALUE=DATE:${nextDateStr}\n`)
|
||||||
|
|
||||||
icsContent += `SUMMARY:Cat Sitting: ${booking.sitterName}\n`
|
icsContent += `SUMMARY:Cat Sitting: ${booking.sitterName}\n`
|
||||||
icsContent += `DESCRIPTION:Cat sitting for plan ${plan.id}\n`
|
icsContent += `DESCRIPTION:Cat sitting for plan ${plan.title}\n`
|
||||||
icsContent += "END:VEVENT\n"
|
icsContent += "END:VEVENT\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
app/health/route.ts
Normal file
5
app/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok' }, { status: 200 });
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
|||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import * as z from "zod"
|
import * as z from "zod"
|
||||||
|
import { PushSubscriptionSettings } from "@/components/push-manager"
|
||||||
import { Settings } from "lucide-react"
|
import { Settings } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -112,6 +113,9 @@ export function PlanSettings({
|
|||||||
{dict.description}
|
{dict.description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<PushSubscriptionSettings planId={planId} />
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
35
components/plan-tracker.tsx
Normal file
35
components/plan-tracker.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
export function PlanTracker({ planId, title }: { planId: string, title: string }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("csp_recent_plans")
|
||||||
|
let plans: { id: string, title: string, lastVisited: number }[] = []
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
plans = JSON.parse(stored)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing entry for this plan
|
||||||
|
plans = plans.filter(p => p.id !== planId)
|
||||||
|
|
||||||
|
// Add to top
|
||||||
|
plans.unshift({
|
||||||
|
id: planId,
|
||||||
|
title: title,
|
||||||
|
lastVisited: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep max 5
|
||||||
|
plans = plans.slice(0, 5)
|
||||||
|
|
||||||
|
localStorage.setItem("csp_recent_plans", JSON.stringify(plans))
|
||||||
|
}, [planId, title])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
174
components/push-manager.tsx
Normal file
174
components/push-manager.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Bell, BellOff, Loader2, AlertTriangle } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { subscribeUser, unsubscribeUser, getVapidPublicKey } from "@/app/actions/subscription"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string) {
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
} catch (e) {
|
||||||
|
console.error("VAPID Key conversion failed", e)
|
||||||
|
throw new Error("Invalid VAPID Key format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PushSubscriptionSettings({ planId }: { planId: string }) {
|
||||||
|
const [isSupported, setIsSupported] = useState(false)
|
||||||
|
const [subscription, setSubscription] = useState<PushSubscription | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [debugInfo, setDebugInfo] = useState<string | null>(null)
|
||||||
|
const [vapidKey, setVapidKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkSupport() {
|
||||||
|
const checks = []
|
||||||
|
if (!('serviceWorker' in navigator)) checks.push("No Service Worker support")
|
||||||
|
if (!('PushManager' in window)) checks.push("No PushManager support")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = await getVapidPublicKey()
|
||||||
|
if (!key) {
|
||||||
|
checks.push("Missing VAPID Key (Server)")
|
||||||
|
} else {
|
||||||
|
setVapidKey(key)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
checks.push("Failed to fetch VAPID Key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checks.length === 0) {
|
||||||
|
setIsSupported(true)
|
||||||
|
registerServiceWorker()
|
||||||
|
} else {
|
||||||
|
console.warn("Push not supported:", checks.join(", "))
|
||||||
|
setDebugInfo(checks.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkSupport()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function registerServiceWorker() {
|
||||||
|
try {
|
||||||
|
// Explicitly register the service worker
|
||||||
|
const registration = await navigator.serviceWorker.register('/push-sw.js')
|
||||||
|
|
||||||
|
// Wait for it to be ready
|
||||||
|
await navigator.serviceWorker.ready
|
||||||
|
|
||||||
|
const sub = await registration.pushManager.getSubscription()
|
||||||
|
setSubscription(sub)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("SW registration error", e)
|
||||||
|
setDebugInfo(`SW Error: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribe() {
|
||||||
|
if (!vapidKey) {
|
||||||
|
toast.error("VAPID Key missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setDebugInfo(null)
|
||||||
|
try {
|
||||||
|
console.log("Requesting permission...")
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
console.log("Permission result:", permission)
|
||||||
|
|
||||||
|
if (permission === 'denied') {
|
||||||
|
toast.error("Notifications are blocked in your browser settings.")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
toast.error("Notifications permission not granted.")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Waiting for SW ready...")
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
console.log("SW Ready. Subscribing...")
|
||||||
|
|
||||||
|
const sub = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(vapidKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("Subscribed locally:", sub)
|
||||||
|
setSubscription(sub)
|
||||||
|
await subscribeUser(planId, sub.toJSON())
|
||||||
|
toast.success("Push Notifications enabled")
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Subscription failed", error)
|
||||||
|
setDebugInfo(`Error: ${error.message || "Unknown error"}`)
|
||||||
|
toast.error("Failed to enable notifications. See debug info.")
|
||||||
|
} 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 && !debugInfo) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-4 border rounded-md mb-4 bg-muted/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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 || !isSupported} variant="secondary">
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Bell className="w-4 h-4 mr-2" />}
|
||||||
|
Enable
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{debugInfo && (
|
||||||
|
<div className="text-xs text-destructive flex items-center gap-1 bg-destructive/10 p-2 rounded">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
{debugInfo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
components/recent-plans.tsx
Normal file
76
components/recent-plans.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { History, ChevronRight, X } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type RecentPlan = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
lastVisited: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentPlans({ lang, dict }: { lang: string, dict: { title: string, noPlans: string, clear: string } }) {
|
||||||
|
const [plans, setPlans] = useState<RecentPlan[]>([])
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
const stored = localStorage.getItem("csp_recent_plans")
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
setPlans(parsed.sort((a: RecentPlan, b: RecentPlan) => b.lastVisited - a.lastVisited))
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse recent plans", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removePlan = (id: string, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const newPlans = plans.filter(p => p.id !== id)
|
||||||
|
setPlans(newPlans)
|
||||||
|
localStorage.setItem("csp_recent_plans", JSON.stringify(newPlans))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted || plans.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
{dict.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<Link
|
||||||
|
key={plan.id}
|
||||||
|
href={`/${lang}/dashboard/${plan.id}`}
|
||||||
|
className="group flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate mr-4">
|
||||||
|
{plan.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => removePlan(plan.id, e)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@
|
|||||||
"title": "Katzen-Sitting-Planer",
|
"title": "Katzen-Sitting-Planer",
|
||||||
"description": "Koordiniere die Pflege deiner Vierbeiner, während du weg bist.",
|
"description": "Koordiniere die Pflege deiner Vierbeiner, während du weg bist.",
|
||||||
"createPlan": "Neuen Plan erstellen",
|
"createPlan": "Neuen Plan erstellen",
|
||||||
"createPlanDesc": "Gib deinem Plan einen Namen, wähle deine Reisedaten und setze ein Gruppen-Passwort."
|
"createPlanDesc": "Gib deinem Plan einen Namen, wähle deine Reisedaten und setze ein Gruppen-Passwort.",
|
||||||
|
"recentPlans": {
|
||||||
|
"title": "Zuletzt besuchte Pläne",
|
||||||
|
"noPlans": "Keine Pläne",
|
||||||
|
"clear": "Leeren"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"createPlanForm": {
|
"createPlanForm": {
|
||||||
"title": "Titel des Plans",
|
"title": "Titel des Plans",
|
||||||
@@ -33,6 +38,10 @@
|
|||||||
"instructionsTitle": "Katzenpflege-Anleitungen",
|
"instructionsTitle": "Katzenpflege-Anleitungen",
|
||||||
"export": "Exportieren",
|
"export": "Exportieren",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
|
"share": "Teilen",
|
||||||
|
"shareTitle": "Plan teilen",
|
||||||
|
"copySuccess": "Link kopiert!",
|
||||||
|
"shareError": "Fehler beim Teilen.",
|
||||||
"feeding": "Füttern",
|
"feeding": "Füttern",
|
||||||
"litter": "Klo reinigen",
|
"litter": "Klo reinigen",
|
||||||
"ownerHome": "Besitzer zu Hause",
|
"ownerHome": "Besitzer zu Hause",
|
||||||
@@ -57,7 +66,14 @@
|
|||||||
"cancelError": "Entfernen fehlgeschlagen",
|
"cancelError": "Entfernen fehlgeschlagen",
|
||||||
"noInstructions": "Noch keine spezifischen Instruktionen hinterlegt.",
|
"noInstructions": "Noch keine spezifischen Instruktionen hinterlegt.",
|
||||||
"markDone": "Job erledigt?",
|
"markDone": "Job erledigt?",
|
||||||
"jobDone": "Job erledigt!"
|
"jobDone": "Job erledigt!",
|
||||||
|
"completeTitle": "Job abschließen",
|
||||||
|
"completeDesc": "Füge optional eine Nachricht oder ein Foto hinzu.",
|
||||||
|
"completeMessage": "Nachricht (optional)",
|
||||||
|
"completeMessagePlaceholder": "Alles super!",
|
||||||
|
"completePhoto": "Foto (optional)",
|
||||||
|
"completeSubmit": "Job als erledigt markieren",
|
||||||
|
"completing": "Speichere..."
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Passwort eingeben",
|
"title": "Passwort eingeben",
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
"title": "Cat Sitting Planner",
|
"title": "Cat Sitting Planner",
|
||||||
"description": "Coordinate care for your furry friends while you're away.",
|
"description": "Coordinate care for your furry friends while you're away.",
|
||||||
"createPlan": "Create a New Plan",
|
"createPlan": "Create a New Plan",
|
||||||
"createPlanDesc": "Give your plan a title, select your travel dates and set a group password."
|
"createPlanDesc": "Give your plan a title, select your travel dates and set a group password.",
|
||||||
|
"recentPlans": {
|
||||||
|
"title": "Recently Visited Plans",
|
||||||
|
"noPlans": "No recent plans",
|
||||||
|
"clear": "Clear"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"createPlanForm": {
|
"createPlanForm": {
|
||||||
"title": "Plan Title",
|
"title": "Plan Title",
|
||||||
@@ -33,6 +38,10 @@
|
|||||||
"instructionsTitle": "Cat Care Instructions",
|
"instructionsTitle": "Cat Care Instructions",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"share": "Share",
|
||||||
|
"shareTitle": "Share Plan",
|
||||||
|
"copySuccess": "Link copied to clipboard!",
|
||||||
|
"shareError": "Could not share plan.",
|
||||||
"feeding": "Feeding",
|
"feeding": "Feeding",
|
||||||
"litter": "Clean litter",
|
"litter": "Clean litter",
|
||||||
"ownerHome": "Owner Home",
|
"ownerHome": "Owner Home",
|
||||||
@@ -57,7 +66,14 @@
|
|||||||
"cancelError": "Cancellation failed",
|
"cancelError": "Cancellation failed",
|
||||||
"noInstructions": "No specific instructions added yet.",
|
"noInstructions": "No specific instructions added yet.",
|
||||||
"markDone": "Job done?",
|
"markDone": "Job done?",
|
||||||
"jobDone": "Job done!"
|
"jobDone": "Job done!",
|
||||||
|
"completeTitle": "Complete Job",
|
||||||
|
"completeDesc": "Add a message or photo (optional) and confirm.",
|
||||||
|
"completeMessage": "Message (optional)",
|
||||||
|
"completeMessagePlaceholder": "All good!",
|
||||||
|
"completePhoto": "Photo (optional)",
|
||||||
|
"completeSubmit": "Complete Job",
|
||||||
|
"completing": "Completing..."
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Enter Password",
|
"title": "Enter Password",
|
||||||
|
|||||||
@@ -7,4 +7,10 @@ services:
|
|||||||
- DATABASE_URL=file:/app/data/dev.db
|
- DATABASE_URL=file:/app/data/dev.db
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./public/uploads:/app/public/uploads
|
||||||
restart: always
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health" ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|||||||
82
implementation_plan_pwa_push.md
Normal file
82
implementation_plan_pwa_push.md
Normal 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
|
||||||
@@ -1,18 +1,37 @@
|
|||||||
export async function sendNotification(webhookUrl: string | null, message: string) {
|
export async function sendNotification(webhookUrl: string | null, message: string, imageUrl?: string) {
|
||||||
if (!webhookUrl) return;
|
if (!webhookUrl) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
content: message,
|
||||||
|
text: message
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Discord: use embed if image exists
|
||||||
|
if (imageUrl && webhookUrl.includes("discord")) {
|
||||||
|
payload.embeds = [{
|
||||||
|
image: {
|
||||||
|
url: imageUrl
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
// For generic webhooks, just append URL to text if not Discord, or leave as is?
|
||||||
|
// Let's create a simpler payload if image exists but not discord?
|
||||||
|
// Actually, if we just send JSON, most won't render it unless specific format.
|
||||||
|
// Let's just append the image URL to the message if it's not a Discord webhook, so it's clickable.
|
||||||
|
if (imageUrl && !webhookUrl.includes("discord")) {
|
||||||
|
payload.content += `\n${imageUrl}`;
|
||||||
|
payload.text += `\n${imageUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
const response = await fetch(webhookUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": "CatSittingPlanner/1.0"
|
"User-Agent": "CatSittingPlanner/1.0"
|
||||||
},
|
},
|
||||||
// Works for Discord (content) and generic Telegram Webhook bridges (text)
|
body: JSON.stringify(payload),
|
||||||
body: JSON.stringify({
|
|
||||||
content: message,
|
|
||||||
text: message
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`[Notification] Webhook failed with status ${response.status}`);
|
console.error(`[Notification] Webhook failed with status ${response.status}`);
|
||||||
@@ -21,3 +40,62 @@ export async function sendNotification(webhookUrl: string | null, message: strin
|
|||||||
console.error("Failed to send notification:", error);
|
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, imageUrl?: string) {
|
||||||
|
// Parallelize sending
|
||||||
|
const promises: Promise<any>[] = [];
|
||||||
|
|
||||||
|
if (webhookUrl) {
|
||||||
|
promises.push(sendNotification(webhookUrl, message, imageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscriptions = await prisma.pushSubscription.findMany({
|
||||||
|
where: { planId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscriptions.length > 0) {
|
||||||
|
console.log(`[Push] Found ${subscriptions.length} subscriptions for plan ${planId}`);
|
||||||
|
const payload: any = {
|
||||||
|
title: "Cat Sitting Planner",
|
||||||
|
body: message,
|
||||||
|
url: `/`,
|
||||||
|
image: imageUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 () => {
|
||||||
|
try {
|
||||||
|
console.log(`[Push] Sending to endpoint ${sub.endpoint.slice(0, 20)}...`);
|
||||||
|
const res = await sendPushNotification(sub, payload);
|
||||||
|
console.log(`[Push] Result for ${sub.endpoint.slice(0, 20)}...:`, res);
|
||||||
|
if (!res.success) {
|
||||||
|
console.error(`[Push] Failed for endpoint: ${res.statusCode}`);
|
||||||
|
if (res.statusCode === 410 || res.statusCode === 404) {
|
||||||
|
console.log(`[Push] Deleting stale subscription`);
|
||||||
|
await prisma.pushSubscription.delete({ where: { id: sub.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Push] Error inside loop:`, err);
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`[Push] No subscriptions found for plan ${planId}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch/send push subscriptions", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
}
|
||||||
|
|||||||
39
lib/push.ts
Normal file
39
lib/push.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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 {
|
||||||
|
console.log(`[PushLib] Sending to ${subscription.endpoint.slice(0, 30)}...`);
|
||||||
|
const result = await webpush.sendNotification({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: subscription.p256dh,
|
||||||
|
auth: subscription.auth,
|
||||||
|
}
|
||||||
|
}, JSON.stringify(payload));
|
||||||
|
console.log(`[PushLib] Success: ${result.statusCode}`);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
if (pathnameHasLocale) return;
|
if (pathnameHasLocale) return;
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api") || pathname.includes("sw.js") || pathname.includes("workbox")) return NextResponse.next();
|
||||||
|
|
||||||
// Redirect if there is no locale
|
// Redirect if there is no locale
|
||||||
const locale = defaultLocale; // For simplicity, we default to 'de' as requested
|
const locale = defaultLocale; // For simplicity, we default to 'de' as requested
|
||||||
request.nextUrl.pathname = `/${locale}${pathname}`;
|
request.nextUrl.pathname = `/${locale}${pathname}`;
|
||||||
@@ -25,6 +27,6 @@ export function middleware(request: NextRequest) {
|
|||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
// Skip all internal paths (_next)
|
// Skip all internal paths (_next)
|
||||||
"/((?!_next|api|public|manifest|icon|file|globe|next|vercel|window).*)",
|
"/((?!_next|api|public|manifest|icon|file|globe|next|vercel|window|push-sw.js|sw.js|workbox|health).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const withPWA = withPWAInit({
|
|||||||
disable: process.env.NODE_ENV === "development",
|
disable: process.env.NODE_ENV === "development",
|
||||||
register: true,
|
register: true,
|
||||||
skipWaiting: true,
|
skipWaiting: true,
|
||||||
|
workboxOptions: {
|
||||||
|
importScripts: ["/push-sw.js"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|||||||
966
package-lock.json
generated
966
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -8,6 +8,9 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"workbox-build": "7.4.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@@ -18,26 +21,30 @@
|
|||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"prisma": "^6.19.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.71.0",
|
"react-hook-form": "^7.71.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5",
|
"uuid": "^13.0.0",
|
||||||
"prisma": "^6.19.1"
|
"web-push": "^3.6.7",
|
||||||
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20.19.28",
|
"@types/node": "^20.19.28",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
@@ -45,4 +52,4 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "completionImage" TEXT;
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "completionMessage" TEXT;
|
||||||
@@ -21,6 +21,7 @@ model Plan {
|
|||||||
litterInterval Int @default(2)
|
litterInterval Int @default(2)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Booking {
|
model Booking {
|
||||||
@@ -33,5 +34,18 @@ model Booking {
|
|||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
completionMessage String?
|
||||||
|
completionImage String?
|
||||||
|
|
||||||
@@unique([planId, date, sitterName])
|
@@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())
|
||||||
|
}
|
||||||
|
|||||||
50
public/push-sw.js
Normal file
50
public/push-sw.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
console.log('[SW] Push Received', event);
|
||||||
|
if (!event.data) {
|
||||||
|
console.log('[SW] No data provided in push event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = event.data.json();
|
||||||
|
console.log('[SW] Push Data:', data);
|
||||||
|
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)
|
||||||
|
.then(() => console.log('[SW] Notification shown'))
|
||||||
|
.catch(e => console.error('[SW] Error showing notification:', e))
|
||||||
|
);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Reference in New Issue
Block a user