mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
250 lines
9.3 KiB
TypeScript
250 lines
9.3 KiB
TypeScript
"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;
|