mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:01:16 +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 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
|
||||
|
||||
|
|
|
|||
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",
|
||||
"@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,10 @@
|
|||
"@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",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"husky": "^9.1.7",
|
||||
|
|
@ -112,7 +117,7 @@
|
|||
"markdown-it": ">=14.1.1",
|
||||
"postcss": ">=8.5.23",
|
||||
"defu": ">=6.1.5",
|
||||
"esbuild": ">=0.28.1",
|
||||
"esbuild": "$esbuild",
|
||||
"vite": ">=7.3.2",
|
||||
"brace-expansion": ">=5.0.9",
|
||||
"ws": ">=8.21.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "PushSubscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"recipientId" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PushSubscription_recipientId_idx" ON "PushSubscription"("recipientId");
|
||||
|
|
@ -266,6 +266,25 @@ model Notification {
|
|||
@@index([recipientId, createdAt])
|
||||
}
|
||||
|
||||
// Web-Push-Subscriptions (ein Datensatz pro Browser/Gerät). recipientId ist
|
||||
// polymorph wie bei Notification (Manager-, Employee- oder User-ID) - Push ist
|
||||
// ein zweiter Zustellkanal für createNotification(), kein eigenes System.
|
||||
// endpoint ist pro Push-Dienst (FCM/Mozilla/...) global eindeutig und dient
|
||||
// als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu
|
||||
// aktualisieren statt zu duplizieren.
|
||||
model PushSubscription {
|
||||
id String @id @default(uuid())
|
||||
recipientId String
|
||||
endpoint String @unique
|
||||
p256dh String // Public Key des Browsers für die Payload-Verschlüsselung
|
||||
auth String // Auth-Secret des Browsers für die Payload-Verschlüsselung
|
||||
userAgent String? // Nur zur Anzeige in einer künftigen "Geräte verwalten"-UI
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert
|
||||
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
||||
// Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit
|
||||
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
|
||||
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
|
||||
|
|
|
|||
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
public/icons/icon-maskable-192.png
Normal file
BIN
public/icons/icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
BIN
public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
36
public/manifest.json
Normal file
36
public/manifest.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "Kumasy",
|
||||
"short_name": "Kumasy",
|
||||
"description": "Kumasy – Kurse buchen und verwalten",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1E293B",
|
||||
"theme_color": "#F4C00C",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,10 +2,13 @@ import type { Metadata, Viewport } from "next";
|
|||
import localFont from "next/font/local";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { SerwistProvider } from "@serwist/turbopack/react";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { AuthInitializer } from "../components/AuthInitializer";
|
||||
import NavigationProgress from "../components/layout/NavigationProgress";
|
||||
import { InstallPrompt } from "../components/InstallPrompt";
|
||||
import { NotificationPrompt } from "../components/NotificationPrompt";
|
||||
|
||||
const salmondFont = localFont({
|
||||
src: "../public/font/salmond.woff",
|
||||
|
|
@ -13,12 +16,23 @@ const salmondFont = localFont({
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kumasy",
|
||||
description: "Generated by create next app",
|
||||
description: "Kumasy – Kurse buchen und verwalten",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Kumasy",
|
||||
},
|
||||
icons: {
|
||||
icon: "/icons/icon-192.png",
|
||||
apple: "/icons/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#F4C00C",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
|
|
@ -31,19 +45,23 @@ export default async function RootLayout({
|
|||
return (
|
||||
<html lang={locale} className={salmondFont.className}>
|
||||
<body className={`antialiased bg-muted`}>
|
||||
<NextIntlClientProvider>
|
||||
<NavigationProgress />
|
||||
<AuthInitializer />
|
||||
<main>{children}</main>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "#1E293B",
|
||||
color: "#F4C00C",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</NextIntlClientProvider>
|
||||
<SerwistProvider swUrl="/serwist/sw.js">
|
||||
<NextIntlClientProvider>
|
||||
<NavigationProgress />
|
||||
<AuthInitializer />
|
||||
<main>{children}</main>
|
||||
<InstallPrompt />
|
||||
<NotificationPrompt />
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "#1E293B",
|
||||
color: "#F4C00C",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</NextIntlClientProvider>
|
||||
</SerwistProvider>
|
||||
</body>
|
||||
</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)
|
||||
* 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 { prisma } from "@/src/lib/prisma";
|
||||
import { sendPushToRecipient } from "@/src/lib/push/send-push";
|
||||
import {
|
||||
NOTIFICATION_TYPES,
|
||||
resolveNotificationSettings,
|
||||
|
|
@ -64,6 +68,15 @@ export async function createNotification(input: CreateNotificationInput): Promis
|
|||
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;
|
||||
} catch (error) {
|
||||
console.error("createNotification fehlgeschlagen:", error);
|
||||
|
|
|
|||
|
|
@ -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,132 +254,172 @@ 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>
|
||||
<Input
|
||||
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">
|
||||
<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 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>
|
||||
<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
|
||||
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>
|
||||
|
||||
{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"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-gray-400 hover:bg-white/60 hover:text-gray-700"
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setForm(emptyForm);
|
||||
}}
|
||||
title="Schließen"
|
||||
>
|
||||
Abbrechen
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
@ -351,25 +464,140 @@ const DiscountCodeList = ({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.isActive && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
|
||||
onClick={() => {
|
||||
// onDeactivate kann rejecten - der Aufrufer zeigt den
|
||||
// Fehler bereits per Toast, hier nur die unhandled
|
||||
// rejection verhindern.
|
||||
onDeactivate(c.id).catch(() => {});
|
||||
}}
|
||||
className="text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
onClick={() => startEditing(c)}
|
||||
disabled={isSaving}
|
||||
title="Deaktivieren"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<Pencil size={16} />
|
||||
</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>
|
||||
|
||||
{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">
|
||||
{c.type === "percent" ? (
|
||||
<Percent size={18} className="text-gray-400" />
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import DiscountCodeList from "../components/DiscountCodeList";
|
|||
import {
|
||||
getDiscountCodes,
|
||||
createDiscountCode,
|
||||
updateDiscountCodeLimits,
|
||||
deactivateDiscountCode,
|
||||
getBusinessCoursesForDiscountPicker,
|
||||
type DiscountCodeDTO,
|
||||
|
|
@ -111,6 +112,35 @@ const SettingsDiscountsPageView = ({
|
|||
});
|
||||
};
|
||||
|
||||
const handleUpdate = (
|
||||
discountCodeId: string,
|
||||
input: {
|
||||
maxRedemptions: number | null;
|
||||
startsAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
startSaving(async () => {
|
||||
try {
|
||||
const result = await updateDiscountCodeLimits(selectedBusinessId, discountCodeId, input);
|
||||
if (result.success) {
|
||||
toast.success("Rabattcode aktualisiert.");
|
||||
setCodes((prev) => prev.map((c) => (c.id === discountCodeId ? { ...c, ...input } : c)));
|
||||
resolve(true);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Rabattcode konnte nicht aktualisiert werden.");
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeactivate = (discountCodeId: string): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
startSaving(async () => {
|
||||
|
|
@ -188,6 +218,7 @@ const SettingsDiscountsPageView = ({
|
|||
codes={codes}
|
||||
courses={courses}
|
||||
onCreate={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
onDeactivate={handleDeactivate}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue