added popup detail page

This commit is contained in:
Rajpreet0 2026-05-05 19:07:18 +02:00
parent 2ca96a7a85
commit dd5b157b5d
3 changed files with 181 additions and 88 deletions

View file

@ -253,14 +253,24 @@ export async function getPublishedCoursesForBusiness(
select: { id: true, firstName: true, lastName: true, pbSrc: true },
})
: [];
const trainerMap = Object.fromEntries(trainers.map((t) => [t.id, t]));
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))] as string[];
const rooms =
roomIds.length > 0
? await prisma.room.findMany({
where: { id: { in: roomIds } },
select: { id: true, name: true },
})
: [];
const roomMap = Object.fromEntries(rooms.map((r) => [r.id, r.name]));
return {
success: true,
courses: courses.map((c) => ({
...c,
currentParticipants: c._count.bookings,
roomName: c.room ? (roomMap[c.room] ?? null) : null,
trainerProfiles: c.trainers.map((id) => trainerMap[id]).filter(Boolean) as {
id: string;
firstName: string | null;

View file

@ -2,8 +2,10 @@
import { useState, useEffect } from "react";
import Image from "next/image";
import { Clock, MapPin, Users, Mail, Phone } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/src/components/ui/dialog";
import { Dialog, 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 { bookCourse, checkUserBookingStatus } from "../../actions/booking-actions";
@ -23,6 +25,12 @@ interface TrainerProfile {
pbSrc: string | null;
}
interface BusinessInfo {
address: string;
email: string;
tel?: string;
}
interface PublicCourse {
id: string;
name: string;
@ -36,21 +44,50 @@ interface PublicCourse {
currentParticipants: number;
description?: string;
room?: string;
roomName?: string | null;
coverImage?: string;
trainerProfiles?: TrainerProfile[];
}
interface PublicCourseCardProps {
course: PublicCourse;
business?: BusinessInfo;
}
// Inner dialog content — only mounts when dialog opens, so booking status
// is checked lazily rather than on every card render.
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 BookingDialog = ({
course,
business,
open,
onOpenChange,
}: {
course: PublicCourse;
business?: BusinessInfo;
open: boolean;
onOpenChange: (v: boolean) => void;
}) => {
@ -61,13 +98,12 @@ const BookingDialog = ({
const safeCurrent = Math.max(0, course.currentParticipants);
const safeMax = Math.max(0, course.maxParticipants);
const isFull = safeCurrent >= safeMax;
const spotsLeft = Math.max(0, safeMax - safeCurrent);
const level = levelConfig[course.level] ?? levelConfig.any;
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
year: "numeric",
month: "long",
day: "numeric",
});
const formattedPrice = course.price.toLocaleString("de-DE", {
@ -120,75 +156,128 @@ const BookingDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogContent className="max-w-3xl w-[90vw] p-0 overflow-hidden gap-0">
<VisuallyHidden>
<DialogTitle>{course.name}</DialogTitle>
</DialogHeader>
</VisuallyHidden>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-gray-500">
<span>{formattedDate}</span>
<span>
{course.timeFrom} – {course.timeTo}
</span>
</div>
{course.sport.length > 0 && <p className="text-gray-600">{course.sport.join(", ")}</p>}
<div className="flex items-center justify-between">
<span
className={`text-xs font-semibold px-2 py-1 rounded-lg ${levelConfig[course.level]?.bg ?? "bg-gray-200"} ${levelConfig[course.level]?.text ?? "text-gray-700"}`}
>
{levelConfig[course.level]?.label ?? "Jedes Niveau"}
</span>
<span className="font-semibold">{formattedPrice}</span>
</div>
<div className={`text-sm font-medium ${isFull ? "text-red-600" : "text-gray-700"}`}>
{isFull
? "Ausgebucht"
: `${spotsLeft} ${spotsLeft === 1 ? "Platz" : "Plätze"} verfügbar`}
</div>
{/* Participant bar */}
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`}
style={{
width: `${safeMax > 0 ? Math.min(100, (safeCurrent / safeMax) * 100) : 0}%`,
}}
/>
</div>
{course.description && (
<div
className="prose prose-sm max-w-none text-gray-600 pt-1"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(course.description) }}
/>
{/* Cover image */}
<div className="relative w-full 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>
<div className="pt-2">
{isCheckingStatus ? (
<Button disabled className="w-full">
Lade Status…
</Button>
) : isBooked ? (
<Button disabled className="w-full">
✓ Bereits angemeldet
</Button>
) : (
<Button onClick={handleBooking} disabled={isSubmitting || isFull} className="w-full">
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Jetzt anmelden"}
</Button>
{/* Body */}
<div className="px-6 pt-5 pb-6 flex flex-col gap-5">
{/* Title row */}
<div className="flex items-start justify-between gap-4">
<h2 className="text-xl font-bold text-gray-900 leading-tight">{course.name}</h2>
<span className="text-sm text-gray-500 shrink-0 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-2.5 text-sm text-gray-700">
<div className="flex items-center gap-2.5">
<Clock size={15} className="text-gray-400 shrink-0" />
<span>
{course.timeFrom} – {course.timeTo} Uhr
</span>
</div>
{(course.roomName || business?.address) && (
<div className="flex items-center gap-2.5">
<MapPin size={15} className="text-gray-400 shrink-0" />
<span>{course.roomName ?? business?.address}</span>
</div>
)}
<div className="flex items-center gap-2.5">
<Users size={15} className="text-gray-400 shrink-0" />
<span>
Teilnehmer: {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">
{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="prose prose-sm max-w-none text-gray-600"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(course.description) }}
/>
)}
</div>
{/* Footer: price + actions */}
<div className="flex items-center justify-between pt-1">
<span className="text-lg font-bold text-gray-900">Preis: {formattedPrice}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Schließen
</Button>
{isCheckingStatus ? (
<Button disabled>Lade…</Button>
) : isBooked ? (
<Button disabled>✓ Angemeldet</Button>
) : (
<Button onClick={handleBooking} disabled={isSubmitting || isFull}>
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
const PublicCourseCard = ({ course }: PublicCourseCardProps) => {
const PublicCourseCard = ({ course, business }: PublicCourseCardProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const cardSafeCurrent = Math.max(0, course.currentParticipants);
@ -249,26 +338,11 @@ const PublicCourseCard = ({ course }: PublicCourseCardProps) => {
{course.trainerProfiles.map((trainer) => {
const fullName =
[trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
const initials =
[trainer.firstName?.[0], trainer.lastName?.[0]]
.filter(Boolean)
.join("")
.toUpperCase() || "T";
return (
<Tooltip key={trainer.id}>
<TooltipTrigger asChild>
<div className="size-8 rounded-full overflow-hidden border border-gray-200 bg-gray-100 flex items-center justify-center shrink-0 cursor-default">
{trainer.pbSrc ? (
<Image
src={trainer.pbSrc}
alt={fullName}
width={32}
height={32}
className="object-cover w-full h-full"
/>
) : (
<span className="text-xs font-medium text-gray-600">{initials}</span>
)}
<div>
<TrainerAvatar trainer={trainer} size="sm" />
</div>
</TooltipTrigger>
<TooltipContent side="top">{fullName}</TooltipContent>
@ -289,7 +363,12 @@ const PublicCourseCard = ({ course }: PublicCourseCardProps) => {
)}
</div>
<BookingDialog course={course} open={dialogOpen} onOpenChange={setDialogOpen} />
<BookingDialog
course={course}
business={business}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
);
};

View file

@ -170,10 +170,10 @@ const BusinessPublicView = ({
)}
</div>
<div className="flex flex-col lg:flex-row gap-8 items-start">
<div className="flex flex-col lg:flex-row gap-8 lg:items-start">
{/* Standing calendar */}
<div className="lg:sticky lg:top-6 shrink-0">
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-4">
<div className="lg:sticky lg:top-6 shrink-0 w-full lg:w-auto">
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-4 flex justify-center">
<Calendar
mode="single"
selected={selectedDate}
@ -196,9 +196,13 @@ const BusinessPublicView = ({
Für diesen Tag sind keine Kurse verfügbar.
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
{displayedCourses.map((course) => (
<PublicCourseCard key={course.id} course={course} />
<PublicCourseCard
key={course.id}
course={course}
business={{ address: business.address, email: business.email }}
/>
))}
</div>
)}