Merge pull request #164 from Since-3/feature/pwa

added pwa infrastructure and push notifcation
This commit is contained in:
Rajpreet0 2026-09-14 16:57:31 +02:00 • committed by GitHub
commit 6d5a1bee7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2068 additions and 206 deletions

9
AGENTS.md Normal file
View 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
View file

@ -0,0 +1 @@
@AGENTS.md

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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");

View file

@ -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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

36
public/manifest.json Normal file
View 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"
}
]
}

View file

@ -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>
);

View 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
View 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);
})()
);
});

View 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>
);
}

View 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
View 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
View 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
View 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";

View 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." };
}
}

View file

@ -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;
}
/**

View file

@ -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 &amp; 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 &amp; 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" ? (

View file

@ -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}
/>