mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
Merge pull request #147 from Since-3/feature/waitinglist
added first come first serve waitinglist with email notification
This commit is contained in:
commit
d1b47e7ce8
10 changed files with 582 additions and 48 deletions
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `guestEmail` on the `CourseWaitlistEntry` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `guestName` on the `CourseWaitlistEntry` table. All the data in the column will be lost.
|
||||||
|
- Made the column `userId` on table `CourseWaitlistEntry` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "CourseWaitlistEntry_courseId_guestEmail_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CourseWaitlistEntry" DROP COLUMN "guestEmail",
|
||||||
|
DROP COLUMN "guestName",
|
||||||
|
ALTER COLUMN "userId" SET NOT NULL;
|
||||||
|
|
@ -17,32 +17,33 @@ datasource db {
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String?
|
name String?
|
||||||
complaints String?
|
complaints String?
|
||||||
birthday DateTime?
|
birthday DateTime?
|
||||||
plz String?
|
plz String?
|
||||||
city String?
|
city String?
|
||||||
street String?
|
street String?
|
||||||
houseNumber String?
|
houseNumber String?
|
||||||
// @unique is Prisma-managed and stays case-sensitive at the schema level —
|
// @unique is Prisma-managed and stays case-sensitive at the schema level —
|
||||||
// Prisma has no functional-index syntax. The actual enforced constraint is
|
// Prisma has no functional-index syntax. The actual enforced constraint is
|
||||||
// a case-insensitive unique index on lower(email), added via raw SQL in
|
// a case-insensitive unique index on lower(email), added via raw SQL in
|
||||||
// migration 20260817000000_case_insensitive_user_email. Do not remove
|
// migration 20260817000000_case_insensitive_user_email. Do not remove
|
||||||
// @unique here; it keeps Prisma's drift detection from trying to recreate
|
// @unique here; it keeps Prisma's drift detection from trying to recreate
|
||||||
// a duplicate case-sensitive constraint.
|
// a duplicate case-sensitive constraint.
|
||||||
email String @unique
|
email String @unique
|
||||||
tel String?
|
tel String?
|
||||||
gender String?
|
gender String?
|
||||||
is2FA Boolean @default(false)
|
is2FA Boolean @default(false)
|
||||||
isSendingMail Boolean @default(false)
|
isSendingMail Boolean @default(false)
|
||||||
isNewsletter Boolean @default(false)
|
isNewsletter Boolean @default(false)
|
||||||
pbSrc String?
|
pbSrc String?
|
||||||
isValid Boolean @default(false)
|
isValid Boolean @default(false)
|
||||||
trainings UserTraining[]
|
trainings UserTraining[]
|
||||||
courseBookings CourseBooking[]
|
courseBookings CourseBooking[]
|
||||||
createdAt DateTime? @default(now())
|
courseWaitlistEntries CourseWaitlistEntry[]
|
||||||
updatedAt DateTime? @updatedAt
|
createdAt DateTime? @default(now())
|
||||||
|
updatedAt DateTime? @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model Employee {
|
model Employee {
|
||||||
|
|
@ -122,10 +123,10 @@ model Business {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Course {
|
model Course {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
sport String[]
|
sport String[]
|
||||||
level String @default("any")
|
level String @default("any")
|
||||||
date DateTime
|
date DateTime
|
||||||
timeFrom String // Anfangszeit
|
timeFrom String // Anfangszeit
|
||||||
timeTo String // Endzeit
|
timeTo String // Endzeit
|
||||||
|
|
@ -133,24 +134,25 @@ model Course {
|
||||||
room String
|
room String
|
||||||
description String // Rich text HTML content
|
description String // Rich text HTML content
|
||||||
maxParticipants Int // Maximum number of participants
|
maxParticipants Int // Maximum number of participants
|
||||||
price Float @default(10.0) // Preis in Euro
|
price Float @default(10.0) // Preis in Euro
|
||||||
isStandingOrder Boolean @default(false)
|
isStandingOrder Boolean @default(false)
|
||||||
frequency String? // daily, weekly, biweekly, monthly, custom
|
frequency String? // daily, weekly, biweekly, monthly, custom
|
||||||
weekdays String[] // For custom frequency
|
weekdays String[] // For custom frequency
|
||||||
weekdayTimings Json? // { "monday": { "timeFrom": "08:00", "timeTo": "09:00" }, ... }
|
weekdayTimings Json? // { "monday": { "timeFrom": "08:00", "timeTo": "09:00" }, ... }
|
||||||
endDate DateTime? // Required when isStandingOrder = true
|
endDate DateTime? // Required when isStandingOrder = true
|
||||||
parentCourseId String? // Null on template; set on every child instance
|
parentCourseId String? // Null on template; set on every child instance
|
||||||
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
|
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
|
||||||
children Course[] @relation("StandingOrderInstances")
|
children Course[] @relation("StandingOrderInstances")
|
||||||
status String @default("draft") // draft, published, cancelled
|
status String @default("draft") // draft, published, cancelled
|
||||||
cancelledAt DateTime? // Zeitpunkt der Kursabsage (nur gesetzt wenn status = "cancelled")
|
cancelledAt DateTime? // Zeitpunkt der Kursabsage (nur gesetzt wenn status = "cancelled")
|
||||||
createdBy String // Manager ID who created the course
|
createdBy String // Manager ID who created the course
|
||||||
businessId String?
|
businessId String?
|
||||||
coverImage String?
|
coverImage String?
|
||||||
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
|
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
|
||||||
bookings CourseBooking[]
|
bookings CourseBooking[]
|
||||||
createdAt DateTime @default(now())
|
waitlistEntries CourseWaitlistEntry[]
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([createdBy])
|
@@index([createdBy])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
|
|
@ -204,3 +206,22 @@ model CourseBooking {
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([guestLeadId])
|
@@index([guestLeadId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
|
||||||
|
// unauthentifiziertes Gast-Formular würde eine beliebige, nicht verifizierte
|
||||||
|
// E-Mail-Adresse als Benachrichtigungsempfänger akzeptieren, ohne dass deren
|
||||||
|
// Kontrolle bestätigt wurde. Der Login-Zwang schließt das und wirkt nebenbei
|
||||||
|
// als Anreiz zur Registrierung (Lead-Generierung).
|
||||||
|
model CourseWaitlistEntry {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
courseId String
|
||||||
|
userId String
|
||||||
|
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
|
||||||
|
@@index([courseId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
revalidatePath("/courses/myCourses");
|
revalidatePath("/courses/myCourses");
|
||||||
|
|
||||||
|
|
@ -648,6 +658,26 @@ 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 ü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. Die Warteliste kennt
|
||||||
|
* ausschließlich registrierte User (kein Gast-Beitritt möglich), daher genügt
|
||||||
|
* ein einzelner userId-Check. 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 },
|
||||||
|
});
|
||||||
|
if (!booking?.userId) return;
|
||||||
|
|
||||||
|
await prisma.courseWaitlistEntry.deleteMany({ where: { courseId, userId: booking.userId } });
|
||||||
|
}
|
||||||
|
|
||||||
async function sendBookingRefund(
|
async function sendBookingRefund(
|
||||||
recipientEmail: string,
|
recipientEmail: string,
|
||||||
courseId: string,
|
courseId: string,
|
||||||
|
|
|
||||||
105
src/lib/mail/templates/waitlist-spot-available.ts
Normal file
105
src/lib/mail/templates/waitlist-spot-available.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -219,21 +219,25 @@ export async function checkUserBookingStatus(courseId: string) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
isBooked: false,
|
isBooked: false,
|
||||||
|
isOnWaitlist: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const booking = await prisma.courseBooking.findUnique({
|
// Beide Checks im selben Aufruf statt eines separaten Wartelisten-Calls,
|
||||||
where: {
|
// damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht.
|
||||||
courseId_userId: {
|
const [booking, waitlistEntry] = await Promise.all([
|
||||||
courseId,
|
prisma.courseBooking.findUnique({
|
||||||
userId: userData.id,
|
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||||
},
|
}),
|
||||||
},
|
prisma.courseWaitlistEntry.findUnique({
|
||||||
});
|
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
isBooked: booking?.paymentStatus === "paid",
|
isBooked: booking?.paymentStatus === "paid",
|
||||||
|
isOnWaitlist: waitlistEntry !== null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking booking status:", error);
|
console.error("Error checking booking status:", error);
|
||||||
|
|
@ -241,6 +245,7 @@ export async function checkUserBookingStatus(courseId: string) {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Fehler beim Prüfen des Buchungsstatus",
|
error: "Fehler beim Prüfen des Buchungsstatus",
|
||||||
isBooked: false,
|
isBooked: false,
|
||||||
|
isOnWaitlist: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { sendMail } from "@/src/lib/mail/resend";
|
||||||
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
|
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
|
||||||
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
||||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
|
import { notifyCourseWaitlist } from "./notify-waitlist";
|
||||||
|
|
||||||
export type CancelBookingResult =
|
export type CancelBookingResult =
|
||||||
| { success: true; refundedAmountCents: number; refundPercent: number }
|
| { success: true; refundedAmountCents: number; refundPercent: number }
|
||||||
|
|
@ -20,6 +21,25 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sockelzeit für den Wartelisten-Mailversand (deckt Kurs-/User-Lookups plus
|
||||||
|
// eine Handvoll E-Mails ab) + zusätzliche Zeit pro Wartelisten-Eintrag, da
|
||||||
|
// der Versand als Promise.allSettled-Fan-out läuft und mit mehr Empfängern
|
||||||
|
// länger dauert. Gedeckelt, damit ein sehr großer Kurs die serverlose
|
||||||
|
// Funktion nicht in die Nähe ihres eigenen Zeitlimits treibt (Vercel Hobby:
|
||||||
|
// 60s/Funktion) - danach bleiben unvollständige Sends bewusst hingenommen
|
||||||
|
// (siehe Docblock an notifyCourseWaitlist), bis der Versand auf einen
|
||||||
|
// eigenen Hintergrund-Job umzieht.
|
||||||
|
const WAITLIST_TIMEOUT_BASE_MS = 2000;
|
||||||
|
const WAITLIST_TIMEOUT_PER_ENTRY_MS = 300;
|
||||||
|
const WAITLIST_TIMEOUT_MAX_MS = 25000;
|
||||||
|
|
||||||
|
function computeWaitlistTimeoutMs(entryCount: number): number {
|
||||||
|
return Math.min(
|
||||||
|
WAITLIST_TIMEOUT_MAX_MS,
|
||||||
|
WAITLIST_TIMEOUT_BASE_MS + entryCount * WAITLIST_TIMEOUT_PER_ENTRY_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function sendCancellationEmail(
|
async function sendCancellationEmail(
|
||||||
booking: {
|
booking: {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
|
@ -215,6 +235,26 @@ export async function executeBookingCancellation(
|
||||||
).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
|
).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
|
||||||
await withTimeout(emailPromise, 2000);
|
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).
|
||||||
|
//
|
||||||
|
// Das Timeout richtet sich nach der Anzahl der Wartelisten-Einträge statt
|
||||||
|
// eines festen Werts - ein Kurs mit vielen Einträgen braucht für den
|
||||||
|
// Promise.allSettled-Fan-out entsprechend länger, ein fixes kurzes Limit
|
||||||
|
// würde dort systematisch Sends abschneiden (siehe computeWaitlistTimeoutMs
|
||||||
|
// oben). Bei einer sehr großen Warteliste greift trotzdem der Deckel -
|
||||||
|
// verbleibende Sends bleiben dann unvollständig (kein Retry), bis der
|
||||||
|
// Versand auf einen eigenen, vom Request-Lebenszyklus unabhängigen
|
||||||
|
// Hintergrund-Job umzieht.
|
||||||
|
const waitlistEntryCount = await prisma.courseWaitlistEntry.count({
|
||||||
|
where: { courseId: booking.course.id },
|
||||||
|
});
|
||||||
|
const waitlistPromise = notifyCourseWaitlist(booking.course.id).catch((err) =>
|
||||||
|
console.error("Warteliste-Benachrichtigung fehlgeschlagen:", err)
|
||||||
|
);
|
||||||
|
await withTimeout(waitlistPromise, computeWaitlistTimeoutMs(waitlistEntryCount));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
refundedAmountCents: amountRefundedCents,
|
refundedAmountCents: amountRefundedCents,
|
||||||
|
|
|
||||||
107
src/modules/courses/actions/notify-waitlist.ts
Normal file
107
src/modules/courses/actions/notify-waitlist.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Bekannte Einschränkung: läuft aktuell synchron innerhalb des
|
||||||
|
* Stornierungs-Requests. Das dortige withTimeout-Limit skaliert mit der
|
||||||
|
* Anzahl der Wartelisten-Einträge (siehe computeWaitlistTimeoutMs in
|
||||||
|
* cancel-booking-shared.ts), ist aber gedeckelt - bei einem außergewöhnlich
|
||||||
|
* großen Kurs können danach einzelne Sends nicht mehr abgewartet werden und
|
||||||
|
* bleiben unvollständig. Bewusst zurückgestellt, bis die geplante
|
||||||
|
* Scheduled-Job-Infrastruktur steht; dann läuft dieser Versand als eigener,
|
||||||
|
* vom Request-Lebenszyklus unabhängiger Job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
|
// Wer inzwischen bereits einen bezahlten Platz hat, soll nicht erneut
|
||||||
|
// eingeladen werden.
|
||||||
|
const userIds = entries.map((e) => e.userId);
|
||||||
|
const paidUserIds = new Set(
|
||||||
|
(
|
||||||
|
await prisma.courseBooking.findMany({
|
||||||
|
where: { courseId, userId: { in: userIds }, paymentStatus: "paid" },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
).map((b) => b.userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toNotify = entries.filter((e) => !paidUserIds.has(e.userId));
|
||||||
|
if (toNotify.length === 0) return;
|
||||||
|
|
||||||
|
const usersById = new Map(
|
||||||
|
(
|
||||||
|
await prisma.user.findMany({
|
||||||
|
where: { id: { in: toNotify.map((e) => e.userId) } },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
})
|
||||||
|
).map((u) => [u.id, u.email] as const)
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipients = toNotify
|
||||||
|
.map((e) => ({ entryId: e.id, email: usersById.get(e.userId) }))
|
||||||
|
.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) => {
|
||||||
|
const result = await sendMail({ to: r.email, subject, html, text });
|
||||||
|
// notifiedAt nur bei tatsächlich erfolgreichem Versand setzen - sonst
|
||||||
|
// würde ein Resend-Fehler (Bounce, Rate-Limit, ...) fälschlich als
|
||||||
|
// "wurde benachrichtigt" festgehalten und dieser Eintrag bekäme bei
|
||||||
|
// einem später erneut frei werdenden Platz keine zweite Chance auf
|
||||||
|
// eine erfolgreiche Zustellung mehr, obwohl er nie wirklich erreicht wurde.
|
||||||
|
if (result.success) {
|
||||||
|
await prisma.courseWaitlistEntry.update({
|
||||||
|
where: { id: r.entryId },
|
||||||
|
data: { notifiedAt: new Date() },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error(`Wartelisten-Mail an ${r.email} fehlgeschlagen:`, result.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/modules/courses/actions/waitlist-actions.ts
Normal file
103
src/modules/courses/actions/waitlist-actions.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* Waitlist Actions - Server-seitige Aktionen für die Kurs-Warteliste
|
||||||
|
*
|
||||||
|
* Einfaches Wartelisten-Konzept ohne Reservierung: bei einem vollen Kurs
|
||||||
|
* können sich registrierte User 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.
|
||||||
|
*
|
||||||
|
* Bewusst nur für registrierte User: ein unauthentifiziertes Gast-Formular
|
||||||
|
* würde eine beliebige, nicht verifizierte E-Mail-Adresse als
|
||||||
|
* Benachrichtigungsempfänger akzeptieren, ohne dass die Kontrolle über diese
|
||||||
|
* Adresse bestätigt wurde. Der Login-Zwang schließt das und erzeugt nebenbei
|
||||||
|
* einen Anreiz zur Registrierung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/src/lib/prisma";
|
||||||
|
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||||
|
import { Prisma } from "@/src/generated/prisma/client";
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True, wenn err eine Prisma-Unique-Constraint-Verletzung (P2002) ist. */
|
||||||
|
function isUniqueConstraintError(err: unknown): boolean {
|
||||||
|
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.courseWaitlistEntry.create({
|
||||||
|
data: { courseId, userId: userData.id },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Zwei parallele Beitritts-Versuche (Doppelklick, zwei Tabs) können beide
|
||||||
|
// den obigen "existingEntry === null"-Read sehen, aber nur einer gewinnt
|
||||||
|
// den Insert - der andere läuft in die Unique-Constraint (courseId,
|
||||||
|
// userId). Das ist kein Fehler, sondern derselbe Fall wie "war schon
|
||||||
|
// eingetragen".
|
||||||
|
if (isUniqueConstraintError(err)) {
|
||||||
|
return { success: true, alreadyOnWaitlist: true };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, alreadyOnWaitlist: false };
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { checkUserBookingStatus } from "../../actions/booking-actions";
|
import { checkUserBookingStatus } from "../../actions/booking-actions";
|
||||||
|
import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
|
||||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||||
import { toast } from "sonner";
|
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" },
|
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 {
|
export interface TrainerProfile {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
|
|
@ -143,6 +155,8 @@ export const BookingDialog = ({
|
||||||
const sanitizedDescription = useSanitizedHtml(course.description);
|
const sanitizedDescription = useSanitizedHtml(course.description);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isBooked, setIsBooked] = useState(false);
|
const [isBooked, setIsBooked] = useState(false);
|
||||||
|
const [isOnWaitlist, setIsOnWaitlist] = useState(false);
|
||||||
|
const [isJoiningWaitlist, setIsJoiningWaitlist] = useState(false);
|
||||||
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
|
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
|
||||||
const [showGuestForm, setShowGuestForm] = useState(false);
|
const [showGuestForm, setShowGuestForm] = useState(false);
|
||||||
const [guestEmail, setGuestEmail] = useState("");
|
const [guestEmail, setGuestEmail] = useState("");
|
||||||
|
|
@ -152,7 +166,7 @@ export const BookingDialog = ({
|
||||||
|
|
||||||
const safeCurrent = Math.max(0, course.currentParticipants);
|
const safeCurrent = Math.max(0, course.currentParticipants);
|
||||||
const safeMax = Math.max(0, course.maxParticipants);
|
const safeMax = Math.max(0, course.maxParticipants);
|
||||||
const isFull = safeCurrent >= safeMax;
|
const isFull = computeIsFull(course);
|
||||||
const level = levelConfig[course.level] ?? levelConfig.any;
|
const level = levelConfig[course.level] ?? levelConfig.any;
|
||||||
|
|
||||||
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
|
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
|
||||||
|
|
@ -177,9 +191,11 @@ export const BookingDialog = ({
|
||||||
const result = await checkUserBookingStatus(course.id);
|
const result = await checkUserBookingStatus(course.id);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setIsBooked(result.success ? result.isBooked : false);
|
setIsBooked(result.success ? result.isBooked : false);
|
||||||
|
setIsOnWaitlist(result.isOnWaitlist);
|
||||||
} catch {
|
} catch {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setIsBooked(false);
|
setIsBooked(false);
|
||||||
|
setIsOnWaitlist(false);
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setIsCheckingStatus(false);
|
if (active) setIsCheckingStatus(false);
|
||||||
}
|
}
|
||||||
|
|
@ -250,6 +266,27 @@ 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 adminStatus = isAdminView
|
const adminStatus = isAdminView
|
||||||
? (ADMIN_PAYMENT_LABELS[paymentStatus!] ?? ADMIN_PAYMENT_LABELS.pending)
|
? (ADMIN_PAYMENT_LABELS[paymentStatus!] ?? ADMIN_PAYMENT_LABELS.pending)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -423,14 +460,29 @@ export const BookingDialog = ({
|
||||||
<Button disabled className="flex-1 sm:flex-initial">
|
<Button disabled className="flex-1 sm:flex-initial">
|
||||||
✓ Angemeldet
|
✓ Angemeldet
|
||||||
</Button>
|
</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
|
<Button
|
||||||
onClick={() => handleBooking()}
|
onClick={() => handleBooking()}
|
||||||
disabled={isSubmitting || isFull}
|
disabled={isSubmitting}
|
||||||
className="flex-1 sm:flex-initial"
|
className="flex-1 sm:flex-initial"
|
||||||
style={!isFull ? accentButtonStyle : undefined}
|
style={accentButtonStyle}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
|
{isSubmitting ? "Wird gebucht…" : "Buchen"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -438,8 +490,27 @@ export const BookingDialog = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gast-Formular: erscheint nach erstem 401 (nicht eingeloggt) */}
|
{/* Warteliste ist bewusst nur registrierten Usern vorbehalten - ein
|
||||||
{!isAdminView && showGuestForm && !isBooked && (
|
unauthentifiziertes Gast-Formular würde beliebige fremde
|
||||||
|
E-Mail-Adressen als Benachrichtigungsempfänger akzeptieren, ohne
|
||||||
|
deren Kontrolle über die Adresse zu bestätigen. Ein Login-Hinweis
|
||||||
|
statt eines Eintrags-Formulars schließt das und wirkt nebenbei
|
||||||
|
als Anreiz zur Registrierung. */}
|
||||||
|
{!isAdminView && !isCheckingStatus && !isBooked && isFull && (
|
||||||
|
<div className="border-t border-gray-100 pt-3">
|
||||||
|
<p className="text-xs text-gray-500 text-center">
|
||||||
|
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
|
||||||
|
Melden Sie sich an
|
||||||
|
</Link>
|
||||||
|
, um sich auf die Warteliste einzutragen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gast-Formular fürs reguläre Buchen: erscheint nach erstem 401
|
||||||
|
(nicht eingeloggt). Bei vollem Kurs entfällt es zugunsten des
|
||||||
|
Login-Hinweises oben. */}
|
||||||
|
{!isAdminView && !isFull && showGuestForm && !isBooked && (
|
||||||
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
|
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Ohne Konto buchen – oder{" "}
|
Ohne Konto buchen – oder{" "}
|
||||||
|
|
@ -465,7 +536,7 @@ export const BookingDialog = ({
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleBooking({ asGuest: true })}
|
onClick={() => handleBooking({ asGuest: true })}
|
||||||
disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
|
disabled={isSubmitting || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={accentButtonStyle}
|
style={accentButtonStyle}
|
||||||
>
|
>
|
||||||
|
|
@ -489,7 +560,7 @@ const PublicCourseCard = ({
|
||||||
|
|
||||||
const cardSafeCurrent = Math.max(0, course.currentParticipants);
|
const cardSafeCurrent = Math.max(0, course.currentParticipants);
|
||||||
const cardSafeMax = Math.max(0, course.maxParticipants);
|
const cardSafeMax = Math.max(0, course.maxParticipants);
|
||||||
const isFull = cardSafeCurrent >= cardSafeMax;
|
const isFull = computeIsFull(course);
|
||||||
const currentLevel = levelConfig[course.level] ?? levelConfig.any;
|
const currentLevel = levelConfig[course.level] ?? levelConfig.any;
|
||||||
|
|
||||||
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
|
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
|
||||||
|
|
@ -576,8 +647,15 @@ const PublicCourseCard = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFull ? (
|
{isFull ? (
|
||||||
<Button disabled variant="outline" className="w-full">
|
<Button
|
||||||
Ausgebucht
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Warteliste
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue