mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge pull request #164 from Since-3/feature/pwa
added pwa infrastructure and push notifcation
This commit is contained in:
commit
6d5a1bee7c
25 changed files with 2068 additions and 206 deletions
9
AGENTS.md
Normal file
9
AGENTS.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
CLAUDE.md
Normal file
1
CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
@AGENTS.md
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
import { withSerwist } from "@serwist/turbopack";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
};
|
||||
|
||||
export default withSentryConfig(withNextIntl(nextConfig), {
|
||||
export default withSentryConfig(withNextIntl(withSerwist(nextConfig)), {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
|
||||
|
|
|
|||
889
package-lock.json
generated
889
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -34,6 +34,7 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@sentry/nextjs": "^10.69.0",
|
||||
"@serwist/turbopack": "^9.5.12",
|
||||
"@stripe/stripe-js": "^9.5.0",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.116.0",
|
||||
|
|
@ -60,9 +61,11 @@
|
|||
"react-phone-number-input": "^3.4.14",
|
||||
"recharts": "^3.10.1",
|
||||
"resend": "^6.18.0",
|
||||
"serwist": "^9.5.12",
|
||||
"sonner": "^2.0.7",
|
||||
"stripe": "^22.1.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
|
|
@ -77,8 +80,11 @@
|
|||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"esbuild": "^0.28.2",
|
||||
"esbuild-wasm": "^0.28.2",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"husky": "^9.1.7",
|
||||
|
|
@ -112,7 +118,7 @@
|
|||
"markdown-it": ">=14.1.1",
|
||||
"postcss": ">=8.5.23",
|
||||
"defu": ">=6.1.5",
|
||||
"esbuild": ">=0.28.1",
|
||||
"esbuild": "$esbuild",
|
||||
"vite": ">=7.3.2",
|
||||
"brace-expansion": ">=5.0.9",
|
||||
"ws": ">=8.21.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "PushSubscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"recipientId" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PushSubscription_recipientId_idx" ON "PushSubscription"("recipientId");
|
||||
|
|
@ -266,6 +266,25 @@ model Notification {
|
|||
@@index([recipientId, createdAt])
|
||||
}
|
||||
|
||||
// Web-Push-Subscriptions (ein Datensatz pro Browser/Gerät). recipientId ist
|
||||
// polymorph wie bei Notification (Manager-, Employee- oder User-ID) - Push ist
|
||||
// ein zweiter Zustellkanal für createNotification(), kein eigenes System.
|
||||
// endpoint ist pro Push-Dienst (FCM/Mozilla/...) global eindeutig und dient
|
||||
// als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu
|
||||
// aktualisieren statt zu duplizieren.
|
||||
model PushSubscription {
|
||||
id String @id @default(uuid())
|
||||
recipientId String
|
||||
endpoint String @unique
|
||||
p256dh String // Public Key des Browsers für die Payload-Verschlüsselung
|
||||
auth String // Auth-Secret des Browsers für die Payload-Verschlüsselung
|
||||
userAgent String? // Nur zur Anzeige in einer künftigen "Geräte verwalten"-UI
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert
|
||||
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
||||
// Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit
|
||||
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
|
||||
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
|
||||
|
|
|
|||
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
public/icons/icon-maskable-192.png
Normal file
BIN
public/icons/icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
BIN
public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
36
public/manifest.json
Normal file
36
public/manifest.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "Kumasy",
|
||||
"short_name": "Kumasy",
|
||||
"description": "Kumasy – Kurse buchen und verwalten",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1E293B",
|
||||
"theme_color": "#F4C00C",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,10 +2,13 @@ import type { Metadata, Viewport } from "next";
|
|||
import localFont from "next/font/local";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { SerwistProvider } from "@serwist/turbopack/react";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { AuthInitializer } from "../components/AuthInitializer";
|
||||
import NavigationProgress from "../components/layout/NavigationProgress";
|
||||
import { InstallPrompt } from "../components/InstallPrompt";
|
||||
import { NotificationPrompt } from "../components/NotificationPrompt";
|
||||
|
||||
const salmondFont = localFont({
|
||||
src: "../public/font/salmond.woff",
|
||||
|
|
@ -13,12 +16,23 @@ const salmondFont = localFont({
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kumasy",
|
||||
description: "Generated by create next app",
|
||||
description: "Kumasy – Kurse buchen und verwalten",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Kumasy",
|
||||
},
|
||||
icons: {
|
||||
icon: "/icons/icon-192.png",
|
||||
apple: "/icons/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#F4C00C",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
|
|
@ -31,10 +45,13 @@ export default async function RootLayout({
|
|||
return (
|
||||
<html lang={locale} className={salmondFont.className}>
|
||||
<body className={`antialiased bg-muted`}>
|
||||
<SerwistProvider swUrl="/serwist/sw.js">
|
||||
<NextIntlClientProvider>
|
||||
<NavigationProgress />
|
||||
<AuthInitializer />
|
||||
<main>{children}</main>
|
||||
<InstallPrompt />
|
||||
<NotificationPrompt />
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
|
|
@ -44,6 +61,7 @@ export default async function RootLayout({
|
|||
}}
|
||||
/>
|
||||
</NextIntlClientProvider>
|
||||
</SerwistProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
18
src/app/serwist/[path]/route.ts
Normal file
18
src/app/serwist/[path]/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { createSerwistRoute } from "@serwist/turbopack";
|
||||
|
||||
const revision =
|
||||
spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" }).stdout?.trim() ||
|
||||
crypto.randomUUID();
|
||||
|
||||
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute(
|
||||
{
|
||||
swSrc: "src/app/sw.ts",
|
||||
additionalPrecacheEntries: [{ url: "/manifest.json", revision }],
|
||||
// Default ist nur auf win32 true (siehe @serwist/turbopack), auf
|
||||
// Linux/macOS sonst esbuild-wasm - explizit erzwungen, damit das
|
||||
// Verhalten plattformunabhängig gleich ist (esbuild bleibt außerdem als
|
||||
// direkte Dependency installiert, esbuild-wasm nur als Fallback-Netz).
|
||||
useNativeEsbuild: true,
|
||||
}
|
||||
);
|
||||
72
src/app/sw.ts
Normal file
72
src/app/sw.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
import { defaultCache } from "@serwist/turbopack/worker";
|
||||
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
|
||||
import { Serwist } from "serwist";
|
||||
|
||||
declare global {
|
||||
interface WorkerGlobalScope extends SerwistGlobalConfig {
|
||||
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
const serwist = new Serwist({
|
||||
precacheEntries: self.__SW_MANIFEST,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
navigationPreload: true,
|
||||
runtimeCaching: defaultCache,
|
||||
});
|
||||
|
||||
serwist.addEventListeners();
|
||||
|
||||
interface PushPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event: PushEvent) => {
|
||||
if (!event.data) return;
|
||||
|
||||
let payload: PushPayload;
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
// Server sendet immer JSON (siehe send-push.ts) - ein Parse-Fehler
|
||||
// deutet auf eine fremde/kaputte Payload hin, dann lieber gar nichts
|
||||
// anzeigen als eine leere/verstümmelte Notification.
|
||||
return;
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title, {
|
||||
body: payload.body,
|
||||
icon: "/icons/icon-192.png",
|
||||
badge: "/icons/icon-192.png",
|
||||
data: { url: payload.url ?? "/" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
||||
event.notification.close();
|
||||
const url = (event.notification.data as { url?: string } | undefined)?.url ?? "/";
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clientsList = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
const existing = clientsList.find((client) => new URL(client.url).pathname === url);
|
||||
if (existing) {
|
||||
await existing.focus();
|
||||
return;
|
||||
}
|
||||
await self.clients.openWindow(url);
|
||||
})()
|
||||
);
|
||||
});
|
||||
124
src/components/InstallPrompt.tsx
Normal file
124
src/components/InstallPrompt.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { X, Share, Plus, Download } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
INSTALL_DISMISSED_KEY,
|
||||
INSTALL_PROMPT_SETTLED_EVENT,
|
||||
isInstallPromptPending,
|
||||
} from "@/src/lib/pwa";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
|
||||
}
|
||||
|
||||
// Kein echtes externes Event zum Abonnieren - subscribe registriert daher
|
||||
// nie ein Update. Der Witz ist nur getServerSnapshot: React rendert serverseitig
|
||||
// (und im allerersten Client-Pass vor der Hydration) "false" und ersetzt es
|
||||
// direkt danach durch den echten Browser-Wert, OHNE die
|
||||
// react-hooks/set-state-in-effect-Regel zu verletzen und OHNE eine
|
||||
// Hydration-Mismatch-Warnung auszulösen (dafür ist der Hook gebaut).
|
||||
function subscribeNever() {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function InstallPrompt() {
|
||||
// useSyncExternalStore statt Lazy-useState/Effect: getServerSnapshot liefert
|
||||
// serverseitig UND im allerersten Client-Pass vor der Hydration "false",
|
||||
// React ersetzt es danach durch den echten Browser-Wert - ohne
|
||||
// Hydration-Mismatch-Warnung und ohne die react-hooks/set-state-in-effect-
|
||||
// Regel zu verletzen (kein setState im Effect-Body nötig).
|
||||
const pending = useSyncExternalStore(subscribeNever, isInstallPromptPending, () => false);
|
||||
const showIosHint = useSyncExternalStore(
|
||||
subscribeNever,
|
||||
() => pending && isIos(),
|
||||
() => false
|
||||
);
|
||||
// dismiss() muss den Prompt sofort ausblenden können, auch bevor sich der
|
||||
// zugrunde liegende localStorage-Wert das nächste Mal ändert - pending
|
||||
// allein kann das nicht (useSyncExternalStore hat kein echtes Update-Event).
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const eligible = pending && !dismissed;
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eligible || showIosHint) return;
|
||||
|
||||
const handleBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
return () => window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
}, [eligible, showIosHint]);
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(INSTALL_DISMISSED_KEY, "1");
|
||||
setDismissed(true);
|
||||
window.dispatchEvent(new Event(INSTALL_PROMPT_SETTLED_EVENT));
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
if (outcome === "accepted") dismiss();
|
||||
};
|
||||
|
||||
if (!eligible || (!deferredPrompt && !showIosHint)) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 inset-x-4 z-50 sm:left-auto sm:right-4 sm:w-80">
|
||||
<div className="border border-white/60 bg-white/80 backdrop-blur-xl rounded-xl shadow-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 flex items-center justify-center size-9 rounded-lg bg-yellow-50 text-yellow-600">
|
||||
<Download size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">Kumasy installieren</p>
|
||||
{showIosHint ? (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Tippe auf <Share size={12} className="inline -mt-0.5" /> und dann auf{" "}
|
||||
<span className="inline-flex items-center gap-0.5 font-medium text-gray-700">
|
||||
<Plus size={12} /> Zum Home-Bildschirm
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Für schnelleren Zugriff und Benachrichtigungen zum Startbildschirm hinzufügen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 -mt-1 -mr-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
onClick={dismiss}
|
||||
title="Schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!showIosHint && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button size="sm" className="flex-1" onClick={handleInstall}>
|
||||
Installieren
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="bg-white/50" onClick={dismiss}>
|
||||
Nicht jetzt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
src/components/NotificationPrompt.tsx
Normal file
209
src/components/NotificationPrompt.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { X, BellRing } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { INSTALL_PROMPT_SETTLED_EVENT, isInstallPromptPending } from "@/src/lib/pwa";
|
||||
import {
|
||||
subscribeToPush,
|
||||
unsubscribeFromPush,
|
||||
} from "@/src/modules/notifications/actions/push-subscription-actions";
|
||||
|
||||
const DISMISSED_KEY = "kumasy-notification-prompt-dismissed";
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = window.atob(base64);
|
||||
const output = new Uint8Array(new ArrayBuffer(rawData.length));
|
||||
for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i);
|
||||
return output;
|
||||
}
|
||||
|
||||
function isPushSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
|
||||
// Basis-Voraussetzungen, die sich zur Laufzeit nicht ändern (API-Support,
|
||||
// VAPID-Konfiguration) - anders als Notification.permission/localStorage
|
||||
// unten, die erst nach einem Nutzer-Entscheid entstehen.
|
||||
function isBaseSupported(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) && isPushSupported();
|
||||
}
|
||||
|
||||
// Anders als in InstallPrompt.tsx gibt es hier ein echtes externes Signal,
|
||||
// auf das reagiert werden muss: computeBaseEligible() hängt (über
|
||||
// isInstallPromptPending()) vom Install-Prompt-Status ab, der sich NACH
|
||||
// unserem Mount ändern kann (Nutzer klickt ihn erst später weg) - INSTALL_
|
||||
// PROMPT_SETTLED_EVENT signalisiert genau das, useSyncExternalStore ruft dann
|
||||
// automatisch computeBaseEligible() erneut auf.
|
||||
function subscribeToInstallPromptSettled(onChange: () => void) {
|
||||
window.addEventListener(INSTALL_PROMPT_SETTLED_EVENT, onChange);
|
||||
return () => window.removeEventListener(INSTALL_PROMPT_SETTLED_EVENT, onChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob der Prompt (erneut) angezeigt werden soll. Nur eine explizite
|
||||
* Ablehnung (Permission "denied") oder ein manuelles Wegklicken persistieren
|
||||
* dauerhaft in localStorage - ein technischer Fehler nach erteilter
|
||||
* Permission (Subscribe/Server) darf den In-App-Retry-Pfad nicht
|
||||
* blockieren, sonst bliebe der Nutzer ohne Push, bis er Site-Daten
|
||||
* zurücksetzt (siehe handleEnable). Deckt NICHT den Fall ab, dass die
|
||||
* Permission bereits erteilt ist, aber die Server-Registrierung fehlte -
|
||||
* das kann nur async per pushManager.getSubscription() geprüft werden,
|
||||
* siehe missingServerRegistration-State in NotificationPrompt.
|
||||
*/
|
||||
function computeBaseEligible(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (!isBaseSupported()) return false;
|
||||
if (Notification.permission === "denied") return false;
|
||||
// Wartet, bis InstallPrompt nicht mehr sichtbar ist (installiert oder
|
||||
// weggeklickt) - sonst überlappen sich beide "fixed bottom-4"-Banner.
|
||||
if (isInstallPromptPending()) return false;
|
||||
return localStorage.getItem(DISMISSED_KEY) !== "1";
|
||||
}
|
||||
|
||||
export function NotificationPrompt() {
|
||||
// useSyncExternalStore statt Lazy-useState/Effect (siehe InstallPrompt.tsx):
|
||||
// getServerSnapshot liefert server- und im allerersten Client-Pass "false",
|
||||
// kein Hydration-Mismatch, kein setState im Effect-Body nötig.
|
||||
const baseEligible = useSyncExternalStore(
|
||||
subscribeToInstallPromptSettled,
|
||||
computeBaseEligible,
|
||||
() => false
|
||||
);
|
||||
// true, wenn Permission bereits erteilt ist, aber der Browser lokal keine
|
||||
// Subscription (mehr) hat - dann war die vorherige Registrierung vermutlich
|
||||
// unterbrochen (siehe handleEnable-Kommentar), der Prompt bleibt trotz
|
||||
// gesetztem Dismissed-Flag sichtbar, damit ein Retry möglich ist. Kann nicht
|
||||
// Teil von computeBaseEligible/useSyncExternalStore sein, weil
|
||||
// getSubscription() ein Promise zurückgibt (kein synchroner Snapshot).
|
||||
const [missingServerRegistration, setMissingServerRegistration] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [isSubscribing, setIsSubscribing] = useState(false);
|
||||
const eligible = (baseEligible || missingServerRegistration) && !dismissed;
|
||||
|
||||
useEffect(() => {
|
||||
if (isBaseSupported() && Notification.permission === "granted") {
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => registration.pushManager.getSubscription())
|
||||
.then((subscription) => {
|
||||
if (!subscription) setMissingServerRegistration(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Schließen-Button/explizite Ablehnung: dauerhaft in localStorage merken.
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(DISMISSED_KEY, "1");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
// Technischer Fehler nach erteilter Permission: NUR den Prompt ausblenden,
|
||||
// ohne localStorage zu setzen, damit der Nutzer es beim nächsten Laden
|
||||
// erneut versuchen kann.
|
||||
const hideWithoutPersisting = () => setDismissed(true);
|
||||
|
||||
const handleEnable = async () => {
|
||||
setIsSubscribing(true);
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
// "denied" wird schon von computeBaseEligible() dauerhaft
|
||||
// ausgeschlossen, kein zusätzliches localStorage-Flag nötig.
|
||||
hideWithoutPersisting();
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
|
||||
});
|
||||
|
||||
const json = subscription.toJSON();
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
throw new Error("Unvollständige Push-Subscription vom Browser erhalten.");
|
||||
}
|
||||
|
||||
const result = await subscribeToPush(
|
||||
{ endpoint: json.endpoint, keys: { p256dh: json.keys.p256dh, auth: json.keys.auth } },
|
||||
navigator.userAgent
|
||||
);
|
||||
if (!result.success) {
|
||||
// Serverseitig nicht gespeichert - Subscription auf dem Gerät nicht
|
||||
// verwaist zurücklassen, sonst denkt der Browser dauerhaft, er sei
|
||||
// angemeldet, ohne dass Push je zugestellt werden könnte. Best-effort:
|
||||
// schlägt das fehl, bleibt eine verwaiste Browser-Subscription zurück,
|
||||
// die der useEffect-Check oben beim nächsten Laden über
|
||||
// getSubscription() erkennt und den Prompt trotzdem wieder anbietet.
|
||||
await subscription.unsubscribe().catch(() => {});
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
dismiss();
|
||||
} catch (error) {
|
||||
console.error("Push-Aktivierung fehlgeschlagen:", error);
|
||||
hideWithoutPersisting();
|
||||
} finally {
|
||||
setIsSubscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 inset-x-4 z-50 sm:left-auto sm:right-4 sm:w-80">
|
||||
<div className="border border-white/60 bg-white/80 backdrop-blur-xl rounded-xl shadow-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 flex items-center justify-center size-9 rounded-lg bg-yellow-50 text-yellow-600">
|
||||
<BellRing size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">Benachrichtigungen aktivieren</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Verpasse keine Buchungsbestätigung oder freien Wartelisten-Plätze mehr.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 -mt-1 -mr-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
onClick={dismiss}
|
||||
title="Schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button size="sm" className="flex-1" onClick={handleEnable} disabled={isSubscribing}>
|
||||
{isSubscribing ? "Aktiviert…" : "Aktivieren"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="bg-white/50" onClick={dismiss}>
|
||||
Nicht jetzt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wird von einer Einstellungen-UI importiert, um Push wieder zu deaktivieren
|
||||
// (z.B. künftige "Geräte verwalten"-Seite) - hier co-located, weil sie
|
||||
// denselben Browser-API-Zugriff (serviceWorker.ready) braucht wie handleEnable.
|
||||
export async function disablePushOnThisDevice(): Promise<void> {
|
||||
if (!isPushSupported()) return;
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
|
||||
await unsubscribeFromPush(subscription.endpoint).catch(() => {});
|
||||
await subscription.unsubscribe().catch(() => {});
|
||||
}
|
||||
80
src/lib/push/send-push.ts
Normal file
80
src/lib/push/send-push.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Versand von Web-Push-Benachrichtigungen an einen Empfänger (alle seine
|
||||
* Geräte). Analog zu sendMail: wirft NIE, ein Push-Fehler darf keinen
|
||||
* aufrufenden Vorgang (Buchung, Storno, ...) kippen.
|
||||
*/
|
||||
|
||||
import { WebPushError } from "web-push";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getWebPush, isPushConfigured } from "./web-push";
|
||||
|
||||
export interface PushPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
/** Ziel-URL, die beim Klick auf die Benachrichtigung geöffnet wird. */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine Push-Benachrichtigung an alle registrierten Geräte eines
|
||||
* Empfängers (Manager-, Employee- oder User-ID). Tote Subscriptions (Browser
|
||||
* hat sie serverseitig verworfen, HTTP 404/410) werden dabei aus der DB
|
||||
* entfernt.
|
||||
*/
|
||||
export async function sendPushToRecipient(
|
||||
recipientId: string,
|
||||
payload: PushPayload
|
||||
): Promise<void> {
|
||||
if (!isPushConfigured()) return;
|
||||
|
||||
try {
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { recipientId },
|
||||
});
|
||||
if (subscriptions.length === 0) return;
|
||||
|
||||
const webpush = getWebPush();
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||
},
|
||||
body,
|
||||
// web-push reicht timeout nur weiter, wenn es explizit gesetzt ist -
|
||||
// ohne das kann ein Endpoint, der die Verbindung annimmt aber nie
|
||||
// antwortet, das Promise unbegrenzt offen halten.
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const deadEndpoints: string[] = [];
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === "rejected") {
|
||||
const error = result.reason;
|
||||
// 404/410 = Subscription existiert clientseitig nicht mehr
|
||||
// (Browser-Daten gelöscht, App deinstalliert, ...) - dauerhaft tot.
|
||||
if (
|
||||
error instanceof WebPushError &&
|
||||
(error.statusCode === 404 || error.statusCode === 410)
|
||||
) {
|
||||
deadEndpoints.push(subscriptions[i].endpoint);
|
||||
} else {
|
||||
console.error("sendPushToRecipient: Zustellung fehlgeschlagen:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (deadEndpoints.length > 0) {
|
||||
await prisma.pushSubscription
|
||||
.deleteMany({ where: { endpoint: { in: deadEndpoints } } })
|
||||
.catch((error) => console.error("sendPushToRecipient: Aufräumen fehlgeschlagen:", error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("sendPushToRecipient fehlgeschlagen:", error);
|
||||
}
|
||||
}
|
||||
43
src/lib/push/web-push.ts
Normal file
43
src/lib/push/web-push.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Web-Push (VAPID) Konfiguration.
|
||||
*
|
||||
* Analog zu src/lib/mail/resend.ts: lazy initialisiert, damit ein fehlender
|
||||
* Schlüssel den Build nicht zum Absturz bringt, nur den tatsächlichen Versand.
|
||||
*/
|
||||
|
||||
import webpush from "web-push";
|
||||
|
||||
let configured = false;
|
||||
|
||||
function ensureConfigured() {
|
||||
if (configured) return;
|
||||
|
||||
const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
||||
const subject = process.env.VAPID_SUBJECT;
|
||||
|
||||
if (!publicKey || !privateKey || !subject) {
|
||||
throw new Error(
|
||||
"Web-Push ist nicht konfiguriert: NEXT_PUBLIC_VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY oder VAPID_SUBJECT fehlt."
|
||||
);
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
configured = true;
|
||||
}
|
||||
|
||||
export function getWebPush(): typeof webpush {
|
||||
ensureConfigured();
|
||||
return webpush;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob die Web-Push-Konfiguration vollständig ist, ohne zu werfen.
|
||||
*/
|
||||
export function isPushConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
|
||||
process.env.VAPID_PRIVATE_KEY &&
|
||||
process.env.VAPID_SUBJECT
|
||||
);
|
||||
}
|
||||
33
src/lib/pwa.ts
Normal file
33
src/lib/pwa.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Geteilte Browser-Checks für InstallPrompt und NotificationPrompt.
|
||||
*/
|
||||
|
||||
export const INSTALL_DISMISSED_KEY = "kumasy-install-prompt-dismissed";
|
||||
|
||||
export function isStandalone(): boolean {
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// Safari-spezifisches Flag, matchMedia erkennt den PWA-Modus auf iOS nicht zuverlässig.
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* true, solange der Install-Prompt dem Nutzer noch angezeigt werden könnte
|
||||
* (weder installiert noch weggeklickt). NotificationPrompt wartet darauf,
|
||||
* dass das false wird, damit sich beide "fixed bottom-4"-Banner nicht
|
||||
* überlappen und Push als bewusster zweiter Schritt nach der Installation
|
||||
* wirkt statt beide gleichzeitig um Aufmerksamkeit konkurrieren zu lassen.
|
||||
*/
|
||||
export function isInstallPromptPending(): boolean {
|
||||
return !isStandalone() && localStorage.getItem(INSTALL_DISMISSED_KEY) !== "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-Name, den InstallPrompt beim Wegklicken/Installieren feuert, damit
|
||||
* NotificationPrompt (bereits gemountet, eligible ggf. noch false) neu prüfen
|
||||
* kann, ob es jetzt an der Reihe ist - beide Komponenten sind unabhängig
|
||||
* in layout.tsx gemountet, ein Custom Event entkoppelt sie ohne Prop-Drilling
|
||||
* oder einen gemeinsamen State-Container nur für diesen einen Handoff.
|
||||
*/
|
||||
export const INSTALL_PROMPT_SETTLED_EVENT = "kumasy:install-prompt-settled";
|
||||
113
src/modules/notifications/actions/push-subscription-actions.ts
Normal file
113
src/modules/notifications/actions/push-subscription-actions.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"use server";
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { requireAuthWithData } from "@/src/lib/auth/getUser";
|
||||
|
||||
export interface PushSubscriptionInput {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Bekannte Push-Dienst-Origins der unterstützten Browser. Ohne diese
|
||||
// Allowlist könnte ein Aufrufer eine beliebige interne HTTPS-URL als
|
||||
// "Endpoint" einreichen - sendPushToRecipient würde später serverseitig
|
||||
// Requests dorthin schicken (SSRF). endpoint kommt vom Client und wird nie
|
||||
// vom Browser selbst gegen eine feste Liste geprüft, daher hier serverseitig.
|
||||
const ALLOWED_PUSH_ORIGINS = new Set([
|
||||
"https://fcm.googleapis.com", // Chrome, Edge, andere Chromium-Browser
|
||||
"https://updates.push.services.mozilla.com", // Firefox
|
||||
]);
|
||||
|
||||
function isAllowedPushEndpoint(endpoint: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== "https:") return false;
|
||||
|
||||
if (ALLOWED_PUSH_ORIGINS.has(url.origin)) return true;
|
||||
// Windows/Edge-Legacy-Endpunkte laufen über wechselnde Subdomains
|
||||
// (wns2-<region>.notify.windows.com), daher Suffix- statt Origin-Check.
|
||||
return url.hostname.endsWith(".notify.windows.com");
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert (oder aktualisiert) die Push-Subscription des eingeloggten
|
||||
* Nutzers. Gilt für alle Rollen (User, Manager, Employee) - anders als das
|
||||
* Notification-Center ist Push nicht auf die Studio-Seite beschränkt.
|
||||
*
|
||||
* Ein Browser kann seine Subscription verändern (z.B. nach Ablauf) und
|
||||
* sendet dann denselben Endpoint erneut - upsert auf endpoint aktualisiert
|
||||
* dabei sowohl die Keys als auch den Empfänger, falls sich zwischenzeitlich
|
||||
* ein anderer Nutzer auf demselben Gerät angemeldet hat.
|
||||
*/
|
||||
export async function subscribeToPush(
|
||||
input: PushSubscriptionInput,
|
||||
userAgent?: string
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
let userData;
|
||||
try {
|
||||
userData = await requireAuthWithData();
|
||||
} catch {
|
||||
return { success: false, error: "Nicht angemeldet." };
|
||||
}
|
||||
|
||||
if (!isAllowedPushEndpoint(input.endpoint)) {
|
||||
return { success: false, error: "Ungültiger Push-Endpoint." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: { endpoint: input.endpoint },
|
||||
create: {
|
||||
recipientId: userData.id,
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.keys.p256dh,
|
||||
auth: input.keys.auth,
|
||||
userAgent: userAgent ?? null,
|
||||
},
|
||||
update: {
|
||||
recipientId: userData.id,
|
||||
p256dh: input.keys.p256dh,
|
||||
auth: input.keys.auth,
|
||||
userAgent: userAgent ?? null,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("subscribeToPush fehlgeschlagen:", error);
|
||||
return { success: false, error: "Push-Registrierung fehlgeschlagen." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt eine Push-Subscription (z.B. wenn der Nutzer Benachrichtigungen
|
||||
* im Browser deaktiviert). Auf recipientId des eingeloggten Nutzers
|
||||
* beschränkt, damit niemand mit einem fremden (z.B. erratenen oder aus
|
||||
* einem Netzwerk-Log kopierten) Endpoint die Push-Zustellung eines anderen
|
||||
* Geräts abschalten kann.
|
||||
*/
|
||||
export async function unsubscribeFromPush(
|
||||
endpoint: string
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
let userData;
|
||||
try {
|
||||
userData = await requireAuthWithData();
|
||||
} catch {
|
||||
return { success: false, error: "Nicht angemeldet." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.pushSubscription.deleteMany({ where: { endpoint, recipientId: userData.id } });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("unsubscribeFromPush fehlgeschlagen:", error);
|
||||
return { success: false, error: "Abmelden fehlgeschlagen." };
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,14 @@
|
|||
* wenn ein businessId übergeben wird. Ohne businessId (kein Business-Kontext)
|
||||
* wird immer zugestellt.
|
||||
* - Empfänger ist die Manager-ID des Business (kein Employee-Fanout im MVP).
|
||||
* - Sendet zusätzlich Web-Push an alle Geräte des Empfängers (zweiter
|
||||
* Zustellkanal für dieselbe Benachrichtigung, kein eigenes Opt-out - wer
|
||||
* Push aktiviert hat, bekommt dieselben Typen wie in-app).
|
||||
*/
|
||||
|
||||
import { after } from "next/server";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { sendPushToRecipient } from "@/src/lib/push/send-push";
|
||||
import {
|
||||
NOTIFICATION_TYPES,
|
||||
resolveNotificationSettings,
|
||||
|
|
@ -64,11 +69,35 @@ export async function createNotification(input: CreateNotificationInput): Promis
|
|||
linkUrl: input.linkUrl ?? null,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("createNotification fehlgeschlagen:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Eigener try/catch AUSSERHALB des obigen Blocks: after() wirft, wenn es
|
||||
// außerhalb eines Request-Scopes aufgerufen wird (z.B. in Tests) - das darf
|
||||
// den bereits erfolgreichen Insert oben nicht nachträglich als
|
||||
// fehlgeschlagen erscheinen lassen (return false), sonst denkt der
|
||||
// Aufrufer, es sei kein Datensatz geschrieben worden, obwohl er es wurde.
|
||||
try {
|
||||
// Läuft erst NACH dem Response-Flush weiter, blockiert den Aufrufer also
|
||||
// nicht - aber die Serverless-Function bleibt bis zum Abschluss aktiv
|
||||
// (anders als ein reines void-Fire-and-forget, das eine Vercel-Function
|
||||
// vor Abschluss eingefroren zurücklassen kann). Push-Fehler dürfen das
|
||||
// In-App-Insert ohnehin nicht mehr rückgängig machen -
|
||||
// sendPushToRecipient wirft selbst nie.
|
||||
after(() =>
|
||||
sendPushToRecipient(input.recipientId, {
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
url: input.linkUrl,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("createNotification: Push-Versand konnte nicht eingeplant werden:", error);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2, Tag, Search, Calendar, Percent, Coins, Layers } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Tag,
|
||||
Search,
|
||||
Calendar,
|
||||
Percent,
|
||||
Coins,
|
||||
Layers,
|
||||
X,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import {
|
||||
|
|
@ -28,6 +40,15 @@ interface DiscountCodeListProps {
|
|||
expiresAt: Date | null;
|
||||
isActive: boolean;
|
||||
}) => Promise<boolean>;
|
||||
onUpdate: (
|
||||
discountCodeId: string,
|
||||
input: {
|
||||
maxRedemptions: number | null;
|
||||
startsAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
) => Promise<boolean>;
|
||||
onDeactivate: (discountCodeId: string) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
|
@ -43,6 +64,24 @@ const emptyForm = {
|
|||
expiresAt: "" as string,
|
||||
};
|
||||
|
||||
function toDateInputValue(date: Date | null): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function toEditForm(code: DiscountCodeDTO) {
|
||||
return {
|
||||
maxRedemptions: code.maxRedemptions !== null ? String(code.maxRedemptions) : "",
|
||||
startsAt: toDateInputValue(code.startsAt),
|
||||
expiresAt: toDateInputValue(code.expiresAt),
|
||||
isActive: code.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
type DisplayStatus = "active" | "inactive" | "expired" | "scheduled" | "exhausted";
|
||||
type StatusFilter = "all" | DisplayStatus;
|
||||
|
||||
|
|
@ -94,6 +133,7 @@ const DiscountCodeList = ({
|
|||
codes,
|
||||
courses,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDeactivate,
|
||||
isSaving,
|
||||
}: DiscountCodeListProps) => {
|
||||
|
|
@ -101,6 +141,8 @@ const DiscountCodeList = ({
|
|||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState<ReturnType<typeof toEditForm> | null>(null);
|
||||
|
||||
const courseOptions = courses.map((c) => ({ value: c.id, label: c.name }));
|
||||
const courseNameById = useMemo(() => new Map(courses.map((c) => [c.id, c.name])), [courses]);
|
||||
|
|
@ -140,6 +182,37 @@ const DiscountCodeList = ({
|
|||
}
|
||||
};
|
||||
|
||||
const startEditing = (code: DiscountCodeDTO) => {
|
||||
setEditingId(code.id);
|
||||
setEditForm(toEditForm(code));
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingId(null);
|
||||
setEditForm(null);
|
||||
};
|
||||
|
||||
const handleUpdate = async (discountCodeId: string) => {
|
||||
if (!editForm) return;
|
||||
// onUpdate kann rejecten (Netzwerk-/Serverfehler) - der Aufrufer meldet das
|
||||
// bereits per Toast, hier nur verhindern, dass das Formular bei einem
|
||||
// Fehler trotzdem als "fertig" geschlossen wird.
|
||||
let success = false;
|
||||
try {
|
||||
success = await onUpdate(discountCodeId, {
|
||||
maxRedemptions: editForm.maxRedemptions.trim() ? Number(editForm.maxRedemptions) : null,
|
||||
startsAt: editForm.startsAt ? parseLocalStartOfDay(editForm.startsAt) : null,
|
||||
expiresAt: editForm.expiresAt ? parseLocalEndOfDay(editForm.expiresAt) : null,
|
||||
isActive: editForm.isActive,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (success) {
|
||||
cancelEditing();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
|
@ -181,10 +254,34 @@ const DiscountCodeList = ({
|
|||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Code</label>
|
||||
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-white/60 bg-white/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-7 rounded-lg bg-blue-50 text-blue-600">
|
||||
<Plus size={14} />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Neuer Rabattcode</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-gray-400 hover:bg-white/60 hover:text-gray-700"
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setForm(emptyForm);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
title="Schließen"
|
||||
>
|
||||
<X size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Code</label>
|
||||
<Input
|
||||
className="bg-white/70 border-white/60 font-mono tracking-wide"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
|
||||
placeholder="z.B. SOMMER25"
|
||||
|
|
@ -192,13 +289,13 @@ const DiscountCodeList = ({
|
|||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Typ</label>
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Typ</label>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, type: v as "percent" | "fixed" }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="bg-white/70 border-white/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -207,11 +304,12 @@ const DiscountCodeList = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
{form.type === "percent" ? "Rabatt in %" : "Rabatt in €"}
|
||||
</label>
|
||||
<Input
|
||||
className="bg-white/70 border-white/60"
|
||||
type="number"
|
||||
min={form.type === "percent" ? 1 : 0.01}
|
||||
max={form.type === "percent" ? 100 : undefined}
|
||||
|
|
@ -226,7 +324,9 @@ const DiscountCodeList = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Gültigkeitsbereich</label>
|
||||
<div className="flex gap-2 border border-white/60 bg-white/40 rounded-xl p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={form.scope === "all_courses" ? "default" : "ghost"}
|
||||
|
|
@ -246,6 +346,7 @@ const DiscountCodeList = ({
|
|||
Bestimmte Kurse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.scope === "specific_courses" && (
|
||||
<MultiSelectDropdown
|
||||
|
|
@ -256,11 +357,18 @@ const DiscountCodeList = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 max-w-xs">
|
||||
<label className="text-xs text-gray-500">
|
||||
<div className="border-t border-white/60 pt-4 space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-gray-500">
|
||||
<SlidersHorizontal size={12} />
|
||||
Limits & Laufzeit (optional)
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 max-w-xs">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Maximale Einlösungen (leer = unbegrenzt)
|
||||
</label>
|
||||
<Input
|
||||
className="bg-white/70 border-white/60"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxRedemptions}
|
||||
|
|
@ -270,27 +378,31 @@ const DiscountCodeList = ({
|
|||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Gültig ab (optional)</label>
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Gültig ab</label>
|
||||
<Input
|
||||
className="bg-white/70 border-white/60"
|
||||
type="date"
|
||||
value={form.startsAt}
|
||||
onChange={(e) => setForm((f) => ({ ...f, startsAt: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Gültig bis (optional)</label>
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Gültig bis</label>
|
||||
<Input
|
||||
className="bg-white/70 border-white/60"
|
||||
type="date"
|
||||
value={form.expiresAt}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expiresAt: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none sm:px-6"
|
||||
onClick={handleCreate}
|
||||
disabled={isSaving || form.code.trim().length < 3}
|
||||
>
|
||||
|
|
@ -299,15 +411,18 @@ const DiscountCodeList = ({
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-white/50"
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setForm(emptyForm);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{codes.length === 0 ? (
|
||||
|
|
@ -351,11 +466,22 @@ const DiscountCodeList = ({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
onClick={() => startEditing(c)}
|
||||
disabled={isSaving}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
{c.isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
|
||||
className="text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => {
|
||||
// onDeactivate kann rejecten - der Aufrufer zeigt den
|
||||
// Fehler bereits per Toast, hier nur die unhandled
|
||||
|
|
@ -369,6 +495,112 @@ const DiscountCodeList = ({
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingId === c.id && editForm && (
|
||||
<div className="border border-blue-200/70 bg-blue-50/40 backdrop-blur-xl rounded-lg overflow-hidden -mx-1">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-blue-200/70 bg-blue-50/60">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-blue-800">
|
||||
<SlidersHorizontal size={12} />
|
||||
Limits & Laufzeit bearbeiten
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-blue-400 hover:bg-blue-100/70 hover:text-blue-700"
|
||||
onClick={cancelEditing}
|
||||
disabled={isSaving}
|
||||
title="Schließen"
|
||||
>
|
||||
<X size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="flex flex-col gap-1.5 max-w-xs">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Maximale Einlösungen (leer = unbegrenzt)
|
||||
</label>
|
||||
<Input
|
||||
className="bg-white/80 border-white/60"
|
||||
type="number"
|
||||
min={1}
|
||||
value={editForm.maxRedemptions}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => (f ? { ...f, maxRedemptions: e.target.value } : f))
|
||||
}
|
||||
placeholder="z.B. 50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Gültig ab</label>
|
||||
<Input
|
||||
className="bg-white/80 border-white/60"
|
||||
type="date"
|
||||
value={editForm.startsAt}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => (f ? { ...f, startsAt: e.target.value } : f))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Gültig bis</label>
|
||||
<Input
|
||||
className="bg-white/80 border-white/60"
|
||||
type="date"
|
||||
value={editForm.expiresAt}
|
||||
onChange={(e) =>
|
||||
setEditForm((f) => (f ? { ...f, expiresAt: e.target.value } : f))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border border-white/60 bg-white/50 rounded-xl p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={editForm.isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setEditForm((f) => (f ? { ...f, isActive: true } : f))}
|
||||
>
|
||||
Aktiv
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={!editForm.isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setEditForm((f) => (f ? { ...f, isActive: false } : f))}
|
||||
>
|
||||
Deaktiviert
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleUpdate(c.id)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-white/60"
|
||||
onClick={cancelEditing}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 text-2xl font-bold text-gray-900">
|
||||
{c.type === "percent" ? (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import DiscountCodeList from "../components/DiscountCodeList";
|
|||
import {
|
||||
getDiscountCodes,
|
||||
createDiscountCode,
|
||||
updateDiscountCodeLimits,
|
||||
deactivateDiscountCode,
|
||||
getBusinessCoursesForDiscountPicker,
|
||||
type DiscountCodeDTO,
|
||||
|
|
@ -111,6 +112,35 @@ const SettingsDiscountsPageView = ({
|
|||
});
|
||||
};
|
||||
|
||||
const handleUpdate = (
|
||||
discountCodeId: string,
|
||||
input: {
|
||||
maxRedemptions: number | null;
|
||||
startsAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
startSaving(async () => {
|
||||
try {
|
||||
const result = await updateDiscountCodeLimits(selectedBusinessId, discountCodeId, input);
|
||||
if (result.success) {
|
||||
toast.success("Rabattcode aktualisiert.");
|
||||
setCodes((prev) => prev.map((c) => (c.id === discountCodeId ? { ...c, ...input } : c)));
|
||||
resolve(true);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Rabattcode konnte nicht aktualisiert werden.");
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeactivate = (discountCodeId: string): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
startSaving(async () => {
|
||||
|
|
@ -188,6 +218,7 @@ const SettingsDiscountsPageView = ({
|
|||
codes={codes}
|
||||
courses={courses}
|
||||
onCreate={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
onDeactivate={handleDeactivate}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue