Kuma/src/modules/courses/ui/components/BookingDialog.tsx
2026-10-01 23:28:32 +02:00

645 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useSyncExternalStore } from "react";
import Image from "next/image";
import Link from "next/link";
import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info, X } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import DOMPurify from "dompurify";
import { checkUserBookingStatus, previewDiscountCode } from "../../actions/booking-actions";
import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { toast } from "sonner";
import { COURSE_LEVELS } from "../../lib/course-levels";
import { formatEuro } from "./course-schedule-shared";
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
// Wert (kein Hydration-Mismatch), und nach dem Mount geht der Wert auf true –
// ohne setState im Effect, also lint-konform.
const subscribe = () => () => {};
const getClientSnapshot = () => true;
const getServerSnapshot = () => false;
/**
* Sanitisiert den HTML-Wert nach dem Mount.
* Server- und initialer Client-Render liefern "" – nach dem Mount läuft
* DOMPurify.sanitize synchron während des Re-Renders. So gibt es keinen
* Hydration-Mismatch und DOMPurify wird nie auf dem Server aufgerufen
* (wo es kein `window` hat).
*/
function useSanitizedHtml(dirty: string | undefined): string {
const isClient = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
if (!isClient || !dirty) return "";
return DOMPurify.sanitize(dirty);
}
const levelConfig = COURSE_LEVELS;
/**
* Einheitliche "voll"-Definition für Karte und Dialog - vorher wurde dieselbe
* Formel an zwei Stellen unabhängig dupliziert, was ein Auseinanderlaufen
* riskierte, sobald eine dritte Stelle (Warteliste) dieselbe Prüfung braucht.
*/
function computeIsFull(course: Pick<PublicCourse, "currentParticipants" | "maxParticipants">) {
const safeCurrent = Math.max(0, course.currentParticipants);
const safeMax = Math.max(0, course.maxParticipants);
return safeCurrent >= safeMax;
}
export interface TrainerProfile {
id: string;
firstName: string | null;
lastName: string | null;
pbSrc: string | null;
}
export interface BusinessInfo {
address: string;
email: string;
tel?: string;
}
export interface PublicCourse {
id: string;
name: string;
sport: string[];
level: string;
date: Date;
timeFrom: string;
timeTo: string;
maxParticipants: number;
price: number;
currentParticipants: number;
description?: string;
room?: string;
roomName?: string | null;
coverImage?: string;
trainerProfiles?: TrainerProfile[];
}
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
const fullName = [trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
const initials =
[trainer.firstName?.[0], trainer.lastName?.[0]].filter(Boolean).join("").toUpperCase() || "T";
const sizeClass = size === "lg" ? "size-10" : "size-8";
const textClass = size === "lg" ? "text-sm" : "text-xs";
return (
<div
className={`${sizeClass} rounded-full overflow-hidden border border-gray-200 bg-gray-100 flex items-center justify-center shrink-0`}
>
{trainer.pbSrc ? (
<Image
src={trainer.pbSrc}
alt={fullName}
width={size === "lg" ? 40 : 32}
height={size === "lg" ? 40 : 32}
className="object-cover w-full h-full"
/>
) : (
<span className={`${textClass} font-medium text-gray-600`}>{initials}</span>
)}
</div>
);
};
const ADMIN_PAYMENT_LABELS: Record<string, { label: string; bg: string; text: string }> = {
paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" },
pending: { label: "Ausstehend", bg: "bg-yellow-100", text: "text-yellow-700" },
refunded: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
partially_refunded: {
label: "Teilweise erstattet",
bg: "bg-orange-100",
text: "text-orange-700",
},
cancelled: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
refunding: { label: "Wird storniert…", bg: "bg-yellow-100", text: "text-yellow-700" },
failed: { label: "Fehlgeschlagen", bg: "bg-red-100", text: "text-red-600" },
};
export const BookingDialog = ({
course,
business,
open,
onOpenChange,
// Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge
paymentStatus,
accentColor,
// Wenn gesetzt → im Footer erscheint ein "Stornieren"-Button (z.B. "Meine
// Kurse": bezahlte, kommende Buchung). Das Öffnen des Bestätigungsdialogs
// liegt beim Aufrufer.
canCancel = false,
onRequestCancel,
// Nur die öffentliche Buchungsansicht übergibt die Gebühr. Admin-Ansicht
// und "Meine Kurse" zeigen weiter den reinen Kurspreis.
serviceFeeCents = 0,
}: {
course: PublicCourse;
business?: BusinessInfo;
open: boolean;
onOpenChange: (v: boolean) => void;
paymentStatus?: string;
accentColor?: string;
canCancel?: boolean;
onRequestCancel?: () => void;
serviceFeeCents?: number;
}) => {
const accentButtonStyle = accentColor
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
: undefined;
const sanitizedDescription = useSanitizedHtml(course.description);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isBooked, setIsBooked] = useState(false);
const [isOnWaitlist, setIsOnWaitlist] = useState(false);
const [isJoiningWaitlist, setIsJoiningWaitlist] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
const [showGuestForm, setShowGuestForm] = useState(false);
const [guestEmail, setGuestEmail] = useState("");
const [guestName, setGuestName] = useState("");
const [discountInput, setDiscountInput] = useState("");
const [isCheckingDiscount, setIsCheckingDiscount] = useState(false);
const [appliedDiscount, setAppliedDiscount] = useState<{
code: string;
discountedPrice: number;
} | null>(null);
const isAdminView = paymentStatus !== undefined;
const safeCurrent = Math.max(0, course.currentParticipants);
const safeMax = Math.max(0, course.maxParticipants);
const isFull = computeIsFull(course);
const level = levelConfig[course.level] ?? levelConfig.any;
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
// Preisangabenverordnung: Der Kunde muss vor dem Checkout den Endpreis
// sehen, also inklusive der Servicegebühr - sonst taucht sie erst bei
// Stripe auf.
const serviceFee = serviceFeeCents / 100;
const formattedPrice = formatEuro(course.price + serviceFee);
// Booking-Status nur laden wenn keine Admin-Ansicht
useEffect(() => {
if (!open || isAdminView) return;
let active = true;
const loadStatus = async () => {
setIsCheckingStatus(true);
try {
const result = await checkUserBookingStatus(course.id);
if (!active) return;
setIsBooked(result.success ? result.isBooked : false);
setIsOnWaitlist(result.isOnWaitlist);
// Gäste sehen das Gast-Formular sofort, statt erst nach einem
// gescheiterten Buchungsversuch.
if (result.isLoggedIn === false) setShowGuestForm(true);
} catch {
if (!active) return;
setIsBooked(false);
setIsOnWaitlist(false);
} finally {
if (active) setIsCheckingStatus(false);
}
};
void loadStatus();
return () => {
active = false;
};
}, [open, course.id, isAdminView]);
// Setzt den "Wird gebucht"-Zustand zurück, wenn die Seite nach einem
// Stripe-Checkout-Abbruch (Zurück-Navigation) aus dem bfcache wiederhergestellt wird.
useEffect(() => {
const resetSubmitting = () => setIsSubmitting(false);
window.addEventListener("pageshow", resetSubmitting);
return () => window.removeEventListener("pageshow", resetSubmitting);
}, []);
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const handleApplyDiscount = async () => {
const code = discountInput.trim();
if (!code) return;
setIsCheckingDiscount(true);
try {
const result = await previewDiscountCode(course.id, code);
if (!result.valid) {
toast.error(result.error);
setAppliedDiscount(null);
return;
}
setAppliedDiscount({ code, discountedPrice: result.discountedPrice });
toast.success("Rabattcode angewendet.");
} catch {
toast.error("Rabattcode konnte nicht geprüft werden.");
setAppliedDiscount(null);
} finally {
setIsCheckingDiscount(false);
}
};
const handleBooking = async (opts?: { asGuest: boolean }) => {
setIsSubmitting(true);
try {
const body: Record<string, string> = { courseId: course.id };
if (appliedDiscount) body.discountCode = appliedDiscount.code;
if (opts?.asGuest) {
const normalizedEmail = guestEmail.trim().toLowerCase();
if (!EMAIL_RE.test(normalizedEmail)) {
toast.error("Bitte eine gültige E-Mail-Adresse angeben.");
setIsSubmitting(false);
return;
}
body.guestEmail = normalizedEmail;
if (guestName.trim()) body.guestName = guestName.trim();
}
const response = await fetch("/api/stripe/checkout/course", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
let data: { url?: string; error?: string; isGuestRequired?: boolean } = {};
try {
data = (await response.json()) as typeof data;
} catch {
// keep fallback error below
}
// Not logged in → show guest form (and show server error if already in guest mode)
if (response.status === 401 || (response.status === 400 && data.isGuestRequired)) {
if (opts?.asGuest && data.error) toast.error(data.error);
setShowGuestForm(true);
// Rabattcodes sind nur für eingeloggte User verfügbar (siehe
// validateDiscountCode) - ein bereits angewendeter Code darf nicht
// stillschweigend im Gast-Checkout landen, wo er ohnehin ignoriert würde.
setAppliedDiscount(null);
setDiscountInput("");
setIsSubmitting(false);
return;
}
if (!response.ok || !data.url) {
toast.error(data.error || "Ein Fehler ist aufgetreten");
setIsSubmitting(false);
return;
}
window.location.assign(data.url);
} catch {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
setIsSubmitting(false);
}
};
const handleJoinWaitlist = async () => {
setIsJoiningWaitlist(true);
try {
const result = await joinWaitlistAsUser(course.id);
if (!result.success) {
toast.error(result.error);
return;
}
setIsOnWaitlist(true);
toast.success(
result.alreadyOnWaitlist
? "Sie stehen bereits auf der Warteliste."
: "Sie wurden auf die Warteliste eingetragen."
);
} catch {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
} finally {
setIsJoiningWaitlist(false);
}
};
const adminStatus = isAdminView
? (ADMIN_PAYMENT_LABELS[paymentStatus!] ?? ADMIN_PAYMENT_LABELS.pending)
: null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* Am Handy als Panel, das unten andockt - Buchen-Button und Formular
liegen so im Daumenbereich. Ab sm bleibt es der mittige Dialog. */}
<DialogContent
showCloseButton={false}
className="sm:max-w-3xl! w-[95vw] max-w-[calc(100%-1rem)] p-0 overflow-hidden gap-0 max-h-[90vh] sm:max-h-[85vh] flex flex-col max-sm:top-auto max-sm:bottom-0 max-sm:w-full max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none max-sm:rounded-t-2xl max-sm:border-x-0 max-sm:border-b-0 max-sm:max-h-[92dvh] max-sm:pb-[env(safe-area-inset-bottom)]"
>
<VisuallyHidden>
<DialogTitle>{course.name}</DialogTitle>
</VisuallyHidden>
<DialogClose className="absolute top-4 right-4 z-10 flex items-center justify-center size-9 rounded-full bg-white/20 backdrop-blur-md border border-white/30 text-white shadow-sm transition-colors hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/50">
<X size={18} />
<span className="sr-only">Schließen</span>
</DialogClose>
{/* Scroll container für den Inhalt – Footer bleibt sichtbar */}
<div className="flex-1 overflow-y-auto">
{/* Cover image – auf Mobile dünner */}
<div className="relative w-full h-40 sm:h-64 bg-gray-200 shrink-0">
{course.coverImage ? (
<Image src={course.coverImage} alt={course.name} fill className="object-cover" />
) : (
<div className="w-full h-full bg-linear-to-br from-gray-300 to-gray-400 flex items-center justify-center">
<span className="text-gray-500 text-sm">Kein Bild vorhanden</span>
</div>
)}
</div>
{/* Body */}
<div className="px-4 sm:px-6 pt-4 sm:pt-5 pb-4 sm:pb-6 flex flex-col gap-4 sm:gap-5">
{/* Title row – auf Mobile gestapelt */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-1 sm:gap-4">
<h2 className="text-lg sm:text-xl font-bold text-gray-900 leading-tight pr-8 sm:pr-0">
{course.name}
</h2>
<span className="text-sm text-gray-500 shrink-0 sm:mt-0.5">{formattedDate}</span>
</div>
{/* Trainer(s) */}
{course.trainerProfiles && course.trainerProfiles.length > 0 && (
<div className="flex flex-col gap-2">
{course.trainerProfiles.map((trainer) => {
const fullName =
[trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
return (
<div key={trainer.id} className="flex items-center gap-3">
<TrainerAvatar trainer={trainer} size="lg" />
<span className="text-sm font-medium text-gray-800">{fullName}</span>
</div>
);
})}
</div>
)}
{/* Level badge */}
<span
className={`self-start text-xs font-semibold px-2.5 py-1 rounded-lg ${level.bg} ${level.text}`}
>
{level.label}
</span>
{/* Info rows */}
<div className="flex flex-col gap-4 text-sm text-gray-700">
<div className="flex items-center gap-2.5">
<Clock size={15} className="text-gray-400 shrink-0" />
<span>
<b>Uhrzeit:</b> {course.timeFrom} – {course.timeTo}
</span>
</div>
{course.roomName && (
<div className="flex items-center gap-2.5">
<DoorOpen size={15} className="text-gray-400 shrink-0" />
<span>
<b>Raum:</b> {course.roomName}
</span>
</div>
)}
{business?.address && (
<div className="flex items-center gap-2.5">
<MapPin size={15} className="text-gray-400 shrink-0" />
<span>
<b>Ort:</b> {business?.address}
</span>
</div>
)}
<div className="flex items-center gap-2.5">
<Users size={15} className="text-gray-400 shrink-0" />
<span>
<b>Teilnehmer:</b> {safeCurrent}/{safeMax}
{isFull && <span className="ml-2 text-red-600 font-medium">· Ausgebucht</span>}
</span>
</div>
{business?.email && (
<div className="flex items-center gap-2.5">
<Mail size={15} className="text-gray-400 shrink-0" />
<a href={`mailto:${business.email}`} className="hover:underline text-gray-700">
<b>Email:</b> {business.email}
</a>
</div>
)}
{business?.tel && (
<div className="flex items-center gap-2.5">
<Phone size={15} className="text-gray-400 shrink-0" />
<a href={`tel:${business.tel}`} className="hover:underline text-gray-700">
{business.tel}
</a>
</div>
)}
{course.description && (
<div className="flex items-start gap-2.5">
<Info size={15} className="text-gray-400 shrink-0 mt-0.5" />
<div>
<b>Was Sie beachten sollten:</b>
<div
className="prose prose-sm max-w-none text-gray-600 mt-1"
dangerouslySetInnerHTML={{ __html: sanitizedDescription }}
/>
</div>
</div>
)}
</div>
</div>
</div>
{/* Sticky Footer */}
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col gap-3">
{/* Rabattcodes sind nur für eingeloggte User verfügbar (siehe
validateDiscountCode) - im Gast-Formular macht das Feld daher
keinen Sinn und würde nur einen ungültigen Code suggerieren. */}
{!isAdminView && !isCheckingStatus && !isFull && !isBooked && !showGuestForm && (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Rabattcode"
value={discountInput}
onChange={(e) => {
setDiscountInput(e.target.value.toUpperCase());
if (appliedDiscount) setAppliedDiscount(null);
}}
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
<Button
variant="outline"
size="sm"
onClick={handleApplyDiscount}
disabled={isCheckingDiscount || !discountInput.trim()}
>
{isCheckingDiscount ? "Prüfe…" : "Anwenden"}
</Button>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-col">
<span className="text-lg font-bold text-gray-900">
{appliedDiscount ? (
<>
<span className="line-through text-gray-400 font-normal mr-2">
{formattedPrice}
</span>
Preis: {formatEuro(appliedDiscount.discountedPrice + serviceFee)}
</>
) : (
`Preis: ${formattedPrice}`
)}
</span>
{serviceFee > 0 && (
<span className="text-xs text-gray-500">
{formatEuro(appliedDiscount ? appliedDiscount.discountedPrice : course.price)}{" "}
Kurs + {formatEuro(serviceFee)} Servicegebühr
</span>
)}
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
{isAdminView ? (
<>
<span
className={`text-sm font-semibold px-3 py-1.5 rounded-full ${adminStatus!.bg} ${adminStatus!.text}`}
>
{adminStatus!.label}
</span>
{canCancel && onRequestCancel && (
<Button
variant="outline"
onClick={onRequestCancel}
className="flex-1 sm:flex-initial text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
>
<X size={14} className="mr-1.5 shrink-0" />
Stornieren
</Button>
)}
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 sm:flex-initial"
>
Schließen
</Button>
</>
) : (
/* Public-Ansicht: Buchen-Button */
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 sm:flex-initial"
>
Schließen
</Button>
{isCheckingStatus ? (
<Button disabled className="flex-1 sm:flex-initial">
Lade…
</Button>
) : isBooked ? (
<Button disabled className="flex-1 sm:flex-initial">
✓ Angemeldet
</Button>
) : isFull ? (
isOnWaitlist ? (
<Button disabled className="flex-1 sm:flex-initial">
✓ Auf Warteliste
</Button>
) : (
<Button
onClick={handleJoinWaitlist}
disabled={isJoiningWaitlist}
className="flex-1 sm:flex-initial"
style={accentButtonStyle}
>
{isJoiningWaitlist ? "Wird eingetragen…" : "Auf Warteliste eintragen"}
</Button>
)
) : showGuestForm ? null : (
// Gäste buchen über das Formular darunter - ein zweiter
// "Buchen"-Button daneben wäre nur verwirrend.
<Button
onClick={() => handleBooking()}
disabled={isSubmitting}
className="flex-1 sm:flex-initial"
style={accentButtonStyle}
>
{isSubmitting ? "Wird gebucht…" : "Buchen"}
</Button>
)}
</>
)}
</div>
</div>
{/* Warteliste ist bewusst nur registrierten Usern vorbehalten - ein
unauthentifiziertes Gast-Formular würde beliebige fremde
E-Mail-Adressen als Benachrichtigungsempfänger akzeptieren, ohne
deren Kontrolle über die Adresse zu bestätigen. Ein Login-Hinweis
statt eines Eintrags-Formulars schließt das und wirkt nebenbei
als Anreiz zur Registrierung. */}
{!isAdminView && !isCheckingStatus && !isBooked && isFull && (
<div className="border-t border-gray-100 pt-3">
<p className="text-xs text-gray-500 text-center">
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
Melden Sie sich an
</Link>
, um sich auf die Warteliste einzutragen.
</p>
</div>
)}
{/* Gast-Formular: erscheint sofort, wenn niemand angemeldet ist
(sonst nach einem "nicht angemeldet" vom Checkout). Bei vollem
Kurs entfällt es zugunsten des Login-Hinweises oben. */}
{!isAdminView && !isFull && showGuestForm && !isBooked && (
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
<p className="text-xs text-gray-500">
Ohne Konto buchen – die Bestätigung kommt per E-Mail, Ihre Rechnungsadresse geben
Sie im nächsten Schritt bei der Zahlung an. Schon Kunde?{" "}
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
Melden Sie sich an
</Link>
, dann können Sie auch einen Rabattcode einlösen.
</p>
<input
type="email"
placeholder="E-Mail *"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
required
/>
<input
type="text"
placeholder="Name (optional)"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
<Button
onClick={() => handleBooking({ asGuest: true })}
disabled={isSubmitting || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
className="w-full"
style={accentButtonStyle}
>
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};