added first come first serve waitinglist with email notification

This commit is contained in:
Rajpreet0 2026-08-27 18:56:35 +02:00
parent 7548473174
commit bd10088e26
9 changed files with 594 additions and 56 deletions

View file

@ -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;

View file

@ -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])
}

View file

@ -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<void> {
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,

View file

@ -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 = `
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
gute Nachrichten: In Ihrem gewünschten Kurs ist gerade ein Platz frei geworden.
</p>
${renderInfoBox({
rows: [
{
label: "Kurs",
valueHtml: `<span style="font-size: 18px; font-weight: 600;">${safeCourseName}</span>`,
},
{ label: "Anbieter", valueHtml: safeBusinessName },
],
})}
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Da mehrere Personen auf der Warteliste stehen können, wird der Platz nach dem
Prinzip <strong>"first come, first served"</strong> vergeben - sichern Sie sich
Ihren Platz am besten zügig.
</p>
${renderButton({ label: "Jetzt buchen", href: courseUrl, branding })}
<p style="margin: 20px 0 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Oder kopieren Sie diesen Link in Ihren Browser:
</p>
<p style="margin: 8px 0 0; color: #1E293B; font-size: 14px; word-break: break-all; text-align: center;">
${safeCourseUrl}
</p>
`;
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 };
}

View file

@ -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,
};
}
}

View file

@ -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,

View file

@ -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<void> {
// 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() },
});
})
);
}

View file

@ -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<JoinWaitlistResult> {
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<JoinWaitlistResult> {
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 };
}

View file

@ -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<string, { label: string; bg: string; text: string }> =
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;
@ -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 = ({
<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 || isFull}
disabled={isSubmitting}
className="flex-1 sm:flex-initial"
style={!isFull ? accentButtonStyle : undefined}
style={accentButtonStyle}
>
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
{isSubmitting ? "Wird gebucht…" : "Buchen"}
</Button>
)}
</>
@ -438,11 +516,14 @@ export const BookingDialog = ({
</div>
</div>
{/* 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) && (
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
<p className="text-xs text-gray-500">
Ohne Konto buchen – oder{" "}
{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>
@ -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"
/>
<Button
onClick={() => handleBooking({ asGuest: true })}
disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
className="w-full"
style={accentButtonStyle}
>
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
</Button>
{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>
@ -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 = ({
</div>
{isFull ? (
<Button disabled variant="outline" className="w-full">
Ausgebucht
<Button
variant="outline"
className="w-full"
onClick={(e) => {
e.stopPropagation();
setDialogOpen(true);
}}
>
Warteliste
</Button>
) : (
<Button