mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
608 lines
20 KiB
TypeScript
608 lines
20 KiB
TypeScript
/**
|
|
* Booking Actions - Server-seitige Aktionen für Kursbuchungen
|
|
*
|
|
* Diese Datei enthält alle Server Actions für Kursbuchungen.
|
|
* Funktionen umfassen: Kurs buchen, Buchungen abrufen und Status prüfen.
|
|
*/
|
|
|
|
"use server";
|
|
|
|
import { prisma } from "@/src/lib/prisma";
|
|
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
|
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
|
|
import { executeBookingCancellation } from "./cancel-booking-shared";
|
|
import type { SeatSelection } from "../lib/seat-cancellation";
|
|
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";
|
|
import { bookableSpots, parseFloorPlan, type FloorPlan } from "@/src/modules/rooms/lib/floor-plan";
|
|
import { getBookedSpotIds, getHeldSpotIds, spotHolderKey } from "../lib/course-spots";
|
|
import { MAX_SEATS_PER_BOOKING, paidSeatCounts } from "../lib/seat-counts";
|
|
import {
|
|
findUsablePassesForCourse,
|
|
type CustomerPassView,
|
|
} from "@/src/modules/products/lib/pass-queries";
|
|
import { hasMarketingConsent } from "@/src/modules/marketing/lib/consent";
|
|
import { consentLabel } from "@/src/modules/marketing/lib/consent-text";
|
|
|
|
export type UserBooking = {
|
|
bookingId: string;
|
|
courseId: string;
|
|
courseName: string;
|
|
date: string; // ISO
|
|
timeFrom: string;
|
|
timeTo: string;
|
|
roomName: string;
|
|
level: string;
|
|
price: number;
|
|
maxParticipants: number;
|
|
currentParticipants: number;
|
|
coverImage: string | null;
|
|
description: string;
|
|
trainerProfiles: {
|
|
id: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
pbSrc: string | null;
|
|
}[];
|
|
trainers: string[];
|
|
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
|
paymentStatus: string;
|
|
/** Mit einer Karte gebucht statt bezahlt. */
|
|
paidWithPass: boolean;
|
|
/** Plätze dieser Buchung - mehr als 1, wenn für Begleitpersonen mitgebucht wurde. */
|
|
seats: number;
|
|
/** Gebuchte Plätze bei Kursen mit Platzwahl, z.B. ["Reformer 3", "Reformer 4"]. */
|
|
spotLabels: string[];
|
|
isPast: boolean;
|
|
businessName: string;
|
|
/** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */
|
|
businessAddress: string | null;
|
|
businessEmail: string | null;
|
|
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
|
|
};
|
|
|
|
export async function getUserBookings(): Promise<
|
|
{ success: true; bookings: UserBooking[] } | { success: false; error: string }
|
|
> {
|
|
const userData = await getUserData();
|
|
if (!userData || !isUser(userData)) {
|
|
return { success: false, error: "Nicht angemeldet." };
|
|
}
|
|
|
|
const userId = userData.id;
|
|
|
|
const fetchBookings = unstable_cache(
|
|
async () => {
|
|
const bookingsRaw = await prisma.courseBooking.findMany({
|
|
where: { userId },
|
|
select: {
|
|
id: true,
|
|
paymentStatus: true,
|
|
passId: true,
|
|
seats: true,
|
|
spotLabels: true,
|
|
course: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
date: true,
|
|
timeFrom: true,
|
|
timeTo: true,
|
|
room: true,
|
|
trainers: true,
|
|
level: true,
|
|
price: true,
|
|
maxParticipants: true,
|
|
coverImage: true,
|
|
description: true,
|
|
business: {
|
|
select: { name: true, address: true, email: true, branding: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
|
|
const seatCounts = await paidSeatCounts(
|
|
prisma,
|
|
bookingsRaw.map((b) => b.course.id)
|
|
);
|
|
|
|
// Collect all room and trainer IDs
|
|
const roomIds = [
|
|
...new Set(bookingsRaw.map((b) => b.course.room).filter(Boolean)),
|
|
] as string[];
|
|
const allTrainerIds = new Set<string>();
|
|
for (const b of bookingsRaw) {
|
|
for (const tid of b.course.trainers) allTrainerIds.add(tid);
|
|
}
|
|
|
|
const [rooms, trainersData] = await Promise.all([
|
|
roomIds.length > 0
|
|
? prisma.room.findMany({
|
|
where: { id: { in: roomIds } },
|
|
select: { id: true, name: true },
|
|
})
|
|
: [],
|
|
allTrainerIds.size > 0
|
|
? prisma.employee.findMany({
|
|
where: { id: { in: Array.from(allTrainerIds) } },
|
|
select: { id: true, firstName: true, lastName: true, pbSrc: true },
|
|
})
|
|
: [],
|
|
]);
|
|
|
|
const roomsMap: Record<string, string> = {};
|
|
for (const r of rooms) roomsMap[r.id] = r.name;
|
|
|
|
const trainersMap: Record<string, { label: string; pbSrc?: string }> = {};
|
|
const trainerProfilesMap: Record<
|
|
string,
|
|
{ id: string; firstName: string | null; lastName: string | null; pbSrc: string | null }
|
|
> = {};
|
|
for (const t of trainersData) {
|
|
const label = [t.firstName, t.lastName].filter(Boolean).join(" ") || "Trainer";
|
|
trainersMap[t.id] = { label, pbSrc: t.pbSrc ?? undefined };
|
|
trainerProfilesMap[t.id] = {
|
|
id: t.id,
|
|
firstName: t.firstName,
|
|
lastName: t.lastName,
|
|
pbSrc: t.pbSrc,
|
|
};
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
return bookingsRaw.map((b) => {
|
|
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
|
|
const branding = b.course.business ? toEmailBranding(b.course.business) : KUMASY_BRANDING;
|
|
return {
|
|
bookingId: b.id,
|
|
courseId: b.course.id,
|
|
courseName: b.course.name,
|
|
date: b.course.date.toISOString(),
|
|
timeFrom: b.course.timeFrom,
|
|
timeTo: b.course.timeTo,
|
|
roomName: roomsMap[b.course.room] ?? b.course.room,
|
|
level: b.course.level,
|
|
price: b.course.price,
|
|
maxParticipants: b.course.maxParticipants,
|
|
currentParticipants: seatCounts.get(b.course.id) ?? 0,
|
|
coverImage: b.course.coverImage ?? null,
|
|
description: b.course.description,
|
|
trainerProfiles: b.course.trainers.map((tid) => trainerProfilesMap[tid]).filter(Boolean),
|
|
trainers: b.course.trainers,
|
|
trainersMap,
|
|
paymentStatus: b.paymentStatus,
|
|
paidWithPass: b.passId !== null,
|
|
seats: b.seats,
|
|
spotLabels: b.spotLabels,
|
|
isPast: courseStart < now,
|
|
businessName: b.course.business?.name ?? branding.companyName,
|
|
businessAddress: b.course.business?.address ?? null,
|
|
businessEmail: b.course.business?.email ?? null,
|
|
businessBranding: {
|
|
logoUrl: branding.logoUrl,
|
|
accentColor: branding.accentColor,
|
|
accentTextColor: branding.accentTextColor,
|
|
},
|
|
} satisfies UserBooking;
|
|
});
|
|
},
|
|
[`user-bookings-${userId}`],
|
|
{ tags: [`user-bookings-${userId}`], revalidate: 60 }
|
|
);
|
|
|
|
try {
|
|
const bookings = await fetchBookings();
|
|
return { success: true, bookings };
|
|
} catch {
|
|
return { success: false, error: "Buchungen konnten nicht geladen werden." };
|
|
}
|
|
}
|
|
|
|
export async function cancelUserBooking(
|
|
bookingId: string,
|
|
seatSelection?: SeatSelection
|
|
): Promise<{ success: true } | { success: false; error: string }> {
|
|
const userData = await getUserData();
|
|
if (!userData || !isUser(userData)) {
|
|
return { success: false, error: "Nicht angemeldet." };
|
|
}
|
|
|
|
try {
|
|
const booking = await prisma.courseBooking.findUnique({
|
|
where: { id: bookingId },
|
|
select: {
|
|
userId: true,
|
|
course: { select: { businessId: true } },
|
|
},
|
|
});
|
|
|
|
if (!booking || booking.userId !== userData.id) {
|
|
return { success: false, error: "Buchung nicht gefunden." };
|
|
}
|
|
|
|
const result = await executeBookingCancellation(bookingId, "user", { seatSelection });
|
|
if (!result.success) return result;
|
|
|
|
revalidatePath("/courses/myCourses");
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(revalidateTag as any)(`business-courses-${booking.course.businessId}`);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(revalidateTag as any)(`user-bookings-${userData.id}`);
|
|
return { success: true };
|
|
} catch {
|
|
return { success: false, error: "Stornierung fehlgeschlagen." };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Überprüft, ob ein User bereits für einen Kurs gebucht hat
|
|
*
|
|
* @param courseId - Die eindeutige ID des Kurses
|
|
* @returns Ein Objekt mit success-Flag und isBooked Status
|
|
*/
|
|
export async function checkUserBookingStatus(courseId: string) {
|
|
try {
|
|
const userData = await getUserData();
|
|
|
|
// isLoggedIn sagt dem Buchungsdialog, ob er Gästen gleich das Gast-
|
|
// Formular zeigen soll, statt sie erst einen Buchungsversuch scheitern
|
|
// zu lassen.
|
|
if (!userData) {
|
|
return {
|
|
success: true,
|
|
isLoggedIn: false,
|
|
isBooked: false,
|
|
isOnWaitlist: false,
|
|
};
|
|
}
|
|
|
|
// Alle Checks im selben Aufruf statt separater Wartelisten-/Karten-Calls,
|
|
// damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht.
|
|
const customerCourse = isUser(userData)
|
|
? await prisma.course.findUnique({
|
|
where: { id: courseId },
|
|
select: { businessId: true, sport: true, business: { select: { name: true } } },
|
|
})
|
|
: null;
|
|
|
|
const [booking, waitlistEntry, passes, consentGranted] = await Promise.all([
|
|
prisma.courseBooking.findUnique({
|
|
where: { courseId_userId: { courseId, userId: userData.id } },
|
|
}),
|
|
prisma.courseWaitlistEntry.findUnique({
|
|
where: { courseId_userId: { courseId, userId: userData.id } },
|
|
}),
|
|
// Karten, mit denen der Kunde diesen Kurs ohne Bezahlen buchen kann.
|
|
customerCourse && isUser(userData)
|
|
? findUsablePassesForCourse(userData, customerCourse)
|
|
: Promise.resolve([] as CustomerPassView[]),
|
|
customerCourse?.businessId
|
|
? hasMarketingConsent(userData.id, customerCourse.businessId)
|
|
: Promise.resolve(true),
|
|
]);
|
|
|
|
return {
|
|
success: true,
|
|
isLoggedIn: true,
|
|
// Nur Kunden-Accounts dürfen buchen - und nur sie bekommen den Platz-Zähler.
|
|
canBookMultiple: isUser(userData),
|
|
isBooked: booking?.paymentStatus === "paid",
|
|
bookedSeats: booking?.paymentStatus === "paid" ? booking.seats : 0,
|
|
isOnWaitlist: waitlistEntry !== null,
|
|
passes,
|
|
// Häkchen für Werbe-Mails des Studios - nur solange der Kunde noch nicht
|
|
// zugestimmt hat (Opt-in, standardmäßig leer).
|
|
marketingConsentLabel:
|
|
customerCourse?.business && !consentGranted
|
|
? consentLabel(customerCourse.business.name)
|
|
: null,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error checking booking status:", error);
|
|
return {
|
|
success: false,
|
|
error: "Fehler beim Prüfen des Buchungsstatus",
|
|
// Unbekannt - der Dialog fällt dann auf den alten Weg zurück und zeigt
|
|
// das Gast-Formular erst, wenn die Buchung "nicht angemeldet" meldet.
|
|
isLoggedIn: null,
|
|
isBooked: false,
|
|
isOnWaitlist: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lädt eine kompakte Kurs-Zusammenfassung inkl. Business-Slug.
|
|
* Wird für die Success-Page (Kursdetails anzeigen) und für den Redirect
|
|
* der alten Buchungs-View auf die öffentliche Business-Seite genutzt.
|
|
*
|
|
* @param courseId - Die eindeutige ID des Kurses
|
|
*/
|
|
export async function getCourseSummaryForConfirmation(courseId: string, sessionId?: string) {
|
|
try {
|
|
const course = await prisma.course.findFirst({
|
|
where: {
|
|
id: courseId,
|
|
business: { isPublic: true },
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
sport: true,
|
|
date: true,
|
|
timeFrom: true,
|
|
timeTo: true,
|
|
price: true,
|
|
room: true,
|
|
business: {
|
|
select: {
|
|
name: true,
|
|
slug: true,
|
|
address: true,
|
|
branding: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!course) {
|
|
return { success: false as const, error: "Kurs nicht gefunden" };
|
|
}
|
|
|
|
// 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;
|
|
let seats = 1;
|
|
let spotLabels: string[] = [];
|
|
if (sessionId) {
|
|
// findFirst statt findUnique mit stripeSessionId allein: eine URL, die
|
|
// courseId von Kurs B mit der session_id von Kurs A kombiniert, würde
|
|
// sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen.
|
|
const booking = await prisma.courseBooking.findFirst({
|
|
where: { stripeSessionId: sessionId, courseId },
|
|
select: { amountPaid: true, discountAmount: true, seats: true, spotLabels: true },
|
|
});
|
|
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
|
|
paidPrice = booking.amountPaid / 100;
|
|
discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null;
|
|
}
|
|
seats = booking?.seats ?? 1;
|
|
spotLabels = booking?.spotLabels ?? [];
|
|
}
|
|
|
|
return { success: true as const, course, paidPrice, discountAmount, seats, spotLabels };
|
|
} catch (error) {
|
|
console.error("Error fetching course summary:", error);
|
|
return { success: false as const, error: "Fehler beim Laden des Kurses" };
|
|
}
|
|
}
|
|
|
|
export type CourseSpotMap = {
|
|
floorPlan: FloorPlan;
|
|
/** Bezahlt gebucht oder gerade von jemand anderem im Checkout gehalten. */
|
|
takenSpotIds: string[];
|
|
/** Plätze der eigenen bezahlten Buchung des eingeloggten Kunden. */
|
|
mySpotIds: string[];
|
|
};
|
|
|
|
/**
|
|
* Grundriss und Live-Belegung für die Platzwahl im Buchungsdialog. Bewusst
|
|
* ungecacht (die Kursliste ist 30 s gecacht) - wer den Dialog öffnet, soll
|
|
* keinen Platz wählen, der seit Sekunden weg ist. null = der Kurs wird ohne
|
|
* Platzwahl gebucht. Verrät nicht, WER auf einem Platz liegt.
|
|
*/
|
|
export async function getCourseSpotMap(courseId: string): Promise<CourseSpotMap | null> {
|
|
try {
|
|
const course = await prisma.course.findFirst({
|
|
where: { id: courseId, status: "published", business: { isPublic: true } },
|
|
select: { spotBooking: true, room: true },
|
|
});
|
|
if (!course?.spotBooking || !course.room) return null;
|
|
|
|
const room = await prisma.room.findUnique({
|
|
where: { id: course.room },
|
|
select: { floorPlan: true },
|
|
});
|
|
const floorPlan = parseFloorPlan(room?.floorPlan);
|
|
if (!floorPlan || bookableSpots(floorPlan).length === 0) return null;
|
|
|
|
const userData = await getUserData();
|
|
const userId = userData && isUser(userData) ? userData.id : null;
|
|
|
|
const [booked, held, myBooking] = await Promise.all([
|
|
getBookedSpotIds(prisma, courseId),
|
|
getHeldSpotIds(
|
|
prisma,
|
|
courseId,
|
|
userId ? { exceptHolderKey: spotHolderKey({ userId }) } : {}
|
|
),
|
|
userId
|
|
? prisma.courseBooking.findFirst({
|
|
where: { courseId, userId, paymentStatus: "paid" },
|
|
select: { spotIds: true },
|
|
})
|
|
: null,
|
|
]);
|
|
|
|
const mySpotIds = myBooking?.spotIds ?? [];
|
|
const takenSpotIds = [...new Set([...booked, ...held])].filter((id) => !mySpotIds.includes(id));
|
|
return { floorPlan, takenSpotIds, mySpotIds };
|
|
} catch (error) {
|
|
console.error("Error fetching course spot map:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getBusinessBySlug(slug: string) {
|
|
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,
|
|
branding: true,
|
|
},
|
|
});
|
|
|
|
if (!business || !business.isPublic) {
|
|
return { success: false as const, error: "Seite nicht gefunden" };
|
|
}
|
|
|
|
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: {
|
|
businessId,
|
|
status: "published",
|
|
date: { gte: options.from, lte: options.to },
|
|
},
|
|
orderBy: { date: "asc" },
|
|
});
|
|
const seatCounts = await paidSeatCounts(
|
|
prisma,
|
|
courses.map((c) => c.id)
|
|
);
|
|
|
|
const trainerIds = [...new Set(courses.flatMap((c) => c.trainers))];
|
|
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))] as string[];
|
|
|
|
const [trainers, rooms] = await Promise.all([
|
|
trainerIds.length > 0
|
|
? prisma.employee.findMany({
|
|
where: { id: { in: trainerIds } },
|
|
select: { id: true, firstName: true, lastName: true, pbSrc: true },
|
|
})
|
|
: Promise.resolve([]),
|
|
roomIds.length > 0
|
|
? 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 {
|
|
success: true,
|
|
courses: courses.map((c) => ({
|
|
...c,
|
|
currentParticipants: seatCounts.get(c.id) ?? 0,
|
|
roomName: c.room ? (roomMap[c.room] ?? null) : null,
|
|
trainerProfiles: c.trainers.map((id) => trainerMap[id]).filter(Boolean) as {
|
|
id: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
pbSrc: string | null;
|
|
}[],
|
|
})),
|
|
};
|
|
} catch (error) {
|
|
console.error("Error fetching courses for business:", error);
|
|
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.
|
|
// Rechnet wie der Checkout auf den Gesamtpreis aller Plätze: discountedPrice
|
|
// ist der Kurspreis für `seats` Plätze nach Rabatt.
|
|
export async function previewDiscountCode(
|
|
courseId: string,
|
|
code: string,
|
|
seats = 1
|
|
): 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 seatCount = Number.isInteger(seats)
|
|
? Math.min(Math.max(seats, 1), MAX_SEATS_PER_BOOKING)
|
|
: 1;
|
|
const { discountedPriceCents, discountAmountCents } = calculateDiscountedPriceCents(
|
|
eurosToCents(course.price) * seatCount,
|
|
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." };
|
|
}
|
|
}
|