Kuma/src/modules/courses/ui/components/PublicCourseCard.tsx

710 lines
26 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
import DOMPurify from "dompurify";
import { checkUserBookingStatus } from "../../actions/booking-actions";
import { joinWaitlistAsUser, joinWaitlistAsGuest } from "../../actions/waitlist-actions";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { toast } from "sonner";
// 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: Record<string, { label: string; bg: string; text: string }> = {
any: { label: "Jedes Niveau", bg: "bg-gray-200", text: "text-gray-700" },
beginner: { label: "Anfänger", bg: "bg-blue-200", text: "text-blue-700" },
advanced: { label: "Fortgeschrittene", bg: "bg-orange-200", text: "text-orange-700" },
pro: { label: "Profi", bg: "bg-red-200", text: "text-red-700" },
};
/**
* 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[];
}
interface PublicCourseCardProps {
course: PublicCourse;
business?: BusinessInfo;
defaultOpen?: boolean; // öffnet das Detail-Popup beim Mount (für Deep-Links)
/** Business-Akzentfarbe für Buchen-Button/Fortschrittsbalken. Ohne Wert
* bleibt das App-Standarddesign (bg-blue) unverändert. */
accentColor?: string;
}
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,
}: {
course: PublicCourse;
business?: BusinessInfo;
open: boolean;
onOpenChange: (v: boolean) => void;
paymentStatus?: string;
accentColor?: string;
}) => {
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 [isJoiningGuestWaitlist, setIsJoiningGuestWaitlist] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
const [showGuestForm, setShowGuestForm] = useState(false);
const [guestEmail, setGuestEmail] = useState("");
const [guestName, setGuestName] = useState("");
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",
});
const formattedPrice = course.price.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
});
// 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);
} 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 handleBooking = async (opts?: { asGuest: boolean }) => {
setIsSubmitting(true);
try {
const body: Record<string, string> = { courseId: course.id };
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);
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 handleGuestJoinWaitlist = async () => {
const normalizedEmail = guestEmail.trim().toLowerCase();
if (!EMAIL_RE.test(normalizedEmail)) {
toast.error("Bitte eine gültige E-Mail-Adresse angeben.");
return;
}
setIsJoiningGuestWaitlist(true);
try {
const result = await joinWaitlistAsGuest(course.id, normalizedEmail, guestName);
if (!result.success) {
toast.error(result.error);
return;
}
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 {
setIsJoiningGuestWaitlist(false);
}
};
const adminStatus = isAdminView
? (ADMIN_PAYMENT_LABELS[paymentStatus!] ?? ADMIN_PAYMENT_LABELS.pending)
: null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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"
>
<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">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-lg font-bold text-gray-900">Preis: {formattedPrice}</span>
<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>
<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>
)
) : (
<Button
onClick={() => handleBooking()}
disabled={isSubmitting}
className="flex-1 sm:flex-initial"
style={accentButtonStyle}
>
{isSubmitting ? "Wird gebucht…" : "Buchen"}
</Button>
)}
</>
)}
</div>
</div>
{/* Gast-Formular: erscheint nach erstem 401 (nicht eingeloggt), oder
direkt bei vollem Kurs - ein anonymer Gast hat sonst keinen Weg,
sich für die Warteliste einzutragen, ohne vorher einen
fehlschlagenden Buchungsversuch auszulösen. */}
{!isAdminView && !isCheckingStatus && !isBooked && (showGuestForm || isFull) && (
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
<p className="text-xs text-gray-500">
{isFull ? "Ohne Konto auf die Warteliste – oder" : "Ohne Konto buchen – oder"}{" "}
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
anmelden
</Link>
.
</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"
/>
{isFull ? (
<Button
onClick={handleGuestJoinWaitlist}
disabled={
isJoiningGuestWaitlist || !EMAIL_RE.test(guestEmail.trim().toLowerCase())
}
className="w-full"
style={accentButtonStyle}
>
{isJoiningGuestWaitlist ? "Wird eingetragen…" : "Auf Warteliste eintragen"}
</Button>
) : (
<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>
);
};
const PublicCourseCard = ({
course,
business,
defaultOpen = false,
accentColor,
}: PublicCourseCardProps) => {
const [dialogOpen, setDialogOpen] = useState(defaultOpen);
const cardSafeCurrent = Math.max(0, course.currentParticipants);
const cardSafeMax = Math.max(0, course.maxParticipants);
const isFull = computeIsFull(course);
const currentLevel = levelConfig[course.level] ?? levelConfig.any;
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "numeric",
day: "numeric",
});
const formattedPrice = course.price.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
});
const fillPercent = cardSafeMax > 0 ? Math.min(100, (cardSafeCurrent / cardSafeMax) * 100) : 0;
return (
<>
<div
onClick={() => setDialogOpen(true)}
className="border border-gray-200 bg-white rounded-xl shadow-sm hover:shadow-md transition flex flex-col cursor-pointer overflow-hidden"
>
{course.coverImage && (
<div className="relative w-full h-36 shrink-0">
<Image src={course.coverImage} alt={course.name} fill className="object-cover" />
</div>
)}
<div className="flex flex-col gap-4 p-5">
<div className="flex items-center justify-between">
<p className="text-gray-500 text-sm">{formattedDate}</p>
<p className="text-gray-500 text-sm">
{course.timeFrom} – {course.timeTo}
</p>
</div>
<div className="flex items-center gap-3">
{course.trainerProfiles && course.trainerProfiles.length > 0 && (
<div className="flex items-center gap-1.5 shrink-0">
{course.trainerProfiles.map((trainer) => {
const fullName =
[trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
return (
<Tooltip key={trainer.id}>
<TooltipTrigger asChild>
<div>
<TrainerAvatar trainer={trainer} size="sm" />
</div>
</TooltipTrigger>
<TooltipContent side="top">{fullName}</TooltipContent>
</Tooltip>
);
})}
</div>
)}
<h2 className="text-xl font-semibold text-gray-900">{course.name}</h2>
</div>
<div className="flex items-center justify-between">
<span
className={`text-xs font-semibold px-2 py-1 rounded-lg ${currentLevel.bg} ${currentLevel.text}`}
>
{currentLevel.label}
</span>
<span className="text-lg font-semibold">{formattedPrice}</span>
</div>
<div className="flex items-center justify-between">
<span
className={isFull ? "text-red-600 font-semibold text-sm" : "text-gray-700 text-sm"}
>
{course.currentParticipants}/{course.maxParticipants} Teilnehmer
</span>
</div>
<div className="w-full h-2 bg-white/40 rounded-full overflow-hidden">
<div
className={`h-full ${isFull ? "bg-red-500" : accentColor ? "" : "bg-blue-500"}`}
style={{
width: `${fillPercent}%`,
backgroundColor: !isFull && accentColor ? accentColor : undefined,
}}
/>
</div>
{isFull ? (
<Button
variant="outline"
className="w-full"
onClick={(e) => {
e.stopPropagation();
setDialogOpen(true);
}}
>
Warteliste
</Button>
) : (
<Button
className="w-full"
onClick={() => setDialogOpen(true)}
style={
accentColor
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
: undefined
}
>
Jetzt anmelden
</Button>
)}
</div>
</div>
<BookingDialog
course={course}
business={business}
open={dialogOpen}
onOpenChange={setDialogOpen}
accentColor={accentColor}
/>
</>
);
};
export default PublicCourseCard;