mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
added pwa infrastructure and push notifcation
This commit is contained in:
parent
b6e1ddc265
commit
163ae46468
25 changed files with 1916 additions and 205 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 { withSentryConfig } from "@sentry/nextjs";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
import { withSerwist } from "@serwist/turbopack";
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
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:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
|
|
||||||
|
|
|
||||||
875
package-lock.json
generated
875
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",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@sentry/nextjs": "^10.69.0",
|
"@sentry/nextjs": "^10.69.0",
|
||||||
|
"@serwist/turbopack": "^9.5.12",
|
||||||
"@stripe/stripe-js": "^9.5.0",
|
"@stripe/stripe-js": "^9.5.0",
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.116.0",
|
"@supabase/supabase-js": "^2.116.0",
|
||||||
|
|
@ -60,9 +61,11 @@
|
||||||
"react-phone-number-input": "^3.4.14",
|
"react-phone-number-input": "^3.4.14",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"resend": "^6.18.0",
|
"resend": "^6.18.0",
|
||||||
|
"serwist": "^9.5.12",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"stripe": "^22.1.1",
|
"stripe": "^22.1.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -77,8 +80,10 @@
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"@vitest/coverage-v8": "^4.1.11",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^9.39.0",
|
"eslint": "^9.39.0",
|
||||||
"eslint-config-next": "16.3.3",
|
"eslint-config-next": "16.3.3",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
|
@ -112,7 +117,7 @@
|
||||||
"markdown-it": ">=14.1.1",
|
"markdown-it": ">=14.1.1",
|
||||||
"postcss": ">=8.5.23",
|
"postcss": ">=8.5.23",
|
||||||
"defu": ">=6.1.5",
|
"defu": ">=6.1.5",
|
||||||
"esbuild": ">=0.28.1",
|
"esbuild": "$esbuild",
|
||||||
"vite": ">=7.3.2",
|
"vite": ">=7.3.2",
|
||||||
"brace-expansion": ">=5.0.9",
|
"brace-expansion": ">=5.0.9",
|
||||||
"ws": ">=8.21.0",
|
"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])
|
@@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
|
// Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit
|
||||||
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
|
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
|
||||||
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
|
// 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 localFont from "next/font/local";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { SerwistProvider } from "@serwist/turbopack/react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { AuthInitializer } from "../components/AuthInitializer";
|
import { AuthInitializer } from "../components/AuthInitializer";
|
||||||
import NavigationProgress from "../components/layout/NavigationProgress";
|
import NavigationProgress from "../components/layout/NavigationProgress";
|
||||||
|
import { InstallPrompt } from "../components/InstallPrompt";
|
||||||
|
import { NotificationPrompt } from "../components/NotificationPrompt";
|
||||||
|
|
||||||
const salmondFont = localFont({
|
const salmondFont = localFont({
|
||||||
src: "../public/font/salmond.woff",
|
src: "../public/font/salmond.woff",
|
||||||
|
|
@ -13,12 +16,23 @@ const salmondFont = localFont({
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Kumasy",
|
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 = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
|
themeColor: "#F4C00C",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
|
|
@ -31,19 +45,23 @@ export default async function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className={salmondFont.className}>
|
<html lang={locale} className={salmondFont.className}>
|
||||||
<body className={`antialiased bg-muted`}>
|
<body className={`antialiased bg-muted`}>
|
||||||
<NextIntlClientProvider>
|
<SerwistProvider swUrl="/serwist/sw.js">
|
||||||
<NavigationProgress />
|
<NextIntlClientProvider>
|
||||||
<AuthInitializer />
|
<NavigationProgress />
|
||||||
<main>{children}</main>
|
<AuthInitializer />
|
||||||
<Toaster
|
<main>{children}</main>
|
||||||
toastOptions={{
|
<InstallPrompt />
|
||||||
style: {
|
<NotificationPrompt />
|
||||||
background: "#1E293B",
|
<Toaster
|
||||||
color: "#F4C00C",
|
toastOptions={{
|
||||||
},
|
style: {
|
||||||
}}
|
background: "#1E293B",
|
||||||
/>
|
color: "#F4C00C",
|
||||||
</NextIntlClientProvider>
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</SerwistProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
13
src/app/serwist/[path]/route.ts
Normal file
13
src/app/serwist/[path]/route.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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 }],
|
||||||
|
}
|
||||||
|
);
|
||||||
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);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
109
src/components/InstallPrompt.tsx
Normal file
109
src/components/InstallPrompt.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-Initializer statt Effect: eligible/showIosHint hängen nur von
|
||||||
|
// Browser-APIs ab, nicht von React-Zustand, der synchronisiert werden müsste.
|
||||||
|
// Serverseitig (kein window) liefert das konsistent false, identisch zum
|
||||||
|
// allerersten Client-Render - kein Hydration-Mismatch.
|
||||||
|
function computeEligible(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return isInstallPromptPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallPrompt() {
|
||||||
|
const [eligible, setEligible] = useState(computeEligible);
|
||||||
|
const [showIosHint] = useState(() => eligible && isIos());
|
||||||
|
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");
|
||||||
|
setEligible(false);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/components/NotificationPrompt.tsx
Normal file
154
src/components/NotificationPrompt.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-Initializer statt Effect (siehe InstallPrompt.tsx): Browser-API-Werte
|
||||||
|
// werden serverseitig identisch mit dem allerersten Client-Render (false)
|
||||||
|
// beantwortet, kein Hydration-Mismatch möglich.
|
||||||
|
function computeEligible(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) return false;
|
||||||
|
if (!isPushSupported()) return false;
|
||||||
|
if (Notification.permission !== "default") return false; // schon erlaubt oder blockiert
|
||||||
|
// 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() {
|
||||||
|
const [eligible, setEligible] = useState(computeEligible);
|
||||||
|
const [isSubscribing, setIsSubscribing] = useState(false);
|
||||||
|
|
||||||
|
// Reagiert auf InstallPrompt, das erst nach unserem Mount weggeklickt wird
|
||||||
|
// (siehe computeEligible) - ansonsten bliebe eligible dauerhaft false.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSettled = () => setEligible(computeEligible());
|
||||||
|
window.addEventListener(INSTALL_PROMPT_SETTLED_EVENT, handleSettled);
|
||||||
|
return () => window.removeEventListener(INSTALL_PROMPT_SETTLED_EVENT, handleSettled);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, "1");
|
||||||
|
setEligible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnable = async () => {
|
||||||
|
setIsSubscribing(true);
|
||||||
|
try {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") {
|
||||||
|
dismiss();
|
||||||
|
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.
|
||||||
|
await subscription.unsubscribe().catch(() => {});
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Push-Aktivierung fehlgeschlagen:", error);
|
||||||
|
dismiss();
|
||||||
|
} 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(() => {});
|
||||||
|
}
|
||||||
76
src/lib/push/send-push.ts
Normal file
76
src/lib/push/send-push.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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." };
|
||||||
|
}
|
||||||
|
|
||||||
|
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). Kein Auth-Check auf recipientId nötig - der
|
||||||
|
* Endpoint ist ein unrateables, gerätespezifisches Secret, das der Client
|
||||||
|
* nur kennt, wenn er es selbst erzeugt hat.
|
||||||
|
*/
|
||||||
|
export async function unsubscribeFromPush(
|
||||||
|
endpoint: string
|
||||||
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
|
try {
|
||||||
|
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("unsubscribeFromPush fehlgeschlagen:", error);
|
||||||
|
return { success: false, error: "Abmelden fehlgeschlagen." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,13 @@
|
||||||
* wenn ein businessId übergeben wird. Ohne businessId (kein Business-Kontext)
|
* wenn ein businessId übergeben wird. Ohne businessId (kein Business-Kontext)
|
||||||
* wird immer zugestellt.
|
* wird immer zugestellt.
|
||||||
* - Empfänger ist die Manager-ID des Business (kein Employee-Fanout im MVP).
|
* - 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 { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
|
import { sendPushToRecipient } from "@/src/lib/push/send-push";
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_TYPES,
|
NOTIFICATION_TYPES,
|
||||||
resolveNotificationSettings,
|
resolveNotificationSettings,
|
||||||
|
|
@ -64,6 +68,15 @@ export async function createNotification(input: CreateNotificationInput): Promis
|
||||||
linkUrl: input.linkUrl ?? null,
|
linkUrl: input.linkUrl ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget: Push-Fehler dürfen das erfolgreiche In-App-Insert
|
||||||
|
// oben nicht rückgängig machen oder den Aufrufer blockieren.
|
||||||
|
void sendPushToRecipient(input.recipientId, {
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
url: input.linkUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("createNotification fehlgeschlagen:", error);
|
console.error("createNotification fehlgeschlagen:", error);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
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 { Button } from "@/src/components/ui/button";
|
||||||
import { Input } from "@/src/components/ui/input";
|
import { Input } from "@/src/components/ui/input";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,6 +40,15 @@ interface DiscountCodeListProps {
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}) => Promise<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>;
|
onDeactivate: (discountCodeId: string) => Promise<boolean>;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +64,24 @@ const emptyForm = {
|
||||||
expiresAt: "" as string,
|
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 DisplayStatus = "active" | "inactive" | "expired" | "scheduled" | "exhausted";
|
||||||
type StatusFilter = "all" | DisplayStatus;
|
type StatusFilter = "all" | DisplayStatus;
|
||||||
|
|
||||||
|
|
@ -94,6 +133,7 @@ const DiscountCodeList = ({
|
||||||
codes,
|
codes,
|
||||||
courses,
|
courses,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: DiscountCodeListProps) => {
|
}: DiscountCodeListProps) => {
|
||||||
|
|
@ -101,6 +141,8 @@ const DiscountCodeList = ({
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
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 courseOptions = courses.map((c) => ({ value: c.id, label: c.name }));
|
||||||
const courseNameById = useMemo(() => new Map(courses.map((c) => [c.id, c.name])), [courses]);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|
@ -181,132 +254,172 @@ const DiscountCodeList = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3">
|
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl overflow-hidden">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-white/60 bg-white/40">
|
||||||
<label className="text-xs text-gray-500">Code</label>
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<div className="flex items-center justify-center size-7 rounded-lg bg-blue-50 text-blue-600">
|
||||||
value={form.code}
|
<Plus size={14} />
|
||||||
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
|
</div>
|
||||||
placeholder="z.B. SOMMER25"
|
<h3 className="text-sm font-semibold text-gray-900">Neuer Rabattcode</h3>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="flex-1 flex flex-col gap-1">
|
|
||||||
<label className="text-xs text-gray-500">Typ</label>
|
|
||||||
<Select
|
|
||||||
value={form.type}
|
|
||||||
onValueChange={(v) => setForm((f) => ({ ...f, type: v as "percent" | "fixed" }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="percent">Prozent</SelectItem>
|
|
||||||
<SelectItem value="fixed">Fixbetrag</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col gap-1">
|
|
||||||
<label className="text-xs text-gray-500">
|
|
||||||
{form.type === "percent" ? "Rabatt in %" : "Rabatt in €"}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={form.type === "percent" ? 1 : 0.01}
|
|
||||||
max={form.type === "percent" ? 100 : undefined}
|
|
||||||
step={form.type === "percent" ? 1 : 0.01}
|
|
||||||
value={form.type === "percent" ? form.value : form.value / 100}
|
|
||||||
onChange={(e) => {
|
|
||||||
const raw = Number(e.target.value);
|
|
||||||
const cents = form.type === "percent" ? raw : Math.round(raw * 100);
|
|
||||||
setForm((f) => ({ ...f, value: Number.isFinite(cents) ? cents : 0 }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-2">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
variant={form.scope === "all_courses" ? "default" : "ghost"}
|
size="icon"
|
||||||
size="sm"
|
className="size-7 text-gray-400 hover:bg-white/60 hover:text-gray-700"
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
|
|
||||||
>
|
|
||||||
Alle Kurse
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={form.scope === "specific_courses" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
|
|
||||||
>
|
|
||||||
Bestimmte Kurse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{form.scope === "specific_courses" && (
|
|
||||||
<MultiSelectDropdown
|
|
||||||
label="Kurse"
|
|
||||||
selected={form.courseIds}
|
|
||||||
onSelect={(values) => setForm((f) => ({ ...f, courseIds: values }))}
|
|
||||||
options={courseOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1 max-w-xs">
|
|
||||||
<label className="text-xs text-gray-500">
|
|
||||||
Maximale Einlösungen (leer = unbegrenzt)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.maxRedemptions}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))}
|
|
||||||
placeholder="z.B. 50"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<Input
|
|
||||||
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>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={form.expiresAt}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, expiresAt: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={isSaving || form.code.trim().length < 3}
|
|
||||||
>
|
|
||||||
{isSaving ? "Speichert…" : "Erstellen"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
}}
|
}}
|
||||||
|
title="Schließen"
|
||||||
>
|
>
|
||||||
Abbrechen
|
<X size={15} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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"
|
||||||
|
/>
|
||||||
|
</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">Typ</label>
|
||||||
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, type: v as "percent" | "fixed" }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/70 border-white/60">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="percent">Prozent</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixbetrag</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<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}
|
||||||
|
step={form.type === "percent" ? 1 : 0.01}
|
||||||
|
value={form.type === "percent" ? form.value : form.value / 100}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = Number(e.target.value);
|
||||||
|
const cents = form.type === "percent" ? raw : Math.round(raw * 100);
|
||||||
|
setForm((f) => ({ ...f, value: Number.isFinite(cents) ? cents : 0 }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
|
||||||
|
>
|
||||||
|
Alle Kurse
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={form.scope === "specific_courses" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
|
||||||
|
>
|
||||||
|
Bestimmte Kurse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.scope === "specific_courses" && (
|
||||||
|
<MultiSelectDropdown
|
||||||
|
label="Kurse"
|
||||||
|
selected={form.courseIds}
|
||||||
|
onSelect={(values) => setForm((f) => ({ ...f, courseIds: values }))}
|
||||||
|
options={courseOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))}
|
||||||
|
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/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.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 pt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 sm:flex-none sm:px-6"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isSaving || form.code.trim().length < 3}
|
||||||
|
>
|
||||||
|
{isSaving ? "Speichert…" : "Erstellen"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white/50"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCreating(false);
|
||||||
|
setForm(emptyForm);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -351,25 +464,140 @@ const DiscountCodeList = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{c.isActive && (
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
|
className="text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||||
onClick={() => {
|
onClick={() => startEditing(c)}
|
||||||
// onDeactivate kann rejecten - der Aufrufer zeigt den
|
|
||||||
// Fehler bereits per Toast, hier nur die unhandled
|
|
||||||
// rejection verhindern.
|
|
||||||
onDeactivate(c.id).catch(() => {});
|
|
||||||
}}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
title="Deaktivieren"
|
title="Bearbeiten"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Pencil size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{c.isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
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
|
||||||
|
// rejection verhindern.
|
||||||
|
onDeactivate(c.id).catch(() => {});
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
title="Deaktivieren"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</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}
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-2xl font-bold text-gray-900">
|
<div className="flex items-center gap-1.5 text-2xl font-bold text-gray-900">
|
||||||
{c.type === "percent" ? (
|
{c.type === "percent" ? (
|
||||||
<Percent size={18} className="text-gray-400" />
|
<Percent size={18} className="text-gray-400" />
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import DiscountCodeList from "../components/DiscountCodeList";
|
||||||
import {
|
import {
|
||||||
getDiscountCodes,
|
getDiscountCodes,
|
||||||
createDiscountCode,
|
createDiscountCode,
|
||||||
|
updateDiscountCodeLimits,
|
||||||
deactivateDiscountCode,
|
deactivateDiscountCode,
|
||||||
getBusinessCoursesForDiscountPicker,
|
getBusinessCoursesForDiscountPicker,
|
||||||
type DiscountCodeDTO,
|
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> => {
|
const handleDeactivate = (discountCodeId: string): Promise<boolean> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
startSaving(async () => {
|
startSaving(async () => {
|
||||||
|
|
@ -188,6 +218,7 @@ const SettingsDiscountsPageView = ({
|
||||||
codes={codes}
|
codes={codes}
|
||||||
courses={courses}
|
courses={courses}
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
onDeactivate={handleDeactivate}
|
onDeactivate={handleDeactivate}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue