mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
see comments + dynamic timeout instead of schedule job
This commit is contained in:
parent
bd10088e26
commit
034e7386c8
7 changed files with 165 additions and 169 deletions
|
|
@ -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;
|
||||
|
|
@ -207,19 +207,21 @@ model CourseBooking {
|
|||
@@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?
|
||||
guestEmail String? // normalisiert lowercase; null wenn userId gesetzt
|
||||
guestName String? // optional, nur bei Gast-Einträgen
|
||||
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)
|
||||
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)
|
||||
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs eintragen
|
||||
@@index([courseId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -660,27 +660,22 @@ 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.
|
||||
* 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, guestLead: { select: { email: true } } },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!booking) return;
|
||||
if (!booking?.userId) 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 },
|
||||
});
|
||||
}
|
||||
await prisma.courseWaitlistEntry.deleteMany({ where: { courseId, userId: booking.userId } });
|
||||
}
|
||||
|
||||
async function sendBookingRefund(
|
||||
|
|
|
|||
|
|
@ -21,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(
|
||||
booking: {
|
||||
userId: string | null;
|
||||
|
|
@ -219,10 +238,22 @@ export async function executeBookingCancellation(
|
|||
// 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, 2000);
|
||||
await withTimeout(waitlistPromise, computeWaitlistTimeoutMs(waitlistEntryCount));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,15 @@
|
|||
* 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";
|
||||
|
|
@ -30,66 +39,36 @@ export async function notifyCourseWaitlist(courseId: string): Promise<void> {
|
|||
|
||||
const entries = await prisma.courseWaitlistEntry.findMany({
|
||||
where: { courseId },
|
||||
select: { id: true, userId: true, guestEmail: true },
|
||||
select: { id: true, userId: 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);
|
||||
// eingeladen werden.
|
||||
const userIds = entries.map((e) => e.userId);
|
||||
const paidUserIds = new Set(
|
||||
userIds.length
|
||||
? (
|
||||
await prisma.courseBooking.findMany({
|
||||
where: { courseId, userId: { in: userIds }, paymentStatus: "paid" },
|
||||
select: { userId: true },
|
||||
})
|
||||
).map((b) => b.userId)
|
||||
: []
|
||||
(
|
||||
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
|
||||
);
|
||||
const toNotify = entries.filter((e) => !paidUserIds.has(e.userId));
|
||||
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)
|
||||
: []
|
||||
(
|
||||
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: e.userId ? usersById.get(e.userId) : e.guestEmail,
|
||||
}))
|
||||
.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;
|
||||
|
||||
|
|
@ -109,11 +88,20 @@ export async function notifyCourseWaitlist(courseId: string): Promise<void> {
|
|||
// (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() },
|
||||
});
|
||||
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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,23 @@
|
|||
* 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
|
||||
* 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";
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
import { Prisma } from "@/src/generated/prisma/client";
|
||||
|
||||
export type JoinWaitlistResult =
|
||||
| { success: true; alreadyOnWaitlist: boolean }
|
||||
|
|
@ -48,6 +53,11 @@ async function assertCourseIsFullAndJoinable(
|
|||
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)) {
|
||||
|
|
@ -73,43 +83,21 @@ export async function joinWaitlistAsUser(courseId: string): Promise<JoinWaitlist
|
|||
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,
|
||||
},
|
||||
});
|
||||
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,7 +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 { joinWaitlistAsUser } from "../../actions/waitlist-actions";
|
||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -157,7 +157,6 @@ export const BookingDialog = ({
|
|||
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("");
|
||||
|
|
@ -288,31 +287,6 @@ export const BookingDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
@ -516,14 +490,30 @@ export const BookingDialog = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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) && (
|
||||
{/* Warteliste ist bewusst nur registrierten Usern vorbehalten - ein
|
||||
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">
|
||||
<p className="text-xs text-gray-500">
|
||||
{isFull ? "Ohne Konto auf die Warteliste – oder" : "Ohne Konto buchen – oder"}{" "}
|
||||
Ohne Konto buchen – oder{" "}
|
||||
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
|
||||
anmelden
|
||||
</Link>
|
||||
|
|
@ -544,27 +534,14 @@ 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 ? (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue