mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
Merge pull request #123 from Since-3/feature/added-guest-checkout
added the guest checkout
This commit is contained in:
commit
1fcd71d038
11 changed files with 638 additions and 274 deletions
|
|
@ -150,20 +150,31 @@ model Room {
|
|||
@@index([createdBy])
|
||||
}
|
||||
|
||||
model GuestLead {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String?
|
||||
bookings CourseBooking[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model CourseBooking {
|
||||
id String @id @default(uuid())
|
||||
courseId String
|
||||
userId String
|
||||
userId String?
|
||||
guestLeadId String?
|
||||
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded"
|
||||
stripeSessionId String? @unique
|
||||
stripePaymentIntentId String? @unique
|
||||
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
|
||||
platformFee Int? // unsere Service-Fee in Cents
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs anmelden
|
||||
@@index([courseId])
|
||||
@@index([userId])
|
||||
@@index([guestLeadId])
|
||||
}
|
||||
|
|
@ -69,6 +69,36 @@ export async function POST(request: Request) {
|
|||
},
|
||||
});
|
||||
|
||||
// Gast-Buchungen mit derselben E-Mail auf den neuen Account übertragen.
|
||||
// GuestLead-Einträge mit dieser Mail suchen und deren Buchungen auf userId setzen.
|
||||
try {
|
||||
const guestLeads = await prisma.guestLead.findMany({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (guestLeads.length > 0) {
|
||||
const guestLeadIds = guestLeads.map((g) => g.id);
|
||||
|
||||
// Nur Buchungen übertragen die noch keinen User haben und paid/pending sind
|
||||
// (failed/refunded sind nicht mehr relevant)
|
||||
await prisma.courseBooking.updateMany({
|
||||
where: {
|
||||
guestLeadId: { in: guestLeadIds },
|
||||
userId: null,
|
||||
paymentStatus: { in: ["paid", "pending"] },
|
||||
},
|
||||
data: {
|
||||
userId: newUser.id,
|
||||
guestLeadId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (mergeError) {
|
||||
// Nicht-fatal: User wurde erfolgreich angelegt, Merge kann manuell nachgeholt werden
|
||||
console.error("Gast-Buchungen konnten nicht übertragen werden:", mergeError);
|
||||
}
|
||||
|
||||
return NextResponse.json({ user: newUser }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error in /api/auth/register:", error);
|
||||
|
|
|
|||
|
|
@ -3,42 +3,97 @@ import { prisma } from "@/src/lib/prisma";
|
|||
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
|
||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||
|
||||
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
||||
// Best-effort only — in multi-instance/serverless deployments each instance has
|
||||
// its own Map, so a determined attacker can bypass it. The main purpose is to
|
||||
// limit accidental bursts and casual abuse on a single instance.
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
const RATE_LIMIT_MAX = 10;
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
|
||||
// Purge stale entries every 5 minutes to prevent unbounded memory growth.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of rateLimitMap) {
|
||||
if (now > entry.resetAt) rateLimitMap.delete(ip);
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
function isRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Erstellt eine Stripe Checkout Session für eine Kurs-Buchung.
|
||||
* Geld geht direkt an das Business-Connect-Account (minus Plattform-Fee).
|
||||
*
|
||||
* Body: { courseId: string }
|
||||
* Auth-Flow → Body: { courseId: string }
|
||||
* Guest-Flow → Body: { courseId: string; guestEmail: string; guestName?: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting per IP
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
if (isRateLimited(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte warte kurz und versuche es erneut." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Auth check — always first, Guest-Path only when no session exists
|
||||
const userData = await getUserData();
|
||||
if (!userData) {
|
||||
return NextResponse.json(
|
||||
{ error: "Sie müssen angemeldet sein, um einen Kurs zu buchen" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Nur normale User (role: "user") können Kurse buchen.
|
||||
// Manager/Employee haben keine Einträge in der User-Tabelle, daher würde
|
||||
// sonst der Foreign-Key-Constraint CourseBooking_userId_fkey im Webhook
|
||||
// verletzt werden – und das erst NACHDEM das Geld geflossen ist.
|
||||
if (!isUser(userData)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Nur Kunden-Accounts können Kurse buchen. Bitte mit einem Nutzer-Konto anmelden.",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { courseId?: string };
|
||||
const { courseId } = body;
|
||||
const body = (await request.json()) as {
|
||||
courseId?: string;
|
||||
guestEmail?: string;
|
||||
guestName?: string;
|
||||
};
|
||||
const { courseId, guestEmail, guestName } = body;
|
||||
|
||||
if (!courseId) {
|
||||
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Determine flow: authenticated user or guest
|
||||
const isGuest = !userData;
|
||||
|
||||
if (!isGuest) {
|
||||
// Nur normale User (role: "user") können Kurse buchen.
|
||||
// Manager/Employee haben keine Einträge in der User-Tabelle, daher würde
|
||||
// sonst der Foreign-Key-Constraint CourseBooking_userId_fkey im Webhook
|
||||
// verletzt werden – und das erst NACHDEM das Geld geflossen ist.
|
||||
if (!isUser(userData)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nur Kunden-Accounts können Kurse buchen. Bitte mit einem Nutzer-Konto anmelden.",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Guest flow: guestEmail required
|
||||
if (!guestEmail || !EMAIL_RE.test(guestEmail.trim())) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bitte eine gültige E-Mail-Adresse angeben.", isGuestRequired: true },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
include: {
|
||||
|
|
@ -85,30 +140,30 @@ export async function POST(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
const existingBooking = await prisma.courseBooking.findUnique({
|
||||
where: {
|
||||
courseId_userId: { courseId, userId: userData.id },
|
||||
},
|
||||
});
|
||||
// Duplicate-booking check only for authenticated users (guests have no unique constraint)
|
||||
if (!isGuest && userData) {
|
||||
const existingBooking = await prisma.courseBooking.findUnique({
|
||||
where: {
|
||||
courseId_userId: { courseId, userId: userData.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingBooking && existingBooking.paymentStatus === "paid") {
|
||||
return NextResponse.json(
|
||||
{ error: "Sie haben sich bereits für diesen Kurs angemeldet" },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (existingBooking && existingBooking.paymentStatus === "paid") {
|
||||
return NextResponse.json(
|
||||
{ error: "Sie haben sich bereits für diesen Kurs angemeldet" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
const priceCents = eurosToCents(course.price);
|
||||
const platformFeeCents = calculatePlatformFeeCents(course.price);
|
||||
|
||||
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
// payment_method_types weggelassen → Checkout Sessions nutzen automatisch
|
||||
// alle im Stripe Dashboard aktivierten und für diese Session kompatiblen
|
||||
// Methoden (Dynamic Payment Methods).
|
||||
// Karte/Apple Pay/Google Pay/Link sind sofort verfügbar. PayPal
|
||||
// unterstützt kein Stripe Connect und muss über die PayPal Dev API eingerichtet werden
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
|
|
@ -129,16 +184,22 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
metadata: {
|
||||
courseId: course.id,
|
||||
userId: userData.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
courseId: course.id,
|
||||
userId: userData.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
},
|
||||
customer_email: userData.email,
|
||||
customer_email: customerEmail,
|
||||
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import Stripe from "stripe";
|
||||
import { stripe, getWebhookSecret } from "@/src/lib/stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
import { sendMail } from "@/src/lib/mail/nodemailer";
|
||||
import { generateBookingConfirmationEmail } from "@/src/lib/mail/templates/booking-confirmation";
|
||||
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
|
||||
|
|
@ -63,13 +63,14 @@ export async function POST(request: NextRequest) {
|
|||
/**
|
||||
* Verarbeitet eine erfolgreiche Checkout-Session.
|
||||
* Erstellt die CourseBooking (falls nicht vorhanden) und setzt sie auf "paid".
|
||||
* Unterstützt Auth-User und Gäste (isGuest === "true" in Metadata).
|
||||
*/
|
||||
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||
const courseId = session.metadata?.courseId;
|
||||
const userId = session.metadata?.userId;
|
||||
const isGuest = session.metadata?.isGuest === "true";
|
||||
|
||||
if (!courseId || !userId) {
|
||||
console.error("checkout.session.completed: courseId oder userId fehlt in Metadata");
|
||||
if (!courseId) {
|
||||
console.error("checkout.session.completed: courseId fehlt in Metadata");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -80,50 +81,92 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Sicherheitsnetz: User muss in der User-Tabelle existieren, sonst würde
|
||||
// der Foreign-Key-Constraint die Buchung ablehnen. Wir loggen das und
|
||||
// geben 200 zurück (kein Stripe-Retry für einen Fehler den ein Retry nicht behebt).
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!userExists) {
|
||||
console.error(
|
||||
`checkout.session.completed: User ${userId} existiert nicht in der User-Tabelle. ` +
|
||||
`Session ${session.id} wird ignoriert. Zahlung muss ggf. manuell erstattet werden.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Beträge in Cents (ganzzahlig) speichern – keine Float-Rundungsfehler.
|
||||
const amountPaidCents = session.amount_total ?? 0;
|
||||
const paymentIntentId = (session.payment_intent as string | null) ?? null;
|
||||
const platformFeeCents = paymentIntentId
|
||||
? await getApplicationFeeFromPaymentIntent(paymentIntentId)
|
||||
: 0;
|
||||
|
||||
// Atomare Kapazitätsprüfung: verhindert dass zwei gleichzeitig zahlende User
|
||||
// den letzten Platz bekommen. Innerhalb der Transaction wird gezählt und
|
||||
// erst dann auf "paid" gesetzt.
|
||||
//
|
||||
// PostgreSQL kann bei Serializable-Isolation transiente Serialisierungsfehler
|
||||
// (P2034) werfen, die Prisma nicht selbst wiederholt. Wir versuchen bis zu 3
|
||||
// Mal mit exponentiellem Backoff+Jitter, bevor wir aufgeben und Stripe den
|
||||
// Webhook wiederholen lassen.
|
||||
// Guest-Flow und Auth-Flow teilen die Kapazitäts-Logik, aber nutzen
|
||||
// unterschiedliche Identifikatoren und DB-Operationen.
|
||||
const MAX_ATTEMPTS = 3;
|
||||
let outcome: Awaited<ReturnType<typeof runCapacityTransaction>> | undefined;
|
||||
let outcome: CapacityOutcome | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
outcome = await runCapacityTransaction({
|
||||
courseId,
|
||||
userId,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
});
|
||||
if (isGuest) {
|
||||
const guestEmail = session.metadata?.guestEmail ?? "";
|
||||
const guestName = session.metadata?.guestName ?? undefined;
|
||||
|
||||
if (!guestEmail) {
|
||||
console.error(
|
||||
"checkout.session.completed: isGuest=true aber guestEmail fehlt in Metadata. " +
|
||||
`Session ${session.id} wird ignoriert.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
outcome = await runCapacityTransactionGuest({
|
||||
courseId,
|
||||
guestEmail,
|
||||
guestName: guestName || undefined,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
});
|
||||
} else {
|
||||
const userId = session.metadata?.userId;
|
||||
// Leerer String bedeutet: Checkout wurde ohne Auth erstellt (Race condition
|
||||
// oder alter Code-Pfad). Als Gast behandeln wenn guestEmail vorhanden.
|
||||
if (!userId) {
|
||||
const fallbackEmail =
|
||||
session.metadata?.guestEmail ||
|
||||
session.customer_details?.email ||
|
||||
session.customer_email;
|
||||
if (fallbackEmail) {
|
||||
outcome = await runCapacityTransactionGuest({
|
||||
courseId,
|
||||
guestEmail: fallbackEmail,
|
||||
guestName: session.metadata?.guestName || undefined,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
"checkout.session.completed: userId und guestEmail fehlen in Metadata. " +
|
||||
`Session ${session.id} wird ignoriert. Zahlung muss ggf. manuell erstattet werden.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Sicherheitsnetz: User muss in der User-Tabelle existieren.
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!userExists) {
|
||||
console.error(
|
||||
`checkout.session.completed: User ${userId} existiert nicht in der User-Tabelle. ` +
|
||||
`Session ${session.id} wird ignoriert. Zahlung muss ggf. manuell erstattet werden.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
outcome = await runCapacityTransactionUser({
|
||||
courseId,
|
||||
userId,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
});
|
||||
}
|
||||
break;
|
||||
} catch (err: unknown) {
|
||||
const isSerializationError =
|
||||
|
|
@ -133,7 +176,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
const delay = 50 * 2 ** (attempt - 1) + Math.random() * 30;
|
||||
console.warn(
|
||||
`Serialisierungskonflikt (Versuch ${attempt}/${MAX_ATTEMPTS}) – ` +
|
||||
`courseId=${courseId} userId=${userId} sessionId=${session.id}. ` +
|
||||
`courseId=${courseId} isGuest=${isGuest} sessionId=${session.id}. ` +
|
||||
`Retry in ${Math.round(delay)}ms.`
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
|
|
@ -143,7 +186,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
if (isSerializationError) {
|
||||
console.error(
|
||||
`Serialisierungskonflikt nach ${MAX_ATTEMPTS} Versuchen – ` +
|
||||
`courseId=${courseId} userId=${userId} sessionId=${session.id} ` +
|
||||
`courseId=${courseId} isGuest=${isGuest} sessionId=${session.id} ` +
|
||||
`paymentIntentId=${paymentIntentId}. Stripe wird den Webhook wiederholen.`
|
||||
);
|
||||
}
|
||||
|
|
@ -153,42 +196,32 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
|
||||
if (!outcome) throw new Error("runCapacityTransaction lieferte kein Ergebnis");
|
||||
|
||||
// "already_failed" = Buchung wurde bei früherem Versuch wegen voller Kapazität
|
||||
// auf "failed" gesetzt, aber der Refund ist noch ausstehend (z.B. damals
|
||||
// fehlgeschlagen). Bei Stripe-Retry erneut versuchen.
|
||||
const needsRefund = outcome === "capacity_exceeded" || outcome === "already_failed";
|
||||
const needsRefund = outcome.result === "capacity_exceeded" || outcome.result === "already_failed";
|
||||
|
||||
let refundSucceeded = false;
|
||||
if (needsRefund && paymentIntentId) {
|
||||
console.warn(
|
||||
`Kurs ${courseId} war voll als Zahlung für User ${userId} ankam. ` +
|
||||
`Kurs ${courseId} war voll als Zahlung ankam. ` +
|
||||
`Automatische Rückerstattung wird ausgelöst (PaymentIntent ${paymentIntentId}).`
|
||||
);
|
||||
refundSucceeded = await refundPayment(paymentIntentId);
|
||||
if (refundSucceeded) {
|
||||
if (refundSucceeded && outcome.bookingId) {
|
||||
await prisma.courseBooking.update({
|
||||
where: { courseId_userId: { courseId, userId } },
|
||||
where: { id: outcome.bookingId },
|
||||
data: { paymentStatus: "refunded" },
|
||||
});
|
||||
} else {
|
||||
// Refund fehlgeschlagen: Buchung bleibt "failed" für manuelle Bearbeitung.
|
||||
// Stripe wiederholt den Webhook, sodass ein erneuter Refund-Versuch erfolgt.
|
||||
} else if (!refundSucceeded) {
|
||||
console.error(
|
||||
`Refund fehlgeschlagen für Kurs ${courseId}, User ${userId}. ` +
|
||||
`Refund fehlgeschlagen für Kurs ${courseId}. ` +
|
||||
`Buchung bleibt "failed" – manuelle Erstattung im Stripe Dashboard nötig.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail-Versand (FR.10) – darf den Webhook NICHT zum Crashen bringen.
|
||||
// Bei Fehler: nur loggen, sonst riskiert man Stripe-Retry-Schleifen und
|
||||
// mehrfache Buchungs-Verarbeitung.
|
||||
// Außerdem: harte 2-Sekunden-Grenze pro Mail, damit ein träger SMTP-Server
|
||||
// den Webhook nicht über sein Timeout-Limit zieht. Der Versand läuft dann
|
||||
// im Hintergrund weiter; der Webhook gibt nur früher 200 zurück.
|
||||
// E-Mail-Versand – darf den Webhook NICHT zum Crashen bringen.
|
||||
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
|
||||
if (recipientEmail) {
|
||||
if (outcome === "booked") {
|
||||
if (outcome.result === "booked") {
|
||||
await withTimeout(sendBookingConfirmation(recipientEmail, courseId, amountPaidCents), 2000);
|
||||
} else if (needsRefund && refundSucceeded) {
|
||||
await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000);
|
||||
|
|
@ -197,33 +230,34 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
|
||||
revalidatePath("/courses");
|
||||
revalidatePath("/courses/myCourses");
|
||||
|
||||
if (outcome.result === "booked" || outcome.result === "capacity_exceeded") {
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { businessId: true },
|
||||
});
|
||||
if (course) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(revalidateTag as any)(`business-courses-${course.businessId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet maximal `ms` Millisekunden auf das Promise. Läuft das Promise länger,
|
||||
* wird `null` zurückgegeben – das eigentliche Promise läuft im Hintergrund weiter.
|
||||
* Wird genutzt, um den Stripe-Webhook nicht über sein Timeout-Limit hinauszuziehen.
|
||||
*/
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
|
||||
]);
|
||||
}
|
||||
type CapacityOutcome = {
|
||||
result:
|
||||
| "booked"
|
||||
| "capacity_exceeded"
|
||||
| "already_paid"
|
||||
| "already_refunded"
|
||||
| "already_failed"
|
||||
| "course_not_found";
|
||||
bookingId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lädt Kurs+Business und schickt die Buchungs-Bestätigung an den Kunden.
|
||||
* Schluckt Fehler bewusst (siehe Caller-Kommentar).
|
||||
* Atomare Kapazitätsprüfung für authentifizierte User.
|
||||
*/
|
||||
type CapacityOutcome =
|
||||
| "booked"
|
||||
| "capacity_exceeded"
|
||||
| "already_paid"
|
||||
| "already_refunded"
|
||||
| "already_failed"
|
||||
| "course_not_found";
|
||||
|
||||
async function runCapacityTransaction(params: {
|
||||
async function runCapacityTransactionUser(params: {
|
||||
courseId: string;
|
||||
userId: string;
|
||||
session: Stripe.Checkout.Session;
|
||||
|
|
@ -239,13 +273,13 @@ async function runCapacityTransaction(params: {
|
|||
where: { courseId_userId: { courseId, userId } },
|
||||
});
|
||||
|
||||
// Idempotenz: Wenn diese Buchung bereits einen Endzustand für DIESE Session
|
||||
// erreicht hat, ist der Event schon verarbeitet. Erneutes Durchlaufen würde
|
||||
// sonst einen zweiten Refund auslösen oder "refunded" → "failed" überschreiben.
|
||||
if (existing && existing.stripeSessionId === session.id) {
|
||||
if (existing.paymentStatus === "paid") return "already_paid" as const;
|
||||
if (existing.paymentStatus === "refunded") return "already_refunded" as const;
|
||||
if (existing.paymentStatus === "failed") return "already_failed" as const;
|
||||
if (existing.paymentStatus === "paid")
|
||||
return { result: "already_paid" as const, bookingId: existing.id };
|
||||
if (existing.paymentStatus === "refunded")
|
||||
return { result: "already_refunded" as const, bookingId: existing.id };
|
||||
if (existing.paymentStatus === "failed")
|
||||
return { result: "already_failed" as const, bookingId: existing.id };
|
||||
}
|
||||
|
||||
const course = await tx.course.findUnique({
|
||||
|
|
@ -253,15 +287,14 @@ async function runCapacityTransaction(params: {
|
|||
select: { maxParticipants: true },
|
||||
});
|
||||
|
||||
if (!course) return "course_not_found" as const;
|
||||
if (!course) return { result: "course_not_found" as const };
|
||||
|
||||
const paidCount = await tx.courseBooking.count({
|
||||
where: { courseId, paymentStatus: "paid" },
|
||||
});
|
||||
|
||||
if (paidCount >= course.maxParticipants) {
|
||||
// Platz ist weg – Buchung als failed markieren, Aufrufer löst Refund aus.
|
||||
await tx.courseBooking.upsert({
|
||||
const booking = await tx.courseBooking.upsert({
|
||||
where: { courseId_userId: { courseId, userId } },
|
||||
update: {
|
||||
paymentStatus: "failed",
|
||||
|
|
@ -280,10 +313,10 @@ async function runCapacityTransaction(params: {
|
|||
platformFee: platformFeeCents,
|
||||
},
|
||||
});
|
||||
return "capacity_exceeded" as const;
|
||||
return { result: "capacity_exceeded" as const, bookingId: booking.id };
|
||||
}
|
||||
|
||||
await tx.courseBooking.upsert({
|
||||
const booking = await tx.courseBooking.upsert({
|
||||
where: { courseId_userId: { courseId, userId } },
|
||||
update: {
|
||||
paymentStatus: "paid",
|
||||
|
|
@ -302,12 +335,118 @@ async function runCapacityTransaction(params: {
|
|||
platformFee: platformFeeCents,
|
||||
},
|
||||
});
|
||||
return "booked" as const;
|
||||
return { result: "booked" as const, bookingId: booking.id };
|
||||
},
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomare Kapazitätsprüfung für Gäste.
|
||||
* Erstellt einen GuestLead und eine CourseBooking ohne userId.
|
||||
* Idempotenz über stripeSessionId (findFirst statt findUnique).
|
||||
*/
|
||||
async function runCapacityTransactionGuest(params: {
|
||||
courseId: string;
|
||||
guestEmail: string;
|
||||
guestName?: string;
|
||||
session: Stripe.Checkout.Session;
|
||||
paymentIntentId: string | null;
|
||||
amountPaidCents: number;
|
||||
platformFeeCents: number;
|
||||
}): Promise<CapacityOutcome> {
|
||||
const {
|
||||
courseId,
|
||||
guestEmail,
|
||||
guestName,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
} = params;
|
||||
|
||||
return prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Idempotenz: Wurde diese Session schon verarbeitet?
|
||||
const existing = await tx.courseBooking.findFirst({
|
||||
where: { stripeSessionId: session.id },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.paymentStatus === "paid")
|
||||
return { result: "already_paid" as const, bookingId: existing.id };
|
||||
if (existing.paymentStatus === "refunded")
|
||||
return { result: "already_refunded" as const, bookingId: existing.id };
|
||||
if (existing.paymentStatus === "failed")
|
||||
return { result: "already_failed" as const, bookingId: existing.id };
|
||||
}
|
||||
|
||||
const course = await tx.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { maxParticipants: true },
|
||||
});
|
||||
|
||||
if (!course) return { result: "course_not_found" as const };
|
||||
|
||||
const paidCount = await tx.courseBooking.count({
|
||||
where: { courseId, paymentStatus: "paid" },
|
||||
});
|
||||
|
||||
// GuestLead erst nach Kapazitätsprüfung anlegen — verhindert Phantom-Einträge
|
||||
// bei Überbuchung. upsert by email: ein Gast bekommt genau einen Lead-Eintrag,
|
||||
// auch bei mehreren Buchungen (@@unique auf email im Schema).
|
||||
if (paidCount >= course.maxParticipants) {
|
||||
const guestLead = await tx.guestLead.upsert({
|
||||
where: { email: guestEmail },
|
||||
update: {},
|
||||
create: { email: guestEmail, name: guestName ?? null },
|
||||
});
|
||||
const booking = await tx.courseBooking.create({
|
||||
data: {
|
||||
courseId,
|
||||
guestLeadId: guestLead.id,
|
||||
paymentStatus: "failed",
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
},
|
||||
});
|
||||
return { result: "capacity_exceeded" as const, bookingId: booking.id };
|
||||
}
|
||||
|
||||
const guestLead = await tx.guestLead.upsert({
|
||||
where: { email: guestEmail },
|
||||
update: {},
|
||||
create: { email: guestEmail, name: guestName ?? null },
|
||||
});
|
||||
const booking = await tx.courseBooking.create({
|
||||
data: {
|
||||
courseId,
|
||||
guestLeadId: guestLead.id,
|
||||
paymentStatus: "paid",
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
},
|
||||
});
|
||||
return { result: "booked" as const, bookingId: booking.id };
|
||||
},
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet maximal `ms` Millisekunden auf das Promise.
|
||||
*/
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function sendBookingConfirmation(
|
||||
recipientEmail: string,
|
||||
courseId: string,
|
||||
|
|
@ -347,10 +486,6 @@ async function sendBookingConfirmation(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt Kurs+Business und schickt die Rückerstattungs-Mail an den Kunden.
|
||||
* Schluckt Fehler bewusst (siehe Caller-Kommentar).
|
||||
*/
|
||||
async function sendBookingRefund(
|
||||
recipientEmail: string,
|
||||
courseId: string,
|
||||
|
|
@ -379,11 +514,6 @@ async function sendBookingRefund(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstattet eine Zahlung vollständig zurück (Reverse Transfer + Application Fee,
|
||||
* damit das Geld komplett zurückfließt – auch der bereits transferierte Anteil
|
||||
* beim Connected Account).
|
||||
*/
|
||||
async function refundPayment(paymentIntentId: string): Promise<boolean> {
|
||||
try {
|
||||
await stripe.refunds.create({
|
||||
|
|
@ -398,10 +528,6 @@ async function refundPayment(paymentIntentId: string): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die application_fee_amount vom PaymentIntent.
|
||||
* Wird gebraucht weil das Feld nicht direkt in der Session steht.
|
||||
*/
|
||||
async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Promise<number> {
|
||||
try {
|
||||
const intent = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
|
|
@ -418,9 +544,26 @@ async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Prom
|
|||
*/
|
||||
async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
||||
const courseId = session.metadata?.courseId;
|
||||
const userId = session.metadata?.userId;
|
||||
const isGuest = session.metadata?.isGuest === "true";
|
||||
|
||||
if (!courseId || !userId) return;
|
||||
if (!courseId) return;
|
||||
|
||||
if (isGuest) {
|
||||
// Gäste: per stripeSessionId suchen (kein courseId_userId Unique-Index)
|
||||
const existing = await prisma.courseBooking.findFirst({
|
||||
where: { stripeSessionId: session.id },
|
||||
});
|
||||
if (existing && existing.paymentStatus === "pending") {
|
||||
await prisma.courseBooking.update({
|
||||
where: { id: existing.id },
|
||||
data: { paymentStatus: "failed" },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = session.metadata?.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const existing = await prisma.courseBooking.findUnique({
|
||||
where: { courseId_userId: { courseId, userId } },
|
||||
|
|
@ -436,7 +579,6 @@ async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
|||
|
||||
/**
|
||||
* Synchronisiert den Stripe Connect Account-Status mit der DB.
|
||||
* Wird ausgelöst wenn z.B. das Onboarding abgeschlossen wird.
|
||||
*/
|
||||
async function handleAccountUpdated(account: Stripe.Account) {
|
||||
const business = await prisma.business.findUnique({
|
||||
|
|
|
|||
|
|
@ -15,9 +15,16 @@ export async function middleware(request: NextRequest) {
|
|||
];
|
||||
const authRoutes = ["/login", "/register", "/forgot-password"];
|
||||
|
||||
const isProtectedRoute = protectedRoutes.some((route) =>
|
||||
request.nextUrl.pathname.startsWith(route)
|
||||
// Guest-accessible sub-paths that sit under a protected prefix
|
||||
const guestAllowedPaths = [
|
||||
"/courses/book/", // success + cancel pages after guest checkout
|
||||
];
|
||||
const isGuestAllowed = guestAllowedPaths.some((path) =>
|
||||
request.nextUrl.pathname.startsWith(path)
|
||||
);
|
||||
|
||||
const isProtectedRoute =
|
||||
!isGuestAllowed && protectedRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
|
||||
const isAuthRoute = authRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
|
||||
const isPasswordReset = request.nextUrl.pathname.startsWith("/reset-password");
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||
import { unstable_cache, revalidatePath } from "next/cache";
|
||||
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
|
||||
|
||||
export type UserBooking = {
|
||||
bookingId: string;
|
||||
|
|
@ -167,7 +167,11 @@ export async function cancelUserBooking(
|
|||
try {
|
||||
const booking = await prisma.courseBooking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { userId: true, paymentStatus: true, course: { select: { date: true } } },
|
||||
select: {
|
||||
userId: true,
|
||||
paymentStatus: true,
|
||||
course: { select: { date: true, businessId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking || booking.userId !== userData.id) {
|
||||
|
|
@ -188,6 +192,8 @@ export async function cancelUserBooking(
|
|||
});
|
||||
|
||||
revalidatePath("/courses/myCourses");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(revalidateTag as any)(`business-courses-${booking.course.businessId}`);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Stornierung fehlgeschlagen." };
|
||||
|
|
@ -279,35 +285,56 @@ export async function getCourseSummaryForConfirmation(courseId: string) {
|
|||
}
|
||||
|
||||
export async function getBusinessBySlug(slug: string) {
|
||||
try {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
address: true,
|
||||
email: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
const fetch = unstable_cache(
|
||||
async () => {
|
||||
try {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
address: true,
|
||||
email: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!business || !business.isPublic) {
|
||||
return { success: false, error: "Seite nicht gefunden" };
|
||||
}
|
||||
if (!business || !business.isPublic) {
|
||||
return { success: false as const, error: "Seite nicht gefunden" };
|
||||
}
|
||||
|
||||
return { success: true, business };
|
||||
} catch (error) {
|
||||
console.error("Error fetching business by slug:", error);
|
||||
return { success: false, error: "Fehler beim Laden" };
|
||||
}
|
||||
return { success: true as const, business };
|
||||
} catch (error) {
|
||||
console.error("Error fetching business by slug:", error);
|
||||
return { success: false as const, error: "Fehler beim Laden" };
|
||||
}
|
||||
},
|
||||
[`business-slug-${slug}`],
|
||||
{ tags: [`business-slug-${slug}`], revalidate: 3600 }
|
||||
);
|
||||
|
||||
return fetch();
|
||||
}
|
||||
|
||||
export async function getPublishedCoursesForBusiness(
|
||||
businessId: string,
|
||||
options: { from: Date; to: Date }
|
||||
) {
|
||||
const fromKey = options.from.toISOString().slice(0, 10);
|
||||
const toKey = options.to.toISOString().slice(0, 10);
|
||||
|
||||
const fetch = unstable_cache(
|
||||
async () => _fetchCoursesForBusiness(businessId, options),
|
||||
[`business-courses-${businessId}-${fromKey}-${toKey}`],
|
||||
{ tags: [`business-courses-${businessId}`], revalidate: 30 }
|
||||
);
|
||||
|
||||
return fetch();
|
||||
}
|
||||
|
||||
async function _fetchCoursesForBusiness(businessId: string, options: { from: Date; to: Date }) {
|
||||
try {
|
||||
const courses = await prisma.course.findMany({
|
||||
where: {
|
||||
|
|
@ -322,23 +349,24 @@ export async function getPublishedCoursesForBusiness(
|
|||
});
|
||||
|
||||
const trainerIds = [...new Set(courses.flatMap((c) => c.trainers))];
|
||||
const trainers =
|
||||
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))] as string[];
|
||||
|
||||
const [trainers, rooms] = await Promise.all([
|
||||
trainerIds.length > 0
|
||||
? await prisma.employee.findMany({
|
||||
? prisma.employee.findMany({
|
||||
where: { id: { in: trainerIds } },
|
||||
select: { id: true, firstName: true, lastName: true, pbSrc: true },
|
||||
})
|
||||
: [];
|
||||
const trainerMap = Object.fromEntries(trainers.map((t) => [t.id, t]));
|
||||
|
||||
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))] as string[];
|
||||
const rooms =
|
||||
: Promise.resolve([]),
|
||||
roomIds.length > 0
|
||||
? await prisma.room.findMany({
|
||||
? prisma.room.findMany({
|
||||
where: { id: { in: roomIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const trainerMap = Object.fromEntries(trainers.map((t) => [t.id, t]));
|
||||
const roomMap = Object.fromEntries(rooms.map((r) => [r.id, r.name]));
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ export const BookingDialog = ({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isBooked, setIsBooked] = useState(false);
|
||||
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
|
||||
const [showGuestForm, setShowGuestForm] = useState(false);
|
||||
const [guestEmail, setGuestEmail] = useState("");
|
||||
const [guestName, setGuestName] = useState("");
|
||||
|
||||
const isAdminView = paymentStatus !== undefined;
|
||||
|
||||
|
|
@ -171,28 +174,44 @@ export const BookingDialog = ({
|
|||
};
|
||||
}, [open, course.id, isAdminView]);
|
||||
|
||||
const handleBooking = async () => {
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const handleBooking = async (opts?: { asGuest: boolean }) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const body: Record<string, string> = { courseId: course.id };
|
||||
if (opts?.asGuest) {
|
||||
const normalizedEmail = guestEmail.trim().toLowerCase();
|
||||
if (!EMAIL_RE.test(normalizedEmail)) {
|
||||
toast.error("Bitte eine gültige E-Mail-Adresse angeben.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
body.guestEmail = normalizedEmail;
|
||||
if (guestName.trim()) body.guestName = guestName.trim();
|
||||
}
|
||||
|
||||
const response = await fetch("/api/stripe/checkout/course", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ courseId: course.id }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.open("/login", "_blank");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let data: { url?: string; error?: string } = {};
|
||||
let data: { url?: string; error?: string; isGuestRequired?: boolean } = {};
|
||||
try {
|
||||
data = (await response.json()) as { url?: string; error?: string };
|
||||
data = (await response.json()) as typeof data;
|
||||
} catch {
|
||||
// keep fallback error below
|
||||
}
|
||||
|
||||
// Not logged in → show guest form (and show server error if already in guest mode)
|
||||
if (response.status === 401 || (response.status === 400 && data.isGuestRequired)) {
|
||||
if (opts?.asGuest && data.error) toast.error(data.error);
|
||||
setShowGuestForm(true);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok || !data.url) {
|
||||
toast.error(data.error || "Ein Fehler ist aufgetreten");
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -333,55 +352,92 @@ export const BookingDialog = ({
|
|||
</div>
|
||||
|
||||
{/* Sticky Footer */}
|
||||
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-lg font-bold text-gray-900">Preis: {formattedPrice}</span>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
||||
{isAdminView ? (
|
||||
<>
|
||||
<span
|
||||
className={`text-sm font-semibold px-3 py-1.5 rounded-full ${adminStatus!.bg} ${adminStatus!.text}`}
|
||||
>
|
||||
{adminStatus!.label}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
/* Public-Ansicht: Buchen-Button */
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
{isCheckingStatus ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
Lade…
|
||||
</Button>
|
||||
) : isBooked ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
✓ Angemeldet
|
||||
</Button>
|
||||
) : (
|
||||
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-lg font-bold text-gray-900">Preis: {formattedPrice}</span>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
||||
{isAdminView ? (
|
||||
<>
|
||||
<span
|
||||
className={`text-sm font-semibold px-3 py-1.5 rounded-full ${adminStatus!.bg} ${adminStatus!.text}`}
|
||||
>
|
||||
{adminStatus!.label}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleBooking}
|
||||
disabled={isSubmitting || isFull}
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
|
||||
Schließen
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Public-Ansicht: Buchen-Button */
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
{isCheckingStatus ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
Lade…
|
||||
</Button>
|
||||
) : isBooked ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
✓ Angemeldet
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleBooking()}
|
||||
disabled={isSubmitting || isFull}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gast-Formular: erscheint nach erstem 401 (nicht eingeloggt) */}
|
||||
{!isAdminView && showGuestForm && !isBooked && (
|
||||
<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{" "}
|
||||
<a href="/login" className="underline text-gray-700 hover:text-gray-900">
|
||||
anmelden
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-Mail *"
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(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"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name (optional)"
|
||||
value={guestName}
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -414,7 +470,7 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
|
|||
<>
|
||||
<div
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl shadow-sm hover:shadow-md hover:bg-white/70 transition flex flex-col cursor-pointer overflow-hidden"
|
||||
className="border border-gray-200 bg-white rounded-xl shadow-sm hover:shadow-md transition flex flex-col cursor-pointer overflow-hidden"
|
||||
>
|
||||
{course.coverImage && (
|
||||
<div className="relative w-full h-36 shrink-0">
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ const BusinessPublicView = ({
|
|||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="border-b border-white/60 bg-white/55 backdrop-blur-xl">
|
||||
<div className="border-b border-gray-200 bg-white/90 backdrop-blur-sm">
|
||||
<div className="max-w-6xl mx-auto px-6 py-10">
|
||||
<h1 className="text-4xl font-bold text-gray-900">{business.name}</h1>
|
||||
{business.title && <p className="text-xl text-gray-500 mt-1">{business.title}</p>}
|
||||
|
|
@ -169,7 +169,7 @@ const BusinessPublicView = ({
|
|||
{/* Left: sticky calendar */}
|
||||
<div className="lg:sticky lg:top-6 shrink-0 w-full lg:w-auto">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-3">{formattedSelectedDate}</h2>
|
||||
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl shadow-sm p-4 flex justify-center">
|
||||
<div className="border border-gray-200 bg-white rounded-xl shadow-sm p-4 flex justify-center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
|
|
@ -194,7 +194,7 @@ const BusinessPublicView = ({
|
|||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
resolvedSport === sport
|
||||
? "bg-gray-900 text-white"
|
||||
: "border border-white/60 bg-white/55 backdrop-blur-sm text-gray-600 hover:bg-white/70"
|
||||
: "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{sport === "Alle" ? "Alle" : capitalize(sport)}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type KundeRow = {
|
|||
email: string;
|
||||
telefon: string;
|
||||
status: "Bezahlt" | "Storniert" | "Ausstehend";
|
||||
isGuest: boolean;
|
||||
};
|
||||
|
||||
function mapPaymentStatus(paymentStatus: string): KundeRow["status"] {
|
||||
|
|
@ -28,7 +29,6 @@ export async function getKunden(): Promise<
|
|||
return { success: true, kunden: [] };
|
||||
}
|
||||
|
||||
// Alle Bookings für Kurse die zu diesem Manager gehören, gruppiert nach User
|
||||
const bookings = await prisma.courseBooking.findMany({
|
||||
where: {
|
||||
course: { businessId: { in: businessIds } },
|
||||
|
|
@ -36,28 +36,43 @@ export async function getKunden(): Promise<
|
|||
select: {
|
||||
paymentStatus: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
tel: true,
|
||||
},
|
||||
select: { id: true, name: true, email: true, tel: true },
|
||||
},
|
||||
guestLead: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
// Pro User nur den letzten (relevantesten) Status behalten
|
||||
// Deduplizieren: pro User-ID bzw. GuestLead-ID nur den letzten Status
|
||||
const seen = new Map<string, KundeRow>();
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!seen.has(booking.user.id)) {
|
||||
seen.set(booking.user.id, {
|
||||
id: booking.user.id,
|
||||
name: booking.user.name ?? booking.user.email,
|
||||
email: booking.user.email,
|
||||
telefon: booking.user.tel ?? "–",
|
||||
status: mapPaymentStatus(booking.paymentStatus),
|
||||
});
|
||||
if (booking.user) {
|
||||
const key = `user:${booking.user.id}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, {
|
||||
id: booking.user.id,
|
||||
name: booking.user.name ?? booking.user.email,
|
||||
email: booking.user.email,
|
||||
telefon: booking.user.tel ?? "–",
|
||||
status: mapPaymentStatus(booking.paymentStatus),
|
||||
isGuest: false,
|
||||
});
|
||||
}
|
||||
} else if (booking.guestLead) {
|
||||
const key = `guest:${booking.guestLead.id}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, {
|
||||
id: booking.guestLead.id,
|
||||
name: booking.guestLead.name ?? booking.guestLead.email,
|
||||
email: booking.guestLead.email,
|
||||
telefon: "–",
|
||||
status: mapPaymentStatus(booking.paymentStatus),
|
||||
isGuest: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,10 +75,19 @@ const KundenTable = ({ kunden }: KundenTableProps) => {
|
|||
kunden.map((kunde) => (
|
||||
<TableRow
|
||||
key={kunde.id}
|
||||
className="border-b border-white/30 transition-colors duration-150 hover:bg-white/70 cursor-pointer"
|
||||
onClick={() => router.push(`/kunden/${kunde.id}`)}
|
||||
className={`border-b border-white/30 transition-colors duration-150 hover:bg-white/70 ${!kunde.isGuest ? "cursor-pointer" : ""}`}
|
||||
onClick={() => !kunde.isGuest && router.push(`/kunden/${kunde.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">{kunde.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
{kunde.name}
|
||||
{kunde.isGuest && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">
|
||||
Gast
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
{kunde.email}
|
||||
</TableCell>
|
||||
|
|
@ -93,17 +102,19 @@ const KundenTable = ({ kunden }: KundenTableProps) => {
|
|||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full hover:bg-white/60"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/kunden/${kunde.id}`);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
{!kunde.isGuest && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full hover:bg-white/60"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/kunden/${kunde.id}`);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export async function updateBusinessInfo(data: {
|
|||
return { success: false, error: "Ungültige E-Mail-Adresse." };
|
||||
}
|
||||
|
||||
await prisma.business.update({
|
||||
const updated = await prisma.business.update({
|
||||
where: { id: data.businessId },
|
||||
data: {
|
||||
address: data.address.trim(),
|
||||
|
|
@ -45,9 +45,12 @@ export async function updateBusinessInfo(data: {
|
|||
ustId: data.ustId?.trim() || null,
|
||||
banking: data.banking?.trim() || null,
|
||||
},
|
||||
select: { slug: true },
|
||||
});
|
||||
|
||||
(revalidateTag as (tag: string, type: "max") => void)(`user-data-${manager.email}`, "max");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (updated.slug) (revalidateTag as any)(`business-slug-${updated.slug}`);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Fehler beim Speichern der Business-Daten." };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue