added discount codes

This commit is contained in:
Rajpreet0 2026-09-13 18:45:46 +02:00
parent d045ee1600
commit db514248a2
13 changed files with 1408 additions and 43 deletions

View file

@ -0,0 +1,70 @@
-- AlterTable
ALTER TABLE "CourseBooking" ADD COLUMN "discountAmount" INTEGER,
ADD COLUMN "discountCodeId" TEXT;
-- CreateTable
CREATE TABLE "DiscountCode" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" INTEGER NOT NULL,
"scope" TEXT NOT NULL,
"courseIds" TEXT[],
"maxRedemptions" INTEGER,
"redeemedCount" INTEGER NOT NULL DEFAULT 0,
"startsAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DiscountCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DiscountRedemption" (
"id" TEXT NOT NULL,
"discountCodeId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DiscountRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DiscountCode_businessId_idx" ON "DiscountCode"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "DiscountCode_businessId_code_key" ON "DiscountCode"("businessId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "DiscountRedemption_bookingId_key" ON "DiscountRedemption"("bookingId");
-- CreateIndex
CREATE INDEX "DiscountRedemption_discountCodeId_idx" ON "DiscountRedemption"("discountCodeId");
-- CreateIndex
CREATE INDEX "DiscountRedemption_userId_idx" ON "DiscountRedemption"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "DiscountRedemption_discountCodeId_userId_key" ON "DiscountRedemption"("discountCodeId", "userId");
-- CreateIndex
CREATE INDEX "CourseBooking_discountCodeId_idx" ON "CourseBooking"("discountCodeId");
-- AddForeignKey
ALTER TABLE "CourseBooking" ADD CONSTRAINT "CourseBooking_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscountCode" ADD CONSTRAINT "DiscountCode_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "CourseBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -42,6 +42,7 @@ model User {
trainings UserTraining[]
courseBookings CourseBooking[]
courseWaitlistEntries CourseWaitlistEntry[]
discountRedemptions DiscountRedemption[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
}
@ -128,10 +129,11 @@ model Business {
onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr
onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet
onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren
manager Manager @relation(fields: [managerId], references: [id])
manager Manager @relation(fields: [managerId], references: [id])
courses Course[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
discountCodes DiscountCode[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([managerId])
}
@ -195,30 +197,35 @@ model GuestLead {
}
model CourseBooking {
id String @id @default(uuid())
id String @id @default(uuid())
courseId String
userId String?
guestLeadId String?
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung)
stripeSessionId String? @unique
stripePaymentIntentId String? @unique
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung)
stripeSessionId String? @unique
stripePaymentIntentId String? @unique
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
platformFee Int? // unsere Service-Fee in Cents
invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben
invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben
invoiceIssuedAt DateTime? // Zeitpunkt der Rechnungsnummer-Vergabe, damit das Rechnungsdatum bei jedem Download gleich bleibt
refundedAmount Int? // tatsächlich erstatteter Betrag in Cents
refundedAt DateTime? // Zeitpunkt der erfolgreichen Stripe-Rückerstattung
cancelledBy String? // "user" | "manager" | "system" (system = automatischer Überbuchungs-Refund)
cancellationReason String?
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())
discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde
discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen)
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)
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
discountRedemption DiscountRedemption?
createdAt DateTime @default(now())
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs anmelden
@@index([courseId])
@@index([userId])
@@index([guestLeadId])
@@index([discountCodeId])
}
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
@ -258,3 +265,49 @@ model Notification {
@@index([recipientId, readAt])
@@index([recipientId, createdAt])
}
// Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
// redeemedCount wird ausschließlich im Stripe-Webhook per {increment: 1} erhöht,
// niemals beim Checkout-Start (siehe DiscountRedemption).
model DiscountCode {
id String @id @default(uuid())
businessId String
code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique)
type String // "percent" | "fixed"
value Int // percent: 1-100, fixed: Cents
scope String // "all_courses" | "specific_courses"
courseIds String[] // Nur relevant wenn scope = "specific_courses"
maxRedemptions Int? // null = unbegrenzt
redeemedCount Int @default(0)
startsAt DateTime? // null = sofort gültig
expiresAt DateTime? // null = kein Ablauf
isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
bookings CourseBooking[]
redemptions DiscountRedemption[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([businessId, code])
@@index([businessId])
}
// Ein Eintrag pro tatsächlich eingelöstem Rabattcode (nur bei bezahlter Buchung,
// siehe Webhook handleCheckoutCompleted). Der @@unique-Constraint erzwingt
// "1x pro User" race-condition-sicher auf DB-Ebene, unabhängig von Anwendungslogik.
model DiscountRedemption {
id String @id @default(uuid())
discountCodeId String
userId String
bookingId String @unique
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
booking CourseBooking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([discountCodeId, userId])
@@index([discountCodeId])
@@index([userId])
}

View file

@ -1,13 +1,41 @@
import { Suspense } from "react";
import { AlertTriangle } from "lucide-react";
import { requireManager } from "@/src/lib/auth/getUser";
import {
getDiscountCodes,
getBusinessCoursesForDiscountPicker,
} from "@/src/modules/settings/actions/discount-actions";
import SettingsDiscountsPageView from "@/src/modules/settings/ui/views/settings-discounts-page-view";
import SettingsLoading from "../loading";
export default async function SettingsDiscountPage() {
await requireManager();
const manager = await requireManager();
const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name }));
const firstBusinessId = businesses[0]?.id;
const [codesResult, coursesResult] = firstBusinessId
? await Promise.all([
getDiscountCodes(firstBusinessId),
getBusinessCoursesForDiscountPicker(firstBusinessId),
])
: [null, null];
if (firstBusinessId && codesResult && !codesResult.success) {
return (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{codesResult.error}</p>
</div>
);
}
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsDiscountsPageView />
<SettingsDiscountsPageView
businesses={businesses}
initialCodes={codesResult?.success ? codesResult.codes : []}
initialCourses={coursesResult?.success ? coursesResult.courses : []}
/>
</Suspense>
);
}

View file

@ -3,6 +3,10 @@ import { prisma } from "@/src/lib/prisma";
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import {
validateDiscountCode,
calculateDiscountedPriceCents,
} from "@/src/modules/settings/lib/discount-validation";
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
// Best-effort only — in multi-instance/serverless deployments each instance has
@ -61,8 +65,9 @@ export async function POST(request: NextRequest) {
courseId?: string;
guestEmail?: string;
guestName?: string;
discountCode?: string;
};
const { courseId, guestEmail, guestName } = body;
const { courseId, guestEmail, guestName, discountCode } = body;
if (!courseId) {
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
@ -172,8 +177,35 @@ export async function POST(request: NextRequest) {
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const priceCents = eurosToCents(course.price);
const platformFeeCents = calculatePlatformFeeCents(course.price);
const originalPriceCents = eurosToCents(course.price);
// Rabattcode ist optional — ein ungültiger/abgelaufener Code lässt die
// Buchung bewusst NICHT scheitern, sondern wird einfach ignoriert (Preis
// bleibt regulär). Harte Fehler nur bei explizit falscher Eingabe wären
// hier unnötig reibungsvoll für den Kunden; die UI zeigt die Ablehnung
// bereits vorab über eine separate Preview-Action (siehe booking UI).
let priceCents = originalPriceCents;
let discountAmountCents = 0;
let appliedDiscountCodeId: string | null = null;
if (discountCode?.trim()) {
const validation = await validateDiscountCode(prisma, {
code: discountCode,
businessId: course.business.id,
courseId: course.id,
userId: isGuest ? null : userData!.id,
});
if (validation.valid) {
const result = calculateDiscountedPriceCents(originalPriceCents, validation.discountCode);
priceCents = result.discountedPriceCents;
discountAmountCents = result.discountAmountCents;
appliedDiscountCodeId = validation.discountCode.id;
}
}
// Platform-Fee auf den tatsächlich zu zahlenden (rabattierten) Betrag,
// nicht den Originalpreis — das entspricht dem, was via
// application_fee_amount wirklich von diesem Payment einbehalten wird.
const platformFeeCents = calculatePlatformFeeCents(priceCents / 100);
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
@ -204,6 +236,8 @@ export async function POST(request: NextRequest) {
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
discountCodeId: appliedDiscountCodeId ?? "",
discountAmountCents: String(discountAmountCents),
},
},
metadata: {
@ -213,13 +247,18 @@ export async function POST(request: NextRequest) {
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
discountCodeId: appliedDiscountCodeId ?? "",
discountAmountCents: String(discountAmountCents),
},
customer_email: customerEmail,
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
});
return NextResponse.json({ url: session.url });
return NextResponse.json({
url: session.url,
discountApplied: appliedDiscountCodeId !== null,
});
} catch (error) {
console.error("Error creating checkout session:", error);
return NextResponse.json(

View file

@ -13,6 +13,7 @@ import {
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import { redeemDiscountCode } from "@/src/modules/settings/lib/discount-validation";
/**
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
@ -107,6 +108,13 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const platformFeeCents = paymentIntentId
? await getApplicationFeeFromPaymentIntent(paymentIntentId)
: 0;
// discountAmountCents ist ein Snapshot vom Checkout-Start (in der Session-
// Metadata eingefroren) — bewusst NICHT aus dem aktuellen DiscountCode neu
// berechnet, damit eine spätere Code-Änderung die Rechnung dieser Buchung
// nicht rückwirkend verändert.
const businessId = session.metadata?.businessId;
const discountCodeId = session.metadata?.discountCodeId || null;
const discountAmountCents = Number(session.metadata?.discountAmountCents ?? "0") || 0;
// Guest-Flow und Auth-Flow teilen die Kapazitäts-Logik, aber nutzen
// unterschiedliche Identifikatoren und DB-Operationen.
@ -164,6 +172,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
paymentIntentId,
amountPaidCents,
platformFeeCents,
businessId,
discountCodeId,
discountAmountCents,
treatDistinctSessionAsDuplicate: true,
})
: await runCapacityTransactionGuest({
@ -214,6 +225,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
paymentIntentId,
amountPaidCents,
platformFeeCents,
businessId,
discountCodeId,
discountAmountCents,
treatDistinctSessionAsDuplicate: true,
})
: await runCapacityTransactionGuest({
@ -256,6 +270,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
paymentIntentId,
amountPaidCents,
platformFeeCents,
businessId,
discountCodeId,
discountAmountCents,
});
}
break;
@ -435,6 +452,9 @@ async function runCapacityTransactionUser(params: {
paymentIntentId: string | null;
amountPaidCents: number;
platformFeeCents: number;
businessId?: string;
discountCodeId?: string | null;
discountAmountCents?: number;
treatDistinctSessionAsDuplicate?: boolean;
}): Promise<CapacityOutcome> {
const {
@ -444,6 +464,9 @@ async function runCapacityTransactionUser(params: {
paymentIntentId,
amountPaidCents,
platformFeeCents,
businessId,
discountCodeId = null,
discountAmountCents = 0,
treatDistinctSessionAsDuplicate = false,
} = params;
@ -546,6 +569,8 @@ async function runCapacityTransactionUser(params: {
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
discountCodeId,
discountAmount: discountCodeId ? discountAmountCents : null,
},
create: {
courseId,
@ -555,8 +580,27 @@ async function runCapacityTransactionUser(params: {
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
discountCodeId,
discountAmount: discountCodeId ? discountAmountCents : null,
},
});
// Re-Validierung + Redemption/Increment innerhalb DIESER Transaktion -
// ein zwischenzeitlich ausgeschöpfter/abgelaufener Code lässt die
// Buchung bewusst NICHT scheitern (Zahlung ist schon erfolgt), sondern
// wird hier still übersprungen: booking.discountCodeId/discountAmount
// bleiben zwar gesetzt (Snapshot für die Rechnung), aber ohne
// DiscountRedemption-Zeile zählt der Code-Verbrauch nicht doppelt.
if (discountCodeId && businessId) {
await redeemDiscountCode(tx, {
discountCodeId,
businessId,
courseId,
userId,
bookingId: booking.id,
});
}
return { result: "booked" as const, bookingId: booking.id };
},
{ isolationLevel: "Serializable" }

View file

@ -11,9 +11,10 @@ interface SuccessPageProps {
searchParams: Promise<{ session_id?: string }>;
}
export default async function CourseBookingSuccessPage({ params }: SuccessPageProps) {
export default async function CourseBookingSuccessPage({ params, searchParams }: SuccessPageProps) {
const { courseId } = await params;
const result = await getCourseSummaryForConfirmation(courseId);
const { session_id: sessionId } = await searchParams;
const result = await getCourseSummaryForConfirmation(courseId, sessionId);
const course = result.success ? result.course : null;
const businessSlug = course?.business?.slug ?? null;
@ -28,9 +29,13 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
})
: null;
const formattedPrice = course
? new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(course.price)
: null;
const priceFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
const formattedPrice = result.success && course ? priceFormatter.format(result.paidPrice) : null;
const hasDiscount = result.success && result.discountAmount !== null;
const formattedDiscount =
result.success && result.discountAmount !== null
? priceFormatter.format(result.discountAmount)
: null;
return (
<div className="max-w-2xl mx-auto p-6">
@ -84,21 +89,29 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
)}
</div>
<div className="border-t border-gray-200 mt-4 pt-3 flex items-center justify-between">
<span className="text-sm text-gray-500">Bezahlt</span>
{branding ? (
<span
className="text-lg font-bold px-2.5 py-0.5 rounded-md"
style={{
backgroundColor: branding.accentColor,
color: getContrastTextColor(branding.accentColor),
}}
>
{formattedPrice}
</span>
) : (
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span>
<div className="border-t border-gray-200 mt-4 pt-3">
{hasDiscount && (
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Rabatt</span>
<span>− {formattedDiscount}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Bezahlt</span>
{branding ? (
<span
className="text-lg font-bold px-2.5 py-0.5 rounded-md"
style={{
backgroundColor: branding.accentColor,
color: getContrastTextColor(branding.accentColor),
}}
>
{formattedPrice}
</span>
) : (
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span>
)}
</div>
</div>
</div>
)}

View file

@ -14,6 +14,11 @@ import { executeBookingCancellation } from "./cancel-booking-shared";
import { combineDateAndTime } from "@/src/lib/course-time";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
import { eurosToCents } from "@/src/lib/stripe";
import {
validateDiscountCode,
calculateDiscountedPriceCents,
} from "@/src/modules/settings/lib/discount-validation";
export type UserBooking = {
bookingId: string;
@ -264,7 +269,7 @@ export async function checkUserBookingStatus(courseId: string) {
*
* @param courseId - Die eindeutige ID des Kurses
*/
export async function getCourseSummaryForConfirmation(courseId: string) {
export async function getCourseSummaryForConfirmation(courseId: string, sessionId?: string) {
try {
const course = await prisma.course.findFirst({
where: {
@ -295,7 +300,25 @@ export async function getCourseSummaryForConfirmation(courseId: string) {
return { success: false as const, error: "Kurs nicht gefunden" };
}
return { success: true as const, course };
// course.price ist der reguläre Kurspreis, NICHT der tatsächlich gezahlte
// Betrag (kann durch einen Rabattcode abweichen). sessionId (aus der
// Stripe-success_url) erlaubt es, die konkrete Buchung nachzuladen - ohne
// sessionId (oder falls der Webhook noch nicht durchgelaufen ist) fällt
// die Anzeige auf den regulären Kurspreis zurück.
let paidPrice = course.price;
let discountAmount: number | null = null;
if (sessionId) {
const booking = await prisma.courseBooking.findUnique({
where: { stripeSessionId: sessionId },
select: { amountPaid: true, discountAmount: true },
});
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
paidPrice = booking.amountPaid / 100;
discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null;
}
}
return { success: true as const, course, paidPrice, discountAmount };
} catch (error) {
console.error("Error fetching course summary:", error);
return { success: false as const, error: "Fehler beim Laden des Kurses" };
@ -407,3 +430,51 @@ async function _fetchCoursesForBusiness(businessId: string, options: { from: Dat
return { success: false, error: "Fehler beim Laden der Kurse", courses: [] };
}
}
// Rein lesende Vorschau für die Buchungs-UI, damit ein Kunde den rabattierten
// Preis VOR dem Stripe-Checkout sieht. Legt keine Redemption an und erhöht
// keinen Counter (siehe redeemDiscountCode) - die Checkout-Route führt exakt
// dieselbe Validierung erneut aus, bevor die Session erstellt wird.
export async function previewDiscountCode(
courseId: string,
code: string
): Promise<
{ valid: true; discountedPrice: number; discountAmount: number } | { valid: false; error: string }
> {
try {
const userData = await getUserData();
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { price: true, businessId: true },
});
if (!course || !course.businessId) {
return { valid: false, error: "Kurs nicht gefunden." };
}
const validation = await validateDiscountCode(prisma, {
code,
businessId: course.businessId,
courseId,
userId: userData && isUser(userData) ? userData.id : null,
});
if (!validation.valid) {
return { valid: false, error: validation.reason };
}
const originalPriceCents = eurosToCents(course.price);
const { discountedPriceCents, discountAmountCents } = calculateDiscountedPriceCents(
originalPriceCents,
validation.discountCode
);
return {
valid: true,
discountedPrice: discountedPriceCents / 100,
discountAmount: discountAmountCents / 100,
};
} catch (error) {
console.error("Error previewing discount code:", error);
return { valid: false, error: "Rabattcode konnte nicht geprüft werden." };
}
}

View file

@ -9,7 +9,7 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/component
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 { checkUserBookingStatus, previewDiscountCode } from "../../actions/booking-actions";
import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { toast } from "sonner";
@ -168,6 +168,12 @@ export const BookingDialog = ({
const [showGuestForm, setShowGuestForm] = useState(false);
const [guestEmail, setGuestEmail] = useState("");
const [guestName, setGuestName] = useState("");
const [discountInput, setDiscountInput] = useState("");
const [isCheckingDiscount, setIsCheckingDiscount] = useState(false);
const [appliedDiscount, setAppliedDiscount] = useState<{
code: string;
discountedPrice: number;
} | null>(null);
const isAdminView = paymentStatus !== undefined;
@ -224,10 +230,32 @@ export const BookingDialog = ({
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const handleApplyDiscount = async () => {
const code = discountInput.trim();
if (!code) return;
setIsCheckingDiscount(true);
try {
const result = await previewDiscountCode(course.id, code);
if (!result.valid) {
toast.error(result.error);
setAppliedDiscount(null);
return;
}
setAppliedDiscount({ code, discountedPrice: result.discountedPrice });
toast.success("Rabattcode angewendet.");
} catch {
toast.error("Rabattcode konnte nicht geprüft werden.");
setAppliedDiscount(null);
} finally {
setIsCheckingDiscount(false);
}
};
const handleBooking = async (opts?: { asGuest: boolean }) => {
setIsSubmitting(true);
try {
const body: Record<string, string> = { courseId: course.id };
if (appliedDiscount) body.discountCode = appliedDiscount.code;
if (opts?.asGuest) {
const normalizedEmail = guestEmail.trim().toLowerCase();
if (!EMAIL_RE.test(normalizedEmail)) {
@ -256,6 +284,11 @@ export const BookingDialog = ({
if (response.status === 401 || (response.status === 400 && data.isGuestRequired)) {
if (opts?.asGuest && data.error) toast.error(data.error);
setShowGuestForm(true);
// Rabattcodes sind nur für eingeloggte User verfügbar (siehe
// validateDiscountCode) - ein bereits angewendeter Code darf nicht
// stillschweigend im Gast-Checkout landen, wo er ohnehin ignoriert würde.
setAppliedDiscount(null);
setDiscountInput("");
setIsSubmitting(false);
return;
}
@ -430,8 +463,48 @@ export const BookingDialog = ({
{/* 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 gap-3">
{/* Rabattcodes sind nur für eingeloggte User verfügbar (siehe
validateDiscountCode) - im Gast-Formular macht das Feld daher
keinen Sinn und würde nur einen ungültigen Code suggerieren. */}
{!isAdminView && !isFull && !isBooked && !showGuestForm && (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Rabattcode"
value={discountInput}
onChange={(e) => {
setDiscountInput(e.target.value.toUpperCase());
if (appliedDiscount) setAppliedDiscount(null);
}}
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
<Button
variant="outline"
size="sm"
onClick={handleApplyDiscount}
disabled={isCheckingDiscount || !discountInput.trim()}
>
{isCheckingDiscount ? "Prüfe…" : "Anwenden"}
</Button>
</div>
)}
<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>
<span className="text-lg font-bold text-gray-900">
{appliedDiscount ? (
<>
<span className="line-through text-gray-400 font-normal mr-2">
{formattedPrice}
</span>
Preis:{" "}
{appliedDiscount.discountedPrice.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
})}
</>
) : (
`Preis: ${formattedPrice}`
)}
</span>
<div className="flex items-center gap-3 w-full sm:w-auto">
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
{isAdminView ? (

View file

@ -0,0 +1,226 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache";
import { discountCodeSchema, type DiscountCodeInput } from "../schemas/discount-code-schema";
export type DiscountCodeDTO = {
id: string;
code: string;
type: "percent" | "fixed";
value: number;
scope: "all_courses" | "specific_courses";
courseIds: string[];
maxRedemptions: number | null;
redeemedCount: number;
startsAt: Date | null;
expiresAt: Date | null;
isActive: boolean;
createdAt: Date;
};
async function assertBusinessOwnership(businessId: string, managerId: string) {
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true },
});
return !!business && business.managerId === managerId;
}
export async function getDiscountCodes(
businessId: string
): Promise<{ success: true; codes: DiscountCodeDTO[] } | { success: false; error: string }> {
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect
// aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen.
const manager = await requireManager();
try {
if (!(await assertBusinessOwnership(businessId, manager.id))) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const codes = await prisma.discountCode.findMany({
where: { businessId },
orderBy: { createdAt: "desc" },
});
return { success: true, codes: codes as DiscountCodeDTO[] };
} catch (error) {
console.error("Error fetching discount codes:", error);
return { success: false, error: "Rabattcodes konnten nicht geladen werden." };
}
}
export async function createDiscountCode(
businessId: string,
input: DiscountCodeInput
): Promise<{ success: true; code: DiscountCodeDTO } | { success: false; error: string }> {
const manager = await requireManager();
try {
if (!(await assertBusinessOwnership(businessId, manager.id))) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const parsed = discountCodeSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
const normalizedCode = parsed.data.code.toUpperCase();
const existing = await prisma.discountCode.findUnique({
where: { businessId_code: { businessId, code: normalizedCode } },
select: { id: true },
});
if (existing) {
return { success: false, error: "Dieser Code existiert bereits für dieses Business." };
}
const created = await prisma.discountCode.create({
data: {
businessId,
code: normalizedCode,
type: parsed.data.type,
value: parsed.data.value,
scope: parsed.data.scope,
courseIds: parsed.data.scope === "specific_courses" ? parsed.data.courseIds : [],
maxRedemptions: parsed.data.maxRedemptions,
startsAt: parsed.data.startsAt,
expiresAt: parsed.data.expiresAt,
isActive: parsed.data.isActive,
},
});
revalidatePath("/settings/discounts");
return { success: true, code: created as DiscountCodeDTO };
} catch (error) {
console.error("Error creating discount code:", error);
return { success: false, error: "Rabattcode konnte nicht erstellt werden." };
}
}
// Bewusst kein voller Edit für code/type/value/scope, sobald ein Code schon
// mindestens einmal eingelöst wurde: eine nachträgliche Änderung würde
// bestehende, bereits abgerechnete Buchungen (CourseBooking.discountAmount)
// rückwirkend inkonsistent mit dem Code-Datensatz machen. Editierbar bleiben
// nur die Limits/Laufzeit/isActive, die den Code lediglich stärker
// einschränken oder ausschalten, nie rückwirkend die Vergangenheit verändern.
export async function updateDiscountCodeLimits(
businessId: string,
discountCodeId: string,
input: {
maxRedemptions: number | null;
startsAt: Date | null;
expiresAt: Date | null;
isActive: boolean;
}
): Promise<{ success: true } | { success: false; error: string }> {
const manager = await requireManager();
try {
if (!(await assertBusinessOwnership(businessId, manager.id))) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
if (input.startsAt && input.expiresAt && input.expiresAt <= input.startsAt) {
return { success: false, error: "Ablaufdatum muss nach dem Startdatum liegen." };
}
if (input.maxRedemptions !== null && input.maxRedemptions <= 0) {
return { success: false, error: "Maximale Einlösungen muss größer als 0 sein." };
}
const code = await prisma.discountCode.findUnique({
where: { id: discountCodeId },
select: { businessId: true },
});
if (!code || code.businessId !== businessId) {
return { success: false, error: "Rabattcode nicht gefunden." };
}
await prisma.discountCode.update({
where: { id: discountCodeId },
data: {
maxRedemptions: input.maxRedemptions,
startsAt: input.startsAt,
expiresAt: input.expiresAt,
isActive: input.isActive,
},
});
revalidatePath("/settings/discounts");
return { success: true };
} catch (error) {
console.error("Error updating discount code:", error);
return { success: false, error: "Rabattcode konnte nicht aktualisiert werden." };
}
}
// Hard-Delete bewusst vermieden: DiscountRedemption/CourseBooking verweisen auf
// die Code-ID (Rechnungs-/Audit-Historie). Deaktivieren reicht, um weitere
// Einlösungen zu verhindern.
export async function deactivateDiscountCode(
businessId: string,
discountCodeId: string
): Promise<{ success: true } | { success: false; error: string }> {
const manager = await requireManager();
try {
if (!(await assertBusinessOwnership(businessId, manager.id))) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const code = await prisma.discountCode.findUnique({
where: { id: discountCodeId },
select: { businessId: true },
});
if (!code || code.businessId !== businessId) {
return { success: false, error: "Rabattcode nicht gefunden." };
}
await prisma.discountCode.update({
where: { id: discountCodeId },
data: { isActive: false },
});
revalidatePath("/settings/discounts");
return { success: true };
} catch (error) {
console.error("Error deactivating discount code:", error);
return { success: false, error: "Rabattcode konnte nicht deaktiviert werden." };
}
}
// Für den "spezifische Kurse"-Picker im Settings-UI. Es gibt keine bestehende
// "Kurse nach Business"-Abfrage (course-actions.ts filtert nach createdBy =
// Manager-ID, nicht businessId) - Rabattcodes sind aber businessId-scoped,
// daher eine eigene, schlanke Query statt getMyCourses() zu erweitern.
export async function getBusinessCoursesForDiscountPicker(
businessId: string
): Promise<
| { success: true; courses: Array<{ id: string; name: string }> }
| { success: false; error: string }
> {
const manager = await requireManager();
try {
if (!(await assertBusinessOwnership(businessId, manager.id))) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const courses = await prisma.course.findMany({
where: { businessId, isStandingOrder: false },
select: { id: true, name: true },
orderBy: { date: "desc" },
});
return { success: true, courses };
} catch (error) {
console.error("Error fetching business courses:", error);
return { success: false, error: "Kurse konnten nicht geladen werden." };
}
}

View file

@ -0,0 +1,154 @@
import type { prisma as prismaClient } from "@/src/lib/prisma";
// Erlaubt sowohl den globalen `prisma`-Client (Checkout-Route, Vorab-Prüfung)
// als auch den `tx`-Parameter aus prisma.$transaction() (Webhook, Source of
// Truth) - Prisma leitet den Transaktions-Client-Typ nicht separat exportierbar
// ab, daher hier über Parameters<> vom $transaction-Callback selbst.
type PrismaOrTx =
| typeof prismaClient
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
export type DiscountValidationResult =
| { valid: true; discountCode: { id: string; type: string; value: number } }
| { valid: false; reason: string };
// Wird an ZWEI Stellen aufgerufen: (1) beim Checkout-Start für sofortiges
// User-Feedback, (2) erneut INNERHALB der Serializable-Transaktion im
// Stripe-Webhook als Source of Truth. Muss deshalb rein lesend und ohne
// Seiteneffekte bleiben (keine Redemption/Increment hier) - das Verbuchen
// passiert ausschließlich in redeemDiscountCode() nach erfolgreicher Zahlung.
export async function validateDiscountCode(
db: PrismaOrTx,
params: { code: string; businessId: string; courseId: string; userId: string | null }
): Promise<DiscountValidationResult> {
const normalizedCode = params.code.trim().toUpperCase();
if (!normalizedCode) {
return { valid: false, reason: "Kein Rabattcode angegeben." };
}
const discountCode = await db.discountCode.findUnique({
where: { businessId_code: { businessId: params.businessId, code: normalizedCode } },
});
if (!discountCode || !discountCode.isActive) {
return { valid: false, reason: "Dieser Rabattcode ist ungültig." };
}
const now = new Date();
if (discountCode.startsAt && now < discountCode.startsAt) {
return { valid: false, reason: "Dieser Rabattcode ist noch nicht gültig." };
}
if (discountCode.expiresAt && now > discountCode.expiresAt) {
return { valid: false, reason: "Dieser Rabattcode ist abgelaufen." };
}
if (
discountCode.scope === "specific_courses" &&
!discountCode.courseIds.includes(params.courseId)
) {
return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." };
}
if (
discountCode.maxRedemptions !== null &&
discountCode.redeemedCount >= discountCode.maxRedemptions
) {
return { valid: false, reason: "Dieser Rabattcode wurde bereits vollständig eingelöst." };
}
// Rabattcodes sind bewusst nur eingeloggten Usern vorbehalten: Gäste haben
// keine stabile Identität (nur eine unverifizierte E-Mail), wodurch weder
// die 1x-pro-User-Regel noch das Gesamtkontingent zuverlässig durchsetzbar
// wären (siehe #182 - Gast-Redemptions wurden zunächst nicht mitgezählt).
if (!params.userId) {
return { valid: false, reason: "Rabattcodes sind nur für angemeldete Kunden verfügbar." };
}
const existingRedemption = await db.discountRedemption.findUnique({
where: { discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId } },
});
if (existingRedemption) {
return { valid: false, reason: "Du hast diesen Rabattcode bereits verwendet." };
}
return {
valid: true,
discountCode: { id: discountCode.id, type: discountCode.type, value: discountCode.value },
};
}
export function calculateDiscountedPriceCents(
originalPriceCents: number,
discount: { type: string; value: number }
): { discountedPriceCents: number; discountAmountCents: number } {
const rawDiscount =
discount.type === "percent"
? Math.round((originalPriceCents * discount.value) / 100)
: discount.value;
// Rabatt darf den Preis nicht unter 0 drücken (relevant v.a. bei "fixed" auf
// günstige Kurse) - Stripe lehnt unit_amount <= 0 in der Regel ohnehin ab.
const discountAmountCents = Math.min(rawDiscount, originalPriceCents);
const discountedPriceCents = originalPriceCents - discountAmountCents;
return { discountedPriceCents, discountAmountCents };
}
// Wird NUR innerhalb der Serializable-Transaktion im Webhook aufgerufen,
// nachdem die Zahlung bereits bestätigt ist und die CourseBooking-Zeile
// bereits existiert (bookingId erforderlich, siehe DiscountRedemption.bookingId
// @unique). Validiert den Code ein zweites Mal (Kontingent/Zeitfenster/
// 1x-pro-User können sich seit dem Checkout-Start geändert haben) und legt bei
// Erfolg atomar Redemption + Counter-Increment an. Wird der Code zwischenzeitlich
// ungültig, tut diese Funktion nichts (kein Fehler) - die Buchung wurde vom
// Aufrufer bereits unabhängig davon zum (schon berechneten) rabattierten Preis
// bestätigt, nur ohne verbuchte Redemption. Das ist bewusstes Verhalten.
export async function redeemDiscountCode(
tx: PrismaOrTx,
params: {
discountCodeId: string;
businessId: string;
courseId: string;
userId: string | null;
bookingId: string;
}
): Promise<boolean> {
if (!params.userId) return false; // Guests haben keine User-Redemption-Zuordnung, siehe validateDiscountCode
const discountCode = await tx.discountCode.findUnique({
where: { id: params.discountCodeId },
});
if (!discountCode || discountCode.businessId !== params.businessId) return false;
const now = new Date();
const stillValid =
discountCode.isActive &&
(!discountCode.startsAt || now >= discountCode.startsAt) &&
(!discountCode.expiresAt || now <= discountCode.expiresAt) &&
(discountCode.scope !== "specific_courses" ||
discountCode.courseIds.includes(params.courseId)) &&
(discountCode.maxRedemptions === null ||
discountCode.redeemedCount < discountCode.maxRedemptions);
if (!stillValid) return false;
const existingRedemption = await tx.discountRedemption.findUnique({
where: {
discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId },
},
});
if (existingRedemption) return false;
await tx.discountCode.update({
where: { id: discountCode.id },
data: { redeemedCount: { increment: 1 } },
});
await tx.discountRedemption.create({
data: {
discountCodeId: discountCode.id,
userId: params.userId,
bookingId: params.bookingId,
},
});
return true;
}

View file

@ -0,0 +1,47 @@
import { z } from "zod";
// type = "percent": value ist 1-100 (%). type = "fixed": value ist Cents (>0).
// Die Bounds hängen vom Typ ab, deshalb superRefine statt eines einzigen
// z.number()-Ranges.
export const discountCodeSchema = z
.object({
code: z
.string()
.trim()
.min(3, "Mindestens 3 Zeichen")
.max(30, "Maximal 30 Zeichen")
.regex(/^[A-Z0-9_-]+$/, "Nur Großbuchstaben, Zahlen, - und _ erlaubt"),
type: z.enum(["percent", "fixed"]),
value: z.number().int().positive(),
scope: z.enum(["all_courses", "specific_courses"]),
courseIds: z.array(z.string()).default([]),
maxRedemptions: z.number().int().positive().nullable(),
startsAt: z.coerce.date().nullable(),
expiresAt: z.coerce.date().nullable(),
isActive: z.boolean().default(true),
})
.superRefine((data, ctx) => {
if (data.type === "percent" && data.value > 100) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Prozentwert darf maximal 100 sein",
});
}
if (data.scope === "specific_courses" && data.courseIds.length === 0) {
ctx.addIssue({
code: "custom",
path: ["courseIds"],
message: "Mindestens ein Kurs muss ausgewählt werden",
});
}
if (data.startsAt && data.expiresAt && data.expiresAt <= data.startsAt) {
ctx.addIssue({
code: "custom",
path: ["expiresAt"],
message: "Ablaufdatum muss nach dem Startdatum liegen",
});
}
});
export type DiscountCodeInput = z.infer<typeof discountCodeSchema>;

View file

@ -0,0 +1,398 @@
"use client";
import { useMemo, useState } from "react";
import { Plus, Trash2, Tag, Search, Calendar, Percent, Coins, Layers } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown";
import type { DiscountCodeDTO } from "../../actions/discount-actions";
interface DiscountCodeListProps {
codes: DiscountCodeDTO[];
courses: { id: string; name: string }[];
onCreate: (input: {
code: string;
type: "percent" | "fixed";
value: number;
scope: "all_courses" | "specific_courses";
courseIds: string[];
maxRedemptions: number | null;
startsAt: Date | null;
expiresAt: Date | null;
isActive: boolean;
}) => Promise<boolean>;
onDeactivate: (discountCodeId: string) => Promise<boolean>;
isSaving: boolean;
}
const emptyForm = {
code: "",
type: "percent" as "percent" | "fixed",
value: 10,
scope: "all_courses" as "all_courses" | "specific_courses",
courseIds: [] as string[],
maxRedemptions: "" as string, // leer = unbegrenzt, siehe toInput()
startsAt: "" as string,
expiresAt: "" as string,
};
type StatusFilter = "all" | "active" | "inactive" | "expired";
function formatDate(date: Date | null): string {
if (!date) return "—";
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(date);
}
function isExpired(code: DiscountCodeDTO): boolean {
return !!code.expiresAt && new Date(code.expiresAt) < new Date();
}
// Rein visueller Ableitungs-Status fürs Filtern/Badges - ändert nichts an
// DiscountCode.isActive selbst (das bleibt der manuelle An/Aus-Schalter,
// siehe deactivateDiscountCode). "expired" hat Vorrang vor "active", weil ein
// abgelaufener Code trotz isActive=true nicht mehr einlösbar ist.
function getDisplayStatus(code: DiscountCodeDTO): "active" | "inactive" | "expired" {
if (!code.isActive) return "inactive";
if (isExpired(code)) return "expired";
return "active";
}
const STATUS_STYLES: Record<
"active" | "inactive" | "expired",
{ label: string; bg: string; text: string }
> = {
active: { label: "Aktiv", bg: "bg-green-100", text: "text-green-700" },
inactive: { label: "Deaktiviert", bg: "bg-gray-200", text: "text-gray-600" },
expired: { label: "Abgelaufen", bg: "bg-orange-100", text: "text-orange-700" },
};
const DiscountCodeList = ({
codes,
courses,
onCreate,
onDeactivate,
isSaving,
}: DiscountCodeListProps) => {
const [form, setForm] = useState(emptyForm);
const [isCreating, setIsCreating] = useState(false);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const courseOptions = courses.map((c) => ({ value: c.id, label: c.name }));
const courseNameById = useMemo(() => new Map(courses.map((c) => [c.id, c.name])), [courses]);
const filteredCodes = useMemo(() => {
const query = search.trim().toUpperCase();
return codes.filter((c) => {
if (query && !c.code.includes(query)) return false;
if (statusFilter !== "all" && getDisplayStatus(c) !== statusFilter) return false;
return true;
});
}, [codes, search, statusFilter]);
const handleCreate = async () => {
const success = await onCreate({
code: form.code,
type: form.type,
value: form.value,
scope: form.scope,
courseIds: form.courseIds,
maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
startsAt: form.startsAt ? new Date(form.startsAt) : null,
expiresAt: form.expiresAt ? new Date(form.expiresAt) : null,
isActive: true,
});
if (success) {
setForm(emptyForm);
setIsCreating(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{codes.length > 0 && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative sm:w-60">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none text-gray-400"
size={16}
/>
<Input
placeholder="Code suchen…"
className="pl-9 bg-white/60 backdrop-blur-xl border-white/40 placeholder:text-gray-400"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Status</SelectItem>
<SelectItem value="active">Aktiv</SelectItem>
<SelectItem value="inactive">Deaktiviert</SelectItem>
<SelectItem value="expired">Abgelaufen</SelectItem>
</SelectContent>
</Select>
</div>
)}
{!isCreating && (
<Button size="sm" onClick={() => setIsCreating(true)} className="sm:ml-auto shrink-0">
<Plus size={14} className="mr-1.5" />
Rabattcode erstellen
</Button>
)}
</div>
{isCreating && (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Code</label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
placeholder="z.B. SOMMER25"
/>
</div>
<div className="flex gap-3">
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">Typ</label>
<Select
value={form.type}
onValueChange={(v) => setForm((f) => ({ ...f, type: v as "percent" | "fixed" }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percent">Prozent</SelectItem>
<SelectItem value="fixed">Fixbetrag</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">
{form.type === "percent" ? "Rabatt in %" : "Rabatt in €"}
</label>
<Input
type="number"
min={form.type === "percent" ? 1 : 0.01}
max={form.type === "percent" ? 100 : undefined}
step={form.type === "percent" ? 1 : 0.01}
value={form.type === "percent" ? form.value : form.value / 100}
onChange={(e) => {
const raw = Number(e.target.value);
const cents = form.type === "percent" ? raw : Math.round(raw * 100);
setForm((f) => ({ ...f, value: Number.isFinite(cents) ? cents : 0 }));
}}
/>
</div>
</div>
<div className="flex gap-2 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-2">
<Button
type="button"
variant={form.scope === "all_courses" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
>
Alle Kurse
</Button>
<Button
type="button"
variant={form.scope === "specific_courses" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
>
Bestimmte Kurse
</Button>
</div>
{form.scope === "specific_courses" && (
<MultiSelectDropdown
label="Kurse"
selected={form.courseIds}
onSelect={(values) => setForm((f) => ({ ...f, courseIds: values }))}
options={courseOptions}
/>
)}
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">
Maximale Einlösungen (leer = unbegrenzt)
</label>
<Input
type="number"
min={1}
value={form.maxRedemptions}
onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))}
placeholder="z.B. 50"
/>
</div>
<div className="flex gap-3">
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">Gültig ab (optional)</label>
<Input
type="date"
value={form.startsAt}
onChange={(e) => setForm((f) => ({ ...f, startsAt: e.target.value }))}
/>
</div>
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">Gültig bis (optional)</label>
<Input
type="date"
value={form.expiresAt}
onChange={(e) => setForm((f) => ({ ...f, expiresAt: e.target.value }))}
/>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreate}
disabled={isSaving || form.code.trim().length < 3}
>
{isSaving ? "Speichert…" : "Erstellen"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsCreating(false);
setForm(emptyForm);
}}
>
Abbrechen
</Button>
</div>
</div>
)}
{codes.length === 0 ? (
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
Noch keine Rabattcodes angelegt.
</p>
) : filteredCodes.length === 0 ? (
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
Keine Rabattcodes gefunden.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filteredCodes.map((c) => {
const status = getDisplayStatus(c);
const statusStyle = STATUS_STYLES[status];
const redemptionPercent =
c.maxRedemptions !== null
? Math.min(100, Math.round((c.redeemedCount / c.maxRedemptions) * 100))
: null;
return (
<div
key={c.id}
className={`relative border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3 transition-opacity ${
status !== "active" ? "opacity-70" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 flex items-center justify-center size-9 rounded-lg bg-blue-50 text-blue-600">
<Tag size={16} />
</div>
<div className="min-w-0">
<p className="text-base font-bold text-gray-900 font-mono tracking-wide truncate">
{c.code}
</p>
<span
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full mt-0.5 ${statusStyle.bg} ${statusStyle.text}`}
>
{statusStyle.label}
</span>
</div>
</div>
{c.isActive && (
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
onClick={() => onDeactivate(c.id)}
disabled={isSaving}
title="Deaktivieren"
>
<Trash2 size={16} />
</Button>
)}
</div>
<div className="flex items-center gap-1.5 text-2xl font-bold text-gray-900">
{c.type === "percent" ? (
<Percent size={18} className="text-gray-400" />
) : (
<Coins size={18} className="text-gray-400" />
)}
{c.type === "percent" ? `${c.value}%` : `${(c.value / 100).toFixed(2)} €`}
<span className="text-xs font-normal text-gray-400 ml-1">Rabatt</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Layers size={13} className="shrink-0 text-gray-400" />
{c.scope === "all_courses" ? (
<span>Gilt für alle Kurse</span>
) : (
<span className="truncate">
Gilt für:{" "}
{c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")}
</span>
)}
</div>
{(c.startsAt || c.expiresAt) && (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Calendar size={13} className="shrink-0 text-gray-400" />
<span>
{formatDate(c.startsAt)} – {formatDate(c.expiresAt)}
</span>
</div>
)}
<div className="space-y-1 pt-1">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Eingelöst</span>
<span className="font-medium text-gray-700">
{c.redeemedCount}
{c.maxRedemptions !== null ? ` / ${c.maxRedemptions}` : " (unbegrenzt)"}
</span>
</div>
{redemptionPercent !== null && (
<div className="h-1.5 w-full rounded-full bg-gray-200 overflow-hidden">
<div
className={`h-full rounded-full ${redemptionPercent >= 100 ? "bg-orange-400" : "bg-blue-500"}`}
style={{ width: `${redemptionPercent}%` }}
/>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default DiscountCodeList;

View file

@ -1,5 +1,154 @@
const SettingsDiscountsPageView = () => {
return <div>SettingsDiscountsPageView</div>;
"use client";
import { useState, useTransition } from "react";
import { Tag, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import DiscountCodeList from "../components/DiscountCodeList";
import {
getDiscountCodes,
createDiscountCode,
deactivateDiscountCode,
getBusinessCoursesForDiscountPicker,
type DiscountCodeDTO,
} from "../../actions/discount-actions";
import type { DiscountCodeInput } from "../../schemas/discount-code-schema";
interface SettingsDiscountsPageViewProps {
businesses: { id: string; name: string }[];
initialCodes: DiscountCodeDTO[];
initialCourses: { id: string; name: string }[];
}
const SettingsDiscountsPageView = ({
businesses,
initialCodes,
initialCourses,
}: SettingsDiscountsPageViewProps) => {
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
const [codes, setCodes] = useState(initialCodes);
const [courses, setCourses] = useState(initialCourses);
// Analog zur Stornierungsseite: eine fehlgeschlagene Anfrage darf nicht als
// leere Codeliste dargestellt werden, sonst wirkt es so, als gäbe es für das
// neue Business einfach keine Codes statt eines Ladefehlers.
const [loadError, setLoadError] = useState<string | null>(null);
const [isLoading, startLoading] = useTransition();
const [isSaving, startSaving] = useTransition();
const handleBusinessChange = (businessId: string) => {
const previousBusinessId = selectedBusinessId;
setSelectedBusinessId(businessId);
setLoadError(null);
startLoading(async () => {
const [codesResult, coursesResult] = await Promise.all([
getDiscountCodes(businessId),
getBusinessCoursesForDiscountPicker(businessId),
]);
if (!codesResult.success) {
setSelectedBusinessId(previousBusinessId);
setLoadError(codesResult.error);
toast.error(codesResult.error);
return;
}
setCodes(codesResult.codes);
setCourses(coursesResult.success ? coursesResult.courses : []);
});
};
const handleCreate = (input: DiscountCodeInput): Promise<boolean> => {
return new Promise((resolve) => {
startSaving(async () => {
const result = await createDiscountCode(selectedBusinessId, input);
if (result.success) {
toast.success("Rabattcode erstellt.");
setCodes((prev) => [result.code, ...prev]);
resolve(true);
} else {
toast.error(result.error);
resolve(false);
}
});
});
};
const handleDeactivate = (discountCodeId: string): Promise<boolean> => {
return new Promise((resolve) => {
startSaving(async () => {
const result = await deactivateDiscountCode(selectedBusinessId, discountCodeId);
if (result.success) {
toast.success("Rabattcode deaktiviert.");
setCodes((prev) =>
prev.map((c) => (c.id === discountCodeId ? { ...c, isActive: false } : c))
);
resolve(true);
} else {
toast.error(result.error);
resolve(false);
}
});
});
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl flex items-center gap-2 font-bold">
<Tag /> Rabattcodes
</h1>
<p className="text-sm text-gray-500 mt-1">
Erstelle Rabattcodes für deine Kurse — prozentual oder als Fixbetrag, mit optionalem
Nutzungslimit und Gültigkeitszeitraum.
</p>
</div>
{businesses.length === 0 ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
</div>
) : (
<div className="space-y-4">
{businesses.length > 1 && (
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
<SelectValue placeholder="Business" />
</SelectTrigger>
<SelectContent>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{loadError ? (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{loadError} Bitte lade die Seite neu, um die Rabattcodes zu bearbeiten.</p>
</div>
) : (
<div className={isLoading ? "opacity-60 transition-opacity" : "transition-opacity"}>
<DiscountCodeList
key={selectedBusinessId}
codes={codes}
courses={courses}
onCreate={handleCreate}
onDeactivate={handleDeactivate}
isSaving={isSaving}
/>
</div>
)}
</div>
)}
</div>
);
};
export default SettingsDiscountsPageView;