mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
645 lines
25 KiB
TypeScript
645 lines
25 KiB
TypeScript
"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>
|
||
);
|
||
};
|