Kuma/src/modules/courses/ui/components/UserBookingCard.tsx
2026-10-02 00:41:54 +02:00

250 lines
9.3 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, useTransition } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { MapPin, Clock, Users } from "lucide-react";
import type { UserBooking } from "../../actions/booking-actions";
import { cancelUserBooking } from "../../actions/booking-actions";
import CancelBookingConfirmDialog from "./CancelBookingConfirmDialog";
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
import { BookingDialog } from "@/src/modules/courses/ui/components/BookingDialog";
import type { PublicCourse, BusinessInfo } from "@/src/modules/courses/ui/components/BookingDialog";
import { toast } from "sonner";
const LEVEL_LABELS: Record<string, { label: string; bg: string; text: string }> = {
any: { label: "Jedes Niveau", bg: "bg-gray-100", text: "text-gray-600" },
beginner: { label: "Anfänger", bg: "bg-blue-100", text: "text-blue-700" },
advanced: { label: "Fortgeschrittene", bg: "bg-orange-100", text: "text-orange-700" },
pro: { label: "Profi", bg: "bg-red-100", text: "text-red-700" },
};
const getDiceBearUrl = (seed: string) =>
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(`t-${seed.slice(0, 6)}`)}`;
const PAYMENT_STATUS: 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" },
};
function toPublicCourse(b: UserBooking): PublicCourse {
return {
id: b.courseId,
name: b.courseName,
sport: [],
level: b.level,
date: new Date(b.date),
timeFrom: b.timeFrom,
timeTo: b.timeTo,
maxParticipants: b.maxParticipants,
price: b.price,
currentParticipants: b.currentParticipants,
description: b.description,
roomName: b.roomName,
spotLabel: b.spotLabel,
coverImage: b.coverImage ?? undefined,
trainerProfiles: b.trainerProfiles,
};
}
/** Standort-Kontaktdaten für den Detail-Dialog (Email + Ort). */
function toBusinessInfo(b: UserBooking): BusinessInfo | undefined {
if (!b.businessAddress && !b.businessEmail) return undefined;
return {
address: b.businessAddress ?? "",
email: b.businessEmail ?? "",
};
}
interface UserBookingCardProps {
booking: UserBooking;
}
const UserBookingCard = ({ booking }: UserBookingCardProps) => {
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [, startTransition] = useTransition();
const [cancelled, setCancelled] = useState(booking.paymentStatus === "refunded");
const effectiveStatus = cancelled ? "refunded" : booking.paymentStatus;
const status = PAYMENT_STATUS[effectiveStatus] ?? PAYMENT_STATUS.pending;
const dateFormatted = new Date(booking.date).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
const canCancel = !booking.isPast && !cancelled && booking.paymentStatus === "paid";
const level = LEVEL_LABELS[booking.level] ?? LEVEL_LABELS.any;
const safeCurrent = Math.max(0, booking.currentParticipants);
const safeMax = Math.max(0, booking.maxParticipants);
const handleConfirmCancel = () =>
new Promise<{ success: true } | { success: false; error: string }>((resolve) => {
startTransition(async () => {
const result = await cancelUserBooking(booking.bookingId);
resolve(result);
});
});
return (
<>
<div className="bg-white/70 backdrop-blur-sm border border-white/50 rounded-xl shadow-sm hover:shadow-md hover:bg-white/85 transition-all flex flex-col gap-0 overflow-hidden">
{/* Kursbild */}
<button
type="button"
onClick={() => setDialogOpen(true)}
className="relative w-full h-32 shrink-0 bg-gray-100 cursor-pointer select-none"
aria-label={`Kurs ${booking.courseName} öffnen`}
>
{booking.coverImage ? (
<Image
src={booking.coverImage}
alt={booking.courseName}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-linear-to-br from-gray-200 to-gray-300 flex items-center justify-center">
<span className="text-xs text-gray-400">Kein Bild</span>
</div>
)}
</button>
{/* Clickable card area */}
<button
type="button"
onClick={() => setDialogOpen(true)}
className="text-left w-full p-4 flex flex-col gap-3 select-none cursor-pointer"
>
{/* Trainer Avatare + Uhrzeit */}
<div className="flex items-center justify-between">
<div className="flex items-center">
{booking.trainers.length > 0 ? (
booking.trainers.map((tid, idx) => {
const info = booking.trainersMap[tid];
const name = info?.label ?? "Trainer";
const src = info?.pbSrc ?? getDiceBearUrl(tid);
return (
<AbstractTooltip key={tid} tooltipText={name}>
<div
className={`relative w-8 h-8 rounded-full border-2 border-white overflow-hidden bg-gray-100 ${
idx !== 0 ? "-ml-2" : ""
}`}
style={{ zIndex: booking.trainers.length - idx }}
>
<Image src={src} alt={name} fill className="object-cover" unoptimized />
</div>
</AbstractTooltip>
);
})
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-xs text-gray-400">
–
</div>
)}
</div>
<span className="text-sm text-gray-500 flex items-center gap-1">
<Clock size={13} className="shrink-0" />
{booking.timeFrom} – {booking.timeTo}
</span>
</div>
{/* Business + Kursname */}
<div className="flex items-center gap-2">
{booking.businessBranding.logoUrl && (
<div className="relative w-6 h-6 shrink-0 rounded overflow-hidden bg-white border border-gray-200">
<Image
src={booking.businessBranding.logoUrl}
alt={booking.businessName}
fill
className="object-contain p-0.5"
/>
</div>
)}
<span className="text-xs text-gray-500 truncate">{booking.businessName}</span>
</div>
<h3 className="font-semibold text-gray-900 text-base leading-tight">
{booking.courseName}
</h3>
{/* Datum + Raum */}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{dateFormatted}</span>
<span className="flex items-center gap-1 min-w-0">
<MapPin size={13} className="shrink-0" />
<span className="truncate">
{booking.roomName}
{booking.spotLabel && ` · ${booking.spotLabel}`}
</span>
</span>
</div>
{/* Schwierigkeitsgrad + Teilnehmerzahl */}
<div className="flex items-center justify-between gap-2">
<span
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${level.bg} ${level.text}`}
>
{level.label}
</span>
<span className="flex items-center gap-1 text-xs text-gray-500 shrink-0">
<Users size={13} className="shrink-0" />
{safeCurrent}/{safeMax}
</span>
</div>
{/* Status Badge */}
<div>
<span
className={`inline-block text-xs font-semibold px-2.5 py-1 rounded-full ${status.bg} ${status.text}`}
>
{status.label}
</span>
</div>
</button>
</div>
<BookingDialog
course={toPublicCourse(booking)}
business={toBusinessInfo(booking)}
open={dialogOpen}
onOpenChange={setDialogOpen}
paymentStatus={effectiveStatus}
accentColor={booking.businessBranding.accentColor}
canCancel={canCancel}
onRequestCancel={() => {
setDialogOpen(false);
setConfirmOpen(true);
}}
/>
<CancelBookingConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
bookingId={booking.bookingId}
courseName={booking.courseName}
variant="user"
onConfirm={handleConfirmCancel}
onSuccess={() => {
setCancelled(true);
router.refresh();
toast.success("Buchung erfolgreich storniert.");
}}
/>
</>
);
};
export default UserBookingCard;