diff --git a/prisma/migrations/20260827163412_add_course_waitlist/migration.sql b/prisma/migrations/20260827163412_add_course_waitlist/migration.sql new file mode 100644 index 0000000..784ff02 --- /dev/null +++ b/prisma/migrations/20260827163412_add_course_waitlist/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "CourseWaitlistEntry" ( + "id" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + "userId" TEXT, + "guestEmail" TEXT, + "guestName" TEXT, + "notifiedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CourseWaitlistEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "CourseWaitlistEntry_courseId_idx" ON "CourseWaitlistEntry"("courseId"); + +-- CreateIndex +CREATE INDEX "CourseWaitlistEntry_userId_idx" ON "CourseWaitlistEntry"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CourseWaitlistEntry_courseId_userId_key" ON "CourseWaitlistEntry"("courseId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CourseWaitlistEntry_courseId_guestEmail_key" ON "CourseWaitlistEntry"("courseId", "guestEmail"); + +-- AddForeignKey +ALTER TABLE "CourseWaitlistEntry" ADD CONSTRAINT "CourseWaitlistEntry_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CourseWaitlistEntry" ADD CONSTRAINT "CourseWaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8f5eb44..7b12478 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,32 +17,33 @@ datasource db { } model User { - id String @id @default(uuid()) - name String? - complaints String? - birthday DateTime? - plz String? - city String? - street String? - houseNumber String? + id String @id @default(uuid()) + name String? + complaints String? + birthday DateTime? + plz String? + city String? + street String? + houseNumber String? // @unique is Prisma-managed and stays case-sensitive at the schema level — // Prisma has no functional-index syntax. The actual enforced constraint is // a case-insensitive unique index on lower(email), added via raw SQL in // migration 20260817000000_case_insensitive_user_email. Do not remove // @unique here; it keeps Prisma's drift detection from trying to recreate // a duplicate case-sensitive constraint. - email String @unique - tel String? - gender String? - is2FA Boolean @default(false) - isSendingMail Boolean @default(false) - isNewsletter Boolean @default(false) - pbSrc String? - isValid Boolean @default(false) - trainings UserTraining[] - courseBookings CourseBooking[] - createdAt DateTime? @default(now()) - updatedAt DateTime? @updatedAt + email String @unique + tel String? + gender String? + is2FA Boolean @default(false) + isSendingMail Boolean @default(false) + isNewsletter Boolean @default(false) + pbSrc String? + isValid Boolean @default(false) + trainings UserTraining[] + courseBookings CourseBooking[] + courseWaitlistEntries CourseWaitlistEntry[] + createdAt DateTime? @default(now()) + updatedAt DateTime? @updatedAt } model Employee { @@ -122,10 +123,10 @@ model Business { } model Course { - id String @id @default(uuid()) + id String @id @default(uuid()) name String sport String[] - level String @default("any") + level String @default("any") date DateTime timeFrom String // Anfangszeit timeTo String // Endzeit @@ -133,24 +134,25 @@ model Course { room String description String // Rich text HTML content maxParticipants Int // Maximum number of participants - price Float @default(10.0) // Preis in Euro - isStandingOrder Boolean @default(false) + price Float @default(10.0) // Preis in Euro + isStandingOrder Boolean @default(false) frequency String? // daily, weekly, biweekly, monthly, custom weekdays String[] // For custom frequency weekdayTimings Json? // { "monday": { "timeFrom": "08:00", "timeTo": "09:00" }, ... } endDate DateTime? // Required when isStandingOrder = true parentCourseId String? // Null on template; set on every child instance - parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade) - children Course[] @relation("StandingOrderInstances") - status String @default("draft") // draft, published, cancelled + parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade) + children Course[] @relation("StandingOrderInstances") + status String @default("draft") // draft, published, cancelled cancelledAt DateTime? // Zeitpunkt der Kursabsage (nur gesetzt wenn status = "cancelled") createdBy String // Manager ID who created the course businessId String? coverImage String? - business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull) + business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull) bookings CourseBooking[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + waitlistEntries CourseWaitlistEntry[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([createdBy]) @@index([date]) @@ -204,3 +206,20 @@ model CourseBooking { @@index([userId]) @@index([guestLeadId]) } + +model CourseWaitlistEntry { + id String @id @default(uuid()) + courseId String + userId String? + guestEmail String? // normalisiert lowercase; null wenn userId gesetzt + guestName String? // optional, nur bei Gast-Einträgen + notifiedAt DateTime? // Zeitpunkt der letzten "Platz frei"-Benachrichtigung, null = noch nie benachrichtigt + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs eintragen (NULL zählt in Postgres nicht als Duplikat, betrifft also nur registrierte User) + @@unique([courseId, guestEmail]) // Dieselbe Gast-E-Mail nur einmal pro Kurs (analog, betrifft nur Gast-Einträge) + @@index([courseId]) + @@index([userId]) +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 67fe2ea..7bbad0d 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -307,6 +307,16 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { } } + // Unabhängig von recipientEmail (Bestätigungs-Mail könnte z.B. wegen + // fehlender Adresse übersprungen worden sein) - eine erfolgreiche Buchung + // entfernt diese Person von der Warteliste desselben Kurses, falls sie dort + // eingetragen war. + if (outcome.result === "booked" && outcome.bookingId) { + await removeFromWaitlistIfPresent(courseId, outcome.bookingId).catch((err) => + console.error("Warteliste-Aufräumen fehlgeschlagen:", err) + ); + } + revalidatePath("/courses"); revalidatePath("/courses/myCourses"); @@ -648,6 +658,31 @@ async function sendBookingConfirmation( } } +/** + * Entfernt die Wartelisten-Eintragung einer Person für einen Kurs, sobald + * ihre Buchung für genau diesen Kurs bezahlt wurde. Liest userId/GuestLead + * über die soeben abgeschlossene Buchung erneut aus, statt die Identität + * durch die verschachtelten Gast-/User-/Fallback-Zweige oben durchzuschleifen + * - robuster gegen künftige Änderungen an diesen Zweigen. deleteMany statt + * delete, damit "war nie auf der Warteliste" (Normalfall) ein stilles No-Op + * bleibt statt einen Fehler zu werfen. + */ +async function removeFromWaitlistIfPresent(courseId: string, bookingId: string): Promise { + const booking = await prisma.courseBooking.findUnique({ + where: { id: bookingId }, + select: { userId: true, guestLead: { select: { email: true } } }, + }); + if (!booking) return; + + if (booking.userId) { + await prisma.courseWaitlistEntry.deleteMany({ where: { courseId, userId: booking.userId } }); + } else if (booking.guestLead?.email) { + await prisma.courseWaitlistEntry.deleteMany({ + where: { courseId, guestEmail: booking.guestLead.email }, + }); + } +} + async function sendBookingRefund( recipientEmail: string, courseId: string, diff --git a/src/lib/mail/templates/waitlist-spot-available.ts b/src/lib/mail/templates/waitlist-spot-available.ts new file mode 100644 index 0000000..f8c9a96 --- /dev/null +++ b/src/lib/mail/templates/waitlist-spot-available.ts @@ -0,0 +1,105 @@ +/** + * E-Mail Template für "Ein Platz ist frei geworden" + * + * Wird an alle Wartelisten-Einträge eines Kurses gesendet, sobald eine + * bezahlte Buchung storniert wird. Es gibt keine Reservierung - alle + * Wartelisten-Einträge bekommen dieselbe Mail gleichzeitig, wer zuerst + * tatsächlich bucht, bekommt den Platz. Der Text macht das bewusst explizit, + * damit niemand denkt, ihm sei der Platz garantiert. + */ + +import { escapeHtml, sanitizeUrl, singleLine } from "./utils"; +import { renderButton, renderInfoBox } from "./components"; +import { renderEmailLayout } from "./layout"; +import { KUMASY_BRANDING, type EmailBranding } from "./tokens"; + +interface WaitlistSpotAvailableEmailParams { + courseName: string; + businessName: string; + courseUrl: string; + companyName?: string; + branding?: EmailBranding; +} + +/** + * Generiert HTML für die "Platz frei"-Benachrichtigung + */ +export function generateWaitlistSpotAvailableEmail({ + courseName, + businessName, + courseUrl, + companyName = "Ihr Unternehmen", + branding = KUMASY_BRANDING, +}: WaitlistSpotAvailableEmailParams): { subject: string; html: string; text: string } { + const safeCourseName = escapeHtml(courseName); + const safeBusinessName = escapeHtml(businessName); + const safeCourseUrl = sanitizeUrl(courseUrl); + + const subject = singleLine(`Ein Platz ist frei geworden – ${courseName}`); + + const bodyHtml = ` +

+ Hallo, +

+ +

+ gute Nachrichten: In Ihrem gewünschten Kurs ist gerade ein Platz frei geworden. +

+ + ${renderInfoBox({ + rows: [ + { + label: "Kurs", + valueHtml: `${safeCourseName}`, + }, + { label: "Anbieter", valueHtml: safeBusinessName }, + ], + })} + +

+ Da mehrere Personen auf der Warteliste stehen können, wird der Platz nach dem + Prinzip "first come, first served" vergeben - sichern Sie sich + Ihren Platz am besten zügig. +

+ + ${renderButton({ label: "Jetzt buchen", href: courseUrl, branding })} + +

+ Oder kopieren Sie diesen Link in Ihren Browser: +

+

+ ${safeCourseUrl} +

+ `; + + const html = renderEmailLayout({ + title: "Ein Platz ist frei geworden", + headerHeading: "Ein Platz ist frei! 🎉", + bodyHtml, + branding, + }); + + const text = ` +Ein Platz ist frei geworden – ${courseName} + +Hallo, + +gute Nachrichten: In Ihrem gewünschten Kurs ist gerade ein Platz frei geworden. + +Kurs: ${courseName} +Anbieter: ${businessName} + +Da mehrere Personen auf der Warteliste stehen können, wird der Platz nach dem +Prinzip "first come, first served" vergeben - sichern Sie sich Ihren Platz am +besten zügig. + +Jetzt buchen: ${safeCourseUrl} + +${companyName} +Diese E-Mail wurde automatisch generiert. + +Powered by Kumasy + `; + + return { subject, html, text }; +} diff --git a/src/modules/courses/actions/booking-actions.ts b/src/modules/courses/actions/booking-actions.ts index 0e1de6b..5656290 100644 --- a/src/modules/courses/actions/booking-actions.ts +++ b/src/modules/courses/actions/booking-actions.ts @@ -219,21 +219,25 @@ export async function checkUserBookingStatus(courseId: string) { return { success: true, isBooked: false, + isOnWaitlist: false, }; } - const booking = await prisma.courseBooking.findUnique({ - where: { - courseId_userId: { - courseId, - userId: userData.id, - }, - }, - }); + // Beide Checks im selben Aufruf statt eines separaten Wartelisten-Calls, + // damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht. + const [booking, waitlistEntry] = await Promise.all([ + prisma.courseBooking.findUnique({ + where: { courseId_userId: { courseId, userId: userData.id } }, + }), + prisma.courseWaitlistEntry.findUnique({ + where: { courseId_userId: { courseId, userId: userData.id } }, + }), + ]); return { success: true, isBooked: booking?.paymentStatus === "paid", + isOnWaitlist: waitlistEntry !== null, }; } catch (error) { console.error("Error checking booking status:", error); @@ -241,6 +245,7 @@ export async function checkUserBookingStatus(courseId: string) { success: false, error: "Fehler beim Prüfen des Buchungsstatus", isBooked: false, + isOnWaitlist: false, }; } } diff --git a/src/modules/courses/actions/cancel-booking-shared.ts b/src/modules/courses/actions/cancel-booking-shared.ts index 47b3de0..22c5677 100644 --- a/src/modules/courses/actions/cancel-booking-shared.ts +++ b/src/modules/courses/actions/cancel-booking-shared.ts @@ -4,6 +4,7 @@ import { sendMail } from "@/src/lib/mail/resend"; import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund"; import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; +import { notifyCourseWaitlist } from "./notify-waitlist"; export type CancelBookingResult = | { success: true; refundedAmountCents: number; refundPercent: number } @@ -215,6 +216,14 @@ export async function executeBookingCancellation( ).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err)); await withTimeout(emailPromise, 2000); + // Ein frei gewordener Platz soll allen Wartelisten-Einträgen des Kurses + // gemeldet werden - notifyCourseWaitlist prüft selbst frisch, ob der Kurs + // noch veröffentlicht ist (bricht bei einer Kurs-weiten Absage von selbst ab). + const waitlistPromise = notifyCourseWaitlist(booking.course.id).catch((err) => + console.error("Warteliste-Benachrichtigung fehlgeschlagen:", err) + ); + await withTimeout(waitlistPromise, 2000); + return { success: true, refundedAmountCents: amountRefundedCents, diff --git a/src/modules/courses/actions/notify-waitlist.ts b/src/modules/courses/actions/notify-waitlist.ts new file mode 100644 index 0000000..4a57b4f --- /dev/null +++ b/src/modules/courses/actions/notify-waitlist.ts @@ -0,0 +1,119 @@ +/** + * Wartelisten-Benachrichtigung bei freiwerdendem Platz + * + * Wird von executeBookingCancellation aufgerufen, sobald eine bezahlte + * Buchung erfolgreich storniert wurde. Benachrichtigt ALLE aktuellen + * Wartelisten-Einträge des Kurses gleichzeitig - kein Reservierungs- + * mechanismus, wer zuerst tatsächlich bucht, bekommt den Platz. + */ + +import { prisma } from "@/src/lib/prisma"; +import { sendMail } from "@/src/lib/mail/resend"; +import { generateWaitlistSpotAvailableEmail } from "@/src/lib/mail/templates/waitlist-spot-available"; +import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; + +export async function notifyCourseWaitlist(courseId: string): Promise { + // Kurs frisch nachladen statt einem durchgereichten Flag zu vertrauen - + // robust unabhängig davon, ob eine Kurs-weite Absage (executeCourseCancellation) + // den Status inzwischen bereits auf "cancelled" gesetzt hat. Ein Kurs, der + // nicht mehr veröffentlicht ist, soll niemanden zum Buchen einladen. + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { + status: true, + name: true, + business: { select: { name: true, branding: true, slug: true } }, + }, + }); + + if (!course || course.status !== "published" || !course.business?.slug) return; + + const entries = await prisma.courseWaitlistEntry.findMany({ + where: { courseId }, + select: { id: true, userId: true, guestEmail: true }, + }); + if (entries.length === 0) return; + + // Wer inzwischen bereits einen bezahlten Platz hat, soll nicht erneut + // eingeladen werden - registrierte User direkt über userId, Gäste nur + // lesend über die bestehende Buchung↔GuestLead-Verknüpfung (es wird dabei + // kein GuestLead erzeugt oder verändert, nur eine vorhandene Buchung gelesen). + const userIds = entries.map((e) => e.userId).filter((id): id is string => id !== null); + const paidUserIds = new Set( + userIds.length + ? ( + await prisma.courseBooking.findMany({ + where: { courseId, userId: { in: userIds }, paymentStatus: "paid" }, + select: { userId: true }, + }) + ).map((b) => b.userId) + : [] + ); + + const guestEmails = entries.map((e) => e.guestEmail).filter((e): e is string => e !== null); + const paidGuestEmails = new Set( + guestEmails.length + ? ( + await prisma.courseBooking.findMany({ + where: { courseId, paymentStatus: "paid", guestLead: { email: { in: guestEmails } } }, + select: { guestLead: { select: { email: true } } }, + }) + ) + .map((b) => b.guestLead?.email) + .filter((email): email is string => !!email) + : [] + ); + + const toNotify = entries.filter((e) => + e.userId + ? !paidUserIds.has(e.userId) + : e.guestEmail + ? !paidGuestEmails.has(e.guestEmail) + : false + ); + if (toNotify.length === 0) return; + + const notifyUserIds = toNotify.filter((e) => e.userId).map((e) => e.userId!); + const usersById = new Map( + notifyUserIds.length + ? ( + await prisma.user.findMany({ + where: { id: { in: notifyUserIds } }, + select: { id: true, email: true }, + }) + ).map((u) => [u.id, u.email] as const) + : [] + ); + + const recipients = toNotify + .map((e) => ({ + entryId: e.id, + email: e.userId ? usersById.get(e.userId) : e.guestEmail, + })) + .filter((r): r is { entryId: string; email: string } => !!r.email); + if (recipients.length === 0) return; + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const courseUrl = `${baseUrl}/business/${course.business.slug}?course=${courseId}`; + const branding = toEmailBranding(course.business); + + const { subject, html, text } = generateWaitlistSpotAvailableEmail({ + courseName: course.name, + businessName: course.business.name, + courseUrl, + companyName: process.env.COMPANY_NAME, + branding, + }); + + // Promise.allSettled statt Promise.all - ein fehlgeschlagener Versand + // (z.B. ungültige/inaktive Adresse) soll die anderen Empfänger nicht blockieren. + await Promise.allSettled( + recipients.map(async (r) => { + await sendMail({ to: r.email, subject, html, text }); + await prisma.courseWaitlistEntry.update({ + where: { id: r.entryId }, + data: { notifiedAt: new Date() }, + }); + }) + ); +} diff --git a/src/modules/courses/actions/waitlist-actions.ts b/src/modules/courses/actions/waitlist-actions.ts new file mode 100644 index 0000000..65d0d5b --- /dev/null +++ b/src/modules/courses/actions/waitlist-actions.ts @@ -0,0 +1,115 @@ +/** + * Waitlist Actions - Server-seitige Aktionen für die Kurs-Warteliste + * + * Einfaches Wartelisten-Konzept ohne Reservierung: bei einem vollen Kurs + * können sich registrierte User und Gäste eintragen. Wird ein Platz durch + * eine Stornierung frei, werden alle eingetragenen Personen gleichzeitig per + * E-Mail informiert (siehe notify-waitlist.ts) - wer zuerst tatsächlich + * bucht, bekommt den Platz. + */ + +"use server"; + +import { prisma } from "@/src/lib/prisma"; +import { getUserData, isUser } from "@/src/lib/auth/getUser"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export type JoinWaitlistResult = + | { success: true; alreadyOnWaitlist: boolean } + | { success: false; error: string }; + +/** + * Prüft, ob ein Kurs überhaupt wartelisten-fähig ist: muss existieren, + * veröffentlicht sein und aktuell voll sein (bezahlte Buchungen >= + * maxParticipants) - dieselbe "voll"-Definition, die überall sonst im + * Code verwendet wird (paymentStatus: "paid"). + */ +async function assertCourseIsFullAndJoinable( + courseId: string +): Promise<{ ok: true } | { ok: false; error: string }> { + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { + status: true, + maxParticipants: true, + _count: { select: { bookings: { where: { paymentStatus: "paid" } } } }, + }, + }); + + if (!course) return { ok: false, error: "Kurs nicht gefunden." }; + if (course.status !== "published") { + return { ok: false, error: "Dieser Kurs ist nicht verfügbar." }; + } + if (course._count.bookings < course.maxParticipants) { + return { ok: false, error: "Dieser Kurs ist nicht ausgebucht." }; + } + + return { ok: true }; +} + +export async function joinWaitlistAsUser(courseId: string): Promise { + const userData = await getUserData(); + if (!userData || !isUser(userData)) { + return { success: false, error: "Nicht angemeldet." }; + } + + const validation = await assertCourseIsFullAndJoinable(courseId); + if (!validation.ok) return { success: false, error: validation.error }; + + // Bereits bezahlt angemeldet? Eintrag in die Warteliste wäre redundant. + const existingBooking = await prisma.courseBooking.findUnique({ + where: { courseId_userId: { courseId, userId: userData.id } }, + select: { paymentStatus: true }, + }); + if (existingBooking?.paymentStatus === "paid") { + return { success: false, error: "Sie sind für diesen Kurs bereits angemeldet." }; + } + + const existingEntry = await prisma.courseWaitlistEntry.findUnique({ + where: { courseId_userId: { courseId, userId: userData.id } }, + }); + if (existingEntry) { + return { success: true, alreadyOnWaitlist: true }; + } + + await prisma.courseWaitlistEntry.create({ + data: { courseId, userId: userData.id }, + }); + + return { success: true, alreadyOnWaitlist: false }; +} + +export async function joinWaitlistAsGuest( + courseId: string, + guestEmail: string, + guestName?: string +): Promise { + const normalizedEmail = guestEmail.trim().toLowerCase(); + if (!EMAIL_RE.test(normalizedEmail)) { + return { success: false, error: "Bitte eine gültige E-Mail-Adresse angeben." }; + } + + const validation = await assertCourseIsFullAndJoinable(courseId); + if (!validation.ok) return { success: false, error: validation.error }; + + // Kein Guard gegen bereits gebuchte Gäste - ohne GuestLead-Verknüpfung gibt + // es keine verlässliche Vor-Identität; im schlimmsten Fall eine + // überflüssige zusätzliche Mail später, unkritisch. + const existingEntry = await prisma.courseWaitlistEntry.findUnique({ + where: { courseId_guestEmail: { courseId, guestEmail: normalizedEmail } }, + }); + if (existingEntry) { + return { success: true, alreadyOnWaitlist: true }; + } + + await prisma.courseWaitlistEntry.create({ + data: { + courseId, + guestEmail: normalizedEmail, + guestName: guestName?.trim() || null, + }, + }); + + return { success: true, alreadyOnWaitlist: false }; +} diff --git a/src/modules/courses/ui/components/PublicCourseCard.tsx b/src/modules/courses/ui/components/PublicCourseCard.tsx index 46e82fd..b87e258 100644 --- a/src/modules/courses/ui/components/PublicCourseCard.tsx +++ b/src/modules/courses/ui/components/PublicCourseCard.tsx @@ -10,6 +10,7 @@ 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"; @@ -41,6 +42,17 @@ const levelConfig: Record = 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) { + 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; @@ -143,6 +155,9 @@ export const BookingDialog = ({ 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(""); @@ -152,7 +167,7 @@ export const BookingDialog = ({ const safeCurrent = Math.max(0, course.currentParticipants); const safeMax = Math.max(0, course.maxParticipants); - const isFull = safeCurrent >= safeMax; + const isFull = computeIsFull(course); const level = levelConfig[course.level] ?? levelConfig.any; const formattedDate = new Date(course.date).toLocaleDateString("de-DE", { @@ -177,9 +192,11 @@ export const BookingDialog = ({ 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); } @@ -250,6 +267,52 @@ export const BookingDialog = ({ } }; + 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; @@ -423,14 +486,29 @@ export const BookingDialog = ({ + ) : isFull ? ( + isOnWaitlist ? ( + + ) : ( + + ) ) : ( )} @@ -438,11 +516,14 @@ export const BookingDialog = ({ - {/* Gast-Formular: erscheint nach erstem 401 (nicht eingeloggt) */} - {!isAdminView && showGuestForm && !isBooked && ( + {/* 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) && (

- Ohne Konto buchen – oder{" "} + {isFull ? "Ohne Konto auf die Warteliste – oder" : "Ohne Konto buchen – oder"}{" "} anmelden @@ -463,14 +544,27 @@ export const BookingDialog = ({ 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 ? ( + + ) : ( + + )}

)} @@ -489,7 +583,7 @@ const PublicCourseCard = ({ const cardSafeCurrent = Math.max(0, course.currentParticipants); const cardSafeMax = Math.max(0, course.maxParticipants); - const isFull = cardSafeCurrent >= cardSafeMax; + const isFull = computeIsFull(course); const currentLevel = levelConfig[course.level] ?? levelConfig.any; const formattedDate = new Date(course.date).toLocaleDateString("de-DE", { @@ -576,8 +670,15 @@ const PublicCourseCard = ({ {isFull ? ( - ) : (