This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export function SignOutButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-ink/30 px-3 py-1 font-semibold"
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
router.replace("/admin/anmelden");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { confirmReviewAction, cancelBookingAction, refundAction, resendConfirmationAction } from "@/app/admin/actions";
|
||||
import { Flash, KeyValue, PageTitle, Panel } from "@/components/admin/AdminUi";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { BOOKING_STATUS_LABEL, INSTALLMENT_STATUS_LABEL, PAYMENT_STATUS_LABEL, REDEMPTION_STATUS_LABEL, RESERVATION_STATUS_LABEL } from "@/lib/labels";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
import { PLAN_LABEL } from "@/lib/payment-plan";
|
||||
import { PHASE_LABEL } from "@/lib/pricing";
|
||||
import { formatDate, formatDateTime } from "@/lib/time";
|
||||
|
||||
export const metadata = { title: "Buchung" };
|
||||
|
||||
export default async function BookingDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ ok?: string; fehler?: string }>;
|
||||
}) {
|
||||
await connection();
|
||||
const { id } = await params;
|
||||
const flash = await searchParams;
|
||||
const db = getDb();
|
||||
const booking = await db.booking.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
installments: { orderBy: { seq: "asc" }, include: { refunds: true } },
|
||||
reservation: true,
|
||||
redemption: true,
|
||||
voucher: true,
|
||||
emails: { orderBy: { createdAt: "desc" }, take: 20 },
|
||||
},
|
||||
});
|
||||
if (!booking) notFound();
|
||||
const log = await db.auditLog.findMany({
|
||||
where: { OR: [{ entityType: "Booking", entityId: id }, { entityType: "Installment", entityId: { in: booking.installments.map((i) => i.id) } }] },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link href="/admin/buchungen" className="text-sm underline">Alle Buchungen</Link>
|
||||
<PageTitle>{booking.reference}</PageTitle>
|
||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||
{booking.reviewReason && booking.status === "NEEDS_REVIEW" && (
|
||||
<p className="mb-6 rounded-lg border border-rosso/40 p-4">
|
||||
<strong>Prüfung nötig:</strong> {booking.reviewReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
<Panel title="Teilnehmerin">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Name", `${booking.firstName} ${booking.lastName}`],
|
||||
["E-Mail", booking.email],
|
||||
["Telefon", booking.phone],
|
||||
["Land", booking.country],
|
||||
["Anmerkungen", booking.notes ?? "–"],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Status">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Modus", booking.livemode ? "Live" : "Test"],
|
||||
["Buchung", BOOKING_STATUS_LABEL[booking.status]],
|
||||
["Zahlung", PAYMENT_STATUS_LABEL[booking.paymentStatus]],
|
||||
["Platz", booking.reservation ? RESERVATION_STATUS_LABEL[booking.reservation.status] : "–"],
|
||||
["Gutschein", booking.voucher ? `${booking.voucher.code} (${booking.redemption ? REDEMPTION_STATUS_LABEL[booking.redemption.status] : "–"})` : "–"],
|
||||
["Angelegt", formatDateTime(booking.createdAt)],
|
||||
["Bestätigt", booking.confirmedAt ? formatDateTime(booking.confirmedAt) : "–"],
|
||||
["Storniert", booking.canceledAt ? `${formatDateTime(booking.canceledAt)}: ${booking.cancelReason ?? ""}` : "–"],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Preis-Snapshot (unveränderlich)">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Phase", PHASE_LABEL[booking.pricePhase]],
|
||||
["Grundpreis", formatEuro(booking.basePriceCents)],
|
||||
["Early Bird", `−${formatEuro(booking.phaseDiscountCents)}`],
|
||||
["Gutschein", `−${formatEuro(booking.voucherDiscountCents)}`],
|
||||
["Gesamt", formatEuro(booking.totalCents)],
|
||||
["Plan", PLAN_LABEL[booking.planType]],
|
||||
["Bezahlt", formatEuro(booking.paidCents)],
|
||||
["Erstattet", formatEuro(booking.refundedCents)],
|
||||
["Bedingungen", booking.termsVersion],
|
||||
["Preis gebunden bis", formatDateTime(booking.priceLockedUntil)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 text-xl font-semibold">Raten und Erstattungen</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[48rem] text-left">
|
||||
<thead className="text-sm text-stone">
|
||||
<tr>
|
||||
<th className="py-2 font-normal">Rate</th>
|
||||
<th className="font-normal">Fällig</th>
|
||||
<th className="text-right font-normal">Betrag</th>
|
||||
<th className="pl-4 font-normal">Status</th>
|
||||
<th className="font-normal">Versuche</th>
|
||||
<th className="font-normal">Stripe</th>
|
||||
<th className="font-normal">Erstattung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{booking.installments.map((i) => (
|
||||
<tr key={i.id} className="border-t border-line align-top">
|
||||
<td className="py-2">{i.seq}</td>
|
||||
<td>{i.dueAt ? formatDate(i.dueAt) : "bei Buchung"}</td>
|
||||
<td className="text-right tabular-nums">{formatEuro(i.amountCents)}</td>
|
||||
<td className="pl-4">
|
||||
{INSTALLMENT_STATUS_LABEL[i.status]}
|
||||
{i.failureCode && <span className="block text-xs text-stone">{i.failureCode}</span>}
|
||||
{i.nextAttemptAt && <span className="block text-xs text-stone">nächster Versuch {formatDateTime(i.nextAttemptAt)}</span>}
|
||||
</td>
|
||||
<td>{i.attemptCount}</td>
|
||||
<td className="break-all text-xs">{i.stripePaymentIntentId ?? "–"}</td>
|
||||
<td className="text-sm">
|
||||
{i.refunds.map((r) => (
|
||||
<span key={r.id} className="block">{formatEuro(r.amountCents)} ({r.status})</span>
|
||||
))}
|
||||
{i.status === "PAID" && i.stripePaymentIntentId && (
|
||||
<form action={refundAction} className="mt-1 flex flex-wrap gap-2">
|
||||
<input type="hidden" name="bookingId" value={booking.id} />
|
||||
<input type="hidden" name="installmentId" value={i.id} />
|
||||
<input type="hidden" name="requestKey" value={crypto.randomUUID()} />
|
||||
<label className="sr-only" htmlFor={`amount-${i.id}`}>Betrag in Euro</label>
|
||||
<input id={`amount-${i.id}`} name="amount" inputMode="decimal" placeholder="Betrag €" className="field min-h-9 w-28 py-1" required />
|
||||
<label className="sr-only" htmlFor={`reason-${i.id}`}>Grund</label>
|
||||
<input id={`reason-${i.id}`} name="reason" placeholder="Grund" className="field min-h-9 w-40 py-1" />
|
||||
<button type="submit" className="btn btn-quiet min-h-9 px-3 py-1 text-sm">Erstatten</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-3">
|
||||
{booking.status === "NEEDS_REVIEW" && (
|
||||
<Panel title="Prüfung abschließen">
|
||||
<form action={confirmReviewAction}>
|
||||
<input type="hidden" name="bookingId" value={booking.id} />
|
||||
<p className="mb-3 text-sm text-stone">Bestätigt die Buchung, wenn ein Platz frei ist. Andernfalls stornieren und erstatten.</p>
|
||||
<button type="submit" className="btn btn-primary">Buchung bestätigen</button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
{booking.status !== "CANCELED" && booking.status !== "EXPIRED" && (
|
||||
<Panel title="Stornieren">
|
||||
<form action={cancelBookingAction} className="space-y-3">
|
||||
<input type="hidden" name="bookingId" value={booking.id} />
|
||||
<label className="field-label" htmlFor="cancel-reason">Grund (Pflicht)</label>
|
||||
<input id="cancel-reason" name="reason" className="field" required />
|
||||
<p className="text-sm text-stone">
|
||||
Beendet alle offenen Raten, gibt Platz und Gutschein frei und trennt die gespeicherte Zahlungsmethode. Erstattungen werden danach gemäß den
|
||||
freigegebenen Stornobedingungen oben je Rate ausgelöst.
|
||||
</p>
|
||||
<button type="submit" className="btn btn-quiet">Buchung stornieren</button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
{booking.status === "CONFIRMED" && (
|
||||
<Panel title="E-Mail">
|
||||
<form action={resendConfirmationAction}>
|
||||
<input type="hidden" name="bookingId" value={booking.id} />
|
||||
<button type="submit" className="btn btn-quiet">Bestätigung erneut senden</button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-2">
|
||||
<Panel title="Protokoll">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{log.map((l) => (
|
||||
<li key={l.id}>
|
||||
<span className="text-stone">{formatDateTime(l.createdAt)}</span> {l.action} <span className="text-stone">({l.actorType})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
<Panel title="E-Mails">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{booking.emails.map((m) => (
|
||||
<li key={m.id}>
|
||||
<span className="text-stone">{formatDateTime(m.createdAt)}</span> {m.subject} – {m.status === "SENT" ? "gesendet" : m.status === "FAILED" ? "fehlgeschlagen" : "wartet"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { connection } from "next/server";
|
||||
import { PageTitle } from "@/components/admin/AdminUi";
|
||||
import { BOOKING_STATUSES, bookingWhere, filterQuery, parseBookingFilter, PAYMENT_STATUSES } from "@/lib/admin-queries";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { BOOKING_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "@/lib/labels";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
import { PLAN_LABEL } from "@/lib/payment-plan";
|
||||
import { formatDateTime } from "@/lib/time";
|
||||
|
||||
export const metadata = { title: "Buchungen" };
|
||||
|
||||
export default async function BookingsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
await connection();
|
||||
const filter = parseBookingFilter(await searchParams, stripeModeOrNull() === "live" ? "live" : "test");
|
||||
const bookings = await getDb().booking.findMany({
|
||||
where: bookingWhere(filter),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 500,
|
||||
include: { voucher: { select: { code: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle action={<a href={`/admin/export?${filterQuery(filter)}`} className="btn btn-quiet">CSV exportieren</a>}>Buchungen</PageTitle>
|
||||
<form className="mb-6 grid gap-3 rounded-xl border border-line p-4 sm:grid-cols-5" method="get">
|
||||
<label className="text-sm">
|
||||
Modus
|
||||
<select name="modus" defaultValue={filter.modus} className="field mt-1">
|
||||
<option value="test">Test</option>
|
||||
<option value="live">Live</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
Buchung
|
||||
<select name="status" defaultValue={filter.status ?? ""} className="field mt-1">
|
||||
<option value="">alle</option>
|
||||
{BOOKING_STATUSES.map((s) => <option key={s} value={s}>{BOOKING_STATUS_LABEL[s]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
Zahlung
|
||||
<select name="zahlung" defaultValue={filter.zahlung ?? ""} className="field mt-1">
|
||||
<option value="">alle</option>
|
||||
{PAYMENT_STATUSES.map((s) => <option key={s} value={s}>{PAYMENT_STATUS_LABEL[s]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
Suche
|
||||
<input name="q" defaultValue={filter.q ?? ""} placeholder="Nummer, Name, E-Mail" className="field mt-1" />
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary self-end">Filtern</button>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[56rem] text-left">
|
||||
<thead className="text-sm text-stone">
|
||||
<tr>
|
||||
<th className="py-2 font-normal">Nummer</th>
|
||||
<th className="font-normal">Name</th>
|
||||
<th className="font-normal">Buchung</th>
|
||||
<th className="font-normal">Zahlung</th>
|
||||
<th className="font-normal">Plan</th>
|
||||
<th className="text-right font-normal">Gesamt</th>
|
||||
<th className="text-right font-normal">Offen</th>
|
||||
<th className="pl-4 font-normal">Angelegt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bookings.map((b) => (
|
||||
<tr key={b.id} className="border-t border-line">
|
||||
<td className="py-2"><Link href={`/admin/buchungen/${b.id}`} className="font-semibold underline">{b.reference}</Link></td>
|
||||
<td>{b.firstName} {b.lastName}{b.voucher && <span className="ml-2 text-xs text-stone">Gutschein</span>}</td>
|
||||
<td>{BOOKING_STATUS_LABEL[b.status]}</td>
|
||||
<td>{PAYMENT_STATUS_LABEL[b.paymentStatus]}</td>
|
||||
<td>{PLAN_LABEL[b.planType]}</td>
|
||||
<td className="text-right tabular-nums">{formatEuro(b.totalCents)}</td>
|
||||
<td className="text-right tabular-nums">{formatEuro(Math.max(0, b.totalCents - b.paidCents))}</td>
|
||||
<td className="pl-4 text-sm">{formatDateTime(b.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{bookings.length === 0 && <p className="py-6 text-stone">Keine Buchungen für diesen Filter.</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { connection } from "next/server";
|
||||
import { updateCampAction } from "@/app/admin/actions";
|
||||
import { Checkbox, Field, Flash, PageTitle, Panel } from "@/components/admin/AdminUi";
|
||||
import { getCamp } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { dbDateToIso } from "@/lib/time";
|
||||
|
||||
export const metadata = { title: "Einstellungen und Freigaben" };
|
||||
|
||||
export default async function SettingsPage({ searchParams }: { searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||
await connection();
|
||||
const flash = await searchParams;
|
||||
const camp = await getCamp(getDb());
|
||||
const mode = stripeModeOrNull();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>Einstellungen und Freigaben</PageTitle>
|
||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||
<p className="mb-6 max-w-3xl text-stone">
|
||||
Der Live-Verkauf öffnet erst, wenn „Live-Verkauf einschalten“ gesetzt ist UND alle Bestätigungen vorliegen. Setze eine Bestätigung nur, wenn die
|
||||
Angabe verbindlich geprüft ist. Jede Änderung wird protokolliert.
|
||||
</p>
|
||||
<form action={updateCampAction} className="grid gap-5 lg:grid-cols-2">
|
||||
<Panel title="Termin und Ort">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="campStartDate" label="Anreisetag" type="date" defaultValue={camp.campStartDate ? dbDateToIso(camp.campStartDate) : ""} />
|
||||
<Field name="campEndDate" label="Abreisetag" type="date" defaultValue={camp.campEndDate ? dbDateToIso(camp.campEndDate) : ""} />
|
||||
</div>
|
||||
<Checkbox name="dateConfirmed" label="Termin bestätigt" defaultChecked={camp.dateConfirmed} hint="Erst dann wird der Termin öffentlich angezeigt." />
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-2">
|
||||
<Field name="locationName" label="Ort" defaultValue={camp.locationName} />
|
||||
<Field name="locationRegion" label="Region" defaultValue={camp.locationRegion} />
|
||||
</div>
|
||||
<Checkbox name="locationConfirmed" label="Ort bestätigt" defaultChecked={camp.locationConfirmed} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="Unterkunft und Leistungen">
|
||||
<Field
|
||||
name="accommodationText"
|
||||
label="Verbindliche Zimmer- und Bettbeschreibung"
|
||||
defaultValue={camp.accommodationText}
|
||||
multiline
|
||||
hint="Wird vor dem Kauf angezeigt und muss bestätigt werden, z. B. ob ein französisches Doppelbett geteilt wird."
|
||||
/>
|
||||
<Checkbox name="accommodationConfirmed" label="Unterbringung bestätigt" defaultChecked={camp.accommodationConfirmed} />
|
||||
<Checkbox
|
||||
name="servicesConfirmed"
|
||||
label="Enthaltene Leistungen bestätigt"
|
||||
defaultChecked={camp.servicesConfirmed}
|
||||
hint="Bezieht sich auf die als bestätigt markierten Leistungen in src/content/site.ts."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Veranstalter und Kontakt">
|
||||
<div className="grid gap-4">
|
||||
<Field name="organizerName" label="Veranstalter" defaultValue={camp.organizerName} />
|
||||
<Field name="organizerAddress" label="Anschrift" defaultValue={camp.organizerAddress} multiline />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="organizerEmail" label="E-Mail" type="email" defaultValue={camp.organizerEmail} />
|
||||
<Field name="organizerPhone" label="Telefon" defaultValue={camp.organizerPhone} />
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox name="organizerConfirmed" label="Veranstalter- und Kontaktdaten bestätigt" defaultChecked={camp.organizerConfirmed} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="Preisangabe, Bedingungen, Recht">
|
||||
<Field name="taxNote" label="Steuerlicher Preishinweis" defaultValue={camp.taxNote} hint="Nur nach steuerlicher Prüfung, z. B. zur Umsatzsteuer." />
|
||||
<Checkbox name="taxConfirmed" label="Steuerliche Preisangabe geprüft" defaultChecked={camp.taxConfirmed} />
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-2">
|
||||
<Field name="termsVersion" label="Version der Bedingungen" defaultValue={camp.termsVersion} />
|
||||
<Field name="termsUrl" label="Link zu den Bedingungen" defaultValue={camp.termsUrl} hint="https://… oder /pfad" />
|
||||
</div>
|
||||
<Checkbox name="termsApproved" label="Buchungs-, Storno- und Zahlungsbedingungen freigegeben" defaultChecked={camp.termsApproved} />
|
||||
<Checkbox
|
||||
name="travelLawChecked"
|
||||
label="Reiserechtliche Einordnung geprüft"
|
||||
defaultChecked={camp.travelLawChecked}
|
||||
hint="Inklusive ggf. erforderlicher Absicherung und Unterlagen."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Kapazität und Kontingent (intern)">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="capacity" label="Verkaufbare Plätze" type="number" defaultValue={camp.capacity} />
|
||||
<Field name="stammmodelQuota" label="Max. Stammmodel-Buchungen" type="number" defaultValue={camp.stammmodelQuota} />
|
||||
</div>
|
||||
<Checkbox name="capacityConfirmed" label="Platzzahl bestätigt" defaultChecked={camp.capacityConfirmed} />
|
||||
<Checkbox name="stammmodelQuotaConfirmed" label="Kontingent bestätigt" defaultChecked={camp.stammmodelQuotaConfirmed} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="Zahlung und Reservierung">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field name="reservationMinutes" label="Reservierung (Min.)" type="number" defaultValue={camp.reservationMinutes} hint="mind. 30" />
|
||||
<Field name="finalInstallmentLeadDays" label="Letzte Rate vor Anreise (Tage)" type="number" defaultValue={camp.finalInstallmentLeadDays} />
|
||||
<Field name="minInstallmentGapDays" label="Mindestabstand Raten (Tage)" type="number" defaultValue={camp.minInstallmentGapDays} />
|
||||
</div>
|
||||
<Checkbox
|
||||
name="installmentRuleApproved"
|
||||
label="Terminregel für Raten freigegeben"
|
||||
defaultChecked={camp.installmentRuleApproved}
|
||||
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Einzug 9 Uhr."
|
||||
/>
|
||||
<Checkbox name="installmentsApproved" label="Ratenzahlung für den Live-Verkauf freigegeben" defaultChecked={camp.installmentsApproved} />
|
||||
<label className="mt-3 block">
|
||||
<span className="field-label">Preisphase simulieren (nur Stripe-Testmodus)</span>
|
||||
<select name="testPhaseOverride" defaultValue={camp.testPhaseOverride ?? ""} className="field">
|
||||
<option value="">keine Simulation</option>
|
||||
<option value="EARLY_BIRD">Early Bird</option>
|
||||
<option value="REGULAR">Regulär</option>
|
||||
</select>
|
||||
</label>
|
||||
{mode === "live" && <p className="mt-2 text-sm text-stone">Im Livemodus wirkungslos.</p>}
|
||||
</Panel>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Panel title="Live-Verkauf">
|
||||
<Checkbox
|
||||
name="salesEnabled"
|
||||
label="Live-Verkauf einschalten"
|
||||
defaultChecked={camp.salesEnabled}
|
||||
hint="Wirkt nur mit Stripe-Live-Schlüssel und vollständigen Freigaben."
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary mt-4">Speichern</button>
|
||||
</Panel>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
||||
import { VoucherForm } from "@/components/admin/VoucherForm";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
||||
|
||||
export const metadata = { title: "Gutschein" };
|
||||
|
||||
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||
await connection();
|
||||
const { id } = await params;
|
||||
const flash = await searchParams;
|
||||
const voucher = await getDb().voucher.findUnique({
|
||||
where: { id },
|
||||
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
|
||||
});
|
||||
if (!voucher) notFound();
|
||||
return (
|
||||
<>
|
||||
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
||||
<PageTitle>{voucher.code}</PageTitle>
|
||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<VoucherForm voucher={voucher} />
|
||||
<section>
|
||||
<h2 className="mb-3 text-xl font-semibold">Einlösungen</h2>
|
||||
<ul className="space-y-2">
|
||||
{voucher.redemptions.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link href={`/admin/buchungen/${r.booking.id}`} className="underline">{r.booking.reference}</Link> – {REDEMPTION_STATUS_LABEL[r.status]}, Buchung{" "}
|
||||
{BOOKING_STATUS_LABEL[r.booking.status]} {r.booking.livemode ? "" : "(Test)"}
|
||||
</li>
|
||||
))}
|
||||
{voucher.redemptions.length === 0 && <li className="text-stone">Noch keine Einlösungen.</li>}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Link from "next/link";
|
||||
import { connection } from "next/server";
|
||||
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
||||
import { VoucherForm } from "@/components/admin/VoucherForm";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { formatDate } from "@/lib/time";
|
||||
|
||||
export const metadata = { title: "Gutscheine" };
|
||||
|
||||
export default async function VouchersPage({ searchParams }: { searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||
await connection();
|
||||
const flash = await searchParams;
|
||||
const livemode = stripeModeOrNull() === "live";
|
||||
const now = new Date();
|
||||
const vouchers = await getDb().voucher.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { redemptions: { where: { livemode }, include: { booking: { select: { reservation: { select: { status: true, expiresAt: true } } } } } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>Gutscheine (Stammmodel-Codes)</PageTitle>
|
||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||
<p className="mb-6 max-w-3xl text-stone">
|
||||
Codes werden persönlich vergeben und nirgends öffentlich gelistet. Ein Code gibt 10 % auf den jeweils gültigen Preis und ist mit Early Bird
|
||||
kombinierbar, aber nicht mit einem weiteren Gutschein. Das Gesamtkontingent steht unter Einstellungen.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[48rem] text-left">
|
||||
<thead className="text-sm text-stone">
|
||||
<tr>
|
||||
<th className="py-2 font-normal">Code</th>
|
||||
<th className="font-normal">Aktiv</th>
|
||||
<th className="font-normal">Gültig</th>
|
||||
<th className="font-normal">Einlösungen</th>
|
||||
<th className="font-normal">Gebunden an</th>
|
||||
<th className="font-normal">Interne Zuordnung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vouchers.map((v) => {
|
||||
const completed = v.redemptions.filter((r) => r.status === "COMPLETED").length;
|
||||
const reserved = v.redemptions.filter(
|
||||
(r) =>
|
||||
r.status === "RESERVED" &&
|
||||
(r.booking.reservation?.status === "HELD" ||
|
||||
r.booking.reservation?.status === "CONVERTED" ||
|
||||
(r.booking.reservation?.status === "ACTIVE" && r.booking.reservation.expiresAt > now)),
|
||||
).length;
|
||||
return (
|
||||
<tr key={v.id} className="border-t border-line">
|
||||
<td className="py-2"><Link href={`/admin/gutscheine/${v.id}`} className="font-semibold underline">{v.code}</Link></td>
|
||||
<td>{v.active ? "ja" : "nein"}</td>
|
||||
<td className="text-sm">
|
||||
{v.validFrom ? formatDate(v.validFrom) : "sofort"} – {v.validUntil ? formatDate(v.validUntil) : "unbegrenzt"}
|
||||
</td>
|
||||
<td>{completed} abgeschlossen, {reserved} reserviert, max. {v.maxRedemptions}</td>
|
||||
<td className="text-sm">{v.boundEmail ?? "–"}</td>
|
||||
<td className="text-sm">{v.internalNote ?? "–"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{vouchers.length === 0 && <p className="py-6 text-stone">Noch keine Codes angelegt.</p>}
|
||||
</div>
|
||||
<section className="mt-10 max-w-2xl">
|
||||
<h2 className="mb-4 text-xl font-semibold">Neuen Code anlegen</h2>
|
||||
<VoucherForm voucher={null} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import { requireAdminPage } from "@/lib/admin-auth";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { SignOutButton } from "./SignOutButton";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/admin", label: "Übersicht" },
|
||||
{ href: "/admin/buchungen", label: "Buchungen" },
|
||||
{ href: "/admin/gutscheine", label: "Gutscheine" },
|
||||
{ href: "/admin/einstellungen", label: "Einstellungen und Freigaben" },
|
||||
];
|
||||
|
||||
export default async function ProtectedAdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const admin = await requireAdminPage();
|
||||
const mode = stripeModeOrNull();
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-line bg-plaster">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin" className="display text-2xl">ARTISTA</Link>
|
||||
<span className={`rounded-full px-3 py-0.5 text-xs font-semibold ${mode === "live" ? "bg-rosso text-paper" : "bg-[#efe0b8] text-[#5c4712]"}`}>
|
||||
{mode === "live" ? "Stripe: Livemodus" : mode === "test" ? "Stripe: Testmodus" : "Stripe: nicht konfiguriert"}
|
||||
</span>
|
||||
</div>
|
||||
<nav aria-label="Verwaltung">
|
||||
<ul className="flex flex-wrap gap-4">
|
||||
{NAV.map((n) => (
|
||||
<li key={n.href}>
|
||||
<Link href={n.href} className="hover:underline">{n.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-stone">{admin.email}</span>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Link from "next/link";
|
||||
import { connection } from "next/server";
|
||||
import { Panel, PageTitle, KeyValue } from "@/components/admin/AdminUi";
|
||||
import { countOccupiedSeats } from "@/lib/booking-service";
|
||||
import { getCamp, salesStateFor } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
import { PHASE_LABEL } from "@/lib/pricing";
|
||||
import { BLOCKER_TEXT } from "@/lib/sales-gate";
|
||||
import { formatDate } from "@/lib/time";
|
||||
import { countUsedRedemptions } from "@/lib/vouchers";
|
||||
|
||||
export const metadata = { title: "Übersicht" };
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
await connection();
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const camp = await getCamp(db);
|
||||
const mode = stripeModeOrNull();
|
||||
const livemode = mode === "live";
|
||||
const state = mode ? salesStateFor(camp, mode, now) : null;
|
||||
|
||||
const [occupied, quotaUsed, byStatus, money, reviews, failedEvents, failedMails, upcoming] = await Promise.all([
|
||||
countOccupiedSeats(db, livemode, now),
|
||||
countUsedRedemptions(db, { livemode, now }),
|
||||
db.booking.groupBy({ by: ["status"], where: { livemode }, _count: true }),
|
||||
db.booking.aggregate({ where: { livemode, status: { in: ["CONFIRMED", "NEEDS_REVIEW"] } }, _sum: { totalCents: true, paidCents: true, refundedCents: true } }),
|
||||
db.booking.findMany({ where: { livemode, status: "NEEDS_REVIEW" }, select: { id: true, reference: true, reviewReason: true }, take: 20 }),
|
||||
db.stripeEvent.count({ where: { status: "FAILED" } }),
|
||||
db.emailMessage.count({ where: { status: "FAILED" } }),
|
||||
db.installment.findMany({
|
||||
where: { booking: { livemode, status: "CONFIRMED" }, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] }, seq: { gt: 1 } },
|
||||
include: { booking: { select: { id: true, reference: true } } },
|
||||
orderBy: { dueAt: "asc" },
|
||||
take: 15,
|
||||
}),
|
||||
]);
|
||||
const count = (s: string) => byStatus.find((b) => b.status === s)?._count ?? 0;
|
||||
const total = money._sum.totalCents ?? 0;
|
||||
const paid = money._sum.paidCents ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>Übersicht</PageTitle>
|
||||
<p className="mb-6 text-stone">Alle Zahlen beziehen sich auf den aktuellen Stripe-Modus ({mode === "live" ? "Live" : "Test"}). Test- und Livebuchungen sind getrennt.</p>
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
<Panel title="Verkauf">
|
||||
{state ? (
|
||||
<>
|
||||
<KeyValue
|
||||
items={[
|
||||
["Preisphase", `${PHASE_LABEL[state.effectivePhase]}${state.phaseSimulated ? " (simuliert)" : ""}`],
|
||||
["Checkout", state.checkoutOpen ? "offen" : "gesperrt"],
|
||||
["Ratenzahlung", state.installmentsAllowed ? "möglich" : "nicht möglich"],
|
||||
]}
|
||||
/>
|
||||
{state.releaseBlockers.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="font-semibold">Fehlt für den Live-Verkauf:</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5 text-sm">
|
||||
{state.releaseBlockers.map((b) => (
|
||||
<li key={b}>{BLOCKER_TEXT[b]}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/admin/einstellungen" className="mt-2 inline-block text-sm underline">Freigaben bearbeiten</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-rosso">STRIPE_SECRET_KEY fehlt oder ist ungültig.</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Kapazität (intern, nicht öffentlich)">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Belegt oder reserviert", `${occupied} von ${camp.capacity}${camp.capacityConfirmed ? "" : " (vorläufig)"}`],
|
||||
["Stammmodel-Buchungen", `${quotaUsed} von ${camp.stammmodelQuota}${camp.stammmodelQuotaConfirmed ? "" : " (vorläufig)"}`],
|
||||
["Bestätigt", count("CONFIRMED")],
|
||||
["Zahlung ausstehend", count("PENDING")],
|
||||
["In Prüfung", count("NEEDS_REVIEW")],
|
||||
["Storniert / verfallen", count("CANCELED") + count("EXPIRED")],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Zahlungen">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Vereinbart", formatEuro(total)],
|
||||
["Eingegangen", formatEuro(paid)],
|
||||
["Offen", formatEuro(Math.max(0, total - paid))],
|
||||
["Erstattet", formatEuro(money._sum.refundedCents ?? 0)],
|
||||
["Fehlgeschlagene Webhooks", failedEvents],
|
||||
["Nicht zustellbare E-Mails", failedMails],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<Panel title="Prüfung nötig">
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-stone">Keine offenen Prüfungen.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{reviews.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link href={`/admin/buchungen/${r.id}`} className="font-semibold underline">{r.reference}</Link>
|
||||
<span className="block text-sm text-stone">{r.reviewReason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Nächste offene Raten">
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-stone">Keine offenen Folgeraten.</p>
|
||||
) : (
|
||||
<table className="w-full text-left text-sm">
|
||||
<tbody>
|
||||
{upcoming.map((i) => (
|
||||
<tr key={i.id} className="border-t border-line">
|
||||
<td className="py-1.5"><Link href={`/admin/buchungen/${i.booking.id}`} className="underline">{i.booking.reference}</Link></td>
|
||||
<td>Rate {i.seq}</td>
|
||||
<td>{i.dueAt ? formatDate(i.dueAt) : "–"}</td>
|
||||
<td className="text-right tabular-nums">{formatEuro(i.amountCents)}</td>
|
||||
<td className="pl-3">{i.status === "PENDING" ? "" : i.status === "FAILED" ? "fehlgeschlagen" : "Aktion nötig"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* Server-Actions des Adminbereichs. JEDE Action prueft zuerst selbst die
|
||||
* Anmeldung inkl. 2FA (requireAdminAction) — unabhaengig davon, ob die
|
||||
* Seite geschuetzt ist, denn Actions sind eigenstaendig aufrufbar.
|
||||
*/
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { adminActor, requireAdminAction } from "@/lib/admin-auth";
|
||||
import {
|
||||
campInputSchema,
|
||||
cancelBooking,
|
||||
confirmReviewedBooking,
|
||||
refundInstallment,
|
||||
resendConfirmation,
|
||||
saveVoucher,
|
||||
updateCamp,
|
||||
voucherInputSchema,
|
||||
type ActionResult,
|
||||
} from "@/lib/admin-service";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAppSecret } from "@/lib/env";
|
||||
import { getGateway } from "@/lib/payment-gateway";
|
||||
|
||||
function back(path: string, result: ActionResult): never {
|
||||
const param = result.ok ? "ok" : "fehler";
|
||||
redirect(`${path}?${param}=${encodeURIComponent(result.message)}`);
|
||||
}
|
||||
|
||||
function str(form: FormData, key: string): string {
|
||||
const value = form.get(key);
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function bool(form: FormData, key: string): boolean {
|
||||
return form.get(key) === "on";
|
||||
}
|
||||
|
||||
function firstIssue(error: { issues: { message: string }[] }): string {
|
||||
return error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||
}
|
||||
|
||||
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
||||
|
||||
export async function cancelBookingAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const id = str(form, "bookingId");
|
||||
if (!ID.test(id)) back("/admin/buchungen", { ok: false, message: "Ungültige Buchung." });
|
||||
const result = await cancelBooking(getDb(), getGateway(), { bookingId: id, reason: str(form, "reason") }, adminActor(admin));
|
||||
revalidatePath(`/admin/buchungen/${id}`);
|
||||
back(`/admin/buchungen/${id}`, result);
|
||||
}
|
||||
|
||||
export async function refundAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const bookingId = str(form, "bookingId");
|
||||
const installmentId = str(form, "installmentId");
|
||||
const requestKey = str(form, "requestKey");
|
||||
if (!ID.test(bookingId) || !ID.test(installmentId) || !/^[0-9a-f-]{36}$/.test(requestKey)) {
|
||||
back("/admin/buchungen", { ok: false, message: "Ungültige Anfrage." });
|
||||
}
|
||||
const euros = Number(str(form, "amount").replace(",", "."));
|
||||
const amountCents = Math.round(euros * 100);
|
||||
const result = await refundInstallment(
|
||||
getDb(),
|
||||
getGateway(),
|
||||
{ installmentId, amountCents, requestKey, reason: str(form, "reason") },
|
||||
adminActor(admin),
|
||||
);
|
||||
revalidatePath(`/admin/buchungen/${bookingId}`);
|
||||
back(`/admin/buchungen/${bookingId}`, result);
|
||||
}
|
||||
|
||||
export async function confirmReviewAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const id = str(form, "bookingId");
|
||||
if (!ID.test(id)) back("/admin/buchungen", { ok: false, message: "Ungültige Buchung." });
|
||||
const result = await confirmReviewedBooking(getDb(), id, adminActor(admin));
|
||||
revalidatePath(`/admin/buchungen/${id}`);
|
||||
back(`/admin/buchungen/${id}`, result);
|
||||
}
|
||||
|
||||
export async function resendConfirmationAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const id = str(form, "bookingId");
|
||||
if (!ID.test(id)) back("/admin/buchungen", { ok: false, message: "Ungültige Buchung." });
|
||||
const result = await resendConfirmation(getDb(), id, requireAppSecret(), adminActor(admin));
|
||||
back(`/admin/buchungen/${id}`, result);
|
||||
}
|
||||
|
||||
export async function saveVoucherAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const id = str(form, "voucherId") || null;
|
||||
if (id && !ID.test(id)) back("/admin/gutscheine", { ok: false, message: "Ungültiger Gutschein." });
|
||||
const parsed = voucherInputSchema.safeParse({
|
||||
code: str(form, "code"),
|
||||
active: bool(form, "active"),
|
||||
validFrom: str(form, "validFrom"),
|
||||
validUntil: str(form, "validUntil"),
|
||||
maxRedemptions: str(form, "maxRedemptions"),
|
||||
boundEmail: str(form, "boundEmail"),
|
||||
internalNote: str(form, "internalNote"),
|
||||
});
|
||||
const target = id ? `/admin/gutscheine/${id}` : "/admin/gutscheine";
|
||||
if (!parsed.success) back(target, { ok: false, message: firstIssue(parsed.error) });
|
||||
const result = await saveVoucher(getDb(), id, parsed.data, adminActor(admin));
|
||||
revalidatePath("/admin/gutscheine");
|
||||
back(result.ok && result.id ? `/admin/gutscheine/${result.id}` : target, result);
|
||||
}
|
||||
|
||||
export async function updateCampAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const parsed = campInputSchema.safeParse({
|
||||
salesEnabled: bool(form, "salesEnabled"),
|
||||
capacity: str(form, "capacity"),
|
||||
capacityConfirmed: bool(form, "capacityConfirmed"),
|
||||
stammmodelQuota: str(form, "stammmodelQuota"),
|
||||
stammmodelQuotaConfirmed: bool(form, "stammmodelQuotaConfirmed"),
|
||||
campStartDate: str(form, "campStartDate"),
|
||||
campEndDate: str(form, "campEndDate"),
|
||||
dateConfirmed: bool(form, "dateConfirmed"),
|
||||
locationName: str(form, "locationName"),
|
||||
locationRegion: str(form, "locationRegion"),
|
||||
locationConfirmed: bool(form, "locationConfirmed"),
|
||||
accommodationText: str(form, "accommodationText"),
|
||||
accommodationConfirmed: bool(form, "accommodationConfirmed"),
|
||||
servicesConfirmed: bool(form, "servicesConfirmed"),
|
||||
organizerName: str(form, "organizerName"),
|
||||
organizerAddress: str(form, "organizerAddress"),
|
||||
organizerEmail: str(form, "organizerEmail"),
|
||||
organizerPhone: str(form, "organizerPhone"),
|
||||
organizerConfirmed: bool(form, "organizerConfirmed"),
|
||||
taxNote: str(form, "taxNote"),
|
||||
taxConfirmed: bool(form, "taxConfirmed"),
|
||||
termsVersion: str(form, "termsVersion"),
|
||||
termsUrl: str(form, "termsUrl"),
|
||||
termsApproved: bool(form, "termsApproved"),
|
||||
travelLawChecked: bool(form, "travelLawChecked"),
|
||||
installmentsApproved: bool(form, "installmentsApproved"),
|
||||
installmentRuleApproved: bool(form, "installmentRuleApproved"),
|
||||
reservationMinutes: str(form, "reservationMinutes"),
|
||||
finalInstallmentLeadDays: str(form, "finalInstallmentLeadDays"),
|
||||
minInstallmentGapDays: str(form, "minInstallmentGapDays"),
|
||||
testPhaseOverride: str(form, "testPhaseOverride"),
|
||||
});
|
||||
if (!parsed.success) back("/admin/einstellungen", { ok: false, message: firstIssue(parsed.error) });
|
||||
const result = await updateCamp(getDb(), parsed.data, adminActor(admin));
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/");
|
||||
back("/admin/einstellungen", result);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export function LoginForm({ initialStep }: { initialStep: "password" | "totp" }) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(initialStep);
|
||||
const [useBackup, setUseBackup] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function signIn(form: FormData) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { data, error: err } = await authClient.signIn.email({
|
||||
email: String(form.get("email") ?? ""),
|
||||
password: String(form.get("password") ?? ""),
|
||||
});
|
||||
setBusy(false);
|
||||
// Bewusst ohne Unterscheidung zwischen falscher E-Mail und falschem Passwort.
|
||||
if (err) {
|
||||
setError(err.status === 429 ? "Zu viele Versuche. Bitte warte einige Minuten." : "Anmeldung fehlgeschlagen. Bitte prüfe E-Mail und Passwort.");
|
||||
return;
|
||||
}
|
||||
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
|
||||
setStep("totp");
|
||||
return;
|
||||
}
|
||||
router.replace("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function verify(form: FormData) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const code = String(form.get("code") ?? "").replace(/\s+/g, "");
|
||||
const result = useBackup
|
||||
? await authClient.twoFactor.verifyBackupCode({ code })
|
||||
: await authClient.twoFactor.verifyTotp({ code });
|
||||
setBusy(false);
|
||||
if (result.error) {
|
||||
setError(result.error.status === 429 ? "Zu viele Versuche. Bitte warte einige Minuten." : "Der Code ist ungültig oder abgelaufen.");
|
||||
return;
|
||||
}
|
||||
router.replace("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (step === "totp") {
|
||||
return (
|
||||
<form action={verify} className="mt-8 space-y-5">
|
||||
<div>
|
||||
<label htmlFor="code" className="field-label">
|
||||
{useBackup ? "Backup-Code" : "Code aus der Authenticator-App"}
|
||||
</label>
|
||||
<input id="code" name="code" className="field" inputMode={useBackup ? "text" : "numeric"} autoComplete="one-time-code" required autoFocus />
|
||||
</div>
|
||||
{error && <p role="alert" className="font-medium text-rosso">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
|
||||
Bestätigen
|
||||
</button>
|
||||
<button type="button" className="text-sm underline" onClick={() => setUseBackup((v) => !v)}>
|
||||
{useBackup ? "Code aus der App verwenden" : "Stattdessen einen Backup-Code verwenden"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={signIn} className="mt-8 space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="field-label">E-Mail</label>
|
||||
<input id="email" name="email" type="email" className="field" autoComplete="username" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="field-label">Passwort</label>
|
||||
<input id="password" name="password" type="password" className="field" autoComplete="current-password" required />
|
||||
</div>
|
||||
{error && <p role="alert" className="font-medium text-rosso">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LoginForm } from "./LoginForm";
|
||||
|
||||
export const metadata = { title: "Anmelden" };
|
||||
|
||||
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ schritt?: string }> }) {
|
||||
const { schritt } = await searchParams;
|
||||
return (
|
||||
<main className="mx-auto max-w-md px-4 py-16">
|
||||
<p className="display text-3xl">ARTISTA</p>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Verwaltung anmelden</h1>
|
||||
<LoginForm initialStep={schritt === "2fa" ? "totp" : "password"} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* CSV-Export der Buchungen (Semikolon, UTF-8 mit BOM fuer Excel).
|
||||
* Nur mit gueltiger Admin-Sitzung inkl. 2FA.
|
||||
*/
|
||||
import { adminOrNull } from "@/lib/admin-auth";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { bookingWhere, csvCell, parseBookingFilter } from "@/lib/admin-queries";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { BOOKING_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "@/lib/labels";
|
||||
import { PLAN_LABEL } from "@/lib/payment-plan";
|
||||
import { PHASE_LABEL } from "@/lib/pricing";
|
||||
import { formatDate, formatDateTime } from "@/lib/time";
|
||||
|
||||
const euro = (cents: number) => (cents / 100).toFixed(2).replace(".", ",");
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const admin = await adminOrNull();
|
||||
if (!admin) return new Response("Nicht angemeldet.", { status: 401 });
|
||||
|
||||
const params = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const filter = parseBookingFilter(params, stripeModeOrNull() === "live" ? "live" : "test");
|
||||
const db = getDb();
|
||||
const bookings = await db.booking.findMany({
|
||||
where: bookingWhere(filter),
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { installments: { orderBy: { seq: "asc" } }, voucher: { select: { code: true } } },
|
||||
});
|
||||
|
||||
const header = [
|
||||
"Buchungsnummer", "Modus", "Buchungsstatus", "Zahlungsstatus", "Vorname", "Nachname", "E-Mail", "Telefon", "Land",
|
||||
"Preisphase", "Zahlungsweise", "Gesamt EUR", "Bezahlt EUR", "Offen EUR", "Erstattet EUR", "Gutschein",
|
||||
"Nächste offene Rate", "Angelegt", "Bestätigt", "Anmerkungen",
|
||||
];
|
||||
const rows = bookings.map((b) => {
|
||||
const next = b.installments.find((i) => i.status !== "PAID" && i.status !== "CANCELED");
|
||||
return [
|
||||
b.reference, b.livemode ? "Live" : "Test", BOOKING_STATUS_LABEL[b.status], PAYMENT_STATUS_LABEL[b.paymentStatus],
|
||||
b.firstName, b.lastName, b.email, b.phone, b.country, PHASE_LABEL[b.pricePhase], PLAN_LABEL[b.planType],
|
||||
euro(b.totalCents), euro(b.paidCents), euro(Math.max(0, b.totalCents - b.paidCents)), euro(b.refundedCents),
|
||||
b.voucher?.code ?? "", next ? `${euro(next.amountCents)} ${next.dueAt ? `am ${formatDate(next.dueAt)}` : "bei Buchung"}` : "",
|
||||
formatDateTime(b.createdAt), b.confirmedAt ? formatDateTime(b.confirmedAt) : "", b.notes ?? "",
|
||||
].map(csvCell).join(";");
|
||||
});
|
||||
|
||||
await audit(db, { type: "ADMIN", id: admin.id }, "bookings.exported", "Booking", "*", { count: bookings.length, mode: filter.modus });
|
||||
const csv = `${[header.map(csvCell).join(";"), ...rows].join("\r\n")}\r\n`;
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="buchungen-${filter.modus}-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "Verwaltung", template: "%s – Verwaltung ARTISTA Modelcamp" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AdminRootLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-dvh bg-paper text-[0.9375rem]">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import QRCode from "qrcode";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export function TwoFactorSetup() {
|
||||
const [stage, setStage] = useState<"password" | "verify" | "done">("password");
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function start(form: FormData) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { data, error: err } = await authClient.twoFactor.enable({ password: String(form.get("password") ?? "") });
|
||||
setBusy(false);
|
||||
if (err || !data || data.method !== "totp") {
|
||||
setError("Passwort falsch oder Einrichtung nicht möglich.");
|
||||
return;
|
||||
}
|
||||
setQr(await QRCode.toDataURL(data.totpURI, { margin: 1, width: 240 }));
|
||||
setSecret(new URL(data.totpURI).searchParams.get("secret"));
|
||||
setBackupCodes(data.backupCodes);
|
||||
setStage("verify");
|
||||
}
|
||||
|
||||
async function verify(form: FormData) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { error: err } = await authClient.twoFactor.verifyTotp({ code: String(form.get("code") ?? "").replace(/\s+/g, "") });
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
setError("Der Code stimmt nicht. Bitte prüfe die Uhrzeit deines Geräts und versuche es erneut.");
|
||||
return;
|
||||
}
|
||||
setStage("done");
|
||||
}
|
||||
|
||||
if (stage === "password") {
|
||||
return (
|
||||
<form action={start} className="mt-8 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="field-label">Passwort bestätigen</label>
|
||||
<input id="password" name="password" type="password" className="field" autoComplete="current-password" required />
|
||||
</div>
|
||||
{error && <p role="alert" className="font-medium text-rosso">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>Einrichtung starten</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === "verify") {
|
||||
return (
|
||||
<div className="mt-8 space-y-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- lokal erzeugte data:-URL */}
|
||||
{qr && <img src={qr} alt="QR-Code für die Authenticator-App" width={240} height={240} />}
|
||||
{secret && (
|
||||
<p>
|
||||
Manuelle Eingabe: <code className="break-all rounded bg-plaster px-2 py-1">{secret}</code>
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="font-semibold">Backup-Codes</h2>
|
||||
<p className="text-stone">Jetzt sicher ablegen (z. B. im Passwortmanager). Sie werden nicht erneut angezeigt.</p>
|
||||
<ul className="mt-2 grid grid-cols-2 gap-1 font-mono text-sm">
|
||||
{backupCodes.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<form action={verify} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="code" className="field-label">Code aus der App</label>
|
||||
<input id="code" name="code" className="field" inputMode="numeric" autoComplete="one-time-code" required />
|
||||
</div>
|
||||
{error && <p role="alert" className="font-medium text-rosso">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>Aktivieren</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<p className="font-semibold">Zwei-Faktor-Anmeldung ist aktiv.</p>
|
||||
<a href="/admin" className="btn btn-primary mt-4">Zur Übersicht</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { requireAdminPage } from "@/lib/admin-auth";
|
||||
import { TwoFactorSetup } from "./TwoFactorSetup";
|
||||
|
||||
export const metadata = { title: "Sicherheit" };
|
||||
|
||||
export default async function SecurityPage() {
|
||||
const admin = await requireAdminPage({ allowWithout2fa: true });
|
||||
return (
|
||||
<main className="mx-auto max-w-xl px-4 py-16">
|
||||
<p className="display text-3xl">ARTISTA</p>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Zwei-Faktor-Anmeldung</h1>
|
||||
{admin.twoFactorEnabled ? (
|
||||
<>
|
||||
<p className="mt-4">Für {admin.email} ist die Zwei-Faktor-Anmeldung aktiv.</p>
|
||||
<Link href="/admin" className="btn btn-primary mt-6">Zur Übersicht</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-4">
|
||||
Der Adminbereich ist nur mit Zwei-Faktor-Anmeldung nutzbar. Richte sie jetzt mit einer Authenticator-App ein (z. B. Aegis, 2FAS oder die App deines
|
||||
Passwortmanagers).
|
||||
</p>
|
||||
<TwoFactorSetup />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return toNextJsHandler(getAuth()).GET(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return toNextJsHandler(getAuth()).POST(request);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Startet eine Buchung. Preis, Gutschein, Zahlungsplan und Verkaufsfreigabe
|
||||
* werden ausschliesslich hier auf dem Server bestimmt — vom Browser kommen
|
||||
* nur Teilnehmerdaten, gewaehlte Zahlungsweise, Gutscheincode und
|
||||
* Erklaerungen, nie ein Betrag.
|
||||
*/
|
||||
import { checkSameOrigin, fail, ok, rateLimited, readJson, type ApiError } from "@/lib/api";
|
||||
import { bookingRequestSchema } from "@/lib/booking-service";
|
||||
import { startCheckout, type CheckoutError } from "@/lib/checkout";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAppSecret } from "@/lib/env";
|
||||
import { getGateway } from "@/lib/payment-gateway";
|
||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||
import { PUBLIC_VOUCHER_REJECTION } from "@/lib/vouchers";
|
||||
|
||||
function mapError(error: CheckoutError): { status: number; body: ApiError } {
|
||||
switch (error.code) {
|
||||
case "SALES_CLOSED":
|
||||
return { status: 409, body: { code: error.code, message: "Die Buchung ist derzeit nicht geöffnet." } };
|
||||
case "SOLD_OUT":
|
||||
return { status: 409, body: { code: error.code, message: "Derzeit ist leider kein Platz frei." } };
|
||||
case "VOUCHER_REJECTED":
|
||||
return { status: 422, body: { code: error.code, message: PUBLIC_VOUCHER_REJECTION, fields: { voucherCode: PUBLIC_VOUCHER_REJECTION } } };
|
||||
case "PLAN_UNAVAILABLE":
|
||||
return { status: 409, body: { code: error.code, message: "Diese Zahlungsweise ist nicht mehr verfügbar. Bitte wähle eine andere." } };
|
||||
case "IDEMPOTENCY_CONFLICT":
|
||||
case "ATTEMPT_EXPIRED":
|
||||
return { status: 409, body: { code: error.code, message: "Die Reservierung ist abgelaufen. Bitte starte die Buchung erneut." } };
|
||||
case "ALREADY_COMPLETED":
|
||||
return { status: 409, body: { code: error.code, message: "Diese Buchung wurde bereits abgeschlossen. Den Stand findest du in deiner Bestätigungs-E-Mail." } };
|
||||
case "PAYMENT_PROVIDER":
|
||||
return { status: 502, body: { code: error.code, message: "Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuche es in einigen Minuten erneut." } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const blocked = checkSameOrigin(request);
|
||||
if (blocked) return blocked;
|
||||
const limited = await rateLimited(request, RATE_LIMITS.checkout);
|
||||
if (limited) return limited;
|
||||
const body = await readJson(request, bookingRequestSchema);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
let gateway;
|
||||
try {
|
||||
gateway = getGateway();
|
||||
} catch {
|
||||
return fail(503, { code: "NOT_CONFIGURED", message: "Die Buchung ist derzeit nicht verfügbar." });
|
||||
}
|
||||
|
||||
const result = await startCheckout(getDb(), gateway, body.data, { now: new Date(), secret: requireAppSecret() });
|
||||
if (result.ok) return ok({ url: result.url, reference: result.reference });
|
||||
const mapped = mapError(result.error);
|
||||
return fail(mapped.status, mapped.body);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { computeQuote } from "@/lib/quote";
|
||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({
|
||||
voucherCode: z.string().trim().max(60).optional(),
|
||||
email: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const blocked = checkSameOrigin(request);
|
||||
if (blocked) return blocked;
|
||||
const body = await readJson(request, schema);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const limited = await rateLimited(request, RATE_LIMITS.quote);
|
||||
if (limited) return limited;
|
||||
// Gutscheinpruefungen zusaetzlich eng begrenzen: gegen Durchprobieren.
|
||||
if (body.data.voucherCode) {
|
||||
const voucherLimited = await rateLimited(request, RATE_LIMITS.voucher);
|
||||
if (voucherLimited) return voucherLimited;
|
||||
}
|
||||
|
||||
const mode = stripeModeOrNull();
|
||||
if (!mode) return fail(503, { code: "NOT_CONFIGURED", message: "Die Buchung ist derzeit nicht verfügbar." });
|
||||
|
||||
const email = body.data.email && z.email().safeParse(body.data.email).success ? body.data.email : undefined;
|
||||
const quote = await computeQuote(getDb(), { voucherCode: body.data.voucherCode || undefined, email }, { now: new Date(), mode });
|
||||
return ok(quote);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Aktionen der Kundin auf ihrer Statusseite. Zugang nur ueber das
|
||||
* Statustoken aus Bestaetigungsmail bzw. Stripe-Ruecksprung.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||
import { abandonCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAppSecret } from "@/lib/env";
|
||||
import { getGateway } from "@/lib/payment-gateway";
|
||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
||||
|
||||
const schema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("pay_installment"), token: z.string(), installmentId: z.string().min(1).max(64) }),
|
||||
z.object({ action: z.literal("resume_checkout"), token: z.string() }),
|
||||
z.object({ action: z.literal("abandon"), token: z.string() }),
|
||||
]);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const blocked = checkSameOrigin(request);
|
||||
if (blocked) return blocked;
|
||||
const limited = await rateLimited(request, RATE_LIMITS.statusAction);
|
||||
if (limited) return limited;
|
||||
const body = await readJson(request, schema);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const notFound = fail(404, { code: "NOT_FOUND", message: "Diese Buchung wurde nicht gefunden." });
|
||||
if (!isWellFormedStatusToken(body.data.token)) return notFound;
|
||||
const db = getDb();
|
||||
const booking = await db.booking.findUnique({ where: { statusTokenHash: sha256Hex(body.data.token) } });
|
||||
if (!booking) return notFound;
|
||||
|
||||
const gateway = getGateway();
|
||||
if (booking.livemode !== (gateway.mode === "live")) return notFound;
|
||||
const now = new Date();
|
||||
|
||||
switch (body.data.action) {
|
||||
case "pay_installment": {
|
||||
const result = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId }, { now, secret: requireAppSecret() });
|
||||
if (result.ok) return ok({ url: result.url });
|
||||
return result.error === "NOT_PAYABLE"
|
||||
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
|
||||
: fail(502, { code: "PAYMENT_PROVIDER", message: "Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuche es später erneut." });
|
||||
}
|
||||
case "resume_checkout": {
|
||||
if (booking.status === "PENDING" && booking.paymentStatus === "UNPAID" && booking.stripeCheckoutUrl && booking.priceLockedUntil > now) {
|
||||
return ok({ url: booking.stripeCheckoutUrl });
|
||||
}
|
||||
return fail(409, { code: "EXPIRED", message: "Die Reservierung ist abgelaufen. Bitte starte eine neue Buchung." });
|
||||
}
|
||||
case "abandon": {
|
||||
await abandonCheckout(db, gateway, booking, { now });
|
||||
return ok({ abandoned: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
/** Readiness: nimmt den Pod aus dem Load-Balancing, wenn die Datenbank fehlt. */
|
||||
export async function GET() {
|
||||
try {
|
||||
await getDb().$queryRaw`SELECT 1`;
|
||||
return Response.json({ status: "ready" }, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch {
|
||||
return Response.json({ status: "database_unavailable" }, { status: 503, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Liveness: bewusst ohne Datenbank — eine DB-Stoerung soll den Pod nicht neu starten. */
|
||||
export function GET() {
|
||||
return Response.json({ status: "ok" }, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Stripe-Webhook. Einzige Stelle (neben dem Worker-Abgleich), die Zahlungen
|
||||
* als eingegangen verbucht — der Aufruf der Erfolgsseite tut das nie.
|
||||
*
|
||||
* Signaturpruefung gegen den unveraenderten Body: deshalb request.text()
|
||||
* statt request.json().
|
||||
*/
|
||||
import { getDb } from "@/lib/db";
|
||||
import { flushEmails } from "@/lib/email";
|
||||
import { env, requireAppSecret } from "@/lib/env";
|
||||
import { getGateway, verifyStripeWebhook } from "@/lib/payment-gateway";
|
||||
import { handleStripeEvent } from "@/lib/webhook";
|
||||
import { detachFinishedPaymentMethods } from "@/lib/worker-jobs";
|
||||
|
||||
const MAX_WEBHOOK_BYTES = 512 * 1024;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = env().STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) return new Response("Webhook nicht konfiguriert", { status: 503 });
|
||||
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) return new Response("Signatur fehlt", { status: 400 });
|
||||
// Stripe-Events sind wenige Kilobyte. Groessere Koerper werden abgewiesen,
|
||||
// bevor sie (unsigniert) vollstaendig verarbeitet werden.
|
||||
const declared = Number(request.headers.get("content-length") ?? "0");
|
||||
if (declared > MAX_WEBHOOK_BYTES) return new Response("Zu gross", { status: 413 });
|
||||
const raw = await request.text();
|
||||
if (raw.length > MAX_WEBHOOK_BYTES) return new Response("Zu gross", { status: 413 });
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = verifyStripeWebhook(raw, signature, secret);
|
||||
} catch {
|
||||
return new Response("Ungueltige Signatur", { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const gateway = getGateway();
|
||||
let bookingIds: string[];
|
||||
try {
|
||||
const result = await handleStripeEvent(db, gateway, event, {
|
||||
now: new Date(),
|
||||
secret: requireAppSecret(),
|
||||
adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null,
|
||||
});
|
||||
bookingIds = result.bookingIds;
|
||||
} catch (error) {
|
||||
// 500 -> Stripe stellt das Event spaeter erneut zu.
|
||||
console.error(`[webhook] Verarbeitung von ${event.id} (${event.type}) fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
return new Response("Verarbeitung fehlgeschlagen", { status: 500 });
|
||||
}
|
||||
|
||||
// Nachgelagert und fehlertolerant: der Worker holt beides nach.
|
||||
if (bookingIds.length > 0) {
|
||||
try {
|
||||
await detachFinishedPaymentMethods(db, gateway, bookingIds);
|
||||
for (const id of bookingIds) await flushEmails(db, { bookingId: id });
|
||||
} catch (error) {
|
||||
console.error(`[webhook] Nacharbeit fuer ${event.id} verschoben: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
}
|
||||
}
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
async function action(body: Record<string, string>): Promise<{ url?: string; error?: string }> {
|
||||
try {
|
||||
const res = await fetch("/api/booking/status-action", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) return { url: json.data.url };
|
||||
return { error: json.error?.message ?? "Das hat nicht geklappt. Bitte lade die Seite neu." };
|
||||
} catch {
|
||||
return { error: "Keine Verbindung. Bitte versuche es erneut." };
|
||||
}
|
||||
}
|
||||
|
||||
/** Fragt nach der Rueckkehr von Stripe einige Male den bestaetigten Stand ab. */
|
||||
export function AutoRefresh() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
let count = 0;
|
||||
const timer = setInterval(() => {
|
||||
count++;
|
||||
router.refresh();
|
||||
if (count >= 20) clearInterval(timer);
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PayInstallmentButton({ token, installmentId }: { token: string; installmentId: string }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary min-h-10 px-4 py-1.5 text-sm"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
const result = await action({ action: "pay_installment", token, installmentId });
|
||||
if (result.url) window.location.assign(result.url);
|
||||
else {
|
||||
setError(result.error ?? null);
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? "Einen Moment …" : "Jetzt bezahlen"}
|
||||
</button>
|
||||
{error && <p role="alert" className="mt-1 text-sm text-rosso">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PendingCheckoutActions({ token, canResume, lockedUntil }: { token: string; canResume: boolean; lockedUntil: string }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="mt-6 rounded-2xl border border-line p-6">
|
||||
<p className="font-semibold">Die Zahlung ist noch nicht abgeschlossen.</p>
|
||||
<p className="mt-2 text-ink/80">
|
||||
{canResume
|
||||
? `Dein Platz und der Preis sind bis ${lockedUntil} reserviert.`
|
||||
: "Die Reservierung ist abgelaufen. Du kannst jederzeit eine neue Buchung starten."}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{canResume && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
const result = await action({ action: "resume_checkout", token });
|
||||
if (result.url) window.location.assign(result.url);
|
||||
else {
|
||||
setError(result.error ?? null);
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Zahlung fortsetzen
|
||||
</button>
|
||||
)}
|
||||
{canResume ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-quiet"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
const result = await action({ action: "abandon", token });
|
||||
setBusy(false);
|
||||
if (result.error) setError(result.error);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
Buchung verwerfen
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/#preis" className="btn btn-quiet">
|
||||
Neue Buchung starten
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{error && <p role="alert" className="mt-3 text-sm text-rosso">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Buchungsstatus fuer die Kundin. Zeigt ausschliesslich den in der
|
||||
* Datenbank bestaetigten Stand. Der Ruecksprung von Stripe (?checkout=success)
|
||||
* aendert nichts — bezahlt ist eine Buchung erst, wenn der Webhook es
|
||||
* bestaetigt hat.
|
||||
*/
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { BOOKING_STATUS_LABEL, INSTALLMENT_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "@/lib/labels";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
import { PLAN_LABEL } from "@/lib/payment-plan";
|
||||
import { PHASE_LABEL } from "@/lib/pricing";
|
||||
import { formatDate, formatDateTime } from "@/lib/time";
|
||||
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
||||
import { AutoRefresh, PayInstallmentButton, PendingCheckoutActions } from "./StatusActions";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Deine Buchung",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function StatusPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
searchParams: Promise<{ checkout?: string }>;
|
||||
}) {
|
||||
await connection();
|
||||
const { token } = await params;
|
||||
const { checkout } = await searchParams;
|
||||
if (!isWellFormedStatusToken(token)) notFound();
|
||||
|
||||
const booking = await getDb().booking.findUnique({
|
||||
where: { statusTokenHash: sha256Hex(token) },
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
if (!booking) notFound();
|
||||
|
||||
const now = new Date();
|
||||
const open = Math.max(0, booking.totalCents - booking.paidCents);
|
||||
const waitingForConfirmation = checkout === "success" && booking.status === "PENDING" && booking.paymentStatus === "UNPAID";
|
||||
const pendingUnpaid = booking.status === "PENDING" && booking.paymentStatus === "UNPAID";
|
||||
|
||||
return (
|
||||
<main className="container-edge max-w-3xl py-12 md:py-20">
|
||||
<Link href="/" className="display text-2xl">
|
||||
ARTISTA <span className="font-sans text-sm text-stone">Modelcamp 2027</span>
|
||||
</Link>
|
||||
<p className="mt-10 text-stone">Buchung {booking.reference}</p>
|
||||
<h1 className="section-title mt-2">Hallo {booking.firstName}</h1>
|
||||
|
||||
{!booking.livemode && (
|
||||
<p className="mt-6">
|
||||
<span className="draft-flag">Testbuchung</span> Diese Buchung wurde im Stripe-Testmodus angelegt; es wurde kein Geld eingezogen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div role="status" className="mt-8 rounded-2xl border border-line bg-paper p-6">
|
||||
{waitingForConfirmation ? (
|
||||
<>
|
||||
<p className="text-lg font-semibold">Wir warten auf die Bestätigung deiner Zahlung.</p>
|
||||
<p className="mt-2 text-ink/80">
|
||||
Das dauert meist nur wenige Sekunden. Diese Seite aktualisiert sich selbst; zusätzlich bekommst du eine E-Mail, sobald die Zahlung bestätigt ist.
|
||||
</p>
|
||||
<AutoRefresh />
|
||||
</>
|
||||
) : (
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-stone">Buchung</dt>
|
||||
<dd className="text-lg font-semibold">{BOOKING_STATUS_LABEL[booking.status]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-stone">Zahlung</dt>
|
||||
<dd className="text-lg font-semibold">{PAYMENT_STATUS_LABEL[booking.paymentStatus]}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
{booking.status === "NEEDS_REVIEW" && (
|
||||
<p className="mt-4 text-ink/80">Deine Zahlung ist eingegangen. Wir prüfen die Buchung kurz manuell und melden uns bei dir.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingUnpaid && checkout !== "success" && (
|
||||
<PendingCheckoutActions token={token} canResume={Boolean(booking.stripeCheckoutUrl) && booking.priceLockedUntil > now} lockedUntil={formatDateTime(booking.priceLockedUntil)} />
|
||||
)}
|
||||
|
||||
<section aria-labelledby="summary" className="mt-12">
|
||||
<h2 id="summary" className="display text-3xl">Übersicht</h2>
|
||||
<dl className="mt-4 divide-y divide-line border-y border-line">
|
||||
<Row label="Preisphase">{PHASE_LABEL[booking.pricePhase]}</Row>
|
||||
<Row label="Gesamtpreis">{formatEuro(booking.totalCents)}</Row>
|
||||
{booking.voucherDiscountCents > 0 && <Row label="Darin enthaltener Gutschein">−{formatEuro(booking.voucherDiscountCents)}</Row>}
|
||||
<Row label="Zahlungsweise">{PLAN_LABEL[booking.planType]}</Row>
|
||||
<Row label="Bezahlt">{formatEuro(booking.paidCents)}</Row>
|
||||
<Row label="Offen">{formatEuro(open)}</Row>
|
||||
{booking.refundedCents > 0 && <Row label="Erstattet">{formatEuro(booking.refundedCents)}</Row>}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="plan" className="mt-12">
|
||||
<h2 id="plan" className="display text-3xl">Zahlungsplan</h2>
|
||||
<table className="mt-4 w-full text-left">
|
||||
<thead className="text-sm text-stone">
|
||||
<tr>
|
||||
<th scope="col" className="py-2 font-normal">Rate</th>
|
||||
<th scope="col" className="py-2 font-normal">Fällig</th>
|
||||
<th scope="col" className="py-2 text-right font-normal">Betrag</th>
|
||||
<th scope="col" className="py-2 pl-4 font-normal">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{booking.installments.map((i) => (
|
||||
<tr key={i.id} className="border-t border-line align-top">
|
||||
<td className="py-3">{booking.installments.length > 1 ? i.seq : "–"}</td>
|
||||
<td className="py-3">{i.dueAt ? formatDate(i.dueAt) : "bei Buchung"}</td>
|
||||
<td className="py-3 text-right tabular-nums">{formatEuro(i.amountCents)}</td>
|
||||
<td className="py-3 pl-4">
|
||||
{INSTALLMENT_STATUS_LABEL[i.status]}
|
||||
{booking.status === "CONFIRMED" && i.seq > 1 && (i.status === "FAILED" || i.status === "REQUIRES_ACTION") && (
|
||||
<PayInstallmentButton token={token} installmentId={i.id} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{booking.installments.length > 1 && (
|
||||
<p className="mt-4 text-[0.9375rem] text-stone">
|
||||
Weitere Raten werden am Fälligkeitstag über die gespeicherte Zahlungsmethode eingezogen. Nach der letzten Rate wird die Zahlungsmethode gelöst.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">{label}</dt>
|
||||
<dd className="text-right tabular-nums">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import { connection } from "next/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LegalPage, PendingLegalNotice } from "@/components/LegalPage";
|
||||
import { getCamp, publicCampFacts } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Buchungsbedingungen", alternates: { canonical: "/buchungsbedingungen" } };
|
||||
|
||||
/**
|
||||
* Die freigegebenen Buchungs-, Storno- und Zahlungsbedingungen werden als
|
||||
* Dokument (termsUrl) im Adminbereich hinterlegt. Hier werden keine
|
||||
* Bedingungen formuliert oder erfunden.
|
||||
*/
|
||||
export default async function TermsPage() {
|
||||
await connection();
|
||||
const facts = publicCampFacts(await getCamp(getDb()));
|
||||
if (facts.termsUrl && facts.termsUrl !== "/buchungsbedingungen") redirect(facts.termsUrl);
|
||||
return (
|
||||
<LegalPage title="Buchungs-, Storno- und Zahlungsbedingungen">
|
||||
<PendingLegalNotice what="Die freigegebenen Buchungs-, Storno- und Zahlungsbedingungen" />
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage, PendingLegalNotice } from "@/components/LegalPage";
|
||||
import { showDevPlaceholders } from "@/lib/env";
|
||||
|
||||
export const metadata: Metadata = { title: "Datenschutz", alternates: { canonical: "/datenschutz" } };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Die Datenschutzerklaerung wird rechtlich geprueft ergaenzt. Die Liste
|
||||
* unten beschreibt nur, was diese Anwendung technisch tatsaechlich tut —
|
||||
* als Grundlage fuer die Erstellung, sichtbar nur ausserhalb der Produktion.
|
||||
*/
|
||||
export default function DatenschutzPage() {
|
||||
return (
|
||||
<LegalPage title="Datenschutz">
|
||||
<PendingLegalNotice what="Die vollständige Datenschutzerklärung" />
|
||||
{showDevPlaceholders() && (
|
||||
<section className="rounded-xl border border-dashed border-stone/60 p-5 text-[0.9375rem]">
|
||||
<p className="font-semibold">Technische Grundlage für die Erstellung (nur in Entwicklung sichtbar)</p>
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5">
|
||||
<li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li>
|
||||
<li>Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.</li>
|
||||
<li>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li>
|
||||
<li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</li>
|
||||
<li>Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.</li>
|
||||
<li>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li>
|
||||
<li>Adminbereich: Sitzungscookie für angemeldete Administratorinnen.</li>
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Gestaltung: Modemagazin trifft italienischen Sommer.
|
||||
* Putz-Weiss (plaster) als Grund, Anthrazit (ink) fuer Schrift und dunkle
|
||||
* Flaechen, Pompeji-Rot (rosso) als sparsam eingesetzter Akzent.
|
||||
* Kontraste (gegen plaster): ink 14.6:1, stone 5.3:1, rosso 6.7:1.
|
||||
*/
|
||||
@theme {
|
||||
--color-plaster: #f2ece3;
|
||||
--color-paper: #faf7f1;
|
||||
--color-ink: #23211f;
|
||||
--color-stone: #676058;
|
||||
--color-line: #d8cec0;
|
||||
--color-rosso: #8e3b2b;
|
||||
--color-rosso-deep: #6f2c20;
|
||||
--color-sand: #c9b79c;
|
||||
|
||||
--font-display: var(--font-bodoni), "Bodoni 72", "Didot", Georgia, serif;
|
||||
--font-sans: var(--font-hanken), "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
--ease-editorial: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 5rem;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-plaster);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.6;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-rosso);
|
||||
color: var(--color-paper);
|
||||
}
|
||||
|
||||
/* Sichtbarer Fokus fuer Tastaturbedienung, nie nur ueber Farbe. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-rosso);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.on-dark :focus-visible {
|
||||
outline-color: var(--color-paper);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container-edge {
|
||||
width: 100%;
|
||||
max-width: 88rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.container-edge {
|
||||
padding-inline: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.02;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: clamp(2.25rem, 1.4rem + 3.6vw, 4.25rem);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.prose-body {
|
||||
max-width: 38rem;
|
||||
}
|
||||
.prose-body p + p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
border-radius: 999px;
|
||||
transition:
|
||||
background-color 180ms var(--ease-editorial),
|
||||
color 180ms var(--ease-editorial),
|
||||
transform 180ms var(--ease-editorial);
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-rosso);
|
||||
color: var(--color-paper);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-rosso-deep);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
background: var(--color-stone);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-quiet {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.btn-quiet:hover {
|
||||
background: var(--color-ink);
|
||||
color: var(--color-paper);
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.field {
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--color-paper);
|
||||
border: 1px solid #b3a794;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.field:focus-visible {
|
||||
outline-offset: 0;
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
.field[aria-invalid="true"] {
|
||||
border-color: var(--color-rosso);
|
||||
}
|
||||
|
||||
.draft-flag {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #efe0b8;
|
||||
color: #5c4712;
|
||||
}
|
||||
}
|
||||
|
||||
/* Masthead: der eine laute Moment der Seite. */
|
||||
.masthead {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: clamp(4.25rem, 21vw, 19rem);
|
||||
line-height: 0.8;
|
||||
letter-spacing: -0.035em;
|
||||
font-variation-settings: "opsz" 96;
|
||||
}
|
||||
|
||||
/*
|
||||
* Einzige orchestrierte Eroeffnung: Masthead steigt auf, das Leitbild setzt
|
||||
* sich, die Cover-Lines folgen. Reines CSS, kein JavaScript noetig; bei
|
||||
* reduzierter Bewegung greift die Regel in @layer base.
|
||||
*/
|
||||
@keyframes hero-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.18em);
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
@keyframes hero-settle {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(1.035);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes hero-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.hero-rise {
|
||||
animation: hero-rise 1.1s var(--ease-editorial) both;
|
||||
}
|
||||
.hero-image {
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-image > * {
|
||||
animation: hero-settle 1.5s var(--ease-editorial) 0.15s both;
|
||||
}
|
||||
.hero-fade {
|
||||
animation: hero-fade 0.9s var(--ease-editorial) 0.5s both;
|
||||
}
|
||||
|
||||
/* Mobile Buchungsleiste: Inhalt am Seitenende nie verdecken. */
|
||||
body:has(.mobile-book-bar[data-visible="true"]) footer {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
body:has(.mobile-book-bar[data-visible="true"]) footer {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
dialog.booking-dialog::backdrop {
|
||||
background: rgb(35 33 31 / 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { connection } from "next/server";
|
||||
import { LegalPage, PendingLegalNotice } from "@/components/LegalPage";
|
||||
import { getCamp, publicCampFacts } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Impressum", alternates: { canonical: "/impressum" } };
|
||||
|
||||
export default async function ImpressumPage() {
|
||||
await connection();
|
||||
const { organizer } = publicCampFacts(await getCamp(getDb()));
|
||||
return (
|
||||
<LegalPage title="Impressum">
|
||||
{organizer ? (
|
||||
<>
|
||||
<p>Angaben gemäß § 5 DDG</p>
|
||||
<address className="not-italic">
|
||||
{organizer.name}
|
||||
<br />
|
||||
<span className="whitespace-pre-line">{organizer.address}</span>
|
||||
</address>
|
||||
<p>
|
||||
E-Mail: <a href={`mailto:${organizer.email}`} className="underline">{organizer.email}</a>
|
||||
{organizer.phone && (
|
||||
<>
|
||||
<br />
|
||||
Telefon: {organizer.phone}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-stone">Weitere Pflichtangaben (z. B. Vertretungsberechtigte, Register, USt-IdNr.) werden nach rechtlicher Prüfung ergänzt.</p>
|
||||
</>
|
||||
) : (
|
||||
<PendingLegalNotice what="Das vollständige Impressum" />
|
||||
)}
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Bodoni_Moda, Hanken_Grotesk } from "next/font/google";
|
||||
import { env } from "@/lib/env";
|
||||
import "./globals.css";
|
||||
|
||||
// next/font laedt die Schriften zur Build-Zeit herunter und liefert sie
|
||||
// selbst aus: Besucherinnen stellen keine Verbindung zu Google her.
|
||||
const bodoni = Bodoni_Moda({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-bodoni",
|
||||
display: "swap",
|
||||
axes: ["opsz"],
|
||||
style: ["normal", "italic"],
|
||||
});
|
||||
|
||||
const hanken = Hanken_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-hanken",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const TITLE = "ARTISTA MODELCAMP 2027";
|
||||
const DESCRIPTION =
|
||||
"Das ARTISTA Modelcamp 2027 in Italien: lernen, arbeiten und wohnen unter einem Dach. Alle Infos zu Unterkunft, Dozentinnen, Preis und Buchung.";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
const { APP_URL, APP_ENV } = env();
|
||||
return {
|
||||
metadataBase: new URL(APP_URL),
|
||||
title: { default: TITLE, template: `%s – ${TITLE}` },
|
||||
description: DESCRIPTION,
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "de_DE",
|
||||
url: "/",
|
||||
siteName: TITLE,
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
},
|
||||
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION },
|
||||
// Nur die Produktivumgebung darf indexiert werden.
|
||||
robots: APP_ENV === "production" ? { index: true, follow: true } : { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#f2ece3",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de" className={`${bodoni.variable} ${hanken.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Auslieferung freigegebener Medien aus dem persistenten Verzeichnis
|
||||
* MEDIA_DIR (Kubernetes: eigenes PersistentVolume, kein emptyDir).
|
||||
*
|
||||
* Nur lesend, nur erlaubte Dateiendungen, kein Verlassen des Verzeichnisses.
|
||||
* Unterstuetzt Range-Anfragen, damit Videos spulbar sind.
|
||||
*/
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".avif": "image/avif",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".vtt": "text/vtt",
|
||||
};
|
||||
|
||||
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path: segments } = await params;
|
||||
const notFound = new Response("Nicht gefunden", { status: 404 });
|
||||
if (segments.length === 0 || segments.length > 4 || !segments.every((s) => SEGMENT.test(s) && !s.includes(".."))) return notFound;
|
||||
|
||||
const base = path.resolve(env().MEDIA_DIR);
|
||||
const file = path.resolve(base, ...segments);
|
||||
if (!file.startsWith(base + path.sep)) return notFound;
|
||||
const type = TYPES[path.extname(file).toLowerCase()];
|
||||
if (!type) return notFound;
|
||||
|
||||
let info;
|
||||
try {
|
||||
info = await stat(file);
|
||||
} catch {
|
||||
return notFound;
|
||||
}
|
||||
if (!info.isFile()) return notFound;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": type,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get("range") ?? "");
|
||||
if (range && (range[1] || range[2])) {
|
||||
const size = info.size;
|
||||
let start = range[1] ? Number(range[1]) : size - Number(range[2]);
|
||||
let end = range[1] && range[2] ? Number(range[2]) : size - 1;
|
||||
start = Math.max(0, start);
|
||||
end = Math.min(end, size - 1);
|
||||
if (start > end) return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
|
||||
const stream = Readable.toWeb(createReadStream(file, { start, end })) as ReadableStream;
|
||||
return new Response(stream, {
|
||||
status: 206,
|
||||
headers: { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": String(end - start + 1) },
|
||||
});
|
||||
}
|
||||
|
||||
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
|
||||
return new Response(stream, { headers: { ...headers, "Content-Length": String(info.size) } });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { connection } from "next/server";
|
||||
import { BookingProvider } from "@/components/booking/BookingProvider";
|
||||
import { MobileBookingBar } from "@/components/booking/MobileBookingBar";
|
||||
import { Hero, priceLine } from "@/components/Hero";
|
||||
import { AccommodationSection, CampSection, ServicesSection } from "@/components/sections/CampSections";
|
||||
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
||||
import { AboutSection, TeachersSection, VoicesSection } from "@/components/sections/PeopleSections";
|
||||
import { PricingSection } from "@/components/sections/PricingSection";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { EventJsonLd } from "@/components/EventJsonLd";
|
||||
import { loadPublicPage } from "@/lib/public-page";
|
||||
|
||||
export default async function HomePage() {
|
||||
// Preisphase und Verkaufsfreigabe haengen von Uhrzeit und Datenbank ab.
|
||||
await connection();
|
||||
const { facts, pricing, showDrafts, externalVideoProvider } = await loadPublicPage();
|
||||
|
||||
return (
|
||||
<BookingProvider pricing={pricing} facts={facts}>
|
||||
<SiteHeader />
|
||||
<main id="inhalt">
|
||||
<Hero facts={facts} pricing={pricing} showDrafts={showDrafts} />
|
||||
<CampSection showDrafts={showDrafts} />
|
||||
<ServicesSection showDrafts={showDrafts} />
|
||||
<AccommodationSection facts={facts} showDrafts={showDrafts} />
|
||||
<TeachersSection showDrafts={showDrafts} />
|
||||
<AboutSection showDrafts={showDrafts} />
|
||||
<VoicesSection showDrafts={showDrafts} provider={externalVideoProvider} />
|
||||
<PricingSection pricing={pricing} facts={facts} showDrafts={showDrafts} />
|
||||
<FaqSection facts={facts} pricing={pricing} />
|
||||
<ClosingSection pricing={pricing} />
|
||||
</main>
|
||||
<SiteFooter facts={facts} showDrafts={showDrafts} />
|
||||
<MobileBookingBar priceLabel={priceLine(pricing)} />
|
||||
<EventJsonLd facts={facts} pricing={pricing} />
|
||||
</BookingProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const { APP_ENV, APP_URL } = env();
|
||||
if (APP_ENV !== "production") return { rules: { userAgent: "*", disallow: "/" } };
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/", disallow: ["/admin", "/buchung", "/api"] },
|
||||
host: new URL(APP_URL).host,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { appUrl } from "@/lib/env";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
|
||||
/**
|
||||
* Strukturierte Veranstaltungsdaten (schema.org/Event) — nur, wenn Termin,
|
||||
* Ort und Veranstalter bestaetigt sind und der Live-Verkauf laeuft.
|
||||
* Unbestaetigte Angaben erscheinen nie in Suchmaschinen.
|
||||
*/
|
||||
export function EventJsonLd({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
||||
if (!facts.startDate || !facts.location || !facts.organizer || pricing.mode !== "live" || !pricing.checkoutOpen || pricing.currentCents === null) {
|
||||
return null;
|
||||
}
|
||||
const data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Event",
|
||||
name: "ARTISTA MODELCAMP 2027",
|
||||
startDate: facts.startDate,
|
||||
...(facts.endDate ? { endDate: facts.endDate } : {}),
|
||||
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
||||
eventStatus: "https://schema.org/EventScheduled",
|
||||
location: { "@type": "Place", name: facts.location, address: { "@type": "PostalAddress", addressCountry: "IT", ...(facts.region ? { addressRegion: facts.region } : {}) } },
|
||||
organizer: { "@type": "Organization", name: facts.organizer.name, email: facts.organizer.email },
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: (pricing.currentCents / 100).toFixed(2),
|
||||
priceCurrency: "EUR",
|
||||
availability: "https://schema.org/InStock",
|
||||
url: appUrl("/#preis"),
|
||||
},
|
||||
};
|
||||
// JSON.stringify + Ersetzen von "<" verhindert, dass Inhalte das
|
||||
// Script-Element vorzeitig schliessen.
|
||||
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }} />;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Media } from "@/content/site";
|
||||
import { MediaFrame } from "./MediaFrame";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
function describe(media: Media): string {
|
||||
return media.kind === "image" ? media.alt : `Platzhalter: ${media.label}`;
|
||||
}
|
||||
|
||||
/** Bildmosaik der Unterkunft mit Vollbildansicht (Pfeiltasten, Esc). */
|
||||
export function Gallery({ images, showLabels }: { images: readonly Media[]; showLabels: boolean }) {
|
||||
const [current, setCurrent] = useState<number | null>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const triggers = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (current !== null && !dialog.open) dialog.showModal();
|
||||
if (current === null && dialog.open) dialog.close();
|
||||
}, [current]);
|
||||
|
||||
const close = () => {
|
||||
const index = current;
|
||||
setCurrent(null);
|
||||
if (index !== null) requestAnimationFrame(() => triggers.current[index]?.focus());
|
||||
};
|
||||
const step = (delta: number) => setCurrent((c) => (c === null ? c : (c + delta + images.length) % images.length));
|
||||
|
||||
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein Panorama.
|
||||
const layout = ["md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "md:col-span-12"];
|
||||
const aspect = (index: number) =>
|
||||
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[4/3] md:aspect-[21/8]" : "aspect-[4/3]";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="mt-12 grid gap-3 md:grid-cols-12 md:gap-4">
|
||||
{images.map((media, index) => (
|
||||
<li key={index} className={layout[index] ?? "md:col-span-4"}>
|
||||
<Reveal delay={index * 0.08} className={index === 0 ? "md:h-full" : ""}>
|
||||
<button
|
||||
ref={(el) => {
|
||||
triggers.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => setCurrent(index)}
|
||||
aria-label={`${describe(media)} vergrößern`}
|
||||
className={`block w-full ${index === 0 ? "md:h-full" : ""}`}
|
||||
>
|
||||
<MediaFrame
|
||||
media={media}
|
||||
aspect={aspect(index)}
|
||||
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 100vw"}
|
||||
showLabel={showLabels}
|
||||
className="w-full"
|
||||
/>
|
||||
</button>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Bildansicht Unterkunft"
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowRight") step(1);
|
||||
if (e.key === "ArrowLeft") step(-1);
|
||||
}}
|
||||
className="booking-dialog m-auto w-full max-w-5xl bg-transparent p-4"
|
||||
>
|
||||
{current !== null && (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className="relative">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div
|
||||
key={current}
|
||||
initial={reduce ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<MediaFrame media={images[current]!} aspect="aspect-[3/2]" sizes="90vw" showLabel={showLabels} />
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-paper">
|
||||
<p aria-live="polite" className="text-sm">
|
||||
Bild {current + 1} von {images.length}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn btn-quiet min-h-11 py-2 text-paper" onClick={() => step(-1)}>
|
||||
Vorheriges
|
||||
</button>
|
||||
<button type="button" className="btn btn-quiet min-h-11 py-2 text-paper" onClick={() => step(1)}>
|
||||
Nächstes
|
||||
</button>
|
||||
<button type="button" className="btn bg-paper min-h-11 py-2 text-ink" onClick={close} autoFocus>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { formatEuroShort } from "@/lib/money";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import { formatIsoDateLong } from "@/lib/time";
|
||||
import { MediaFrame } from "./MediaFrame";
|
||||
import { BookButton } from "./booking/BookingProvider";
|
||||
|
||||
export function dateLine(facts: PublicCampFacts): string {
|
||||
if (!facts.startDate) return "Termin wird bekanntgegeben";
|
||||
if (!facts.endDate) return `ab ${formatIsoDateLong(facts.startDate)}`;
|
||||
return `${formatIsoDateLong(facts.startDate)} bis ${formatIsoDateLong(facts.endDate)}`;
|
||||
}
|
||||
|
||||
export function priceLine(pricing: PublicPricing): string {
|
||||
if (pricing.phase === "EARLY_BIRD" && pricing.currentCents !== null) {
|
||||
return `${formatEuroShort(pricing.currentCents)} Early Bird statt ${formatEuroShort(pricing.regularCents)}`;
|
||||
}
|
||||
if (pricing.phase === "REGULAR" && pricing.currentCents !== null) return `${formatEuroShort(pricing.currentCents)} pro Person`;
|
||||
return `Early Bird ab 1. November 2026: ${formatEuroShort(pricing.earlyBirdCents)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero als Magazin-Cover: der Masthead "ARTISTA" ueberlappt das Leitbild,
|
||||
* Termin, Ort und Preis stehen als Cover-Lines daneben.
|
||||
*/
|
||||
export function Hero({ facts, pricing, showDrafts }: { facts: PublicCampFacts; pricing: PublicPricing; showDrafts: boolean }) {
|
||||
const place = facts.location ? `${facts.location}${facts.region ? `, ${facts.region}` : ""}` : "Italien";
|
||||
return (
|
||||
<section id="top" aria-labelledby="hero-title" className="relative pb-16 pt-4 md:pb-24">
|
||||
<div className="container-edge">
|
||||
<p className="hero-fade flex items-baseline justify-between border-b border-ink pb-2 text-sm">
|
||||
<span>Modelcamp 2027</span>
|
||||
<span>{place}</span>
|
||||
</p>
|
||||
<h1 id="hero-title" className="relative z-10 mt-3">
|
||||
<span className="masthead hero-rise block" aria-hidden>
|
||||
ARTISTA
|
||||
</span>
|
||||
<span className="sr-only">ARTISTA Modelcamp 2027 – {site.hero.headline}</span>
|
||||
</h1>
|
||||
|
||||
<div className="mt-[-3.2vw] grid gap-8 md:grid-cols-12 md:gap-10">
|
||||
<div className="hero-image md:col-span-7 lg:col-span-8">
|
||||
<MediaFrame
|
||||
media={site.hero.media}
|
||||
aspect="aspect-[4/5] md:aspect-[5/4] lg:aspect-[16/11]"
|
||||
sizes="(min-width: 1024px) 66vw, (min-width: 768px) 58vw, 100vw"
|
||||
showLabel={showDrafts}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hero-fade flex flex-col justify-end md:col-span-5 lg:col-span-4">
|
||||
<p className="display text-[2.25rem] leading-[1.05] md:text-[2.75rem]">{site.hero.headline}</p>
|
||||
<p className="mt-5 max-w-md text-lg text-ink/85">{site.hero.subline}</p>
|
||||
<dl className="mt-8 divide-y divide-line border-y border-line text-[0.9375rem]">
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Termin</dt>
|
||||
<dd className="text-right">{dateLine(facts)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Ort</dt>
|
||||
<dd className="text-right">{facts.location ?? "Italien, genauer Ort folgt"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Preis</dt>
|
||||
<dd className="text-right">{priceLine(pricing)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn btn-primary">Platz buchen</BookButton>
|
||||
) : (
|
||||
<a href="#preis" className="btn btn-primary">
|
||||
Preis und Buchungsstart
|
||||
</a>
|
||||
)}
|
||||
<a href="#camp" className="btn btn-quiet">
|
||||
Mehr über das Camp
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/** Rahmen fuer die rechtlichen Seiten. Inhalte ausschliesslich freigegeben. */
|
||||
export function LegalPage({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="container-edge max-w-3xl py-12 md:py-20">
|
||||
<Link href="/" className="display text-2xl">
|
||||
ARTISTA <span className="font-sans text-sm text-stone">Modelcamp 2027</span>
|
||||
</Link>
|
||||
<h1 className="section-title mt-12">{title}</h1>
|
||||
<div className="prose-body mt-8 space-y-4 text-ink/90">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function PendingLegalNotice({ what }: { what: string }) {
|
||||
return (
|
||||
<p className="rounded-xl border border-line bg-paper p-5">
|
||||
{what} veröffentlichen wir vor dem Verkaufsstart. Bis dahin ist keine verbindliche Buchung möglich.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Image from "next/image";
|
||||
import type { Media, PlaceholderTone } from "@/content/site";
|
||||
|
||||
const TONES: Record<PlaceholderTone, { background: string; shape: "arch" | "cypress" | "window" | "figure" }> = {
|
||||
villa: { background: "linear-gradient(172deg, #efe2cc 0%, #e0c7a2 52%, #c49a6c 100%)", shape: "arch" },
|
||||
terrace: { background: "linear-gradient(185deg, #e9dcc6 0%, #d7c29d 60%, #9f8b62 100%)", shape: "cypress" },
|
||||
room: { background: "linear-gradient(160deg, #f3ebdf 0%, #e3d3bb 60%, #cdb592 100%)", shape: "window" },
|
||||
garden: { background: "linear-gradient(190deg, #e6e0cc 0%, #c9c3a0 55%, #8c8a64 100%)", shape: "cypress" },
|
||||
portrait: { background: "linear-gradient(170deg, #eadccb 0%, #d4b99c 60%, #a8805f 100%)", shape: "figure" },
|
||||
studio: { background: "linear-gradient(175deg, #e4ddd3 0%, #cabdac 60%, #8f8273 100%)", shape: "figure" },
|
||||
};
|
||||
|
||||
function Shape({ shape }: { shape: (typeof TONES)[PlaceholderTone]["shape"] }) {
|
||||
const common = { fill: "currentColor", className: "absolute inset-0 h-full w-full text-ink/10" } as const;
|
||||
switch (shape) {
|
||||
case "arch":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<path d="M120 500V250a80 80 0 0 1 160 0v250z" />
|
||||
<path d="M20 500V330a40 40 0 0 1 80 0v170zM300 500V330a40 40 0 0 1 80 0v170z" opacity=".6" />
|
||||
</svg>
|
||||
);
|
||||
case "cypress":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<path d="M90 500c-22-90-18-230 12-330 26 100 30 240 10 330z" />
|
||||
<path d="M290 500c-18-70-15-180 10-260 21 80 24 190 8 260z" opacity=".7" />
|
||||
<rect x="0" y="440" width="400" height="60" opacity=".5" />
|
||||
</svg>
|
||||
);
|
||||
case "window":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMid slice" aria-hidden {...common}>
|
||||
<path d="M150 90h100v190H150z" opacity=".7" />
|
||||
<rect x="60" y="360" width="280" height="70" rx="6" />
|
||||
</svg>
|
||||
);
|
||||
case "figure":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<circle cx="200" cy="185" r="62" />
|
||||
<path d="M80 500c0-120 54-200 120-200s120 80 120 200z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
media: Media;
|
||||
/** Tailwind-Klasse fuer das Seitenverhaeltnis, z. B. "aspect-[4/5]". */
|
||||
aspect: string;
|
||||
sizes: string;
|
||||
showLabel: boolean;
|
||||
priority?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bild oder erkennbarer Platzhalter. Platzhalter tragen in Entwicklung und
|
||||
* Staging einen sichtbaren Hinweis, damit fehlende Freigaben nicht
|
||||
* uebersehen werden.
|
||||
*/
|
||||
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "" }: Props) {
|
||||
if (media.kind === "image") {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-sand ${aspect} ${className}`}>
|
||||
<Image src={media.src} alt={media.alt} fill sizes={sizes} priority={priority} className="object-cover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const tone = TONES[media.tone];
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`Platzhalter: ${media.label}`}
|
||||
className={`relative overflow-hidden ${aspect} ${className}`}
|
||||
style={{ background: tone.background }}
|
||||
>
|
||||
<Shape shape={tone.shape} />
|
||||
{showLabel && (
|
||||
<span className="absolute bottom-3 left-3 rounded-full bg-paper/85 px-3 py-1 text-xs font-semibold text-ink">
|
||||
Platzhalter: {media.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
|
||||
/**
|
||||
* Einmaliges Aufdecken eines Bildes beim Hineinscrollen (Clip von unten).
|
||||
* Nur fuer Bildflaechen — Text erscheint sofort, damit nichts auf eine
|
||||
* Animation warten muss.
|
||||
*/
|
||||
export function Reveal({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
|
||||
const reduce = useReducedMotion();
|
||||
if (reduce) return <div className={className}>{children}</div>;
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className={className}
|
||||
initial={{ clipPath: "inset(12% 0 0 0)", opacity: 0.4 }}
|
||||
whileInView={{ clipPath: "inset(0% 0 0 0)", opacity: 1 }}
|
||||
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
|
||||
export function SiteFooter({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
const organizer = facts.organizer;
|
||||
return (
|
||||
<footer id="site-footer" className="border-t border-line bg-plaster">
|
||||
<div className="container-edge grid gap-10 py-14 md:grid-cols-12">
|
||||
<div className="md:col-span-5">
|
||||
<p className="display text-4xl">ARTISTA</p>
|
||||
<p className="mt-1 text-stone">Modelcamp 2027</p>
|
||||
</div>
|
||||
<div className="md:col-span-4">
|
||||
<h2 className="font-semibold">Kontakt</h2>
|
||||
{organizer ? (
|
||||
<address className="mt-3 not-italic leading-relaxed">
|
||||
{organizer.name}
|
||||
<br />
|
||||
<span className="whitespace-pre-line">{organizer.address}</span>
|
||||
<br />
|
||||
<a href={`mailto:${organizer.email}`} className="underline underline-offset-4">
|
||||
{organizer.email}
|
||||
</a>
|
||||
{organizer.phone && (
|
||||
<>
|
||||
<br />
|
||||
<a href={`tel:${organizer.phone.replace(/\s+/g, "")}`}>{organizer.phone}</a>
|
||||
</>
|
||||
)}
|
||||
</address>
|
||||
) : (
|
||||
<p className="mt-3 text-stone">
|
||||
{showDrafts ? <span className="draft-flag">Veranstalter- und Kontaktdaten folgen</span> : "Kontaktdaten folgen in Kürze."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<nav aria-label="Rechtliches" className="md:col-span-3">
|
||||
<h2 className="font-semibold">Rechtliches</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<li><a href="/impressum" className="hover:underline">Impressum</a></li>
|
||||
<li><a href="/datenschutz" className="hover:underline">Datenschutz</a></li>
|
||||
<li><a href={facts.termsUrl ?? "/buchungsbedingungen"} className="hover:underline">Buchungsbedingungen</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { LazyMotion, domAnimation, m } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NAV_ITEMS } from "@/content/site";
|
||||
|
||||
export function SiteHeader() {
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuButton = useRef<HTMLButtonElement>(null);
|
||||
const firstLink = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Markiert den Abschnitt, der gerade die Bildschirmmitte kreuzt.
|
||||
useEffect(() => {
|
||||
const sections = NAV_ITEMS.map((item) => document.getElementById(item.id)).filter(Boolean) as HTMLElement[];
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) if (entry.isIntersecting) setActive(entry.target.id);
|
||||
},
|
||||
{ rootMargin: "-45% 0px -50% 0px" },
|
||||
);
|
||||
sections.forEach((s) => observer.observe(s));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
firstLink.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setMenuOpen(false);
|
||||
menuButton.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-40 transition-colors duration-300 ${scrolled ? "bg-plaster/92 shadow-[0_1px_0_var(--color-line)] backdrop-blur" : "bg-transparent"}`}
|
||||
>
|
||||
<a href="#inhalt" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-3 focus:z-50 focus:bg-paper focus:px-3 focus:py-2">
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<div className="container-edge flex h-16 items-center justify-between gap-6">
|
||||
<a href="#top" className="display text-xl tracking-tight" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang">
|
||||
ARTISTA <span className="font-sans text-sm text-stone">Modelcamp 2027</span>
|
||||
</a>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<nav aria-label="Abschnitte" className="hidden lg:block">
|
||||
<ul className="flex items-center gap-6 text-[0.9375rem]">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.id} className="relative">
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
aria-current={active === item.id ? "location" : undefined}
|
||||
className="block py-2 text-ink/80 hover:text-ink aria-[current]:text-ink"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{active === item.id && (
|
||||
<m.span layoutId="nav-indicator" className="absolute inset-x-0 -bottom-0.5 h-px bg-rosso" transition={{ type: "spring", stiffness: 380, damping: 34 }} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</LazyMotion>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
|
||||
Platz buchen
|
||||
</a>
|
||||
<button
|
||||
ref={menuButton}
|
||||
type="button"
|
||||
className="inline-flex h-11 items-center rounded-full border border-ink/30 px-4 text-sm font-semibold lg:hidden"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
{menuOpen ? "Schließen" : "Menü"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{menuOpen && (
|
||||
<nav id="mobile-menu" aria-label="Abschnitte" className="border-t border-line bg-plaster lg:hidden">
|
||||
<ul className="container-edge py-4">
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
ref={index === 0 ? firstLink : undefined}
|
||||
href={`#${item.id}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="display block border-b border-line/70 py-3 text-2xl"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Media } from "@/content/site";
|
||||
import { MediaFrame } from "./MediaFrame";
|
||||
|
||||
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null; externalId: string | null };
|
||||
|
||||
const EXTERNAL = {
|
||||
"youtube-nocookie": { label: "YouTube", url: (id: string) => `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&rel=0` },
|
||||
vimeo: { label: "Vimeo", url: (id: string) => `https://player.vimeo.com/video/${encodeURIComponent(id)}?autoplay=1&dnt=1` },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Ein Feedback-Video. Geladen wird erst nach Klick (preload="none"); es
|
||||
* startet nie von selbst mit Ton. Externe Player (YouTube/Vimeo) werden nur
|
||||
* geladen, wenn das Datenschutzkonzept sie erlaubt (EXTERNAL_VIDEO_PROVIDER)
|
||||
* und die Besucherin dem Laden ausdruecklich zustimmt.
|
||||
*/
|
||||
export function VideoSlot({ slot, index, provider, showDrafts }: { slot: Slot; index: number; provider: "none" | "youtube-nocookie" | "vimeo"; showDrafts: boolean }) {
|
||||
const [stage, setStage] = useState<"poster" | "consent" | "playing">("poster");
|
||||
const label = slot.name ? `Video von ${slot.name} abspielen` : `Feedback-Video ${index + 1} abspielen`;
|
||||
const external = slot.externalId && provider !== "none" ? EXTERNAL[provider] : null;
|
||||
const playable = Boolean(slot.src) || Boolean(external);
|
||||
const poster: Media = slot.poster ?? { kind: "placeholder", label: `Feedback-Video ${index + 1} folgt`, tone: "studio" };
|
||||
|
||||
return (
|
||||
<figure>
|
||||
<div className="relative aspect-[4/5] overflow-hidden bg-ink">
|
||||
{stage === "playing" && slot.src && (
|
||||
<video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)}
|
||||
{stage === "playing" && !slot.src && external && slot.externalId && (
|
||||
<iframe
|
||||
src={external.url(slot.externalId)}
|
||||
title={slot.name ? `Video von ${slot.name}` : `Feedback-Video ${index + 1}`}
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="h-full w-full"
|
||||
/>
|
||||
)}
|
||||
{stage !== "playing" && (
|
||||
<>
|
||||
<MediaFrame media={poster} aspect="h-full w-full" sizes="(min-width: 768px) 33vw, 100vw" showLabel={showDrafts} />
|
||||
{playable && stage === "poster" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStage(external && !slot.src ? "consent" : "playing")}
|
||||
aria-label={label}
|
||||
className="absolute inset-0 m-auto flex h-20 w-20 items-center justify-center rounded-full bg-paper/90 text-ink transition-transform hover:scale-105"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="ml-1 h-8 w-8" aria-hidden>
|
||||
<path d="M7 4.5v15l12-7.5z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{stage === "consent" && external && (
|
||||
<div className="absolute inset-0 flex flex-col justify-end gap-3 bg-ink/85 p-5 text-paper">
|
||||
<p className="text-[0.9375rem]">
|
||||
Beim Abspielen wird eine Verbindung zu {external.label} hergestellt und Daten werden dorthin übertragen. Details in der{" "}
|
||||
<a href="/datenschutz" className="underline">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn bg-paper text-ink" onClick={() => setStage("playing")}>
|
||||
Video laden
|
||||
</button>
|
||||
<button type="button" className="btn btn-quiet text-paper" onClick={() => setStage("poster")}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<figcaption className="mt-4">
|
||||
{slot.name ? (
|
||||
<span className="block font-semibold">{slot.name}</span>
|
||||
) : (
|
||||
showDrafts && <span className="draft-flag">Name und Freigabe folgen</span>
|
||||
)}
|
||||
{slot.caption && <span className="mt-1 block text-ink/80">{slot.caption}</span>}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Kleine, gemeinsame Bausteine fuer die Admin-Seiten. */
|
||||
|
||||
export function Flash({ ok, fehler }: { ok?: string; fehler?: string }) {
|
||||
if (!ok && !fehler) return null;
|
||||
return (
|
||||
<p role={fehler ? "alert" : "status"} className={`mb-6 rounded-lg border p-3 font-medium ${fehler ? "border-rosso/40 text-rosso" : "border-line bg-plaster"}`}>
|
||||
{fehler ?? ok}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<h1 className="display text-4xl">{children}</h1>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-line bg-plaster/60 p-5">
|
||||
<h2 className="mb-3 font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyValue({ items }: { items: [string, React.ReactNode][] }) {
|
||||
return (
|
||||
<dl className="divide-y divide-line">
|
||||
{items.map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-4 py-1.5">
|
||||
<dt className="text-stone">{k}</dt>
|
||||
<dd className="text-right tabular-nums">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function Checkbox({ name, label, defaultChecked, hint }: { name: string; label: string; defaultChecked: boolean; hint?: string }) {
|
||||
return (
|
||||
<label className="flex gap-3 py-1.5">
|
||||
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5 h-5 w-5 shrink-0 accent-[var(--color-rosso)]" />
|
||||
<span>
|
||||
{label}
|
||||
{hint && <span className="block text-sm text-stone">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
type = "text",
|
||||
hint,
|
||||
multiline,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue: string | number | null | undefined;
|
||||
type?: string;
|
||||
hint?: string;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={`f-${name}`} className="field-label">{label}</label>
|
||||
{multiline ? (
|
||||
<textarea id={`f-${name}`} name={name} defaultValue={defaultValue ?? ""} rows={4} className="field" />
|
||||
) : (
|
||||
<input id={`f-${name}`} name={name} type={type} defaultValue={defaultValue ?? ""} className="field" />
|
||||
)}
|
||||
{hint && <p className="mt-1 text-sm text-stone">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Voucher } from "@/generated/prisma/client";
|
||||
import { saveVoucherAction } from "@/app/admin/actions";
|
||||
import { berlinDateString } from "@/lib/time";
|
||||
import { Checkbox, Field } from "./AdminUi";
|
||||
|
||||
/** Anlegen und Bearbeiten eines Stammmodel-Codes. Alle Felder bleiben aenderbar. */
|
||||
export function VoucherForm({ voucher }: { voucher: Voucher | null }) {
|
||||
return (
|
||||
<form action={saveVoucherAction} className="space-y-4">
|
||||
{voucher && <input type="hidden" name="voucherId" value={voucher.id} />}
|
||||
<Field name="code" label="Code" defaultValue={voucher?.code} hint="Wird in Großbuchstaben gespeichert. Keine öffentlich erratbaren Codes verwenden." />
|
||||
<Checkbox name="active" label="Aktiv" defaultChecked={voucher?.active ?? true} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="validFrom" label="Gültig ab (optional)" type="date" defaultValue={voucher?.validFrom ? berlinDateString(voucher.validFrom) : ""} />
|
||||
<Field
|
||||
name="validUntil"
|
||||
label="Gültig bis einschließlich (optional)"
|
||||
type="date"
|
||||
defaultValue={voucher?.validUntil ? berlinDateString(new Date(voucher.validUntil.getTime() - 1)) : ""}
|
||||
/>
|
||||
</div>
|
||||
<Field name="maxRedemptions" label="Maximale Einlösungen" type="number" defaultValue={voucher?.maxRedemptions ?? 1} />
|
||||
<Field name="boundEmail" label="Nur für diese E-Mail-Adresse (optional)" type="email" defaultValue={voucher?.boundEmail} />
|
||||
<Field name="internalNote" label="Interne Zuordnung" defaultValue={voucher?.internalNote} hint="Nur hier sichtbar, z. B. Name des Stammmodels." />
|
||||
<button type="submit" className="btn btn-primary">{voucher ? "Speichern" : "Code anlegen"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import type { QuoteData } from "@/lib/quote";
|
||||
|
||||
type PlanType = "FULL" | "TWO_INSTALLMENTS" | "THREE_INSTALLMENTS";
|
||||
|
||||
const eur = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
const money = (cents: number) => eur.format(cents / 100);
|
||||
const dateFmt = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC" });
|
||||
const isoDate = (iso: string) => dateFmt.format(new Date(`${iso}T00:00:00Z`));
|
||||
|
||||
const STEPS = ["Deine Daten", "Zahlungsweise", "Prüfen und bezahlen"] as const;
|
||||
|
||||
type Participant = { firstName: string; lastName: string; email: string; phone: string; country: string; notes: string };
|
||||
type Consents = { adult: boolean; terms: boolean; accommodation: boolean; privacy: boolean; installmentMandate: boolean };
|
||||
type FieldErrors = Record<string, string>;
|
||||
|
||||
const EMPTY: Participant = { firstName: "", lastName: "", email: "", phone: "", country: "Deutschland", notes: "" };
|
||||
|
||||
type ApiResult<T> = { success: true; data: T } | { success: false; error: { code: string; message: string; fields?: FieldErrors } };
|
||||
|
||||
async function post<T>(url: string, body: unknown): Promise<ApiResult<T>> {
|
||||
try {
|
||||
const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
return (await res.json()) as ApiResult<T>;
|
||||
} catch {
|
||||
return { success: false, error: { code: "NETWORK", message: "Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut." } };
|
||||
}
|
||||
}
|
||||
|
||||
function validateParticipant(p: Participant): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!p.firstName.trim()) errors["participant.firstName"] = "Bitte gib deinen Vornamen an.";
|
||||
if (!p.lastName.trim()) errors["participant.lastName"] = "Bitte gib deinen Nachnamen an.";
|
||||
if (!/^\S+@\S+\.\S+$/.test(p.email.trim())) errors["participant.email"] = "Bitte gib eine gültige E-Mail-Adresse an.";
|
||||
if (!/^[+0-9 ()/-]{6,40}$/.test(p.phone.trim())) errors["participant.phone"] = "Bitte gib eine Telefonnummer mit mindestens sechs Ziffern an.";
|
||||
if (p.country.trim().length < 2) errors["participant.country"] = "Bitte gib dein Land an.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function BookingDialog({
|
||||
open,
|
||||
onClose,
|
||||
pricing,
|
||||
facts,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
pricing: PublicPricing;
|
||||
facts: PublicCampFacts;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
const titleId = useId();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
||||
const [chosenPlan, setPlanType] = useState<PlanType>("FULL");
|
||||
const [voucherInput, setVoucherInput] = useState("");
|
||||
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
||||
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
||||
const [consents, setConsents] = useState<Consents>({ adult: false, terms: false, accommodation: false, privacy: false, installmentMandate: false });
|
||||
const [quote, setQuote] = useState<QuoteData | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Idempotenz: identische Absendungen (Doppelklick, Wiederholung nach
|
||||
// Netzfehler) tragen denselben Schluessel, jede geaenderte Angabe einen neuen.
|
||||
const lastSubmission = useRef<{ fingerprint: string; key: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadQuote = useCallback(
|
||||
async (voucherCode: string | null) => {
|
||||
const result = await post<QuoteData>("/api/booking/quote", {
|
||||
voucherCode: voucherCode ?? undefined,
|
||||
email: participant.email.trim() || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
setQuote(result.data);
|
||||
return result.data;
|
||||
}
|
||||
setFormError(result.error.message);
|
||||
return null;
|
||||
},
|
||||
[participant.email],
|
||||
);
|
||||
|
||||
// Erste Preisvorschau beim Oeffnen.
|
||||
useEffect(() => {
|
||||
if (!open || quote) return;
|
||||
let cancelled = false;
|
||||
post<QuoteData>("/api/booking/quote", {}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.success) setQuote(result.data);
|
||||
else setFormError(result.error.message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, quote]);
|
||||
|
||||
// Eine nicht (mehr) verfuegbare Ratenvariante faellt auf Einmalzahlung zurueck.
|
||||
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
||||
const planType: PlanType = chosen && !chosen.available ? "FULL" : chosenPlan;
|
||||
|
||||
const selectedPlan = quote?.plans.find((p) => p.type === planType) ?? null;
|
||||
|
||||
function updateParticipant(field: keyof Participant, value: string) {
|
||||
setParticipant((prev) => ({ ...prev, [field]: value }));
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`participant.${field}`];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function next() {
|
||||
setFormError(null);
|
||||
if (step === 0) {
|
||||
const found = validateParticipant(participant);
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) {
|
||||
requestAnimationFrame(() => (dialogRef.current?.querySelector('[aria-invalid="true"]') as HTMLElement | null)?.focus());
|
||||
return;
|
||||
}
|
||||
}
|
||||
setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
}
|
||||
|
||||
function back() {
|
||||
setFormError(null);
|
||||
setStep((s) => Math.max(s - 1, 0));
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
}
|
||||
|
||||
async function applyVoucher() {
|
||||
const code = voucherInput.trim();
|
||||
if (!code) return;
|
||||
setBusy(true);
|
||||
const data = await loadQuote(code);
|
||||
setBusy(false);
|
||||
if (!data) return;
|
||||
if (data.voucher.state === "applied") {
|
||||
setAppliedVoucher(code);
|
||||
setVoucherMessage("Gutschein angewendet.");
|
||||
} else {
|
||||
setAppliedVoucher(null);
|
||||
setVoucherMessage(data.voucher.message ?? "Dieser Gutscheincode kann leider nicht angewendet werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeVoucher() {
|
||||
setAppliedVoucher(null);
|
||||
setVoucherInput("");
|
||||
setVoucherMessage(null);
|
||||
await loadQuote(null);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setFormError(null);
|
||||
const missing: FieldErrors = {};
|
||||
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
||||
if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
|
||||
if (!consents.accommodation) missing["consents.accommodation"] = "Bitte bestätige die Unterbringung.";
|
||||
if (!consents.privacy) missing["consents.privacy"] = "Bitte bestätige die Datenschutzhinweise.";
|
||||
if (planType !== "FULL" && !consents.installmentMandate) missing["consents.installmentMandate"] = "Bitte stimme dem Einzug der weiteren Raten zu.";
|
||||
setErrors(missing);
|
||||
if (Object.keys(missing).length > 0) return;
|
||||
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
participant: { ...participant, notes: participant.notes || undefined },
|
||||
planType,
|
||||
voucherCode: appliedVoucher ?? undefined,
|
||||
consents,
|
||||
};
|
||||
const fingerprint = JSON.stringify(payload);
|
||||
if (lastSubmission.current?.fingerprint !== fingerprint) {
|
||||
lastSubmission.current = { fingerprint, key: crypto.randomUUID() };
|
||||
}
|
||||
const result = await post<{ url: string }>("/api/booking/checkout", { idempotencyKey: lastSubmission.current.key, ...payload });
|
||||
if (result.success) {
|
||||
window.location.assign(result.data.url);
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
if (result.error.fields) {
|
||||
setErrors(result.error.fields);
|
||||
if (Object.keys(result.error.fields).some((k) => k.startsWith("participant."))) setStep(0);
|
||||
}
|
||||
setFormError(result.error.message);
|
||||
if (result.error.code === "VOUCHER_REJECTED") {
|
||||
setAppliedVoucher(null);
|
||||
void loadQuote(null);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldProps = (field: keyof Participant) => ({
|
||||
id: `bk-${field}`,
|
||||
value: participant[field],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => updateParticipant(field, e.target.value),
|
||||
"aria-invalid": errors[`participant.${field}`] ? true : undefined,
|
||||
"aria-describedby": errors[`participant.${field}`] ? `bk-${field}-error` : undefined,
|
||||
className: "field",
|
||||
});
|
||||
|
||||
const fieldError = (key: string, id: string) =>
|
||||
errors[key] ? (
|
||||
<p id={id} className="mt-1.5 text-sm font-medium text-rosso">
|
||||
{errors[key]}
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
const today = selectedPlan?.installments[0]?.amountCents ?? null;
|
||||
const organizer = facts.organizer?.name ?? "den Veranstalter";
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
className="booking-dialog m-0 h-dvh max-h-dvh w-full max-w-full bg-plaster p-0 text-ink backdrop:bg-transparent md:m-auto md:h-auto md:max-h-[92dvh] md:max-w-3xl md:rounded-2xl"
|
||||
>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
initial={reduce ? false : { opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="flex h-full max-h-[inherit] flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-line px-5 pb-4 pt-5 md:px-8">
|
||||
<div>
|
||||
<p id={titleId} className="text-sm text-stone">
|
||||
Buchung ARTISTA Modelcamp 2027
|
||||
{pricing.mode === "test" && <span className="ml-2 draft-flag">Testmodus</span>}
|
||||
</p>
|
||||
<h2 ref={headingRef} tabIndex={-1} className="display mt-1 text-3xl outline-none md:text-4xl">
|
||||
{STEPS[step]}
|
||||
</h2>
|
||||
<ol className="mt-3 flex gap-2" aria-label="Fortschritt">
|
||||
{STEPS.map((label, index) => (
|
||||
<li key={label} aria-current={index === step ? "step" : undefined} className={`h-1 w-10 rounded-full ${index <= step ? "bg-rosso" : "bg-line"}`}>
|
||||
<span className="sr-only">
|
||||
Schritt {index + 1} von {STEPS.length}: {label}
|
||||
{index < step ? " (erledigt)" : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-ink/30 px-4 py-2 text-sm font-semibold">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-6 md:px-8">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div
|
||||
key={step}
|
||||
initial={reduce ? false : { opacity: 0, x: 16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={reduce ? { opacity: 0 } : { opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{step === 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-firstName">Vorname</label>
|
||||
<input {...fieldProps("firstName")} autoComplete="given-name" required />
|
||||
{fieldError("participant.firstName", "bk-firstName-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-lastName">Nachname</label>
|
||||
<input {...fieldProps("lastName")} autoComplete="family-name" required />
|
||||
{fieldError("participant.lastName", "bk-lastName-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-email">E-Mail</label>
|
||||
<input {...fieldProps("email")} type="email" autoComplete="email" inputMode="email" required />
|
||||
{fieldError("participant.email", "bk-email-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-phone">Telefon</label>
|
||||
<input {...fieldProps("phone")} type="tel" autoComplete="tel" required />
|
||||
{fieldError("participant.phone", "bk-phone-error")}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="field-label" htmlFor="bk-country">Land</label>
|
||||
<input {...fieldProps("country")} autoComplete="country-name" required />
|
||||
{fieldError("participant.country", "bk-country-error")}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="field-label" htmlFor="bk-notes">Anmerkungen (optional)</label>
|
||||
<textarea {...fieldProps("notes")} rows={3} maxLength={1000} aria-describedby="bk-notes-hint" />
|
||||
<p id="bk-notes-hint" className="mt-1.5 text-sm text-stone">
|
||||
Bitte hier keine Gesundheitsdaten eintragen – dazu sprechen wir bei Bedarf persönlich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-8">
|
||||
<fieldset>
|
||||
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
||||
<div className="mt-2 space-y-3">
|
||||
{quote?.plans.map((plan) => (
|
||||
<label
|
||||
key={plan.type}
|
||||
className={`flex cursor-pointer gap-4 rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "cursor-not-allowed opacity-60"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="planType"
|
||||
value={plan.type}
|
||||
checked={planType === plan.type}
|
||||
disabled={!plan.available}
|
||||
onChange={() => {
|
||||
setPlanType(plan.type);
|
||||
}}
|
||||
className="mt-1 h-5 w-5 accent-[var(--color-rosso)]"
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="block font-semibold">{plan.label}</span>
|
||||
{plan.available ? (
|
||||
<span className="mt-1 block text-[0.9375rem] text-ink/80">
|
||||
{plan.installments.map((i) => money(i.amountCents)).join(" + ")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-voucher">Gutscheincode (optional)</label>
|
||||
{appliedVoucher ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded-full bg-paper px-4 py-2 font-semibold">{appliedVoucher.toUpperCase()}</span>
|
||||
<button type="button" className="text-sm font-semibold underline" onClick={removeVoucher}>
|
||||
Gutschein entfernen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="bk-voucher"
|
||||
className="field"
|
||||
value={voucherInput}
|
||||
onChange={(e) => setVoucherInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
aria-describedby={voucherMessage ? "bk-voucher-msg" : undefined}
|
||||
/>
|
||||
<button type="button" className="btn btn-quiet shrink-0" onClick={applyVoucher} disabled={busy || !voucherInput.trim()}>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p id="bk-voucher-msg" role="status" className={`mt-1.5 text-sm ${appliedVoucher ? "text-ink" : "text-rosso"}`}>
|
||||
{voucherMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quote?.price && <PriceBreakdown quote={quote} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && quote?.price && selectedPlan && (
|
||||
<div className="space-y-8">
|
||||
<section aria-labelledby="sum-price">
|
||||
<h3 id="sum-price" className="font-semibold">Preis und Zahlungsplan</h3>
|
||||
<PriceBreakdown quote={quote} />
|
||||
<table className="mt-4 w-full text-[0.9375rem]">
|
||||
<caption className="sr-only">Zahlungsplan</caption>
|
||||
<thead>
|
||||
<tr className="text-left text-stone">
|
||||
<th scope="col" className="py-1 font-normal">Rate</th>
|
||||
<th scope="col" className="py-1 font-normal">Fällig</th>
|
||||
<th scope="col" className="py-1 text-right font-normal">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedPlan.installments.map((i) => (
|
||||
<tr key={i.seq} className="border-t border-line">
|
||||
<td className="py-2">{selectedPlan.installments.length > 1 ? `Rate ${i.seq}` : "Einmalzahlung"}</td>
|
||||
<td className="py-2">{i.dueDate ? isoDate(i.dueDate) : "heute, bei Buchung"}</td>
|
||||
<td className="py-2 text-right tabular-nums">{money(i.amountCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{today !== null && (
|
||||
<p className="mt-3 font-semibold">
|
||||
Heute fällig: {money(today)}
|
||||
{selectedPlan.installments.length > 1 && `, danach noch ${money(quote.price.totalCents - today)}`}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-stone">
|
||||
Der Preis ist ab Klick auf „Zahlungspflichtig buchen“ für {quote.priceLockMinutes} Minuten reserviert. Wird die Zahlung in dieser Zeit
|
||||
nicht abgeschlossen, verfällt die Reservierung.
|
||||
</p>
|
||||
{quote.taxNote ? <p className="mt-2 text-sm text-stone">{quote.taxNote}</p> : quote.mode === "test" && <p className="mt-2"><span className="draft-flag">Steuerlicher Hinweis folgt vor Verkaufsstart</span></p>}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="sum-stay">
|
||||
<h3 id="sum-stay" className="font-semibold">Unterkunft und Leistungen</h3>
|
||||
<p className="mt-2">Teilnahme pro Person mit geteilter Unterbringung. Einzelbelegung und Zimmer-Upgrades sind nicht buchbar.</p>
|
||||
{facts.accommodationText ? (
|
||||
<p className="mt-2 whitespace-pre-line">{facts.accommodationText}</p>
|
||||
) : (
|
||||
<p className="mt-2">
|
||||
<span className="draft-flag">Testmodus</span>{" "}
|
||||
<span className="text-stone">Die verbindliche Zimmer- und Bettbeschreibung wird vor dem Live-Verkauf ergänzt.</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-stone">Nicht enthalten: Anreise nach Italien und zurück, persönliche Ausgaben vor Ort, Reiserücktrittsversicherung.</p>
|
||||
</section>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="font-semibold">Erklärungen</legend>
|
||||
<Consent id="adult" checked={consents.adult} error={errors["consents.adult"]} onChange={(v) => setConsents((c) => ({ ...c, adult: v }))}>
|
||||
Ich bin volljährig.
|
||||
</Consent>
|
||||
<Consent id="accommodation" checked={consents.accommodation} error={errors["consents.accommodation"]} onChange={(v) => setConsents((c) => ({ ...c, accommodation: v }))}>
|
||||
Ich habe die Beschreibung der Unterbringung gelesen und buche einen Platz mit geteilter Unterbringung.
|
||||
</Consent>
|
||||
<Consent id="terms" checked={consents.terms} error={errors["consents.terms"]} onChange={(v) => setConsents((c) => ({ ...c, terms: v }))}>
|
||||
Ich akzeptiere die{" "}
|
||||
<a href={facts.termsUrl ?? "/buchungsbedingungen"} target="_blank" rel="noopener" className="underline">
|
||||
Buchungs-, Storno- und Zahlungsbedingungen
|
||||
</a>
|
||||
.
|
||||
</Consent>
|
||||
<Consent id="privacy" checked={consents.privacy} error={errors["consents.privacy"]} onChange={(v) => setConsents((c) => ({ ...c, privacy: v }))}>
|
||||
Ich habe die{" "}
|
||||
<a href="/datenschutz" target="_blank" rel="noopener" className="underline">
|
||||
Datenschutzhinweise
|
||||
</a>{" "}
|
||||
gelesen.
|
||||
</Consent>
|
||||
{planType !== "FULL" && (
|
||||
<Consent
|
||||
id="installmentMandate"
|
||||
checked={consents.installmentMandate}
|
||||
error={errors["consents.installmentMandate"]}
|
||||
onChange={(v) => setConsents((c) => ({ ...c, installmentMandate: v }))}
|
||||
>
|
||||
Ich ermächtige {organizer}, die weiteren Raten zu den oben genannten Terminen über die bei der Zahlung gespeicherte Zahlungsmethode
|
||||
einzuziehen. Nach der letzten Rate wird die Zahlungsmethode gelöst; weitere Abbuchungen finden nicht statt.
|
||||
</Consent>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
{formError && (
|
||||
<p role="alert" className="mt-6 rounded-lg border border-rosso/40 bg-paper p-4 text-[0.9375rem] font-medium text-rosso">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-line px-5 py-4 md:px-8">
|
||||
{step > 0 ? (
|
||||
<button type="button" className="btn btn-quiet" onClick={back} disabled={busy}>
|
||||
Zurück
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{step < STEPS.length - 1 ? (
|
||||
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && !quote?.price}>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={busy || !quote?.checkoutOpen}>
|
||||
{busy ? "Einen Moment …" : "Zahlungspflichtig buchen"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
||||
const p = quote.price!;
|
||||
return (
|
||||
<dl className="mt-3 space-y-1.5 text-[0.9375rem]">
|
||||
<div className="flex justify-between">
|
||||
<dt>Regulärer Preis</dt>
|
||||
<dd className="tabular-nums">{money(p.basePriceCents)}</dd>
|
||||
</div>
|
||||
{p.phaseDiscountCents > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<dt>Early-Bird-Vorteil (20 %)</dt>
|
||||
<dd className="tabular-nums">−{money(p.phaseDiscountCents)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{p.voucherDiscountCents > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<dt>Gutschein (10 %)</dt>
|
||||
<dd className="tabular-nums">−{money(p.voucherDiscountCents)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between border-t border-ink pt-2 text-lg font-semibold">
|
||||
<dt>Gesamtpreis pro Person</dt>
|
||||
<dd className="tabular-nums">{money(p.totalCents)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function Consent({
|
||||
id,
|
||||
checked,
|
||||
error,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
error?: string;
|
||||
onChange: (value: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={`bk-c-${id}`} className="flex cursor-pointer gap-3 text-[0.9375rem] leading-snug">
|
||||
<input
|
||||
id={`bk-c-${id}`}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `bk-c-${id}-error` : undefined}
|
||||
className="mt-0.5 h-5 w-5 shrink-0 accent-[var(--color-rosso)]"
|
||||
/>
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p id={`bk-c-${id}-error`} className="ml-8 mt-1 text-sm font-medium text-rosso">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
|
||||
// Der Dialog (Formular, Motion, Preislogik-Anzeige) wird erst beim ersten
|
||||
// Oeffnen geladen und belastet den ersten Seitenaufbau nicht.
|
||||
const BookingDialog = dynamic(() => import("./BookingDialog").then((m) => m.BookingDialog), { ssr: false });
|
||||
|
||||
type BookingContextValue = { open: () => void; pricing: PublicPricing };
|
||||
const BookingContext = createContext<BookingContextValue | null>(null);
|
||||
|
||||
export function useBooking(): BookingContextValue {
|
||||
const ctx = useContext(BookingContext);
|
||||
if (!ctx) throw new Error("useBooking ausserhalb von BookingProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function BookingProvider({
|
||||
pricing,
|
||||
facts,
|
||||
children,
|
||||
}: {
|
||||
pricing: PublicPricing;
|
||||
facts: PublicCampFacts;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const trigger = useRef<HTMLElement | null>(null);
|
||||
|
||||
const open = useCallback(() => {
|
||||
trigger.current = document.activeElement as HTMLElement | null;
|
||||
setMounted(true);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
// Fokus zurueck auf den ausloesenden Button.
|
||||
requestAnimationFrame(() => trigger.current?.focus());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BookingContext.Provider value={{ open, pricing }}>
|
||||
{children}
|
||||
{mounted && <BookingDialog open={isOpen} onClose={close} pricing={pricing} facts={facts} />}
|
||||
</BookingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookButton({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
const { open, pricing } = useBooking();
|
||||
return (
|
||||
<button type="button" className={className} onClick={open} disabled={!pricing.checkoutOpen} aria-haspopup="dialog">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BookButton, useBooking } from "./BookingProvider";
|
||||
|
||||
/**
|
||||
* Buchungsleiste fuer kleine Bildschirme. Erscheint erst, wenn der Hero
|
||||
* verlassen ist, und verschwindet im Preisbereich und am Seitenende, damit
|
||||
* sie dort keine Inhalte oder Bedienelemente verdeckt.
|
||||
*/
|
||||
export function MobileBookingBar({ priceLabel }: { priceLabel: string }) {
|
||||
const { pricing } = useBooking();
|
||||
const [heroVisible, setHeroVisible] = useState(true);
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hero = document.getElementById("top");
|
||||
const blockers = ["preis", "site-footer"].map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];
|
||||
const visible = new Set<string>();
|
||||
const heroObserver = new IntersectionObserver(([entry]) => setHeroVisible(entry?.isIntersecting ?? false));
|
||||
const blockObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) visible.add(entry.target.id);
|
||||
else visible.delete(entry.target.id);
|
||||
}
|
||||
setBlocked(visible.size > 0);
|
||||
});
|
||||
if (hero) heroObserver.observe(hero);
|
||||
blockers.forEach((b) => blockObserver.observe(b));
|
||||
return () => {
|
||||
heroObserver.disconnect();
|
||||
blockObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const show = !heroVisible && !blocked;
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<m.div
|
||||
className="mobile-book-bar fixed inset-x-0 bottom-0 z-30 border-t border-line bg-plaster/95 backdrop-blur lg:hidden"
|
||||
data-visible="true"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="container-edge flex items-center justify-between gap-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<p className="text-sm leading-tight">
|
||||
<span className="block font-semibold">Modelcamp 2027</span>
|
||||
<span className="text-stone">{priceLabel}</span>
|
||||
</p>
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn btn-primary min-h-11 px-5 py-2 text-sm">Platz buchen</BookButton>
|
||||
) : (
|
||||
<a href="#preis" className="btn btn-quiet min-h-11 px-5 py-2 text-sm">
|
||||
Zum Preis
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { Gallery } from "../Gallery";
|
||||
|
||||
function DraftFlag({ show }: { show: boolean }) {
|
||||
return show ? <span className="draft-flag ml-2 align-middle">Entwurf, nicht bestätigt</span> : null;
|
||||
}
|
||||
|
||||
export function CampSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { camp } = site;
|
||||
const showAudience = camp.audience.confirmed || showDrafts;
|
||||
return (
|
||||
<section id="camp" aria-labelledby="camp-title" className="py-20 md:py-32">
|
||||
<div className="container-edge grid gap-10 md:grid-cols-12">
|
||||
<div className="md:col-span-6 lg:col-span-5">
|
||||
<h2 id="camp-title" className="section-title">{camp.title}</h2>
|
||||
<p className="display mt-8 text-[1.75rem] leading-[1.2] text-ink md:text-[2.125rem]">{camp.lead}</p>
|
||||
</div>
|
||||
<div className="md:col-span-6 md:col-start-7 lg:col-span-5 lg:col-start-8 md:pt-24">
|
||||
<div className="prose-body text-ink/90">
|
||||
{camp.paragraphs.map((p) => (
|
||||
<p key={p}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
{showAudience && (
|
||||
<div className="mt-12 border-t border-ink pt-6">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{camp.audience.title}
|
||||
<DraftFlag show={!camp.audience.confirmed} />
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{camp.audience.items.map((item) => (
|
||||
<li key={item} className="flex gap-3">
|
||||
<span aria-hidden className="mt-[0.7em] h-px w-5 shrink-0 bg-rosso" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { services } = site;
|
||||
const included = services.included.filter((s) => s.confirmed || showDrafts);
|
||||
return (
|
||||
<section id="leistungen" aria-labelledby="leistungen-title" className="on-dark bg-ink py-20 text-paper md:py-32">
|
||||
<div className="container-edge">
|
||||
<h2 id="leistungen-title" className="section-title max-w-3xl">{services.title}</h2>
|
||||
<div className="mt-14 grid gap-12 md:grid-cols-2 md:gap-16">
|
||||
<div>
|
||||
<h3 className="border-b border-paper/30 pb-3 text-lg font-semibold">Im Preis enthalten</h3>
|
||||
<ul className="mt-2 divide-y divide-paper/15">
|
||||
{included.map((item) => (
|
||||
<li key={item.text} className="py-4 text-lg">
|
||||
{item.text}
|
||||
{!item.confirmed && <DraftFlag show />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!showDrafts && included.length < services.included.length && (
|
||||
<p className="mt-4 text-paper/75">Weitere Leistungen veröffentlichen wir, sobald sie verbindlich feststehen.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="border-b border-paper/30 pb-3 text-lg font-semibold">Nicht enthalten</h3>
|
||||
<ul className="mt-2 divide-y divide-paper/15">
|
||||
{services.excluded.map((item) => (
|
||||
<li key={item} className="py-4 text-lg text-paper/85">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 border-t border-paper/30 pt-10">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<h3 className="display text-3xl">So könnte ein Tag im Camp aussehen</h3>
|
||||
<p className="max-w-md text-[0.9375rem] text-paper/75">{services.exampleSchedule.note}</p>
|
||||
</div>
|
||||
{/* Nummeriert, weil es eine Abfolge ist — ausdruecklich als Beispiel gekennzeichnet. */}
|
||||
<ol className="mt-10 grid gap-8 md:grid-cols-3">
|
||||
{services.exampleSchedule.steps.map((step, index) => (
|
||||
<li key={step.title} className="border-t border-paper/30 pt-5">
|
||||
<span className="display text-5xl text-paper/40">{index + 1}</span>
|
||||
<p className="mt-3 text-lg font-semibold">{step.title}</p>
|
||||
<p className="mt-2 text-paper/80">{step.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
const { accommodation } = site;
|
||||
return (
|
||||
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-20 md:py-32">
|
||||
<div className="container-edge">
|
||||
<div className="grid gap-8 md:grid-cols-12">
|
||||
<h2 id="unterkunft-title" className="section-title md:col-span-6">{accommodation.title}</h2>
|
||||
<p className="text-lg text-ink/85 md:col-span-5 md:col-start-8 md:self-end">{accommodation.lead}</p>
|
||||
</div>
|
||||
<Gallery images={accommodation.images} showLabels={showDrafts} />
|
||||
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-7">
|
||||
<h3 className="text-lg font-semibold">Zimmer und Unterbringung</h3>
|
||||
<p className="mt-3 max-w-2xl">
|
||||
Gebucht wird ein Platz pro Person mit geteilter Unterbringung. Einzelbelegung und Zimmer-Upgrades sind nicht buchbar.
|
||||
</p>
|
||||
{facts.accommodationText ? (
|
||||
<p className="mt-3 max-w-2xl whitespace-pre-line">{facts.accommodationText}</p>
|
||||
) : (
|
||||
<p className="mt-3 max-w-2xl text-stone">
|
||||
Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-4 md:col-start-9">
|
||||
{accommodation.bookingUrl ? (
|
||||
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet">
|
||||
Unterkunft ansehen (externe Seite)
|
||||
</a>
|
||||
) : (
|
||||
showDrafts && <span className="draft-flag">Link zur Unterkunft folgt</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import { BookButton } from "../booking/BookingProvider";
|
||||
|
||||
type Faq = { q: string; a: React.ReactNode };
|
||||
|
||||
function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
|
||||
return [
|
||||
{
|
||||
q: "Wie läuft die Buchung ab?",
|
||||
a: "Du gibst deine Daten ein, wählst die Zahlungsweise und prüfst alles in einer Übersicht. Bezahlt wird auf einer sicheren Zahlungsseite unseres Zahlungsdienstleisters Stripe. Verbindlich bestätigt ist deine Buchung, sobald die Zahlung bei uns eingegangen ist – dann bekommst du eine E-Mail.",
|
||||
},
|
||||
{
|
||||
q: "Wie lange gilt der angezeigte Preis?",
|
||||
a: `Es gilt der Preis zum Zeitpunkt, an dem du die Buchung startest. Er bleibt ${pricing.reservationMinutes} Minuten für dich reserviert. Schließt du die Zahlung in dieser Zeit nicht ab, verfällt die Reservierung, und ein neuer Buchungsversuch wird zum dann gültigen Preis berechnet. Der Early-Bird-Preis endet am 31. Dezember 2026 um 24 Uhr und wird nicht verlängert.`,
|
||||
},
|
||||
{
|
||||
q: "Kann ich in Raten zahlen?",
|
||||
a: pricing.installmentsAllowed
|
||||
? "Ja, in zwei oder drei Raten. Die erste Rate zahlst du bei der Buchung, die weiteren werden zu festen Terminen über deine hinterlegte Zahlungsmethode eingezogen – die letzte spätestens 30 Tage vor Anreise. Alle Beträge und Termine siehst du vor der Zahlung. Nach der letzten Rate finden keine weiteren Abbuchungen statt."
|
||||
: "Ob eine Ratenzahlung möglich ist, geben wir bekannt, sobald der Camp-Termin feststeht.",
|
||||
},
|
||||
{
|
||||
q: "Welche Zahlungsarten gibt es?",
|
||||
a: "Die verfügbaren Zahlungsarten siehst du auf der Zahlungsseite von Stripe. Kartendaten gibst du nur dort ein; wir speichern sie nicht.",
|
||||
},
|
||||
{
|
||||
q: "Wie bin ich untergebracht?",
|
||||
a: facts.accommodationText
|
||||
? `Du buchst einen Platz pro Person mit geteilter Unterbringung. ${facts.accommodationText}`
|
||||
: "Du buchst einen Platz pro Person mit geteilter Unterbringung. Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.",
|
||||
},
|
||||
{
|
||||
q: "Wie reise ich an?",
|
||||
a: `Die Anreise nach Italien und zurück organisierst du selbst; sie ist nicht im Preis enthalten.${facts.location ? ` Das Camp findet in ${facts.location} statt.` : " Den genauen Ort geben wir bekannt, sobald er feststeht."}`,
|
||||
},
|
||||
{
|
||||
q: "Was ist nicht im Preis enthalten?",
|
||||
a: `${site.services.excluded.join(", ")}.`,
|
||||
},
|
||||
{
|
||||
q: "Was gilt, wenn ich nicht teilnehmen kann?",
|
||||
a: facts.termsUrl ? (
|
||||
<>
|
||||
Das regeln unsere{" "}
|
||||
<a href={facts.termsUrl} className="underline">
|
||||
Buchungs-, Storno- und Zahlungsbedingungen
|
||||
</a>
|
||||
. Eine Reiserücktrittsversicherung ist nicht enthalten.
|
||||
</>
|
||||
) : (
|
||||
"Die Buchungs-, Storno- und Zahlungsbedingungen veröffentlichen wir vor dem Verkaufsstart. Eine Reiserücktrittsversicherung ist nicht enthalten."
|
||||
),
|
||||
},
|
||||
{
|
||||
q: "Ich habe einen Gutscheincode. Wo gebe ich ihn ein?",
|
||||
a: "Im zweiten Schritt der Buchung. Pro Buchung lässt sich ein Gutschein einlösen.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function FaqSection({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
||||
return (
|
||||
<section id="fragen" aria-labelledby="fragen-title" className="py-20 md:py-32">
|
||||
<div className="container-edge grid gap-10 md:grid-cols-12">
|
||||
<h2 id="fragen-title" className="section-title md:col-span-4">{site.faq.title}</h2>
|
||||
<div className="md:col-span-8">
|
||||
{faqs(facts, pricing).map((item) => (
|
||||
<details key={item.q} className="group border-b border-line py-1 first:border-t">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-6 py-4 text-lg font-semibold [&::-webkit-details-marker]:hidden">
|
||||
{item.q}
|
||||
<span aria-hidden className="text-2xl font-normal text-rosso transition-transform duration-200 group-open:rotate-45">
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<div className="max-w-2xl pb-5 text-ink/85">{item.a}</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClosingSection({ pricing }: { pricing: PublicPricing }) {
|
||||
return (
|
||||
<section aria-labelledby="schluss-title" className="on-dark bg-ink py-24 text-paper md:py-36">
|
||||
<div className="container-edge">
|
||||
<h2 id="schluss-title" className="display max-w-4xl text-[clamp(2.75rem,7vw,6rem)] leading-[0.98]">
|
||||
Wir freuen uns auf dich in Italien.
|
||||
</h2>
|
||||
<div className="mt-10 flex flex-wrap gap-3">
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">Platz buchen</BookButton>
|
||||
) : (
|
||||
<a href="#preis" className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">
|
||||
Zum Buchungsstart
|
||||
</a>
|
||||
)}
|
||||
<a href="#fragen" className="btn btn-quiet text-paper">
|
||||
Fragen ansehen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { site } from "@/content/site";
|
||||
import { MediaFrame } from "../MediaFrame";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { VideoSlot } from "../VideoSlot";
|
||||
|
||||
export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { teachers } = site;
|
||||
return (
|
||||
<section id="dozentinnen" aria-labelledby="dozentinnen-title" className="bg-paper py-20 md:py-32">
|
||||
<div className="container-edge">
|
||||
<h2 id="dozentinnen-title" className="section-title">{teachers.title}</h2>
|
||||
<div className="mt-14 grid gap-16 md:grid-cols-12 md:gap-10">
|
||||
{teachers.people.map((person, index) => (
|
||||
<article
|
||||
key={index}
|
||||
aria-label={person.name ?? `Dozentin ${index + 1}`}
|
||||
className={index === 0 ? "md:col-span-5" : "md:col-span-5 md:col-start-8 md:mt-40"}
|
||||
>
|
||||
<Reveal>
|
||||
<MediaFrame media={person.portrait} aspect="aspect-[3/4]" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
|
||||
</Reveal>
|
||||
<h3 className="display mt-6 text-3xl">
|
||||
{person.name ?? (showDrafts ? `Dozentin ${index + 1}: Name folgt` : "Wird in Kürze vorgestellt")}
|
||||
</h3>
|
||||
{person.role && <p className="mt-1 text-stone">{person.role}</p>}
|
||||
{person.bio ? (
|
||||
<p className="mt-4 max-w-md">{person.bio}</p>
|
||||
) : (
|
||||
showDrafts && <p className="mt-4"><span className="draft-flag">Kurzbeschreibung folgt</span></p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { about } = site;
|
||||
return (
|
||||
<section id="artista" aria-labelledby="artista-title" className="py-20 md:py-32">
|
||||
<div className="container-edge grid gap-8 md:grid-cols-12">
|
||||
<h2 id="artista-title" className="section-title md:col-span-5">{about.title}</h2>
|
||||
<div className="prose-body md:col-span-6 md:col-start-7">
|
||||
{about.paragraphs.length > 0 ? (
|
||||
about.paragraphs.map((p) => <p key={p}>{p}</p>)
|
||||
) : (
|
||||
<p className="text-stone">
|
||||
{showDrafts ? (
|
||||
<>
|
||||
<span className="draft-flag">Inhalt folgt</span> Grundlage wird die ARTISTA-Stammmodel-Seite (Link folgt).
|
||||
</>
|
||||
) : (
|
||||
"Mehr über ARTISTA erfährst du in Kürze hier."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{about.sourceUrl && (
|
||||
<p className="mt-6">
|
||||
<a href={about.sourceUrl} target="_blank" rel="noopener noreferrer" className="font-semibold underline underline-offset-4">
|
||||
Zur ARTISTA-Stammmodel-Seite
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoicesSection({ showDrafts, provider }: { showDrafts: boolean; provider: "none" | "youtube-nocookie" | "vimeo" }) {
|
||||
const { videos } = site;
|
||||
const filled = videos.slots.filter((s) => s.src || s.externalId);
|
||||
// Leere Slots nur in Entwicklung/Staging zeigen; oeffentlich nie ein
|
||||
// erfundenes Testimonial als Ersatz.
|
||||
const slots = showDrafts ? videos.slots : filled;
|
||||
if (slots.length === 0) return null;
|
||||
return (
|
||||
<section id="stimmen" aria-labelledby="stimmen-title" className="border-t border-line py-20 md:py-28">
|
||||
<div className="container-edge">
|
||||
<h2 id="stimmen-title" className="section-title">{videos.title}</h2>
|
||||
<ul className="mt-12 grid gap-10 md:grid-cols-3">
|
||||
{slots.map((slot, index) => (
|
||||
<li key={index}>
|
||||
<VideoSlot slot={slot} index={index} provider={provider} showDrafts={showDrafts} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { formatEuroShort } from "@/lib/money";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import { BookButton } from "../booking/BookingProvider";
|
||||
|
||||
const CLOSED_TEXT: Record<Exclude<PublicPricing["closedReason"], null>, string> = {
|
||||
PREVIEW: "Die Buchung öffnet am 1. November 2026.",
|
||||
NOT_RELEASED: "Die Buchung ist noch nicht freigeschaltet. Wir veröffentlichen alle verbindlichen Angaben vor dem Verkaufsstart.",
|
||||
CAMP_STARTED: "Die Buchung ist geschlossen.",
|
||||
NOT_CONFIGURED: "Die Buchung ist derzeit nicht verfügbar.",
|
||||
};
|
||||
|
||||
export function PricingSection({ pricing, facts, showDrafts }: { pricing: PublicPricing; facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
const included = site.services.included.filter((s) => s.confirmed);
|
||||
return (
|
||||
<section id="preis" aria-labelledby="preis-title" className="bg-paper py-20 md:py-32">
|
||||
<div className="container-edge grid gap-14 md:grid-cols-12">
|
||||
<div className="md:col-span-7">
|
||||
<h2 id="preis-title" className="section-title">Preis und Buchung</h2>
|
||||
|
||||
{pricing.mode === "test" && (
|
||||
<p className="mt-6 rounded-lg border border-line bg-plaster p-4 text-[0.9375rem]">
|
||||
<span className="draft-flag">Testmodus</span> Zahlungen laufen über Stripe-Testdaten, es wird kein Geld eingezogen.
|
||||
{pricing.phaseSimulated && " Die Preisphase ist für Tests simuliert."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-10">
|
||||
{pricing.phase === "EARLY_BIRD" && pricing.currentCents !== null && (
|
||||
<>
|
||||
<p className="text-stone">
|
||||
Regulärer Preis <s className="tabular-nums">{formatEuroShort(pricing.regularCents)}</s>
|
||||
</p>
|
||||
<p className="display mt-2 text-[clamp(4rem,12vw,8rem)] leading-none tabular-nums">{formatEuroShort(pricing.currentCents)}</p>
|
||||
<p className="mt-3 text-lg">Early-Bird-Preis pro Person, gültig bis einschließlich 31. Dezember 2026.</p>
|
||||
</>
|
||||
)}
|
||||
{pricing.phase === "REGULAR" && pricing.currentCents !== null && (
|
||||
<>
|
||||
<p className="display text-[clamp(4rem,12vw,8rem)] leading-none tabular-nums">{formatEuroShort(pricing.currentCents)}</p>
|
||||
<p className="mt-3 text-lg">pro Person</p>
|
||||
</>
|
||||
)}
|
||||
{pricing.phase === "PREVIEW" && (
|
||||
<dl className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="border-t border-ink pt-4">
|
||||
<dt className="text-stone">Early Bird, 1. November bis 31. Dezember 2026</dt>
|
||||
<dd className="display mt-2 text-6xl tabular-nums">{formatEuroShort(pricing.earlyBirdCents)}</dd>
|
||||
</div>
|
||||
<div className="border-t border-line pt-4">
|
||||
<dt className="text-stone">Regulär, ab 1. Januar 2027</dt>
|
||||
<dd className="display mt-2 text-6xl tabular-nums">{formatEuroShort(pricing.regularCents)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 max-w-xl space-y-2 text-[0.9375rem] text-ink/85">
|
||||
<li>Gesamtpreis pro Person für die Teilnahme mit geteilter Unterbringung.</li>
|
||||
<li>
|
||||
{pricing.installmentsAllowed
|
||||
? "Bezahlen kannst du auf einmal oder in bis zu drei Raten. Die erste Rate ist bei Buchung fällig, die letzte spätestens 30 Tage vor Anreise."
|
||||
: "Bezahlt wird bei Buchung. Ob zusätzlich Ratenzahlung möglich ist, geben wir bekannt, sobald der Termin feststeht."}
|
||||
</li>
|
||||
<li>Einen Gutscheincode kannst du im Buchungsschritt eingeben.</li>
|
||||
{facts.taxNote ? (
|
||||
<li>{facts.taxNote}</li>
|
||||
) : (
|
||||
showDrafts && (
|
||||
<li>
|
||||
<span className="draft-flag">Steuerlicher Preishinweis folgt vor Verkaufsstart</span>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="mt-10">
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn btn-primary px-8 text-lg">Platz buchen</BookButton>
|
||||
) : (
|
||||
<p className="max-w-xl text-lg font-semibold">{CLOSED_TEXT[pricing.closedReason ?? "NOT_CONFIGURED"]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside aria-labelledby="preis-umfang" className="md:col-span-4 md:col-start-9 md:pt-24">
|
||||
<h3 id="preis-umfang" className="text-lg font-semibold">Was du buchst</h3>
|
||||
<ul className="mt-4 divide-y divide-line border-y border-line">
|
||||
{included.map((item) => (
|
||||
<li key={item.text} className="py-3">{item.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-6 text-[0.9375rem] text-stone">
|
||||
Nicht enthalten: {site.services.excluded.join(", ")}.
|
||||
</p>
|
||||
<p className="mt-4 text-[0.9375rem] text-stone">
|
||||
Vor der Zahlung siehst du Gesamtpreis, heute fälligen Betrag, alle weiteren Raten mit Terminen und die Beschreibung der Unterbringung auf einen Blick.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Zentrale Inhalte der Onepage.
|
||||
*
|
||||
* REGELN FUER DIESE DATEI
|
||||
* - Nichts erfinden: Termine, Leistungen, Qualifikationen, Bewertungen und
|
||||
* Rechtliches nur aus bestaetigten Angaben.
|
||||
* - "confirmed: false" heisst Entwurf. Entwuerfe erscheinen in Entwicklung
|
||||
* und Staging sichtbar als solche markiert und werden im Produktivbetrieb
|
||||
* (APP_ENV=production) ausgeblendet oder durch einen neutralen Hinweis
|
||||
* ersetzt.
|
||||
* - Termin, Ort, Zimmer-/Bettbeschreibung, Veranstalter, Steuerhinweis und
|
||||
* Bedingungen pflegt der Adminbereich (Datenbank), weil sie den
|
||||
* Live-Verkauf freischalten. Hier steht alles Uebrige.
|
||||
* - Bilder: nur mit bestaetigter Nutzungsberechtigung, als lokale Datei im
|
||||
* Medienverzeichnis (MEDIA_DIR, ausgeliefert unter /media/…). Kein
|
||||
* Hotlinking fremder Bilder (z. B. von Booking.com).
|
||||
*
|
||||
* Offene Punkte: docs/OFFENE-PUNKTE.md
|
||||
*/
|
||||
|
||||
export type PlaceholderTone = "villa" | "garden" | "room" | "terrace" | "portrait" | "studio";
|
||||
|
||||
export type Media =
|
||||
| { kind: "placeholder"; label: string; tone: PlaceholderTone }
|
||||
| { kind: "image"; src: string; alt: string; width: number; height: number };
|
||||
|
||||
export type Confirmable<T> = T & { confirmed: boolean };
|
||||
|
||||
const placeholder = (label: string, tone: PlaceholderTone): Media => ({ kind: "placeholder", label, tone });
|
||||
|
||||
export const site = {
|
||||
hero: {
|
||||
// Entwurf zur Freigabe: bewusst ohne Versprechen zu Shootings,
|
||||
// Vermittlung oder Ergebnissen.
|
||||
headline: "Gemeinsam wachsen. In Italien.",
|
||||
subline:
|
||||
"Das ARTISTA Modelcamp 2027 bringt Models an einem Ort in Italien zusammen – zum Lernen, Arbeiten und Wohnen unter einem Dach.",
|
||||
media: placeholder("Leitmotiv: Bild oder Video folgt", "villa"),
|
||||
},
|
||||
|
||||
camp: {
|
||||
title: "Das Camp",
|
||||
lead: "Ein paar Tage, an denen sich alles um deine Arbeit als Model dreht – in Ruhe, in einer kleinen Gruppe und mit Zeit füreinander.",
|
||||
paragraphs: [
|
||||
"Das Modelcamp verbindet gemeinsames Arbeiten mit dem Leben unter einem Dach. Du wohnst mit den anderen Teilnehmerinnen in einer Unterkunft in Italien, lernst von zwei Dozentinnen und tauschst dich mit Menschen aus, die dieselbe Leidenschaft teilen.",
|
||||
"Das Programm wird gerade zusammengestellt. Was bereits feststeht, findest du unten unter Leistungen – alles andere ergänzen wir, sobald es verbindlich ist.",
|
||||
],
|
||||
audience: {
|
||||
title: "Für wen das Camp gedacht ist",
|
||||
items: [
|
||||
"Models, die bereits mit ARTISTA arbeiten",
|
||||
"Models, die ARTISTA und die Arbeit vor der Kamera näher kennenlernen möchten",
|
||||
"Alle, die Lust auf eine konzentrierte Zeit in einer kleinen Gruppe haben",
|
||||
],
|
||||
confirmed: false,
|
||||
},
|
||||
// Ausdruecklich KEINE Aussagen zu Agenturvermittlung, Auftraegen,
|
||||
// Verdienst oder Karriereerfolg (Briefing Abschnitt 4).
|
||||
},
|
||||
|
||||
services: {
|
||||
title: "Erlebnisse und Leistungen",
|
||||
included: [
|
||||
// Bestaetigt durch das Buchungsprodukt selbst:
|
||||
{ text: "Teilnahme am Modelcamp 2027", confirmed: true },
|
||||
{ text: "Unterbringung in einer geteilten Unterkunft in Italien", confirmed: true },
|
||||
// Entwuerfe: erst nach Bestaetigung (Admin: "Leistungen bestaetigt")
|
||||
// auf confirmed: true setzen.
|
||||
{ text: "Workshops mit den beiden Dozentinnen", confirmed: false },
|
||||
{ text: "Verpflegung vor Ort (Umfang folgt)", confirmed: false },
|
||||
] satisfies Confirmable<{ text: string }>[],
|
||||
excluded: [
|
||||
// Laut Vorlage ausdruecklich nicht enthalten.
|
||||
"Anreise nach Italien und zurück",
|
||||
"Persönliche Ausgaben vor Ort",
|
||||
"Reiserücktrittsversicherung",
|
||||
],
|
||||
exampleSchedule: {
|
||||
// Beispiel, KEIN verbindliches Programm.
|
||||
note: "Beispielhafter Ablauf – das verbindliche Programm veröffentlichen wir, sobald alle Punkte bestätigt sind.",
|
||||
steps: [
|
||||
{ title: "Ankommen", text: "Anreise, Zimmer beziehen, erstes Kennenlernen der Gruppe." },
|
||||
{ title: "Arbeiten", text: "Gemeinsame Einheiten mit den Dozentinnen und Zeit für Fragen." },
|
||||
{ title: "Abschied", text: "Rückblick, Austausch und individuelle Abreise." },
|
||||
],
|
||||
confirmed: false,
|
||||
},
|
||||
},
|
||||
|
||||
accommodation: {
|
||||
title: "Die Unterkunft",
|
||||
lead: "Wohnen, arbeiten und abends zusammensitzen – alles an einem Ort.",
|
||||
// Link zur Unterkunft (z. B. Booking.com), nachzureichen.
|
||||
bookingUrl: null as string | null,
|
||||
images: [
|
||||
placeholder("Unterkunft: Außenansicht", "villa"),
|
||||
placeholder("Unterkunft: Garten oder Terrasse", "terrace"),
|
||||
placeholder("Unterkunft: Zimmer", "room"),
|
||||
placeholder("Unterkunft: Gemeinschaftsbereich", "garden"),
|
||||
] as Media[],
|
||||
},
|
||||
|
||||
teachers: {
|
||||
title: "Die Dozentinnen",
|
||||
people: [
|
||||
{ name: null as string | null, role: null as string | null, bio: null as string | null, portrait: placeholder("Porträt Dozentin", "portrait") as Media },
|
||||
{ name: null as string | null, role: null as string | null, bio: null as string | null, portrait: placeholder("Porträt Dozentin", "studio") as Media },
|
||||
],
|
||||
},
|
||||
|
||||
about: {
|
||||
title: "Über ARTISTA",
|
||||
// Grundlage soll die ARTISTA-Stammmodel-Seite sein (Link folgt). Bis
|
||||
// dahin keine Unternehmensgeschichte und keine Zahlen.
|
||||
sourceUrl: null as string | null,
|
||||
paragraphs: [] as string[],
|
||||
},
|
||||
|
||||
videos: {
|
||||
title: "Stimmen",
|
||||
// Genau drei Slots. Nur echte, freigegebene Videos eintragen.
|
||||
slots: [
|
||||
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
|
||||
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
|
||||
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
|
||||
],
|
||||
},
|
||||
|
||||
faq: {
|
||||
title: "Häufige Fragen",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ id: "camp", label: "Das Camp" },
|
||||
{ id: "leistungen", label: "Leistungen" },
|
||||
{ id: "unterkunft", label: "Unterkunft" },
|
||||
{ id: "dozentinnen", label: "Dozentinnen" },
|
||||
{ id: "artista", label: "Über ARTISTA" },
|
||||
{ id: "preis", label: "Preis" },
|
||||
{ id: "fragen", label: "Fragen" },
|
||||
] as const;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Serverseitige Zugriffskontrolle fuer den Adminbereich. Wird von JEDER
|
||||
* Admin-Seite, Server-Action und jedem Admin-Endpunkt aufgerufen — der
|
||||
* Proxy (src/proxy.ts) leitet nur vorab um und ist keine Sicherheitsgrenze.
|
||||
*/
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Actor } from "./audit";
|
||||
import { getAuth } from "./auth";
|
||||
|
||||
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
||||
|
||||
async function currentAdmin(): Promise<AdminUser | null> {
|
||||
const session = await getAuth().api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
|
||||
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
|
||||
}
|
||||
|
||||
/** Fuer Seiten: leitet zur Anmeldung bzw. zur 2FA-Einrichtung um. */
|
||||
export async function requireAdminPage(options: { allowWithout2fa?: boolean } = {}): Promise<AdminUser> {
|
||||
const admin = await currentAdmin();
|
||||
if (!admin) redirect("/admin/anmelden");
|
||||
if (!admin.twoFactorEnabled && !options.allowWithout2fa) redirect("/admin/sicherheit");
|
||||
return admin;
|
||||
}
|
||||
|
||||
/** Fuer Server-Actions und Endpunkte: wirft statt umzuleiten. */
|
||||
export async function requireAdminAction(): Promise<AdminUser> {
|
||||
const admin = await currentAdmin();
|
||||
if (!admin || !admin.twoFactorEnabled) throw new Error("Nicht berechtigt.");
|
||||
return admin;
|
||||
}
|
||||
|
||||
/** Fuer Route-Handler: null statt Ausnahme. */
|
||||
export async function adminOrNull(): Promise<AdminUser | null> {
|
||||
const admin = await currentAdmin();
|
||||
return admin?.twoFactorEnabled ? admin : null;
|
||||
}
|
||||
|
||||
export function adminActor(admin: AdminUser): Actor {
|
||||
return { type: "ADMIN", id: admin.id };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { BookingStatus, PaymentStatus, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const BOOKING_STATUSES: BookingStatus[] = ["PENDING", "CONFIRMED", "CANCELED", "EXPIRED", "NEEDS_REVIEW"];
|
||||
const PAYMENT_STATUSES: PaymentStatus[] = ["UNPAID", "PROCESSING", "PARTIALLY_PAID", "PAID", "FAILED", "PARTIALLY_REFUNDED", "REFUNDED"];
|
||||
|
||||
export type BookingFilter = { status?: BookingStatus; zahlung?: PaymentStatus; modus: "test" | "live"; q?: string };
|
||||
|
||||
/** Liest Filter aus der URL; Unbekanntes wird ignoriert statt uebernommen. */
|
||||
export function parseBookingFilter(params: Record<string, string | string[] | undefined>, defaultMode: "test" | "live"): BookingFilter {
|
||||
const get = (k: string) => (typeof params[k] === "string" ? (params[k] as string) : undefined);
|
||||
const status = get("status");
|
||||
const zahlung = get("zahlung");
|
||||
const modus = get("modus");
|
||||
const q = get("q")?.trim().slice(0, 100);
|
||||
return {
|
||||
status: BOOKING_STATUSES.includes(status as BookingStatus) ? (status as BookingStatus) : undefined,
|
||||
zahlung: PAYMENT_STATUSES.includes(zahlung as PaymentStatus) ? (zahlung as PaymentStatus) : undefined,
|
||||
modus: modus === "live" || modus === "test" ? modus : defaultMode,
|
||||
q: q || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function bookingWhere(filter: BookingFilter): Prisma.BookingWhereInput {
|
||||
return {
|
||||
livemode: filter.modus === "live",
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
...(filter.zahlung ? { paymentStatus: filter.zahlung } : {}),
|
||||
...(filter.q
|
||||
? {
|
||||
OR: [
|
||||
{ reference: { contains: filter.q, mode: "insensitive" } },
|
||||
{ email: { contains: filter.q, mode: "insensitive" } },
|
||||
{ lastName: { contains: filter.q, mode: "insensitive" } },
|
||||
{ firstName: { contains: filter.q, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterQuery(filter: BookingFilter): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("modus", filter.modus);
|
||||
if (filter.status) params.set("status", filter.status);
|
||||
if (filter.zahlung) params.set("zahlung", filter.zahlung);
|
||||
if (filter.q) params.set("q", filter.q);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/** Schutz vor Formel-Injektion: Zellen, die mit = + - @ beginnen, werden entschaerft. */
|
||||
export function csvCell(value: string | number | null | undefined): string {
|
||||
let s = value === null || value === undefined ? "" : String(value);
|
||||
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export { BOOKING_STATUSES, PAYMENT_STATUSES };
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Verwaltungsaktionen. Jede Aktion prueft ihre Eingaben, laeuft unter
|
||||
* Buchungssperre und wird im AuditLog festgehalten.
|
||||
*
|
||||
* Stornierung und Erstattung sind bewusst getrennt: Die freigegebenen
|
||||
* Stornobedingungen bestimmen, welcher Betrag erstattet wird — diese
|
||||
* Entscheidung trifft ein Mensch, nicht die Software (docs/BETRIEB.md).
|
||||
*/
|
||||
import "./zod-de";
|
||||
import { z } from "zod";
|
||||
import { audit, type Actor } from "./audit";
|
||||
import { countOccupiedSeats, lockBooking, lockCamp } from "./booking-service";
|
||||
import { CAMP_ID, type Db } from "./db";
|
||||
import { enqueueEmail } from "./email";
|
||||
import { bookingConfirmedEmail } from "./email-templates";
|
||||
import type { PaymentGateway } from "./payment-gateway";
|
||||
import { getCamp } from "./camp";
|
||||
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
|
||||
import { statusTokenFor } from "./tokens";
|
||||
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
|
||||
import { detachFinishedPaymentMethods } from "./worker-jobs";
|
||||
|
||||
export type ActionResult = { ok: true; message: string } | { ok: false; message: string };
|
||||
|
||||
export async function cancelBooking(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
params: { bookingId: string; reason: string },
|
||||
actor: Actor,
|
||||
now = new Date(),
|
||||
): Promise<ActionResult> {
|
||||
const reason = params.reason.trim().slice(0, 500);
|
||||
if (!reason) return { ok: false, message: "Bitte einen Grund angeben." };
|
||||
|
||||
const before = await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, params.bookingId);
|
||||
const booking = await tx.booking.findUnique({ where: { id: params.bookingId }, include: { installments: true } });
|
||||
if (!booking || booking.status === "CANCELED") return null;
|
||||
await tx.installment.updateMany({
|
||||
where: { bookingId: booking.id, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
||||
data: { status: "CANCELED", nextAttemptAt: null },
|
||||
});
|
||||
await tx.seatReservation.updateMany({
|
||||
where: { bookingId: booking.id, status: { not: "RELEASED" } },
|
||||
data: { status: "RELEASED", releasedAt: now, releaseReason: "canceled_by_admin" },
|
||||
});
|
||||
await tx.voucherRedemption.updateMany({
|
||||
where: { bookingId: booking.id, status: { not: "RELEASED" } },
|
||||
data: { status: "RELEASED", releasedAt: now },
|
||||
});
|
||||
await tx.booking.update({ where: { id: booking.id }, data: { status: "CANCELED", canceledAt: now, cancelReason: reason } });
|
||||
await audit(tx, actor, "booking.canceled", "Booking", booking.id, { reason, previousStatus: booking.status, paidCents: booking.paidCents });
|
||||
return booking;
|
||||
});
|
||||
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
|
||||
|
||||
// Offene Stripe-Sessions schliessen und gespeicherte Zahlungsmethode
|
||||
// trennen: nach einer Stornierung darf nichts mehr abgebucht werden.
|
||||
const sessions = [before.stripeCheckoutSessionId, ...before.installments.map((i) => i.checkoutSessionId)].filter(Boolean) as string[];
|
||||
for (const id of sessions) await gateway.expireCheckoutSession(id).catch(() => undefined);
|
||||
await detachFinishedPaymentMethods(db, gateway, [before.id]);
|
||||
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
|
||||
}
|
||||
|
||||
export async function refundInstallment(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
params: { installmentId: string; amountCents: number; requestKey: string; reason: string },
|
||||
actor: Actor,
|
||||
): Promise<ActionResult> {
|
||||
if (!Number.isSafeInteger(params.amountCents) || params.amountCents <= 0) return { ok: false, message: "Ungültiger Betrag." };
|
||||
const installment = await db.installment.findUnique({ where: { id: params.installmentId }, include: { refunds: true } });
|
||||
if (!installment || installment.status !== "PAID" || !installment.stripePaymentIntentId) {
|
||||
return { ok: false, message: "Nur bezahlte Raten können erstattet werden." };
|
||||
}
|
||||
const alreadyRequested = installment.refunds.filter((r) => r.status !== "failed" && r.status !== "canceled").reduce((s, r) => s + r.amountCents, 0);
|
||||
const refundable = installment.amountCents - Math.max(alreadyRequested, installment.refundedCents);
|
||||
if (params.amountCents > refundable) return { ok: false, message: `Höchstens ${(refundable / 100).toFixed(2)} € erstattbar.` };
|
||||
|
||||
const refund = await gateway.createRefund(
|
||||
{ paymentIntentId: installment.stripePaymentIntentId, amountCents: params.amountCents, metadata: { bookingId: installment.bookingId, installmentId: installment.id } },
|
||||
`refund:${params.requestKey}`,
|
||||
);
|
||||
await db.refund.upsert({
|
||||
where: { stripeRefundId: refund.id },
|
||||
create: {
|
||||
bookingId: installment.bookingId,
|
||||
installmentId: installment.id,
|
||||
stripeRefundId: refund.id,
|
||||
amountCents: refund.amount,
|
||||
status: refund.status,
|
||||
reason: params.reason.slice(0, 500) || null,
|
||||
createdBy: actor.id ?? null,
|
||||
},
|
||||
update: { status: refund.status },
|
||||
});
|
||||
await audit(db, actor, "payment.refund_requested", "Installment", installment.id, {
|
||||
bookingId: installment.bookingId,
|
||||
refundId: refund.id,
|
||||
amountCents: refund.amount,
|
||||
});
|
||||
return { ok: true, message: "Erstattung bei Stripe ausgelöst. Der Zahlungsstatus aktualisiert sich, sobald Stripe sie bestätigt." };
|
||||
}
|
||||
|
||||
/** Entscheidung ueber eine Buchung im Status NEEDS_REVIEW. */
|
||||
export async function confirmReviewedBooking(db: Db, bookingId: string, actor: Actor, now = new Date()): Promise<ActionResult> {
|
||||
return db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, bookingId);
|
||||
const booking = await tx.booking.findUnique({ where: { id: bookingId }, include: { reservation: true } });
|
||||
if (!booking || booking.status !== "NEEDS_REVIEW") return { ok: false, message: "Buchung ist nicht in Prüfung." };
|
||||
if (booking.paidCents <= 0) return { ok: false, message: "Ohne Zahlungseingang kann nicht bestätigt werden." };
|
||||
if (!booking.reservation) return { ok: false, message: "Zu dieser Buchung gibt es keine Platzreservierung." };
|
||||
if (booking.reservation.status !== "CONVERTED") {
|
||||
await lockCamp(tx);
|
||||
const camp = await getCamp(tx);
|
||||
const occupied = await countOccupiedSeats(tx, booking.livemode, now);
|
||||
if (occupied >= camp.capacity) {
|
||||
return { ok: false, message: "Kein Platz frei. Kapazität in den Einstellungen erhöhen oder die Buchung stornieren." };
|
||||
}
|
||||
await tx.seatReservation.update({ where: { bookingId }, data: { status: "CONVERTED", releasedAt: null, releaseReason: null } });
|
||||
}
|
||||
await tx.booking.update({ where: { id: bookingId }, data: { status: "CONFIRMED", confirmedAt: now, reviewReason: null } });
|
||||
await audit(tx, actor, "booking.review_confirmed", "Booking", bookingId, { previousReason: booking.reviewReason });
|
||||
return { ok: true, message: "Buchung bestätigt." };
|
||||
});
|
||||
}
|
||||
|
||||
export async function resendConfirmation(db: Db, bookingId: string, secret: string, actor: Actor): Promise<ActionResult> {
|
||||
const booking = await db.booking.findUnique({ where: { id: bookingId }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||
if (!booking || booking.status !== "CONFIRMED") return { ok: false, message: "Nur bestätigte Buchungen." };
|
||||
const mail = bookingConfirmedEmail(booking, statusTokenFor(booking.id, secret));
|
||||
await enqueueEmail(db, { ...mail, dedupeKey: `${mail.dedupeKey}:resend:${Date.now()}` });
|
||||
await audit(db, actor, "booking.confirmation_resent", "Booking", bookingId);
|
||||
return { ok: true, message: "Bestätigung erneut in den Postausgang gelegt." };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gutscheine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dateOnly = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), "Datum im Format JJJJ-MM-TT");
|
||||
/** "Gueltig ab" = 00:00 Uhr Berlin des Tages. */
|
||||
const validFromDate = dateOnly.transform((v) => (v ? berlinStartOfDay(v) : null));
|
||||
/** "Gueltig bis einschliesslich" = exklusive Grenze 00:00 Uhr Berlin am Folgetag. */
|
||||
const validUntilDate = dateOnly.transform((v) => (v ? berlinStartOfDay(addDays(v, 1)) : null));
|
||||
|
||||
export const voucherInputSchema = z
|
||||
.object({
|
||||
code: z.string().transform((v, ctx) => {
|
||||
const code = normalizeVoucherCode(v);
|
||||
if (!code) {
|
||||
ctx.addIssue({ code: "custom", message: "Code: 4–40 Zeichen, nur Buchstaben, Ziffern und Bindestrich." });
|
||||
return z.NEVER;
|
||||
}
|
||||
return code;
|
||||
}),
|
||||
active: z.boolean(),
|
||||
validFrom: validFromDate,
|
||||
validUntil: validUntilDate,
|
||||
maxRedemptions: z.coerce.number().int().min(1).max(100),
|
||||
boundEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => (v ? normalizeEmail(v) : null))
|
||||
.refine((v) => v === null || z.email().safeParse(v).success, "Ungültige E-Mail-Adresse"),
|
||||
internalNote: z.string().trim().max(500).transform((v) => v || null),
|
||||
})
|
||||
.refine((v) => !v.validFrom || !v.validUntil || v.validFrom < v.validUntil, { message: "Gültig bis muss nach Gültig ab liegen.", path: ["validUntil"] });
|
||||
|
||||
export type VoucherInput = z.infer<typeof voucherInputSchema>;
|
||||
|
||||
export async function saveVoucher(db: Db, id: string | null, input: VoucherInput, actor: Actor): Promise<ActionResult & { id?: string }> {
|
||||
const clash = await db.voucher.findUnique({ where: { code: input.code } });
|
||||
if (clash && clash.id !== id) return { ok: false, message: "Dieser Code existiert bereits." };
|
||||
const voucher = id ? await db.voucher.update({ where: { id }, data: input }) : await db.voucher.create({ data: input });
|
||||
await audit(db, actor, id ? "voucher.updated" : "voucher.created", "Voucher", voucher.id, {
|
||||
active: input.active,
|
||||
maxRedemptions: input.maxRedemptions,
|
||||
bound: input.boundEmail !== null,
|
||||
});
|
||||
return { ok: true, message: id ? "Gutschein gespeichert." : "Gutschein angelegt.", id: voucher.id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camp-Konfiguration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const text = (max: number) => z.string().trim().max(max).transform((v) => v || null);
|
||||
const isoDate = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), "Datum im Format JJJJ-MM-TT")
|
||||
.transform((v) => (v ? isoToDbDate(v) : null));
|
||||
|
||||
export const campInputSchema = z.object({
|
||||
salesEnabled: z.boolean(),
|
||||
capacity: z.coerce.number().int().min(0).max(200),
|
||||
capacityConfirmed: z.boolean(),
|
||||
stammmodelQuota: z.coerce.number().int().min(0).max(200),
|
||||
stammmodelQuotaConfirmed: z.boolean(),
|
||||
campStartDate: isoDate,
|
||||
campEndDate: isoDate,
|
||||
dateConfirmed: z.boolean(),
|
||||
locationName: text(200),
|
||||
locationRegion: text(200),
|
||||
locationConfirmed: z.boolean(),
|
||||
accommodationText: text(3000),
|
||||
accommodationConfirmed: z.boolean(),
|
||||
servicesConfirmed: z.boolean(),
|
||||
organizerName: text(200),
|
||||
organizerAddress: text(500),
|
||||
organizerEmail: text(200).refine((v) => v === null || z.email().safeParse(v).success, "Ungültige E-Mail-Adresse"),
|
||||
organizerPhone: text(60),
|
||||
organizerConfirmed: z.boolean(),
|
||||
taxNote: text(500),
|
||||
taxConfirmed: z.boolean(),
|
||||
termsVersion: text(60),
|
||||
termsUrl: text(500).refine((v) => v === null || v.startsWith("/") || v.startsWith("https://"), "Nur https:// oder ein Pfad dieser Seite"),
|
||||
termsApproved: z.boolean(),
|
||||
travelLawChecked: z.boolean(),
|
||||
installmentsApproved: z.boolean(),
|
||||
installmentRuleApproved: z.boolean(),
|
||||
reservationMinutes: z.coerce.number().int().min(30).max(120),
|
||||
finalInstallmentLeadDays: z.coerce.number().int().min(0).max(120),
|
||||
minInstallmentGapDays: z.coerce.number().int().min(1).max(90),
|
||||
testPhaseOverride: z.enum(["", "EARLY_BIRD", "REGULAR"]).transform((v) => (v ? v : null)),
|
||||
});
|
||||
|
||||
export type CampInput = z.infer<typeof campInputSchema>;
|
||||
|
||||
export async function updateCamp(db: Db, input: CampInput, actor: Actor): Promise<ActionResult> {
|
||||
if (input.campStartDate && input.campEndDate && input.campEndDate < input.campStartDate) {
|
||||
return { ok: false, message: "Das Enddatum liegt vor dem Anreisetag." };
|
||||
}
|
||||
const before = await getCamp(db);
|
||||
const changed = (Object.keys(input) as (keyof CampInput)[]).filter((key) => {
|
||||
const a = before[key];
|
||||
const b = input[key];
|
||||
return (a instanceof Date ? a.getTime() : a) !== (b instanceof Date ? b.getTime() : b);
|
||||
});
|
||||
await db.camp.update({ where: { id: CAMP_ID }, data: { ...input, updatedBy: actor.id ?? null } });
|
||||
await audit(db, actor, "camp.updated", "Camp", CAMP_ID, { changed });
|
||||
return { ok: true, message: changed.length ? `Gespeichert (${changed.length} Änderungen).` : "Keine Änderungen." };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Gemeinsame Bausteine fuer die oeffentlichen API-Routen: einheitliche
|
||||
* Antwortform, Schutz schreibender Endpunkte, Rate Limiting.
|
||||
*/
|
||||
import "./zod-de";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { z } from "zod";
|
||||
import { getDb } from "./db";
|
||||
import { env, requireAppSecret } from "./env";
|
||||
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit";
|
||||
|
||||
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
||||
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
|
||||
export function ok<T>(data: T, status = 200): NextResponse {
|
||||
return NextResponse.json({ success: true, data }, { status, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
export function fail(status: number, error: ApiError, headers: Record<string, string> = {}): NextResponse {
|
||||
return NextResponse.json({ success: false, error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF-Schutz: schreibende Aufrufe nur von der eigenen Seite (Origin bzw.
|
||||
* Sec-Fetch-Site) und nur mit JSON — ein fremdes HTML-Formular kann weder
|
||||
* das eine noch das andere liefern.
|
||||
*/
|
||||
export function checkSameOrigin(request: Request): NextResponse | null {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.startsWith("application/json")) {
|
||||
return fail(415, { code: "UNSUPPORTED_MEDIA_TYPE", message: "Anfrage muss JSON sein." });
|
||||
}
|
||||
const site = request.headers.get("sec-fetch-site");
|
||||
const origin = request.headers.get("origin");
|
||||
const forbidden = fail(403, { code: "FORBIDDEN", message: "Anfrage nicht erlaubt." });
|
||||
// Fail closed: ohne jede Herkunftsangabe keine Annahme.
|
||||
if (!site && !origin) return forbidden;
|
||||
if (site && site !== "same-origin") return forbidden;
|
||||
if (origin && origin !== new URL(env().APP_URL).origin) return forbidden;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readJson<T extends z.ZodType>(
|
||||
request: Request,
|
||||
schema: T,
|
||||
): Promise<{ ok: true; data: z.infer<T> } | { ok: false; response: NextResponse }> {
|
||||
const raw = await request.text();
|
||||
if (raw.length > MAX_BODY_BYTES) {
|
||||
return { ok: false, response: fail(413, { code: "TOO_LARGE", message: "Anfrage ist zu groß." }) };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, response: fail(400, { code: "INVALID_JSON", message: "Anfrage konnte nicht gelesen werden." }) };
|
||||
}
|
||||
const result = schema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (!fields[key]) fields[key] = issue.message;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
response: fail(422, { code: "VALIDATION", message: "Bitte prüfe die markierten Angaben.", fields }),
|
||||
};
|
||||
}
|
||||
return { ok: true, data: result.data };
|
||||
}
|
||||
|
||||
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
||||
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret());
|
||||
if (result.allowed) return null;
|
||||
return fail(
|
||||
429,
|
||||
{ code: "RATE_LIMITED", message: "Zu viele Anfragen. Bitte warte einen Moment und versuche es dann erneut." },
|
||||
{ "Retry-After": String(result.retryAfterSeconds) },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ActorType, Prisma } from "@/generated/prisma/client";
|
||||
import type { Db, Tx } from "./db";
|
||||
|
||||
export type Actor = { type: ActorType; id?: string | null };
|
||||
|
||||
export const SYSTEM: Actor = { type: "SYSTEM" };
|
||||
export const STRIPE_ACTOR: Actor = { type: "STRIPE" };
|
||||
export const WORKER: Actor = { type: "WORKER" };
|
||||
export const CUSTOMER: Actor = { type: "CUSTOMER" };
|
||||
|
||||
/**
|
||||
* Protokolliert Buchungs-, Bestands- und Zahlungsaktionen. "details" enthaelt
|
||||
* Betraege, Status und Stripe-IDs — keine Kontaktdaten der Teilnehmerin.
|
||||
*/
|
||||
export async function audit(
|
||||
db: Db | Tx,
|
||||
actor: Actor,
|
||||
action: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
details?: Prisma.InputJsonValue,
|
||||
): Promise<void> {
|
||||
await db.auditLog.create({
|
||||
data: { actorType: actor.type, actorId: actor.id ?? null, action, entityType, entityId, details },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { twoFactorClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
// Den zweiten Anmeldeschritt steuert LoginForm selbst (twoFactorRedirect).
|
||||
plugins: [twoFactorClient()],
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Anmeldung fuer den Adminbereich mit Better Auth (etablierte Loesung statt
|
||||
* Eigenbau).
|
||||
*
|
||||
* - Keine Selbstregistrierung: Konten entstehen nur per
|
||||
* `pnpm admin:create` (scripts/create-admin.ts), ohne Standardpasswort.
|
||||
* - Zwei-Faktor-Anmeldung (TOTP) ist Pflicht: admin-auth.ts laesst ohne
|
||||
* aktivierte 2FA nur die Einrichtungsseite zu.
|
||||
* - Rate Limits liegen in PostgreSQL und gelten damit ueber alle Replikate.
|
||||
* - Passwoerter werden gegen bekannte Datenlecks geprueft (k-Anonymitaet,
|
||||
* nur ein Hash-Praefix verlaesst den Server).
|
||||
*/
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
|
||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||
import { getDb } from "./db";
|
||||
import { env } from "./env";
|
||||
|
||||
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
||||
const e = env();
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret || secret.length < 32) throw new Error("BETTER_AUTH_SECRET fehlt oder ist kuerzer als 32 Zeichen.");
|
||||
return betterAuth({
|
||||
appName: "ARTISTA Modelcamp Verwaltung",
|
||||
baseURL: e.APP_URL,
|
||||
secret,
|
||||
database: prismaAdapter(getDb(), { provider: "postgresql" }),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
disableSignUp: !options.allowSignUp,
|
||||
minPasswordLength: 12,
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 8,
|
||||
updateAge: 60 * 30,
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
storage: "database",
|
||||
modelName: "rateLimit",
|
||||
window: 60,
|
||||
max: 60,
|
||||
customRules: {
|
||||
"/sign-in/email": { window: 300, max: 5 },
|
||||
"/two-factor/verify-totp": { window: 300, max: 5 },
|
||||
"/two-factor/verify-backup-code": { window: 300, max: 5 },
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
useSecureCookies: e.APP_URL.startsWith("https://"),
|
||||
ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] },
|
||||
},
|
||||
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
||||
});
|
||||
}
|
||||
|
||||
type Auth = ReturnType<typeof createAuth>;
|
||||
let instance: Auth | null = null;
|
||||
|
||||
/**
|
||||
* Nur fuer scripts/create-admin.ts (Kommandozeile im Cluster): eine eigene
|
||||
* Instanz, die Registrierung erlaubt. Der Webserver nutzt sie nie — dort
|
||||
* bleibt disableSignUp aktiv.
|
||||
*/
|
||||
export function createProvisioningAuth(): Auth {
|
||||
return createAuth({ allowSignUp: true });
|
||||
}
|
||||
|
||||
/** Lazy, damit der Build ohne Datenbank und Secrets auskommt. */
|
||||
export function getAuth(): Auth {
|
||||
if (!instance) instance = createAuth();
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Anlage einer Buchung samt Platzreservierung (Briefing Abschnitte 5–8).
|
||||
*
|
||||
* Nebenlaeufigkeit: Jede Transaktion, die Kapazitaet belegt, sperrt zuerst
|
||||
* die Camp-Zeile (SELECT … FOR UPDATE) und danach ggf. die Gutscheinzeile.
|
||||
* Parallele Checkouts laufen dadurch fuer den kurzen Moment der Pruefung
|
||||
* strikt nacheinander — zwei Personen koennen nie denselben letzten Platz
|
||||
* oder dieselbe letzte Einloesung erhalten. Die feste Sperrreihenfolge
|
||||
* (bestehende Buchung -> Camp -> Gutschein) schliesst Deadlocks aus: neue
|
||||
* Buchungen sperren nur Camp und Gutschein, Zahlungs- und Adminaktionen
|
||||
* sperren zuerst die Buchung und erst danach ggf. Camp bzw. Gutschein.
|
||||
*/
|
||||
import "./zod-de";
|
||||
import { z } from "zod";
|
||||
import type { Booking, Installment, PlanType as DbPlanType } from "@/generated/prisma/client";
|
||||
import { audit, CUSTOMER } from "./audit";
|
||||
import { campStartIso, getCamp, salesStateFor } from "./camp";
|
||||
import { CAMP_ID, type Db, type Tx } from "./db";
|
||||
import { buildSchedule, INSTALLMENT_COUNT, PLAN_TYPES, type PlanType, type ScheduledInstallment } from "./payment-plan";
|
||||
import { calculatePrice, type PriceBreakdown } from "./pricing";
|
||||
import { sellablePhase, type SalesState, type StripeMode } from "./sales-gate";
|
||||
import { generateReference, sha256Hex, statusTokenFor } from "./tokens";
|
||||
import { evaluateVoucher, normalizeEmail, type VoucherRejection } from "./vouchers";
|
||||
|
||||
/** Puffer zwischen Ablauf der Stripe-Session und Freigabe des Platzes, damit
|
||||
* ein kurz vor Ablauf abgeschlossener Checkout seinen Platz sicher behaelt. */
|
||||
export const RESERVATION_GRACE_MINUTES = 10;
|
||||
|
||||
export const participantSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "Bitte gib deinen Vornamen an.").max(80),
|
||||
lastName: z.string().trim().min(1, "Bitte gib deinen Nachnamen an.").max(80),
|
||||
email: z.email("Bitte gib eine gültige E-Mail-Adresse an.").max(200).transform(normalizeEmail),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(6, "Bitte gib eine Telefonnummer an.")
|
||||
.max(40)
|
||||
.regex(/^[+0-9 ()/-]+$/, "Die Telefonnummer darf nur Ziffern, Leerzeichen und + ( ) / - enthalten."),
|
||||
country: z.string().trim().min(2, "Bitte gib dein Land an.").max(60),
|
||||
notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)),
|
||||
});
|
||||
|
||||
export const bookingRequestSchema = z
|
||||
.object({
|
||||
idempotencyKey: z.uuid(),
|
||||
participant: participantSchema,
|
||||
planType: z.enum(PLAN_TYPES as [PlanType, ...PlanType[]]),
|
||||
voucherCode: z.string().trim().max(60).optional().transform((v) => (v ? v : undefined)),
|
||||
consents: z.object({
|
||||
adult: z.literal(true, "Bitte bestätige, dass du volljährig bist."),
|
||||
terms: z.literal(true, "Bitte akzeptiere die Buchungsbedingungen."),
|
||||
accommodation: z.literal(true, "Bitte bestätige die Unterbringung."),
|
||||
privacy: z.literal(true, "Bitte bestätige die Datenschutzhinweise."),
|
||||
installmentMandate: z.boolean(),
|
||||
}),
|
||||
})
|
||||
.refine((v) => v.planType === "FULL" || v.consents.installmentMandate, {
|
||||
message: "Bitte stimme dem Einzug der weiteren Raten zu.",
|
||||
path: ["consents", "installmentMandate"],
|
||||
});
|
||||
|
||||
export type BookingRequest = z.infer<typeof bookingRequestSchema>;
|
||||
|
||||
export type BookingError =
|
||||
| { code: "SALES_CLOSED"; reason: SalesState["closedReason"] }
|
||||
| { code: "SOLD_OUT" }
|
||||
| { code: "VOUCHER_REJECTED"; reason: VoucherRejection }
|
||||
| { code: "PLAN_UNAVAILABLE" }
|
||||
| { code: "IDEMPOTENCY_CONFLICT" };
|
||||
|
||||
export type CreatedBooking = Booking & { installments: Installment[] };
|
||||
|
||||
export type CreateBookingResult =
|
||||
| { ok: true; reused: boolean; booking: CreatedBooking }
|
||||
| { ok: false; error: BookingError };
|
||||
|
||||
export type BookingContext = { now: Date; mode: StripeMode; secret: string };
|
||||
|
||||
/** Belegte Plaetze desselben Modus (Test/Live getrennt). */
|
||||
export async function countOccupiedSeats(db: Db | Tx, livemode: boolean, now: Date): Promise<number> {
|
||||
const rows = await db.$queryRaw<{ n: number }[]>`
|
||||
SELECT count(*)::int AS n FROM "SeatReservation"
|
||||
WHERE "campId" = ${CAMP_ID} AND "livemode" = ${livemode}
|
||||
AND ("status" IN ('HELD', 'CONVERTED') OR ("status" = 'ACTIVE' AND "expiresAt" > ${now}))`;
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
export async function lockCamp(tx: Tx): Promise<void> {
|
||||
await tx.$queryRaw`SELECT "id" FROM "Camp" WHERE "id" = ${CAMP_ID} FOR UPDATE`;
|
||||
}
|
||||
|
||||
export async function createBooking(db: Db, request: BookingRequest, ctx: BookingContext): Promise<CreateBookingResult> {
|
||||
const livemode = ctx.mode === "live";
|
||||
await getCamp(db); // stellt sicher, dass die zu sperrende Zeile existiert
|
||||
|
||||
return db.$transaction(
|
||||
async (tx) => {
|
||||
// Erst sperren, dann nach dem Idempotenzschluessel suchen: ein parallel
|
||||
// laufender Doppelklick sieht so die bereits festgeschriebene Buchung.
|
||||
await lockCamp(tx);
|
||||
const existing = await tx.booking.findUnique({
|
||||
where: { idempotencyKey: request.idempotencyKey },
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.email !== request.participant.email) return { ok: false, error: { code: "IDEMPOTENCY_CONFLICT" } };
|
||||
return { ok: true, reused: true, booking: existing };
|
||||
}
|
||||
|
||||
const camp = await getCamp(tx);
|
||||
const state = salesStateFor(camp, ctx.mode, ctx.now);
|
||||
const phase = sellablePhase(state);
|
||||
if (!state.checkoutOpen || !phase) return { ok: false, error: { code: "SALES_CLOSED", reason: state.closedReason } };
|
||||
|
||||
const occupied = await countOccupiedSeats(tx, livemode, ctx.now);
|
||||
if (occupied >= camp.capacity) return { ok: false, error: { code: "SOLD_OUT" } };
|
||||
|
||||
let voucherId: string | null = null;
|
||||
if (request.voucherCode) {
|
||||
const evaluation = await evaluateVoucher(tx, {
|
||||
code: request.voucherCode,
|
||||
email: request.participant.email,
|
||||
now: ctx.now,
|
||||
livemode,
|
||||
quota: camp.stammmodelQuota,
|
||||
lock: true,
|
||||
});
|
||||
if (!evaluation.ok) return { ok: false, error: { code: "VOUCHER_REJECTED", reason: evaluation.reason } };
|
||||
voucherId = evaluation.voucher.id;
|
||||
}
|
||||
|
||||
const price = calculatePrice(phase, voucherId !== null);
|
||||
if (request.planType !== "FULL" && !state.installmentsAllowed) return { ok: false, error: { code: "PLAN_UNAVAILABLE" } };
|
||||
const schedule = buildSchedule({
|
||||
planType: request.planType,
|
||||
totalCents: price.totalCents,
|
||||
now: ctx.now,
|
||||
campStartDate: campStartIso(camp),
|
||||
finalInstallmentLeadDays: camp.finalInstallmentLeadDays,
|
||||
minInstallmentGapDays: camp.minInstallmentGapDays,
|
||||
});
|
||||
if (!schedule.ok) return { ok: false, error: { code: "PLAN_UNAVAILABLE" } };
|
||||
|
||||
// Stripe verlangt fuer expires_at mindestens 30 Minuten; eine Minute
|
||||
// Reserve gleicht Uhrabweichungen aus.
|
||||
const priceLockedUntil = new Date(ctx.now.getTime() + (camp.reservationMinutes + 1) * 60_000);
|
||||
const reservationExpiresAt = new Date(priceLockedUntil.getTime() + RESERVATION_GRACE_MINUTES * 60_000);
|
||||
const p = request.participant;
|
||||
const bookingId = crypto.randomUUID();
|
||||
|
||||
const booking = await tx.booking.create({
|
||||
data: {
|
||||
id: bookingId,
|
||||
reference: generateReference(),
|
||||
campId: CAMP_ID,
|
||||
livemode,
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
statusTokenHash: sha256Hex(statusTokenFor(bookingId, ctx.secret)),
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
email: p.email,
|
||||
phone: p.phone,
|
||||
country: p.country,
|
||||
notes: p.notes ?? null,
|
||||
adultConfirmedAt: ctx.now,
|
||||
termsAcceptedAt: ctx.now,
|
||||
termsVersion: camp.termsVersion ?? "ENTWURF-NICHT-FREIGEGEBEN",
|
||||
accommodationAcknowledgedAt: ctx.now,
|
||||
privacyAcknowledgedAt: ctx.now,
|
||||
installmentMandateAt: request.planType === "FULL" ? null : ctx.now,
|
||||
pricePhase: price.phase,
|
||||
basePriceCents: price.basePriceCents,
|
||||
phaseDiscountCents: price.phaseDiscountCents,
|
||||
voucherDiscountCents: price.voucherDiscountCents,
|
||||
totalCents: price.totalCents,
|
||||
planType: request.planType as DbPlanType,
|
||||
priceSnapshot: snapshotJson(price, schedule.installments, state, camp.termsVersion),
|
||||
priceLockedUntil,
|
||||
voucherId,
|
||||
installments: {
|
||||
create: schedule.installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueAt: i.dueAt })),
|
||||
},
|
||||
reservation: {
|
||||
create: { campId: CAMP_ID, livemode, status: "ACTIVE", expiresAt: reservationExpiresAt },
|
||||
},
|
||||
...(voucherId ? { redemption: { create: { voucherId, livemode, status: "RESERVED" } } } : {}),
|
||||
},
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
|
||||
await audit(tx, CUSTOMER, "booking.created", "Booking", booking.id, {
|
||||
reference: booking.reference,
|
||||
livemode,
|
||||
phase: price.phase,
|
||||
totalCents: price.totalCents,
|
||||
planType: request.planType,
|
||||
voucher: voucherId !== null,
|
||||
seatsOccupiedBefore: occupied,
|
||||
reservationExpiresAt: reservationExpiresAt.toISOString(),
|
||||
});
|
||||
|
||||
return { ok: true, reused: false, booking };
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotJson(price: PriceBreakdown, installments: ScheduledInstallment[], state: SalesState, termsVersion: string | null) {
|
||||
return {
|
||||
currency: "EUR",
|
||||
phase: price.phase,
|
||||
phaseSimulatedForTest: state.phaseSimulated,
|
||||
basePriceCents: price.basePriceCents,
|
||||
phaseDiscountCents: price.phaseDiscountCents,
|
||||
phasePriceCents: price.phasePriceCents,
|
||||
voucherDiscountCents: price.voucherDiscountCents,
|
||||
totalCents: price.totalCents,
|
||||
installments: installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueDate: i.dueDate })),
|
||||
installmentCount: installments.length,
|
||||
termsVersion,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Platz und Gutscheinreservierung einer (noch) nicht bezahlten Buchung
|
||||
* frei. Muss innerhalb einer Transaktion mit gesperrter Buchungszeile laufen.
|
||||
*/
|
||||
export async function releaseBookingHold(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
params: { status: "EXPIRED" | "CANCELED"; reason: string; now: Date },
|
||||
): Promise<void> {
|
||||
await tx.seatReservation.updateMany({
|
||||
where: { bookingId, status: { in: ["ACTIVE", "HELD"] } },
|
||||
data: { status: "RELEASED", releasedAt: params.now, releaseReason: params.reason },
|
||||
});
|
||||
await tx.voucherRedemption.updateMany({
|
||||
where: { bookingId, status: "RESERVED" },
|
||||
data: { status: "RELEASED", releasedAt: params.now },
|
||||
});
|
||||
await tx.installment.updateMany({
|
||||
where: { bookingId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
||||
data: { status: "CANCELED" },
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: bookingId },
|
||||
data:
|
||||
params.status === "CANCELED"
|
||||
? { status: "CANCELED", canceledAt: params.now, cancelReason: params.reason }
|
||||
: { status: "EXPIRED" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function lockBooking(tx: Tx, bookingId: string): Promise<void> {
|
||||
await tx.$queryRaw`SELECT "id" FROM "Booking" WHERE "id" = ${bookingId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
export function installmentCount(planType: PlanType): number {
|
||||
return INSTALLMENT_COUNT[planType];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Camp } from "@/generated/prisma/client";
|
||||
import { CAMP_ID, type Db, type Tx } from "./db";
|
||||
import { evaluateSales, type CampGateFields, type SalesState, type StripeMode } from "./sales-gate";
|
||||
import { dbDateToIso } from "./time";
|
||||
|
||||
/**
|
||||
* Laedt die Camp-Konfiguration. Fehlt die Zeile (frische Datenbank), wird
|
||||
* sie mit sicheren Standardwerten angelegt: Live-Verkauf aus, keine
|
||||
* Freigaben, 10 Plaetze und 4 Stammmodel-Buchungen als vorlaeufige Werte.
|
||||
*/
|
||||
export async function getCamp(db: Db | Tx): Promise<Camp> {
|
||||
return db.camp.upsert({ where: { id: CAMP_ID }, create: { id: CAMP_ID }, update: {} });
|
||||
}
|
||||
|
||||
export function campStartIso(camp: Pick<Camp, "campStartDate">): string | null {
|
||||
return camp.campStartDate ? dbDateToIso(camp.campStartDate) : null;
|
||||
}
|
||||
|
||||
export function toGateFields(camp: Camp): CampGateFields {
|
||||
return {
|
||||
salesEnabled: camp.salesEnabled,
|
||||
capacityConfirmed: camp.capacityConfirmed,
|
||||
stammmodelQuotaConfirmed: camp.stammmodelQuotaConfirmed,
|
||||
campStartDate: campStartIso(camp),
|
||||
dateConfirmed: camp.dateConfirmed,
|
||||
locationName: camp.locationName,
|
||||
locationConfirmed: camp.locationConfirmed,
|
||||
accommodationText: camp.accommodationText,
|
||||
accommodationConfirmed: camp.accommodationConfirmed,
|
||||
servicesConfirmed: camp.servicesConfirmed,
|
||||
organizerName: camp.organizerName,
|
||||
organizerAddress: camp.organizerAddress,
|
||||
organizerEmail: camp.organizerEmail,
|
||||
organizerConfirmed: camp.organizerConfirmed,
|
||||
taxNote: camp.taxNote,
|
||||
taxConfirmed: camp.taxConfirmed,
|
||||
termsVersion: camp.termsVersion,
|
||||
termsApproved: camp.termsApproved,
|
||||
travelLawChecked: camp.travelLawChecked,
|
||||
installmentsApproved: camp.installmentsApproved,
|
||||
installmentRuleApproved: camp.installmentRuleApproved,
|
||||
testPhaseOverride: camp.testPhaseOverride,
|
||||
};
|
||||
}
|
||||
|
||||
export function salesStateFor(camp: Camp, mode: StripeMode, now: Date = new Date()): SalesState {
|
||||
return evaluateSales(now, toGateFields(camp), mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentlich anzeigbare, bestaetigte Fakten. Unbestaetigte Werte werden
|
||||
* hier bewusst zu null — die Seite zeigt dann "wird bekanntgegeben" statt
|
||||
* eines ungeprueften Datums oder Orts.
|
||||
*/
|
||||
export type PublicCampFacts = {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
location: string | null;
|
||||
region: string | null;
|
||||
accommodationText: string | null;
|
||||
taxNote: string | null;
|
||||
organizer: { name: string; address: string; email: string; phone: string | null } | null;
|
||||
termsUrl: string | null;
|
||||
termsVersion: string | null;
|
||||
};
|
||||
|
||||
export function publicCampFacts(camp: Camp): PublicCampFacts {
|
||||
return {
|
||||
startDate: camp.dateConfirmed ? campStartIso(camp) : null,
|
||||
endDate: camp.dateConfirmed && camp.campEndDate ? dbDateToIso(camp.campEndDate) : null,
|
||||
location: camp.locationConfirmed ? camp.locationName : null,
|
||||
region: camp.locationConfirmed ? camp.locationRegion : null,
|
||||
accommodationText: camp.accommodationConfirmed ? camp.accommodationText : null,
|
||||
taxNote: camp.taxConfirmed ? camp.taxNote : null,
|
||||
organizer:
|
||||
camp.organizerConfirmed && camp.organizerName && camp.organizerAddress && camp.organizerEmail
|
||||
? {
|
||||
name: camp.organizerName,
|
||||
address: camp.organizerAddress,
|
||||
email: camp.organizerEmail,
|
||||
phone: camp.organizerPhone,
|
||||
}
|
||||
: null,
|
||||
termsUrl: camp.termsApproved ? camp.termsUrl : null,
|
||||
termsVersion: camp.termsApproved ? camp.termsVersion : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Buchungsstrecke bis zur Stripe-Zahlungsseite.
|
||||
*
|
||||
* Preisbindung (Briefing Abschnitt 5): Massgeblich ist der Zeitpunkt, zu dem
|
||||
* die Buchung gestartet wird. Der dabei berechnete Preis gilt, wenn die
|
||||
* Zahlung innerhalb der Reservierungszeit (Standard 30 Minuten) abgeschlossen
|
||||
* wird. Stripe erzwingt das ueber expires_at der Checkout-Session: danach ist
|
||||
* sie nicht mehr bezahlbar. Ein neuer Buchungsstart wird zum dann gueltigen
|
||||
* Preis berechnet — auch ueber den Jahreswechsel hinweg: Wer am 31.12. um
|
||||
* 23:50 startet, zahlt den Early-Bird-Preis, sofern die Zahlung bis zum
|
||||
* angezeigten Ablauf gelingt; eine Verlaengerung gibt es nicht.
|
||||
*/
|
||||
import type { Booking, Installment } from "@/generated/prisma/client";
|
||||
import { audit, CUSTOMER } from "./audit";
|
||||
import { createBooking, lockBooking, releaseBookingHold, type BookingError, type BookingRequest } from "./booking-service";
|
||||
import type { Db } from "./db";
|
||||
import { appUrl } from "./env";
|
||||
import { formatEuro } from "./money";
|
||||
import type { PaymentGateway } from "./payment-gateway";
|
||||
import { statusTokenFor } from "./tokens";
|
||||
import { formatDate, formatDateTime } from "./time";
|
||||
|
||||
export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027 – Teilnahme";
|
||||
export const PRODUCT_DESCRIPTION = "Teilnahme pro Person mit geteilter Unterbringung";
|
||||
/** Stripe akzeptiert expires_at erst ab 30 Minuten in der Zukunft. */
|
||||
const MIN_STRIPE_SESSION_MS = 30 * 60_000;
|
||||
|
||||
export type CheckoutError =
|
||||
| BookingError
|
||||
| { code: "PAYMENT_PROVIDER" }
|
||||
| { code: "ATTEMPT_EXPIRED" }
|
||||
| { code: "ALREADY_COMPLETED"; statusUrl: string };
|
||||
|
||||
export type CheckoutResult = { ok: true; url: string; reference: string } | { ok: false; error: CheckoutError };
|
||||
|
||||
export function planSummary(installments: Pick<Installment, "seq" | "amountCents" | "dueAt">[], totalCents: number): string {
|
||||
const sorted = [...installments].sort((a, b) => a.seq - b.seq);
|
||||
const today = `Heute fällig: ${formatEuro(sorted[0]!.amountCents)}.`;
|
||||
const later = sorted
|
||||
.slice(1)
|
||||
.map((i) => `${formatEuro(i.amountCents)} am ${i.dueAt ? formatDate(i.dueAt) : "–"}`)
|
||||
.join(", ");
|
||||
return [
|
||||
`Gesamtpreis ${formatEuro(totalCents)}.`,
|
||||
today,
|
||||
later ? `Weitere Raten: ${later} – automatisch über die hier gespeicherte Zahlungsmethode.` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export async function startCheckout(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
request: BookingRequest,
|
||||
ctx: { now: Date; secret: string },
|
||||
): Promise<CheckoutResult> {
|
||||
const created = await createBooking(db, request, { now: ctx.now, mode: gateway.mode, secret: ctx.secret });
|
||||
if (!created.ok) return { ok: false, error: created.error };
|
||||
const booking = created.booking;
|
||||
const token = statusTokenFor(booking.id, ctx.secret);
|
||||
|
||||
if (created.reused) {
|
||||
if (booking.status !== "PENDING") {
|
||||
return { ok: false, error: { code: "ALREADY_COMPLETED", statusUrl: appUrl(`/buchung/status/${token}`) } };
|
||||
}
|
||||
if (booking.stripeCheckoutUrl && booking.priceLockedUntil > ctx.now) {
|
||||
return { ok: true, url: booking.stripeCheckoutUrl, reference: booking.reference };
|
||||
}
|
||||
}
|
||||
if (booking.priceLockedUntil.getTime() - ctx.now.getTime() < MIN_STRIPE_SESSION_MS - 60_000) {
|
||||
return { ok: false, error: { code: "ATTEMPT_EXPIRED" } };
|
||||
}
|
||||
|
||||
try {
|
||||
// Idempotenzschluessel je Buchung: ein wiederholter Aufruf (Doppelklick,
|
||||
// Netzabbruch) erzeugt bei Stripe keinen zweiten Kunden und keine zweite
|
||||
// Session.
|
||||
const customer = await gateway.createCustomer(
|
||||
{
|
||||
email: booking.email,
|
||||
name: `${booking.firstName} ${booking.lastName}`,
|
||||
metadata: { bookingId: booking.id, reference: booking.reference },
|
||||
},
|
||||
`customer:${booking.id}`,
|
||||
);
|
||||
const first = booking.installments.find((i) => i.seq === 1)!;
|
||||
const count = booking.installments.length;
|
||||
const session = await gateway.createCheckoutSession(
|
||||
{
|
||||
customerId: customer.id,
|
||||
amountCents: first.amountCents,
|
||||
productName: PRODUCT_NAME,
|
||||
description: count > 1 ? `${PRODUCT_DESCRIPTION} · Rate 1 von ${count}` : PRODUCT_DESCRIPTION,
|
||||
metadata: { kind: "booking", bookingId: booking.id, reference: booking.reference, installmentId: first.id },
|
||||
saveForFutureUsage: count > 1,
|
||||
expiresAt: booking.priceLockedUntil,
|
||||
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
|
||||
cancelUrl: appUrl(`/buchung/status/${token}?checkout=cancel`),
|
||||
submitMessage: `${planSummary(booking.installments, booking.totalCents)} Preis gebunden bis ${formatDateTime(booking.priceLockedUntil)}.`,
|
||||
},
|
||||
`checkout:${booking.id}`,
|
||||
);
|
||||
await db.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { stripeCustomerId: customer.id, stripeCheckoutSessionId: session.id, stripeCheckoutUrl: session.url },
|
||||
});
|
||||
await audit(db, CUSTOMER, "checkout.session_created", "Booking", booking.id, { sessionId: session.id });
|
||||
return { ok: true, url: session.url, reference: booking.reference };
|
||||
} catch (error) {
|
||||
console.error(`[checkout] Stripe-Fehler fuer Buchung ${booking.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, booking.id);
|
||||
const current = await tx.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
if (current.status === "PENDING" && current.paymentStatus === "UNPAID" && !current.stripeCheckoutSessionId) {
|
||||
await releaseBookingHold(tx, booking.id, { status: "EXPIRED", reason: "payment_provider_error", now: ctx.now });
|
||||
}
|
||||
});
|
||||
return { ok: false, error: { code: "PAYMENT_PROVIDER" } };
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallmentCheckoutResult =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
|
||||
|
||||
/**
|
||||
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug, bei
|
||||
* verlangter Authentifizierung oder freiwillig vorzeitig). Die Rate wird
|
||||
* fuer die Laufzeit der Session markiert, damit der Worker sie nicht
|
||||
* gleichzeitig off-session einzieht.
|
||||
*/
|
||||
export async function startInstallmentCheckout(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
params: { bookingId: string; installmentId: string },
|
||||
ctx: { now: Date; secret: string },
|
||||
): Promise<InstallmentCheckoutResult> {
|
||||
const sessionExpiresAt = new Date(ctx.now.getTime() + MIN_STRIPE_SESSION_MS + 60_000);
|
||||
|
||||
const claim = await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, params.bookingId);
|
||||
const booking = await tx.booking.findUniqueOrThrow({
|
||||
where: { id: params.bookingId },
|
||||
include: { installments: true },
|
||||
});
|
||||
const installment = booking.installments.find((i) => i.id === params.installmentId);
|
||||
if (!installment || installment.seq === 1) return null;
|
||||
if (booking.status !== "CONFIRMED" || !booking.stripeCustomerId) return null;
|
||||
if (!["PENDING", "FAILED", "REQUIRES_ACTION"].includes(installment.status)) return null;
|
||||
if (booking.paidCents + installment.amountCents > booking.totalCents) return null;
|
||||
if (installment.checkoutSessionId && installment.checkoutExpiresAt && installment.checkoutExpiresAt > new Date(ctx.now.getTime() + 5 * 60_000)) {
|
||||
return { booking, installment, existingSessionId: installment.checkoutSessionId };
|
||||
}
|
||||
await tx.installment.update({
|
||||
where: { id: installment.id },
|
||||
data: { checkoutExpiresAt: sessionExpiresAt, checkoutSessionId: null },
|
||||
});
|
||||
return { booking, installment, existingSessionId: null as string | null };
|
||||
});
|
||||
if (!claim) return { ok: false, error: "NOT_PAYABLE" };
|
||||
|
||||
const { booking, installment } = claim;
|
||||
try {
|
||||
if (claim.existingSessionId) {
|
||||
const existing = await gateway.retrieveCheckoutSession(claim.existingSessionId);
|
||||
if (existing.status === "open" && existing.url) return { ok: true, url: existing.url };
|
||||
}
|
||||
const token = statusTokenFor(booking.id, ctx.secret);
|
||||
const session = await gateway.createCheckoutSession(
|
||||
{
|
||||
customerId: booking.stripeCustomerId!,
|
||||
amountCents: installment.amountCents,
|
||||
productName: PRODUCT_NAME,
|
||||
description: `${PRODUCT_DESCRIPTION} · Rate ${installment.seq} von ${booking.installments.length}`,
|
||||
metadata: { kind: "installment", bookingId: booking.id, reference: booking.reference, installmentId: installment.id },
|
||||
saveForFutureUsage: booking.installments.some((i) => i.seq > installment.seq && i.status !== "PAID"),
|
||||
expiresAt: sessionExpiresAt,
|
||||
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
|
||||
cancelUrl: appUrl(`/buchung/status/${token}`),
|
||||
submitMessage: `Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für Buchung ${booking.reference}.`,
|
||||
},
|
||||
`installment-checkout:${installment.id}:${sessionExpiresAt.getTime()}`,
|
||||
);
|
||||
await db.installment.update({ where: { id: installment.id }, data: { checkoutSessionId: session.id } });
|
||||
await audit(db, CUSTOMER, "checkout.installment_session_created", "Installment", installment.id, { sessionId: session.id });
|
||||
return { ok: true, url: session.url };
|
||||
} catch (error) {
|
||||
console.error(`[checkout] Stripe-Fehler fuer Rate ${installment.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, checkoutSessionId: null },
|
||||
data: { checkoutExpiresAt: null },
|
||||
});
|
||||
return { ok: false, error: "PAYMENT_PROVIDER" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Kundin verwirft eine begonnene, unbezahlte Buchung selbst. */
|
||||
export async function abandonCheckout(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
booking: Pick<Booking, "id" | "stripeCheckoutSessionId">,
|
||||
ctx: { now: Date },
|
||||
): Promise<void> {
|
||||
if (booking.stripeCheckoutSessionId) {
|
||||
await gateway.expireCheckoutSession(booking.stripeCheckoutSessionId);
|
||||
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId);
|
||||
// Wurde doch noch bezahlt, entscheidet der Webhook — hier nichts freigeben.
|
||||
if (session.status === "complete") return;
|
||||
}
|
||||
await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, booking.id);
|
||||
const current = await tx.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
if (current.status !== "PENDING" || current.paymentStatus !== "UNPAID") return;
|
||||
await releaseBookingHold(tx, booking.id, { status: "EXPIRED", reason: "abandoned_by_customer", now: ctx.now });
|
||||
await audit(tx, CUSTOMER, "booking.abandoned", "Booking", booking.id);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export type Db = PrismaClient;
|
||||
/** Transaktions-Client innerhalb von db.$transaction(async (tx) => …). */
|
||||
export type Tx = Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0];
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
function createClient(): PrismaClient {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
|
||||
const adapter = new PrismaPg({ connectionString, max: Number(process.env.DATABASE_POOL_SIZE ?? 10) });
|
||||
return new PrismaClient({ adapter, log: ["error"] });
|
||||
}
|
||||
|
||||
/** Lazy, damit der Build (ohne Datenbank) Module importieren kann. */
|
||||
export function getDb(): PrismaClient {
|
||||
if (!globalForPrisma.prisma) globalForPrisma.prisma = createClient();
|
||||
return globalForPrisma.prisma;
|
||||
}
|
||||
|
||||
export const CAMP_ID = "modelcamp-2027";
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Texte der Buchungs-E-Mails. Jede Mail nennt nur den tatsaechlich
|
||||
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
|
||||
*/
|
||||
import type { Booking, Installment } from "@/generated/prisma/client";
|
||||
import { escapeHtml, type EmailDraft } from "./email";
|
||||
import { appUrl } from "./env";
|
||||
import { formatEuro } from "./money";
|
||||
import { PLAN_LABEL } from "./payment-plan";
|
||||
import { PHASE_LABEL } from "./pricing";
|
||||
import { formatDate } from "./time";
|
||||
|
||||
type BookingWithInstallments = Booking & { installments: Installment[] };
|
||||
|
||||
const BRAND = "ARTISTA MODELCAMP 2027";
|
||||
|
||||
function statusLink(token: string): string {
|
||||
return appUrl(`/buchung/status/${token}`);
|
||||
}
|
||||
|
||||
function planLines(booking: BookingWithInstallments): string[] {
|
||||
return booking.installments
|
||||
.slice()
|
||||
.sort((a, b) => a.seq - b.seq)
|
||||
.map((i) => {
|
||||
const due = i.dueAt ? `fällig am ${formatDate(i.dueAt)}` : "bei Buchung";
|
||||
const state =
|
||||
i.status === "PAID" ? "bezahlt" : i.status === "CANCELED" ? "storniert" : i.status === "PROCESSING" ? "in Bearbeitung" : "offen";
|
||||
return `Rate ${i.seq}: ${formatEuro(i.amountCents)} – ${due} – ${state}`;
|
||||
});
|
||||
}
|
||||
|
||||
function wrap(subject: string, paragraphs: string[], link?: { href: string; label: string }): { text: string; html: string } {
|
||||
const text = [...paragraphs, ...(link ? [`${link.label}: ${link.href}`] : []), "", `${BRAND}`].join("\n\n");
|
||||
const html = `<!doctype html><html lang="de"><body style="font-family:Georgia,serif;color:#1f1d1b;background:#f6f1ea;padding:24px">
|
||||
<div style="max-width:560px;margin:0 auto;background:#fffdf9;padding:32px;border:1px solid #e6ddd0">
|
||||
<p style="letter-spacing:.2em;font-size:12px;text-transform:uppercase;color:#8a5a3b">${BRAND}</p>
|
||||
<h1 style="font-weight:normal;font-size:24px">${escapeHtml(subject)}</h1>
|
||||
${paragraphs.map((p) => `<p style="font-family:Arial,sans-serif;font-size:15px;line-height:1.6;white-space:pre-line">${escapeHtml(p)}</p>`).join("\n")}
|
||||
${link ? `<p><a href="${escapeHtml(link.href)}" style="display:inline-block;background:#1f1d1b;color:#fffdf9;padding:12px 20px;text-decoration:none;font-family:Arial,sans-serif">${escapeHtml(link.label)}</a></p>` : ""}
|
||||
</div></body></html>`;
|
||||
return { text, html };
|
||||
}
|
||||
|
||||
function draft(key: string, booking: Booking, subject: string, body: { text: string; html: string }): EmailDraft {
|
||||
return { dedupeKey: key, to: booking.email, subject: `${subject} – ${BRAND}`, bookingId: booking.id, ...body };
|
||||
}
|
||||
|
||||
export function bookingConfirmedEmail(booking: BookingWithInstallments, token: string): EmailDraft {
|
||||
const fullyPaid = booking.paidCents >= booking.totalCents;
|
||||
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt – erste Rate erhalten";
|
||||
const lines = [
|
||||
`Hallo ${booking.firstName},`,
|
||||
`vielen Dank! Deine Buchung ${booking.reference} ist bestätigt.`,
|
||||
`Gesamtpreis: ${formatEuro(booking.totalCents)} (${PHASE_LABEL[booking.pricePhase]}${booking.voucherDiscountCents > 0 ? ", inkl. Gutschein" : ""})\nZahlungsweise: ${PLAN_LABEL[booking.planType]}\nBezahlt: ${formatEuro(booking.paidCents)}\nOffen: ${formatEuro(Math.max(0, booking.totalCents - booking.paidCents))}`,
|
||||
planLines(booking).join("\n"),
|
||||
];
|
||||
if (!fullyPaid) {
|
||||
lines.push("Die weiteren Raten werden zu den genannten Terminen über die hinterlegte Zahlungsmethode eingezogen. Du erhältst jeweils eine Bestätigung.");
|
||||
}
|
||||
lines.push("Alle Details und den aktuellen Zahlungsstand findest du jederzeit auf deiner Buchungsseite.");
|
||||
return draft(`booking-confirmed:${booking.id}`, booking, subject, wrap(subject, lines, { href: statusLink(token), label: "Buchung ansehen" }));
|
||||
}
|
||||
|
||||
export function paymentProcessingEmail(booking: Booking, token: string): EmailDraft {
|
||||
const subject = "Deine Zahlung wird verarbeitet";
|
||||
return draft(
|
||||
`payment-processing:${booking.id}`,
|
||||
booking,
|
||||
subject,
|
||||
wrap(
|
||||
subject,
|
||||
[
|
||||
`Hallo ${booking.firstName},`,
|
||||
`wir haben deine Buchung ${booking.reference} erhalten. Deine Zahlungsart braucht einige Tage, bis die Zahlung bestätigt ist. Dein Platz bleibt so lange reserviert.`,
|
||||
"Sobald die Zahlung eingegangen ist, bekommst du eine Bestätigung. Erst dann ist die Buchung verbindlich bestätigt.",
|
||||
],
|
||||
{ href: statusLink(token), label: "Status ansehen" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function paymentFailedEmail(booking: Booking): EmailDraft {
|
||||
const subject = "Deine Zahlung ist fehlgeschlagen";
|
||||
return draft(
|
||||
`payment-failed:${booking.id}`,
|
||||
booking,
|
||||
subject,
|
||||
wrap(subject, [
|
||||
`Hallo ${booking.firstName},`,
|
||||
`leider konnte die Zahlung für deine Buchung ${booking.reference} nicht abgeschlossen werden. Die Reservierung wurde deshalb aufgehoben.`,
|
||||
"Du kannst jederzeit eine neue Buchung starten.",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function installmentPaidEmail(booking: BookingWithInstallments, installment: Installment, token: string): EmailDraft {
|
||||
const fullyPaid = booking.paidCents >= booking.totalCents;
|
||||
const subject = fullyPaid ? "Vollständig bezahlt – danke!" : `Rate ${installment.seq} erhalten`;
|
||||
return draft(
|
||||
`installment-paid:${installment.id}`,
|
||||
booking,
|
||||
subject,
|
||||
wrap(
|
||||
subject,
|
||||
[
|
||||
`Hallo ${booking.firstName},`,
|
||||
`wir haben Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für deine Buchung ${booking.reference} erhalten.`,
|
||||
fullyPaid
|
||||
? "Damit ist dein Platz vollständig bezahlt. Weitere Abbuchungen finden nicht statt."
|
||||
: `Bezahlt: ${formatEuro(booking.paidCents)} · Offen: ${formatEuro(booking.totalCents - booking.paidCents)}`,
|
||||
planLines(booking).join("\n"),
|
||||
],
|
||||
{ href: statusLink(token), label: "Buchung ansehen" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function installmentActionEmail(booking: Booking, installment: Installment, token: string, requiresAction: boolean): EmailDraft {
|
||||
const subject = requiresAction ? `Bitte bestätige Rate ${installment.seq}` : `Rate ${installment.seq} konnte nicht eingezogen werden`;
|
||||
return draft(
|
||||
`installment-action:${installment.id}:${installment.attemptCount}:${requiresAction ? "auth" : "fail"}`,
|
||||
booking,
|
||||
subject,
|
||||
wrap(
|
||||
subject,
|
||||
[
|
||||
`Hallo ${booking.firstName},`,
|
||||
requiresAction
|
||||
? `deine Bank möchte die Zahlung von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) zusätzlich bestätigen lassen.`
|
||||
: `der Einzug von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) ist leider fehlgeschlagen.`,
|
||||
"Du kannst die Rate über den folgenden Link selbst begleichen – auch mit einer anderen Zahlungsmethode.",
|
||||
],
|
||||
{ href: statusLink(token), label: "Rate jetzt bezahlen" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function adminReviewEmail(to: string, booking: Booking, reason: string): EmailDraft {
|
||||
const subject = `Prüfung nötig: Buchung ${booking.reference}`;
|
||||
const body = wrap(subject, [
|
||||
`Die Buchung ${booking.reference} benötigt eine manuelle Entscheidung.`,
|
||||
`Grund: ${reason}`,
|
||||
"Details stehen im Adminbereich unter Buchungen.",
|
||||
], { href: appUrl(`/admin/buchungen/${booking.id}`), label: "Im Adminbereich öffnen" });
|
||||
return { dedupeKey: `admin-review:${booking.id}:${reason}`, to, subject, bookingId: booking.id, ...body };
|
||||
}
|
||||
|
||||
export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft {
|
||||
const subject = "Deine Zahlung ist eingegangen – wir melden uns";
|
||||
return draft(
|
||||
`review-customer:${booking.id}`,
|
||||
booking,
|
||||
subject,
|
||||
wrap(
|
||||
subject,
|
||||
[
|
||||
`Hallo ${booking.firstName},`,
|
||||
`deine Zahlung für die Buchung ${booking.reference} ist bei uns eingegangen. Bevor wir die Buchung bestätigen können, müssen wir sie kurz manuell prüfen.`,
|
||||
"Wir melden uns umgehend bei dir. Solltest du keinen Platz erhalten, wird dir der gezahlte Betrag vollständig erstattet.",
|
||||
],
|
||||
{ href: statusLink(token), label: "Status ansehen" },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* E-Mail-Versand ueber einen transaktionalen Postausgang.
|
||||
*
|
||||
* enqueueEmail() schreibt in derselben Datenbanktransaktion wie die
|
||||
* Statusaenderung — eine bestaetigte Buchung ohne Bestaetigungsmail kann so
|
||||
* nicht entstehen. Versendet wird danach (flushEmails) und, falls das
|
||||
* scheitert, erneut durch den Worker.
|
||||
*
|
||||
* MAIL_TRANSPORT=file (Standard in der Entwicklung) schreibt jede Mail als
|
||||
* .eml-Datei nach MAIL_OUTBOX_DIR statt sie zu verschicken: ein sicherer
|
||||
* Testausgang, der nie echte Postfaecher erreicht.
|
||||
*/
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import type { Db, Tx } from "./db";
|
||||
import { env, isProduction } from "./env";
|
||||
|
||||
export type EmailDraft = {
|
||||
dedupeKey: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
bookingId?: string | null;
|
||||
};
|
||||
|
||||
export const MAX_EMAIL_ATTEMPTS = 8;
|
||||
|
||||
export async function enqueueEmail(db: Db | Tx, draft: EmailDraft): Promise<void> {
|
||||
await db.emailMessage.createMany({
|
||||
data: [
|
||||
{
|
||||
dedupeKey: draft.dedupeKey,
|
||||
toAddress: draft.to,
|
||||
subject: draft.subject,
|
||||
textBody: draft.text,
|
||||
htmlBody: draft.html,
|
||||
bookingId: draft.bookingId ?? null,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
function smtpTransport(): Transporter {
|
||||
if (!transporter) {
|
||||
const e = env();
|
||||
if (!e.SMTP_HOST) throw new Error("SMTP_HOST ist nicht gesetzt.");
|
||||
transporter = nodemailer.createTransport({
|
||||
host: e.SMTP_HOST,
|
||||
port: e.SMTP_PORT,
|
||||
secure: e.SMTP_SECURE === "true",
|
||||
auth: e.SMTP_USER ? { user: e.SMTP_USER, pass: e.SMTP_PASSWORD ?? "" } : undefined,
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
async function deliver(message: { id: string; toAddress: string; subject: string; textBody: string; htmlBody: string }) {
|
||||
const e = env();
|
||||
if (e.MAIL_TRANSPORT === "file") {
|
||||
if (isProduction()) throw new Error("MAIL_TRANSPORT=file ist im Produktivbetrieb nicht erlaubt.");
|
||||
const dir = path.resolve(e.MAIL_OUTBOX_DIR);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const stream = nodemailer.createTransport({ streamTransport: true, buffer: true, newline: "unix" });
|
||||
const info = await stream.sendMail({
|
||||
from: e.MAIL_FROM,
|
||||
to: message.toAddress,
|
||||
subject: message.subject,
|
||||
text: message.textBody,
|
||||
html: message.htmlBody,
|
||||
});
|
||||
await writeFile(path.join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${message.id}.eml`), info.message as Buffer);
|
||||
return;
|
||||
}
|
||||
await smtpTransport().sendMail({
|
||||
from: e.MAIL_FROM,
|
||||
to: message.toAddress,
|
||||
subject: message.subject,
|
||||
text: message.textBody,
|
||||
html: message.htmlBody,
|
||||
});
|
||||
}
|
||||
|
||||
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
|
||||
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
|
||||
const pending = await db.emailMessage.findMany({
|
||||
where: {
|
||||
status: "PENDING",
|
||||
attempts: { lt: MAX_EMAIL_ATTEMPTS },
|
||||
...(params.bookingId ? { bookingId: params.bookingId } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: params.limit ?? 50,
|
||||
});
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const message of pending) {
|
||||
// Beanspruchen, damit parallele Replikate dieselbe Mail nicht doppelt senden.
|
||||
const claimed = await db.emailMessage.updateMany({
|
||||
where: { id: message.id, status: "PENDING", attempts: message.attempts },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
if (claimed.count === 0) continue;
|
||||
try {
|
||||
await deliver(message);
|
||||
await db.emailMessage.update({ where: { id: message.id }, data: { status: "SENT", sentAt: new Date(), lastError: null } });
|
||||
sent++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
const reason = error instanceof Error ? error.message.slice(0, 300) : "unbekannter Fehler";
|
||||
await db.emailMessage.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
lastError: reason,
|
||||
status: message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
|
||||
},
|
||||
});
|
||||
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}): ${reason}`);
|
||||
}
|
||||
}
|
||||
return { sent, failed };
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Zentrale, validierte Konfiguration aus Umgebungsvariablen.
|
||||
* Vorlage mit Erklaerungen: .env.example
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StripeMode } from "./sales-gate";
|
||||
|
||||
const schema = z.object({
|
||||
APP_ENV: z.enum(["development", "staging", "production"]).default("development"),
|
||||
APP_URL: z.url().default("http://localhost:3000"),
|
||||
APP_SECRET: z.string().min(32).optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
MAIL_TRANSPORT: z.enum(["smtp", "file"]).default("file"),
|
||||
MAIL_OUTBOX_DIR: z.string().default(".mail-outbox"),
|
||||
MAIL_FROM: z.string().default("ARTISTA MODELCAMP <no-reply@localhost>"),
|
||||
ADMIN_NOTIFY_EMAIL: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.coerce.number().int().positive().default(587),
|
||||
SMTP_SECURE: z.enum(["true", "false"]).default("false"),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
MEDIA_DIR: z.string().default("./media"),
|
||||
/** Datenschutzkonzept fuer externe Videos: "none" laedt nie externe Player. */
|
||||
EXTERNAL_VIDEO_PROVIDER: z.enum(["none", "youtube-nocookie", "vimeo"]).default("none"),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof schema>;
|
||||
|
||||
let cached: Env | null = null;
|
||||
|
||||
export function env(): Env {
|
||||
if (!cached) {
|
||||
const parsed = schema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
// Nur Feldnamen ausgeben, nie Werte (koennten Geheimnisse sein).
|
||||
const fields = parsed.error.issues.map((i) => i.path.join(".")).join(", ");
|
||||
throw new Error(`Ungueltige Konfiguration: ${fields}`);
|
||||
}
|
||||
cached = parsed.data;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: Konfiguration nach Aenderung von process.env neu lesen. */
|
||||
export function resetEnvCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export function isProduction(): boolean {
|
||||
return env().APP_ENV === "production";
|
||||
}
|
||||
|
||||
/** Entwicklungs-Platzhalter (leere Video-Slots usw.) sichtbar? */
|
||||
export function showDevPlaceholders(): boolean {
|
||||
return env().APP_ENV !== "production";
|
||||
}
|
||||
|
||||
export function appUrl(path = "/"): string {
|
||||
return new URL(path, env().APP_URL).toString();
|
||||
}
|
||||
|
||||
export function requireAppSecret(): string {
|
||||
const secret = env().APP_SECRET;
|
||||
if (!secret) throw new Error("APP_SECRET ist nicht gesetzt (mindestens 32 Zeichen).");
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modus aus dem Stripe-Schluessel. Ein Live-Schluessel ausserhalb von
|
||||
* APP_ENV=production ist ein Konfigurationsfehler: so kann eine Entwicklungs-
|
||||
* oder Staging-Umgebung nie versehentlich echtes Geld einziehen.
|
||||
*/
|
||||
export function stripeMode(): StripeMode {
|
||||
const key = env().STRIPE_SECRET_KEY;
|
||||
if (!key) throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt.");
|
||||
if (/^(sk|rk)_live_/.test(key)) {
|
||||
if (!isProduction()) throw new Error("Stripe-Live-Schluessel ist nur mit APP_ENV=production erlaubt.");
|
||||
return "live";
|
||||
}
|
||||
if (/^(sk|rk)_test_/.test(key)) return "test";
|
||||
throw new Error("STRIPE_SECRET_KEY hat kein bekanntes Format.");
|
||||
}
|
||||
|
||||
/** Wie stripeMode(), aber ohne Ausnahme (fuer reine Anzeige). */
|
||||
export function stripeModeOrNull(): StripeMode | null {
|
||||
try {
|
||||
return stripeMode();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { BookingStatus, InstallmentStatus, PaymentStatus, ReservationStatus, RedemptionStatus } from "@/generated/prisma/client";
|
||||
|
||||
export const BOOKING_STATUS_LABEL: Record<BookingStatus, string> = {
|
||||
PENDING: "Reserviert, Zahlung ausstehend",
|
||||
CONFIRMED: "Bestätigt",
|
||||
CANCELED: "Storniert",
|
||||
EXPIRED: "Verfallen",
|
||||
NEEDS_REVIEW: "In Prüfung",
|
||||
};
|
||||
|
||||
export const PAYMENT_STATUS_LABEL: Record<PaymentStatus, string> = {
|
||||
UNPAID: "Unbezahlt",
|
||||
PROCESSING: "Zahlung in Bearbeitung",
|
||||
PARTIALLY_PAID: "Teilweise bezahlt",
|
||||
PAID: "Vollständig bezahlt",
|
||||
FAILED: "Zahlung fehlgeschlagen",
|
||||
PARTIALLY_REFUNDED: "Teilweise erstattet",
|
||||
REFUNDED: "Erstattet",
|
||||
};
|
||||
|
||||
export const INSTALLMENT_STATUS_LABEL: Record<InstallmentStatus, string> = {
|
||||
PENDING: "Offen",
|
||||
PROCESSING: "In Bearbeitung",
|
||||
PAID: "Bezahlt",
|
||||
FAILED: "Fehlgeschlagen",
|
||||
REQUIRES_ACTION: "Deine Bestätigung nötig",
|
||||
CANCELED: "Storniert",
|
||||
};
|
||||
|
||||
export const RESERVATION_STATUS_LABEL: Record<ReservationStatus, string> = {
|
||||
ACTIVE: "Reserviert (befristet)",
|
||||
HELD: "Gehalten (Zahlung unterwegs)",
|
||||
CONVERTED: "Belegt",
|
||||
RELEASED: "Freigegeben",
|
||||
};
|
||||
|
||||
export const REDEMPTION_STATUS_LABEL: Record<RedemptionStatus, string> = {
|
||||
RESERVED: "Reserviert",
|
||||
COMPLETED: "Eingelöst",
|
||||
RELEASED: "Freigegeben",
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Alle Betraege sind ganzzahlige Cent in EUR. */
|
||||
|
||||
const eur = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/** 220000 -> "2.200,00 €" */
|
||||
export function formatEuro(cents: number): string {
|
||||
assertCents(cents);
|
||||
// Intl setzt ein geschuetztes Leerzeichen vor das Eurozeichen; fuer
|
||||
// E-Mails/CSV ist ein normales Leerzeichen robuster.
|
||||
return eur.format(cents / 100).replace(/ /g, " ");
|
||||
}
|
||||
|
||||
/** 220000 -> "2.200 €" (nur fuer glatte Betraege in Ueberschriften). */
|
||||
export function formatEuroShort(cents: number): string {
|
||||
assertCents(cents);
|
||||
if (cents % 100 !== 0) return formatEuro(cents);
|
||||
return `${new Intl.NumberFormat("de-DE").format(cents / 100)} €`;
|
||||
}
|
||||
|
||||
export function assertCents(cents: number): void {
|
||||
if (!Number.isSafeInteger(cents)) {
|
||||
throw new Error(`Betrag muss ganzzahlige Cent sein, erhalten: ${cents}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Zustandsuebergaenge fuer Zahlungen.
|
||||
*
|
||||
* Grundsaetze:
|
||||
* - Jede Funktion laeuft in einer Transaktion und sperrt zuerst die
|
||||
* Buchungszeile. Doppelte, verspaetete oder ungeordnete Stripe-Events
|
||||
* treffen dadurch immer auf einen konsistenten Stand.
|
||||
* - Uebergaenge sind monoton: Eine bezahlte Rate wird nie wieder offen, ein
|
||||
* spaetes "expired" hebt keine bezahlte Buchung auf.
|
||||
* - Buchungsstatus (BookingStatus) und Zahlungsstatus (PaymentStatus) sind
|
||||
* getrennt; eine bezahlte erste Rate heisst bei Ratenzahlung PARTIALLY_PAID.
|
||||
* - Was nicht automatisch sicher entschieden werden kann (verspaetete Zahlung
|
||||
* ohne freien Platz, Doppelzahlung, Rueckbuchung), landet als
|
||||
* NEEDS_REVIEW mit Begruendung im Adminbereich — nie still verworfen.
|
||||
*/
|
||||
import type { Booking, Installment, PaymentStatus } from "@/generated/prisma/client";
|
||||
import { audit, type Actor } from "./audit";
|
||||
import { countOccupiedSeats, lockBooking, lockCamp, releaseBookingHold } from "./booking-service";
|
||||
import { getCamp } from "./camp";
|
||||
import type { Tx } from "./db";
|
||||
import { enqueueEmail } from "./email";
|
||||
import {
|
||||
adminReviewEmail,
|
||||
bookingConfirmedEmail,
|
||||
installmentActionEmail,
|
||||
installmentPaidEmail,
|
||||
paymentFailedEmail,
|
||||
paymentProcessingEmail,
|
||||
reviewCustomerEmail,
|
||||
} from "./email-templates";
|
||||
import { statusTokenFor } from "./tokens";
|
||||
import { countUsedRedemptions } from "./vouchers";
|
||||
|
||||
export const MAX_OFF_SESSION_ATTEMPTS = 3;
|
||||
export const RETRY_DELAY_DAYS = 3;
|
||||
|
||||
export type EventContext = { now: Date; secret: string; actor: Actor; adminEmail: string | null };
|
||||
|
||||
export type PaymentInfo = {
|
||||
paymentIntentId: string;
|
||||
chargeId: string | null;
|
||||
paymentMethodId: string | null;
|
||||
customerId: string | null;
|
||||
amountCents: number;
|
||||
};
|
||||
|
||||
type FullBooking = Booking & { installments: Installment[] };
|
||||
|
||||
async function loadLocked(tx: Tx, bookingId: string) {
|
||||
await lockBooking(tx, bookingId);
|
||||
return tx.booking.findUniqueOrThrow({
|
||||
where: { id: bookingId },
|
||||
include: { installments: { orderBy: { seq: "asc" } }, reservation: true, redemption: true },
|
||||
});
|
||||
}
|
||||
|
||||
function paymentStatusFor(paidCents: number, totalCents: number, refundedCents: number): PaymentStatus {
|
||||
if (refundedCents > 0) return refundedCents >= paidCents ? "REFUNDED" : "PARTIALLY_REFUNDED";
|
||||
if (paidCents <= 0) return "UNPAID";
|
||||
return paidCents >= totalCents ? "PAID" : "PARTIALLY_PAID";
|
||||
}
|
||||
|
||||
async function flagForReview(tx: Tx, booking: Booking, reason: string, ctx: EventContext): Promise<void> {
|
||||
await tx.booking.update({ where: { id: booking.id }, data: { status: "NEEDS_REVIEW", reviewReason: reason } });
|
||||
await audit(tx, ctx.actor, "booking.needs_review", "Booking", booking.id, { reason });
|
||||
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, booking, reason));
|
||||
}
|
||||
|
||||
export type FirstPaymentOutcome = "confirmed" | "duplicate" | "needs_review";
|
||||
|
||||
/** Erste Rate (bzw. Einmalzahlung) ist bei Stripe erfolgreich eingegangen. */
|
||||
export async function applyFirstPaymentSucceeded(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
info: PaymentInfo,
|
||||
ctx: EventContext,
|
||||
): Promise<FirstPaymentOutcome> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
const first = booking.installments.find((i) => i.seq === 1)!;
|
||||
|
||||
if (first.status === "PAID") {
|
||||
if (first.stripePaymentIntentId && first.stripePaymentIntentId !== info.paymentIntentId) {
|
||||
await flagForReview(tx, booking, "Doppelzahlung der ersten Rate – bitte eine Zahlung erstatten.", ctx);
|
||||
return "needs_review";
|
||||
}
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
const problems: string[] = [];
|
||||
if (info.amountCents !== first.amountCents) {
|
||||
problems.push(`Betrag weicht ab (erwartet ${first.amountCents}, erhalten ${info.amountCents} Cent).`);
|
||||
}
|
||||
|
||||
// Platz: normalerweise noch reserviert. Wurde er inzwischen freigegeben
|
||||
// (Zahlung kam nach Ablauf an), wird er nur uebernommen, wenn noch frei.
|
||||
const reservation = booking.reservation!;
|
||||
if (reservation.status === "ACTIVE" || reservation.status === "HELD") {
|
||||
await tx.seatReservation.update({ where: { id: reservation.id }, data: { status: "CONVERTED" } });
|
||||
} else if (reservation.status === "RELEASED") {
|
||||
await lockCamp(tx);
|
||||
const camp = await getCamp(tx);
|
||||
const occupied = await countOccupiedSeats(tx, booking.livemode, ctx.now);
|
||||
if (occupied < camp.capacity) {
|
||||
await tx.seatReservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: { status: "CONVERTED", releasedAt: null, releaseReason: null },
|
||||
});
|
||||
await audit(tx, ctx.actor, "seat.reacquired_after_late_payment", "Booking", booking.id, { occupiedBefore: occupied });
|
||||
} else {
|
||||
problems.push("Zahlung nach Ablauf der Reservierung eingegangen, inzwischen ist kein Platz mehr frei.");
|
||||
}
|
||||
}
|
||||
|
||||
const redemption = booking.redemption;
|
||||
if (redemption?.status === "RESERVED") {
|
||||
await tx.voucherRedemption.update({ where: { id: redemption.id }, data: { status: "COMPLETED" } });
|
||||
} else if (redemption?.status === "RELEASED") {
|
||||
await tx.$queryRaw`SELECT "id" FROM "Voucher" WHERE "id" = ${redemption.voucherId} FOR UPDATE`;
|
||||
const [voucher, camp] = await Promise.all([
|
||||
tx.voucher.findUniqueOrThrow({ where: { id: redemption.voucherId } }),
|
||||
getCamp(tx),
|
||||
]);
|
||||
const used = await countUsedRedemptions(tx, { livemode: booking.livemode, now: ctx.now, voucherId: voucher.id });
|
||||
const quotaUsed = await countUsedRedemptions(tx, { livemode: booking.livemode, now: ctx.now });
|
||||
if (used < voucher.maxRedemptions && quotaUsed < camp.stammmodelQuota) {
|
||||
await tx.voucherRedemption.update({ where: { id: redemption.id }, data: { status: "COMPLETED", releasedAt: null } });
|
||||
} else {
|
||||
problems.push("Gutscheincode war bei verspäteter Zahlung nicht mehr verfügbar.");
|
||||
}
|
||||
}
|
||||
|
||||
await tx.installment.update({
|
||||
where: { id: first.id },
|
||||
data: {
|
||||
status: "PAID",
|
||||
paidAt: ctx.now,
|
||||
stripePaymentIntentId: info.paymentIntentId,
|
||||
stripeChargeId: info.chargeId,
|
||||
},
|
||||
});
|
||||
|
||||
const paidCents = booking.paidCents + info.amountCents;
|
||||
const updated = await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
status: problems.length > 0 ? "NEEDS_REVIEW" : "CONFIRMED",
|
||||
reviewReason: problems.length > 0 ? problems.join(" ") : null,
|
||||
paymentStatus: paymentStatusFor(paidCents, booking.totalCents, booking.refundedCents),
|
||||
paidCents,
|
||||
confirmedAt: problems.length > 0 ? null : ctx.now,
|
||||
stripeCustomerId: info.customerId ?? booking.stripeCustomerId,
|
||||
// Nur bei Ratenzahlung gespeichert; bei Einmalzahlung gibt es nichts
|
||||
// wiederzuverwenden.
|
||||
stripePaymentMethodId: booking.planType === "FULL" ? null : info.paymentMethodId,
|
||||
},
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
|
||||
await audit(tx, ctx.actor, "payment.first_succeeded", "Booking", booking.id, {
|
||||
paymentIntentId: info.paymentIntentId,
|
||||
amountCents: info.amountCents,
|
||||
paidCents,
|
||||
previousStatus: booking.status,
|
||||
problems,
|
||||
});
|
||||
|
||||
const token = statusTokenFor(booking.id, ctx.secret);
|
||||
if (problems.length > 0) {
|
||||
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, updated, problems.join(" ")));
|
||||
await enqueueEmail(tx, reviewCustomerEmail(updated, token));
|
||||
return "needs_review";
|
||||
}
|
||||
await enqueueEmail(tx, bookingConfirmedEmail(updated, token));
|
||||
return "confirmed";
|
||||
}
|
||||
|
||||
/** Checkout abgeschlossen, Zahlung aber noch unterwegs (z. B. SEPA-Lastschrift). */
|
||||
export async function applyFirstPaymentProcessing(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
info: { paymentIntentId: string | null; customerId: string | null },
|
||||
ctx: EventContext,
|
||||
): Promise<"processing" | "ignored"> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
const first = booking.installments.find((i) => i.seq === 1)!;
|
||||
if (booking.status !== "PENDING" || first.status !== "PENDING") return "ignored";
|
||||
|
||||
await tx.seatReservation.updateMany({ where: { bookingId, status: "ACTIVE" }, data: { status: "HELD" } });
|
||||
await tx.installment.update({
|
||||
where: { id: first.id },
|
||||
data: { status: "PROCESSING", stripePaymentIntentId: info.paymentIntentId },
|
||||
});
|
||||
const updated = await tx.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { paymentStatus: "PROCESSING", stripeCustomerId: info.customerId ?? booking.stripeCustomerId },
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.first_processing", "Booking", bookingId, { paymentIntentId: info.paymentIntentId });
|
||||
await enqueueEmail(tx, paymentProcessingEmail(updated, statusTokenFor(bookingId, ctx.secret)));
|
||||
return "processing";
|
||||
}
|
||||
|
||||
/** Verzoegerte erste Zahlung endgueltig fehlgeschlagen: Platz freigeben. */
|
||||
export async function applyFirstPaymentFailed(tx: Tx, bookingId: string, ctx: EventContext): Promise<"failed" | "ignored"> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
const first = booking.installments.find((i) => i.seq === 1)!;
|
||||
if (first.status === "PAID" || booking.status === "CANCELED") return "ignored";
|
||||
|
||||
await tx.installment.update({ where: { id: first.id }, data: { status: "FAILED", failureCode: "async_payment_failed" } });
|
||||
await releaseBookingHold(tx, bookingId, { status: "EXPIRED", reason: "payment_failed", now: ctx.now });
|
||||
const updated = await tx.booking.update({ where: { id: bookingId }, data: { paymentStatus: "FAILED" } });
|
||||
await audit(tx, ctx.actor, "payment.first_failed", "Booking", bookingId);
|
||||
await enqueueEmail(tx, paymentFailedEmail(updated));
|
||||
return "failed";
|
||||
}
|
||||
|
||||
/** Checkout-Session abgelaufen oder abgebrochen: nur unbezahlte Buchungen freigeben. */
|
||||
export async function applyCheckoutExpired(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
sessionId: string | null,
|
||||
ctx: EventContext & { reason?: string },
|
||||
): Promise<"released" | "ignored"> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
if (sessionId && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== sessionId) return "ignored";
|
||||
if (booking.status !== "PENDING" || booking.paymentStatus !== "UNPAID") return "ignored";
|
||||
await releaseBookingHold(tx, bookingId, { status: "EXPIRED", reason: ctx.reason ?? "checkout_expired", now: ctx.now });
|
||||
await audit(tx, ctx.actor, "booking.expired", "Booking", bookingId, { sessionId, reason: ctx.reason ?? "checkout_expired" });
|
||||
return "released";
|
||||
}
|
||||
|
||||
export type InstallmentPaidOutcome = { outcome: "paid" | "duplicate" | "needs_review"; fullyPaid: boolean };
|
||||
|
||||
/** Folgerate bezahlt (Off-Session-Einzug oder selbst bezahlt per Link). */
|
||||
export async function applyInstallmentPaid(
|
||||
tx: Tx,
|
||||
installmentId: string,
|
||||
info: PaymentInfo,
|
||||
ctx: EventContext,
|
||||
): Promise<InstallmentPaidOutcome> {
|
||||
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
const installment = booking.installments.find((i) => i.id === installmentId)!;
|
||||
|
||||
if (installment.status === "PAID") {
|
||||
if (installment.stripePaymentIntentId !== info.paymentIntentId) {
|
||||
await flagForReview(tx, booking, `Rate ${installment.seq} wurde doppelt bezahlt – bitte eine Zahlung erstatten.`, ctx);
|
||||
return { outcome: "needs_review", fullyPaid: booking.paidCents >= booking.totalCents };
|
||||
}
|
||||
return { outcome: "duplicate", fullyPaid: booking.paidCents >= booking.totalCents };
|
||||
}
|
||||
|
||||
const paidCents = booking.paidCents + info.amountCents;
|
||||
await tx.installment.update({
|
||||
where: { id: installmentId },
|
||||
data: {
|
||||
status: "PAID",
|
||||
paidAt: ctx.now,
|
||||
stripePaymentIntentId: info.paymentIntentId,
|
||||
stripeChargeId: info.chargeId,
|
||||
nextAttemptAt: null,
|
||||
failureCode: null,
|
||||
},
|
||||
});
|
||||
const updated = await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
paidCents,
|
||||
paymentStatus: paymentStatusFor(paidCents, booking.totalCents, booking.refundedCents),
|
||||
...(info.paymentMethodId && booking.planType !== "FULL" && !booking.paymentMethodDetachedAt
|
||||
? { stripePaymentMethodId: info.paymentMethodId }
|
||||
: {}),
|
||||
},
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.installment_succeeded", "Installment", installmentId, {
|
||||
bookingId: booking.id,
|
||||
seq: installment.seq,
|
||||
paymentIntentId: info.paymentIntentId,
|
||||
amountCents: info.amountCents,
|
||||
paidCents,
|
||||
});
|
||||
|
||||
const problems: string[] = [];
|
||||
if (info.amountCents !== installment.amountCents) problems.push(`Betrag von Rate ${installment.seq} weicht ab.`);
|
||||
if (installment.status === "CANCELED" || booking.status === "CANCELED") problems.push(`Rate ${installment.seq} ging nach Stornierung ein.`);
|
||||
if (paidCents > booking.totalCents) problems.push("Es wurde mehr als der Gesamtpreis gezahlt.");
|
||||
if (problems.length > 0) {
|
||||
await flagForReview(tx, updated, problems.join(" "), ctx);
|
||||
return { outcome: "needs_review", fullyPaid: paidCents >= booking.totalCents };
|
||||
}
|
||||
|
||||
await enqueueEmail(tx, installmentPaidEmail(updated, { ...installment, status: "PAID" }, statusTokenFor(booking.id, ctx.secret)));
|
||||
return { outcome: "paid", fullyPaid: paidCents >= booking.totalCents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Off-Session-Einzug fehlgeschlagen. Greift nur, solange die Rate vom Worker
|
||||
* beansprucht ist (PROCESSING) — so zaehlt ein Fehler nie doppelt, egal ob
|
||||
* ihn der Worker direkt oder der Webhook zuerst meldet.
|
||||
*/
|
||||
export async function applyInstallmentFailed(
|
||||
tx: Tx,
|
||||
installmentId: string,
|
||||
info: { paymentIntentId: string | null; failureCode: string | null; requiresAction: boolean },
|
||||
ctx: EventContext,
|
||||
): Promise<"recorded" | "ignored"> {
|
||||
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
const installment = booking.installments.find((i) => i.id === installmentId)!;
|
||||
if (installment.status !== "PROCESSING") return "ignored";
|
||||
|
||||
const attempt = installment.attemptCount + 1;
|
||||
const retry = !info.requiresAction && attempt < MAX_OFF_SESSION_ATTEMPTS;
|
||||
const updatedInstallment = await tx.installment.update({
|
||||
where: { id: installmentId },
|
||||
data: {
|
||||
status: info.requiresAction ? "REQUIRES_ACTION" : "FAILED",
|
||||
attemptCount: attempt,
|
||||
failureCode: info.failureCode,
|
||||
stripePaymentIntentId: info.paymentIntentId ?? installment.stripePaymentIntentId,
|
||||
nextAttemptAt: retry ? new Date(ctx.now.getTime() + RETRY_DELAY_DAYS * 86_400_000) : null,
|
||||
},
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installmentId, {
|
||||
bookingId: booking.id,
|
||||
seq: installment.seq,
|
||||
attempt,
|
||||
failureCode: info.failureCode,
|
||||
requiresAction: info.requiresAction,
|
||||
nextAttemptAt: updatedInstallment.nextAttemptAt?.toISOString() ?? null,
|
||||
});
|
||||
await enqueueEmail(
|
||||
tx,
|
||||
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
|
||||
);
|
||||
if (!retry && !info.requiresAction) {
|
||||
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${attempt} Versuchen nicht eingegangen.`, ctx);
|
||||
}
|
||||
return "recorded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstattungsstand einer Zahlung. Stripe liefert den kumulierten Betrag
|
||||
* (amount_refunded) — absolute Werte machen ungeordnete Events harmlos.
|
||||
*/
|
||||
export async function applyRefundState(
|
||||
tx: Tx,
|
||||
params: { paymentIntentId: string; amountRefundedCents: number },
|
||||
ctx: EventContext,
|
||||
): Promise<"updated" | "ignored"> {
|
||||
const target = await tx.installment.findUnique({ where: { stripePaymentIntentId: params.paymentIntentId } });
|
||||
if (!target) return "ignored";
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
await tx.installment.update({ where: { id: target.id }, data: { refundedCents: params.amountRefundedCents } });
|
||||
const refundedCents = booking.installments.reduce(
|
||||
(sum, i) => sum + (i.id === target.id ? params.amountRefundedCents : i.refundedCents),
|
||||
0,
|
||||
);
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { refundedCents, paymentStatus: paymentStatusFor(booking.paidCents, booking.totalCents, refundedCents) },
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.refund_state", "Booking", booking.id, {
|
||||
paymentIntentId: params.paymentIntentId,
|
||||
installmentRefundedCents: params.amountRefundedCents,
|
||||
bookingRefundedCents: refundedCents,
|
||||
});
|
||||
return "updated";
|
||||
}
|
||||
|
||||
export async function applyDispute(tx: Tx, paymentIntentId: string, ctx: EventContext): Promise<"flagged" | "ignored"> {
|
||||
const target = await tx.installment.findUnique({ where: { stripePaymentIntentId: paymentIntentId } });
|
||||
if (!target) return "ignored";
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
await flagForReview(tx, booking, `Rückbuchung (Dispute) zu Rate ${target.seq} eröffnet.`, ctx);
|
||||
return "flagged";
|
||||
}
|
||||
|
||||
export type { FullBooking };
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Schmale Schnittstelle zu Stripe.
|
||||
*
|
||||
* Die Geschaeftslogik spricht nur mit PaymentGateway; die Tests setzen eine
|
||||
* Attrappe ein (tests/support/fake-gateway.ts), der Betrieb die Stripe-
|
||||
* Implementierung unten. Kartendaten beruehren diese Anwendung nie — Stripe
|
||||
* Checkout erfasst sie, hier liegen nur Objekt-IDs.
|
||||
*
|
||||
* Architekturentscheidung Ratenzahlung (docs/ARCHITEKTUR.md, ADR-3):
|
||||
* Erste Rate ueber Stripe Checkout mit setup_future_usage=off_session,
|
||||
* Folgeraten als einzelne Off-Session-PaymentIntents, ausgeloest durch einen
|
||||
* idempotenten Kubernetes-CronJob. Kein Abonnement, kein Subscription
|
||||
* Schedule: deren Intervalle passen nicht zur Regel "letzte Rate 30 Tage vor
|
||||
* Anreise", und die Planlaenge ist hier durch die Ratenzeilen selbst
|
||||
* begrenzt.
|
||||
*/
|
||||
import Stripe from "stripe";
|
||||
import { env, stripeMode } from "./env";
|
||||
import type { StripeMode } from "./sales-gate";
|
||||
|
||||
export type CheckoutSessionInfo = {
|
||||
id: string;
|
||||
status: "open" | "complete" | "expired";
|
||||
paymentStatus: "paid" | "unpaid" | "no_payment_required";
|
||||
paymentIntentId: string | null;
|
||||
customerId: string | null;
|
||||
url: string | null;
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
export type PaymentIntentInfo = {
|
||||
id: string;
|
||||
status: string;
|
||||
amountReceived: number;
|
||||
paymentMethodId: string | null;
|
||||
chargeId: string | null;
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
export type OffSessionResult = {
|
||||
outcome: "succeeded" | "processing" | "requires_action" | "failed";
|
||||
paymentIntentId: string | null;
|
||||
chargeId: string | null;
|
||||
failureCode: string | null;
|
||||
};
|
||||
|
||||
export type CreateCheckoutParams = {
|
||||
customerId: string;
|
||||
amountCents: number;
|
||||
productName: string;
|
||||
description: string;
|
||||
metadata: Record<string, string>;
|
||||
/** Zahlungsmethode fuer spaetere Raten speichern. */
|
||||
saveForFutureUsage: boolean;
|
||||
expiresAt: Date;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
submitMessage: string;
|
||||
};
|
||||
|
||||
export interface PaymentGateway {
|
||||
readonly mode: StripeMode;
|
||||
createCustomer(params: { email: string; name: string; metadata: Record<string, string> }, idempotencyKey: string): Promise<{ id: string }>;
|
||||
createCheckoutSession(params: CreateCheckoutParams, idempotencyKey: string): Promise<{ id: string; url: string }>;
|
||||
retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo>;
|
||||
expireCheckoutSession(id: string): Promise<void>;
|
||||
retrievePaymentIntent(id: string): Promise<PaymentIntentInfo>;
|
||||
chargeOffSession(
|
||||
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
|
||||
idempotencyKey: string,
|
||||
): Promise<OffSessionResult>;
|
||||
detachPaymentMethod(id: string): Promise<void>;
|
||||
createRefund(
|
||||
params: { paymentIntentId: string; amountCents: number; metadata: Record<string, string> },
|
||||
idempotencyKey: string,
|
||||
): Promise<{ id: string; status: string; amount: number }>;
|
||||
}
|
||||
|
||||
/** Unbekannte, kuenftige Werte gelten vorsichtshalber als unbezahlt. */
|
||||
export function normalizePaymentStatus(value: string): CheckoutSessionInfo["paymentStatus"] {
|
||||
return value === "paid" ? "paid" : value === "no_payment_required" ? "no_payment_required" : "unpaid";
|
||||
}
|
||||
|
||||
function idOf(value: string | { id: string } | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
return typeof value === "string" ? value : value.id;
|
||||
}
|
||||
|
||||
export class StripeGateway implements PaymentGateway {
|
||||
readonly mode: StripeMode;
|
||||
private readonly stripe: Stripe;
|
||||
|
||||
constructor(secretKey: string, mode: StripeMode) {
|
||||
this.mode = mode;
|
||||
this.stripe = new Stripe(secretKey, { maxNetworkRetries: 2, timeout: 20_000 });
|
||||
}
|
||||
|
||||
async createCustomer(params: { email: string; name: string; metadata: Record<string, string> }, idempotencyKey: string) {
|
||||
const customer = await this.stripe.customers.create(
|
||||
{ email: params.email, name: params.name, metadata: params.metadata, preferred_locales: ["de"] },
|
||||
{ idempotencyKey },
|
||||
);
|
||||
return { id: customer.id };
|
||||
}
|
||||
|
||||
async createCheckoutSession(params: CreateCheckoutParams, idempotencyKey: string) {
|
||||
const session = await this.stripe.checkout.sessions.create(
|
||||
{
|
||||
mode: "payment",
|
||||
customer: params.customerId,
|
||||
client_reference_id: params.metadata.bookingId,
|
||||
locale: "de",
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
unit_amount: params.amountCents,
|
||||
product_data: { name: params.productName, description: params.description },
|
||||
},
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
description: params.description,
|
||||
metadata: params.metadata,
|
||||
...(params.saveForFutureUsage ? { setup_future_usage: "off_session" as const } : {}),
|
||||
},
|
||||
metadata: params.metadata,
|
||||
// Preisbindung: die Session laeuft genau mit der Reservierung ab und
|
||||
// kann danach nicht mehr bezahlt werden.
|
||||
expires_at: Math.floor(params.expiresAt.getTime() / 1000),
|
||||
success_url: params.successUrl,
|
||||
cancel_url: params.cancelUrl,
|
||||
custom_text: { submit: { message: params.submitMessage } },
|
||||
},
|
||||
{ idempotencyKey },
|
||||
);
|
||||
if (!session.url) throw new Error("Stripe hat keine Checkout-URL geliefert.");
|
||||
return { id: session.id, url: session.url };
|
||||
}
|
||||
|
||||
async retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo> {
|
||||
const s = await this.stripe.checkout.sessions.retrieve(id);
|
||||
return {
|
||||
id: s.id,
|
||||
status: (s.status ?? "open") as CheckoutSessionInfo["status"],
|
||||
paymentStatus: normalizePaymentStatus(s.payment_status),
|
||||
paymentIntentId: idOf(s.payment_intent),
|
||||
customerId: idOf(s.customer),
|
||||
url: s.url ?? null,
|
||||
metadata: (s.metadata ?? {}) as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
async expireCheckoutSession(id: string): Promise<void> {
|
||||
try {
|
||||
await this.stripe.checkout.sessions.expire(id);
|
||||
} catch (error) {
|
||||
// Bereits abgelaufen oder abgeschlossen: kein Fehler fuer uns, der
|
||||
// Aufrufer prueft den Zustand anschliessend erneut.
|
||||
if (!(error instanceof Stripe.errors.StripeInvalidRequestError)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async retrievePaymentIntent(id: string): Promise<PaymentIntentInfo> {
|
||||
const pi = await this.stripe.paymentIntents.retrieve(id);
|
||||
return {
|
||||
id: pi.id,
|
||||
status: pi.status,
|
||||
amountReceived: pi.amount_received,
|
||||
paymentMethodId: idOf(pi.payment_method),
|
||||
chargeId: idOf(pi.latest_charge),
|
||||
metadata: (pi.metadata ?? {}) as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
async chargeOffSession(
|
||||
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
|
||||
idempotencyKey: string,
|
||||
): Promise<OffSessionResult> {
|
||||
try {
|
||||
const pi = await this.stripe.paymentIntents.create(
|
||||
{
|
||||
amount: params.amountCents,
|
||||
currency: "eur",
|
||||
customer: params.customerId,
|
||||
payment_method: params.paymentMethodId,
|
||||
off_session: true,
|
||||
confirm: true,
|
||||
description: params.description,
|
||||
metadata: params.metadata,
|
||||
},
|
||||
{ idempotencyKey },
|
||||
);
|
||||
return {
|
||||
outcome:
|
||||
pi.status === "succeeded"
|
||||
? "succeeded"
|
||||
: pi.status === "processing"
|
||||
? "processing"
|
||||
: pi.status === "requires_action"
|
||||
? "requires_action"
|
||||
: "failed",
|
||||
paymentIntentId: pi.id,
|
||||
chargeId: idOf(pi.latest_charge),
|
||||
failureCode: pi.last_payment_error?.code ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeCardError) {
|
||||
return {
|
||||
outcome: error.code === "authentication_required" ? "requires_action" : "failed",
|
||||
paymentIntentId: error.payment_intent?.id ?? null,
|
||||
chargeId: null,
|
||||
failureCode: error.decline_code ?? error.code ?? "card_error",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async detachPaymentMethod(id: string): Promise<void> {
|
||||
try {
|
||||
await this.stripe.paymentMethods.detach(id);
|
||||
} catch (error) {
|
||||
// Bereits getrennt -> Ziel erreicht.
|
||||
if (!(error instanceof Stripe.errors.StripeInvalidRequestError)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createRefund(
|
||||
params: { paymentIntentId: string; amountCents: number; metadata: Record<string, string> },
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const refund = await this.stripe.refunds.create(
|
||||
{ payment_intent: params.paymentIntentId, amount: params.amountCents, metadata: params.metadata },
|
||||
{ idempotencyKey },
|
||||
);
|
||||
return { id: refund.id, status: refund.status ?? "pending", amount: refund.amount };
|
||||
}
|
||||
}
|
||||
|
||||
let gateway: PaymentGateway | null = null;
|
||||
|
||||
export function getGateway(): PaymentGateway {
|
||||
if (!gateway) {
|
||||
const key = env().STRIPE_SECRET_KEY;
|
||||
if (!key) throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt.");
|
||||
gateway = new StripeGateway(key, stripeMode());
|
||||
}
|
||||
return gateway;
|
||||
}
|
||||
|
||||
/** Nur fuer Tests. */
|
||||
export function setGatewayForTests(fake: PaymentGateway | null): void {
|
||||
gateway = fake;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signaturpruefung gegen den UNVERAENDERTEN Request-Body. Braucht keinen
|
||||
* API-Schluessel, nur das Webhook-Secret.
|
||||
*/
|
||||
export function verifyStripeWebhook(rawBody: string, signature: string, secret: string): Stripe.Event {
|
||||
return Stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Zahlungsplaene (Briefing Abschnitt 7).
|
||||
*
|
||||
* Aufteilung: Laesst sich der Gesamtpreis ohne Centrest gleichmaessig
|
||||
* teilen, sind alle Raten gleich (z. B. 1.237,50 € + 1.237,50 €). Sonst
|
||||
* werden die Folgeraten auf volle 50 € abgerundet und der Rest faellt in die
|
||||
* erste Rate (z. B. 800 € + 700 € + 700 €). Das reproduziert exakt die
|
||||
* Vorgabetabelle; tests/payment-plan.test.ts prueft alle zwoelf Faelle.
|
||||
*
|
||||
* Faelligkeiten: Erste Rate bei Buchung. Die letzte Rate ist am Stichtag
|
||||
* faellig = Anreise minus finalInstallmentLeadDays (Standard 30 Tage). Bei
|
||||
* drei Raten liegt die zweite in der Mitte zwischen Buchungstag und
|
||||
* Stichtag. Eingezogen wird am Faelligkeitstag um 09:00 Uhr Berlin. Diese
|
||||
* Terminregel ist ein technischer Vorschlag und muss vor dem Live-Verkauf
|
||||
* freigegeben werden (Camp.installmentRuleApproved).
|
||||
*/
|
||||
import { addDays, berlinDateString, berlinStartOfDay, daysBetween } from "./time";
|
||||
|
||||
export type PlanType = "FULL" | "TWO_INSTALLMENTS" | "THREE_INSTALLMENTS";
|
||||
|
||||
export const PLAN_TYPES: readonly PlanType[] = ["FULL", "TWO_INSTALLMENTS", "THREE_INSTALLMENTS"];
|
||||
|
||||
export const INSTALLMENT_COUNT: Record<PlanType, number> = {
|
||||
FULL: 1,
|
||||
TWO_INSTALLMENTS: 2,
|
||||
THREE_INSTALLMENTS: 3,
|
||||
};
|
||||
|
||||
export const PLAN_LABEL: Record<PlanType, string> = {
|
||||
FULL: "Einmalzahlung",
|
||||
TWO_INSTALLMENTS: "Zwei Raten",
|
||||
THREE_INSTALLMENTS: "Drei Raten",
|
||||
};
|
||||
|
||||
const ROUNDING_STEP_CENTS = 5_000;
|
||||
export const CHARGE_HOUR_BERLIN = 9;
|
||||
|
||||
export function splitIntoInstallments(totalCents: number, count: number): number[] {
|
||||
if (!Number.isSafeInteger(totalCents) || totalCents <= 0) throw new Error("Ungueltiger Gesamtbetrag");
|
||||
if (count < 1 || count > 3) throw new Error("Maximal drei Raten");
|
||||
if (count === 1) return [totalCents];
|
||||
if (totalCents % count === 0) return Array.from({ length: count }, () => totalCents / count);
|
||||
const later = Math.floor(totalCents / count / ROUNDING_STEP_CENTS) * ROUNDING_STEP_CENTS;
|
||||
const first = totalCents - later * (count - 1);
|
||||
return [first, ...Array.from({ length: count - 1 }, () => later)];
|
||||
}
|
||||
|
||||
export type ScheduledInstallment = { seq: number; amountCents: number; dueAt: Date | null; dueDate: string | null };
|
||||
|
||||
export type ScheduleUnavailableReason = "CAMP_DATE_MISSING" | "TOO_LATE" | "NOT_APPROVED";
|
||||
|
||||
export type ScheduleResult =
|
||||
| { ok: true; installments: ScheduledInstallment[] }
|
||||
| { ok: false; reason: ScheduleUnavailableReason };
|
||||
|
||||
export type ScheduleInput = {
|
||||
planType: PlanType;
|
||||
totalCents: number;
|
||||
now: Date;
|
||||
/** Anreisetag YYYY-MM-DD oder null, solange unbekannt. */
|
||||
campStartDate: string | null;
|
||||
finalInstallmentLeadDays: number;
|
||||
minInstallmentGapDays: number;
|
||||
};
|
||||
|
||||
export function buildSchedule(input: ScheduleInput): ScheduleResult {
|
||||
const count = INSTALLMENT_COUNT[input.planType];
|
||||
const amounts = splitIntoInstallments(input.totalCents, count);
|
||||
if (count === 1) {
|
||||
return { ok: true, installments: [{ seq: 1, amountCents: amounts[0]!, dueAt: null, dueDate: null }] };
|
||||
}
|
||||
if (!input.campStartDate) return { ok: false, reason: "CAMP_DATE_MISSING" };
|
||||
|
||||
const today = berlinDateString(input.now);
|
||||
const deadline = addDays(input.campStartDate, -input.finalInstallmentLeadDays);
|
||||
const available = daysBetween(today, deadline);
|
||||
// Jede Folgerate braucht mindestens minInstallmentGapDays Abstand zur
|
||||
// vorherigen — sonst ist die Variante serverseitig nicht waehlbar.
|
||||
if (available < input.minInstallmentGapDays * (count - 1)) return { ok: false, reason: "TOO_LATE" };
|
||||
|
||||
const dueDates: string[] =
|
||||
count === 2 ? [deadline] : [addDays(today, Math.floor(available / 2)), deadline];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
installments: amounts.map((amountCents, index) => {
|
||||
if (index === 0) return { seq: 1, amountCents, dueAt: null, dueDate: null };
|
||||
const dueDate = dueDates[index - 1]!;
|
||||
return { seq: index + 1, amountCents, dueDate, dueAt: berlinStartOfDay(dueDate, CHARGE_HOUR_BERLIN) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const SCHEDULE_REASON_TEXT: Record<ScheduleUnavailableReason, string> = {
|
||||
CAMP_DATE_MISSING: "Ratenzahlung ist verfügbar, sobald der Camp-Termin feststeht.",
|
||||
TOO_LATE: "Für eine Ratenzahlung bleibt bis zur letzten Frist nicht mehr genug Zeit.",
|
||||
NOT_APPROVED: "Ratenzahlung ist derzeit nicht freigegeben.",
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Verbindliche Preislogik (Briefing Abschnitt 5). Reine Funktionen ohne
|
||||
* Datenbank — Grundlage fuer Anzeige, Checkout und Tests.
|
||||
*
|
||||
* Grundpreis 2.750,00 €
|
||||
* Early Bird (-20 %) 2.200,00 € 01.11.2026 00:00 – 31.12.2026 24:00 Berlin
|
||||
* Regulaer 2.750,00 € ab 01.01.2027 00:00 Berlin
|
||||
* Stammmodel-Code weitere -10 % auf den jeweils gueltigen Preis
|
||||
* (Early Bird 1.980,00 € = -28 % gesamt, Regulaer 2.475,00 €)
|
||||
*/
|
||||
import { assertCents } from "./money";
|
||||
import { berlinToUtc } from "./time";
|
||||
|
||||
export type PricePhase = "PREVIEW" | "EARLY_BIRD" | "REGULAR";
|
||||
export type SellablePhase = Exclude<PricePhase, "PREVIEW">;
|
||||
|
||||
export const BASE_PRICE_CENTS = 275_000;
|
||||
export const EARLY_BIRD_DISCOUNT_PERCENT = 20;
|
||||
export const STAMMMODEL_DISCOUNT_PERCENT = 10;
|
||||
|
||||
/** 01.11.2026 00:00 Europe/Berlin */
|
||||
export const EARLY_BIRD_START = berlinToUtc(2026, 11, 1);
|
||||
/** 01.01.2027 00:00 Europe/Berlin — Ende Early Bird, Beginn Regulaer. */
|
||||
export const REGULAR_START = berlinToUtc(2027, 1, 1);
|
||||
|
||||
export function pricePhaseAt(now: Date): PricePhase {
|
||||
const t = now.getTime();
|
||||
if (t < EARLY_BIRD_START.getTime()) return "PREVIEW";
|
||||
if (t < REGULAR_START.getTime()) return "EARLY_BIRD";
|
||||
return "REGULAR";
|
||||
}
|
||||
|
||||
export type PriceBreakdown = {
|
||||
phase: SellablePhase;
|
||||
basePriceCents: number;
|
||||
phaseDiscountCents: number;
|
||||
/** Preis nach Phasenrabatt, vor Gutschein. */
|
||||
phasePriceCents: number;
|
||||
voucherDiscountCents: number;
|
||||
totalCents: number;
|
||||
hasStammmodelCode: boolean;
|
||||
};
|
||||
|
||||
export function calculatePrice(phase: SellablePhase, hasStammmodelCode: boolean): PriceBreakdown {
|
||||
const basePriceCents = BASE_PRICE_CENTS;
|
||||
const phaseDiscountCents = phase === "EARLY_BIRD" ? percentOf(basePriceCents, EARLY_BIRD_DISCOUNT_PERCENT) : 0;
|
||||
const phasePriceCents = basePriceCents - phaseDiscountCents;
|
||||
// Der Code wirkt NACH dem Phasenrabatt auf den dann gueltigen Preis
|
||||
// (multiplikativ, nicht additiv: 28 % statt 30 % im Early Bird).
|
||||
const voucherDiscountCents = hasStammmodelCode ? percentOf(phasePriceCents, STAMMMODEL_DISCOUNT_PERCENT) : 0;
|
||||
const totalCents = phasePriceCents - voucherDiscountCents;
|
||||
assertCents(totalCents);
|
||||
return {
|
||||
phase,
|
||||
basePriceCents,
|
||||
phaseDiscountCents,
|
||||
phasePriceCents,
|
||||
voucherDiscountCents,
|
||||
totalCents,
|
||||
hasStammmodelCode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prozentanteil in ganzen Cent. Die vorgegebenen Preise ergeben immer glatte
|
||||
* Cent-Betraege; alles andere waere ein Konfigurationsfehler und bricht ab,
|
||||
* statt still zu runden.
|
||||
*/
|
||||
function percentOf(cents: number, percent: number): number {
|
||||
const raw = cents * percent;
|
||||
if (raw % 100 !== 0) {
|
||||
throw new Error(`Rabatt ergibt keinen glatten Cent-Betrag: ${cents} × ${percent} %`);
|
||||
}
|
||||
return raw / 100;
|
||||
}
|
||||
|
||||
export const PHASE_LABEL: Record<PricePhase, string> = {
|
||||
PREVIEW: "Vorschau",
|
||||
EARLY_BIRD: "Early Bird",
|
||||
REGULAR: "Regulär",
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Daten fuer die oeffentliche Onepage. Liefert ausschliesslich, was
|
||||
* oeffentlich sein darf: keine Kapazitaet, keine Restplaetze, kein
|
||||
* Kontingent, keine Gutscheine, keine internen Kalkulationswerte.
|
||||
*/
|
||||
import { getCamp, publicCampFacts, salesStateFor, type PublicCampFacts } from "./camp";
|
||||
import { getDb } from "./db";
|
||||
import { env, showDevPlaceholders, stripeModeOrNull } from "./env";
|
||||
import { BASE_PRICE_CENTS, calculatePrice, EARLY_BIRD_START, REGULAR_START, type PricePhase } from "./pricing";
|
||||
import type { SalesState, StripeMode } from "./sales-gate";
|
||||
|
||||
export type PublicPricing = {
|
||||
mode: StripeMode | null;
|
||||
checkoutOpen: boolean;
|
||||
closedReason: SalesState["closedReason"] | "NOT_CONFIGURED";
|
||||
phase: PricePhase;
|
||||
phaseSimulated: boolean;
|
||||
regularCents: number;
|
||||
earlyBirdCents: number;
|
||||
/** Aktuell gueltiger Preis ohne Gutschein; null in der Vorschau. */
|
||||
currentCents: number | null;
|
||||
earlyBirdStartIso: string;
|
||||
earlyBirdEndIso: string;
|
||||
installmentsAllowed: boolean;
|
||||
reservationMinutes: number;
|
||||
};
|
||||
|
||||
export type PublicPageData = {
|
||||
facts: PublicCampFacts;
|
||||
pricing: PublicPricing;
|
||||
showDrafts: boolean;
|
||||
externalVideoProvider: "none" | "youtube-nocookie" | "vimeo";
|
||||
};
|
||||
|
||||
export async function loadPublicPage(now: Date = new Date()): Promise<PublicPageData> {
|
||||
const camp = await getCamp(getDb());
|
||||
const mode = stripeModeOrNull();
|
||||
const facts = publicCampFacts(camp);
|
||||
|
||||
let pricing: PublicPricing;
|
||||
const base = {
|
||||
regularCents: BASE_PRICE_CENTS,
|
||||
earlyBirdCents: calculatePrice("EARLY_BIRD", false).totalCents,
|
||||
earlyBirdStartIso: EARLY_BIRD_START.toISOString(),
|
||||
earlyBirdEndIso: REGULAR_START.toISOString(),
|
||||
reservationMinutes: camp.reservationMinutes,
|
||||
};
|
||||
if (!mode) {
|
||||
pricing = {
|
||||
...base,
|
||||
mode: null,
|
||||
checkoutOpen: false,
|
||||
closedReason: "NOT_CONFIGURED",
|
||||
phase: "PREVIEW",
|
||||
phaseSimulated: false,
|
||||
currentCents: null,
|
||||
installmentsAllowed: false,
|
||||
};
|
||||
} else {
|
||||
const state = salesStateFor(camp, mode, now);
|
||||
pricing = {
|
||||
...base,
|
||||
mode,
|
||||
checkoutOpen: state.checkoutOpen,
|
||||
closedReason: state.closedReason,
|
||||
phase: state.effectivePhase,
|
||||
phaseSimulated: state.phaseSimulated,
|
||||
currentCents: state.effectivePhase === "PREVIEW" ? null : calculatePrice(state.effectivePhase, false).totalCents,
|
||||
installmentsAllowed: state.installmentsAllowed,
|
||||
};
|
||||
}
|
||||
return { facts, pricing, showDrafts: showDevPlaceholders(), externalVideoProvider: env().EXTERNAL_VIDEO_PROVIDER };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Preisvorschau fuer den Buchungsdialog — rein lesend, ohne Reservierung.
|
||||
* Die verbindliche Berechnung wiederholt createBooking() beim Checkout.
|
||||
*/
|
||||
import { campStartIso, getCamp, publicCampFacts, salesStateFor } from "./camp";
|
||||
import type { Db } from "./db";
|
||||
import { buildSchedule, PLAN_LABEL, PLAN_TYPES, SCHEDULE_REASON_TEXT, type PlanType } from "./payment-plan";
|
||||
import { calculatePrice, type PricePhase } from "./pricing";
|
||||
import { sellablePhase, type SalesState, type StripeMode } from "./sales-gate";
|
||||
import { evaluateVoucher, PUBLIC_VOUCHER_REJECTION } from "./vouchers";
|
||||
|
||||
export type QuotePlan = {
|
||||
type: PlanType;
|
||||
label: string;
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
installments: { seq: number; amountCents: number; dueDate: string | null }[];
|
||||
};
|
||||
|
||||
export type QuoteData = {
|
||||
checkoutOpen: boolean;
|
||||
closedReason: SalesState["closedReason"];
|
||||
mode: StripeMode;
|
||||
phase: PricePhase;
|
||||
phaseSimulated: boolean;
|
||||
price: { basePriceCents: number; phaseDiscountCents: number; voucherDiscountCents: number; totalCents: number } | null;
|
||||
voucher: { state: "none" | "applied" | "rejected"; message?: string };
|
||||
plans: QuotePlan[];
|
||||
priceLockMinutes: number;
|
||||
taxNote: string | null;
|
||||
};
|
||||
|
||||
export async function computeQuote(
|
||||
db: Db,
|
||||
input: { voucherCode?: string; email?: string },
|
||||
ctx: { now: Date; mode: StripeMode },
|
||||
): Promise<QuoteData> {
|
||||
const camp = await getCamp(db);
|
||||
const state = salesStateFor(camp, ctx.mode, ctx.now);
|
||||
const phase = sellablePhase(state);
|
||||
|
||||
let voucher: QuoteData["voucher"] = { state: "none" };
|
||||
if (input.voucherCode) {
|
||||
const evaluation = await evaluateVoucher(db, {
|
||||
code: input.voucherCode,
|
||||
email: input.email ?? null,
|
||||
now: ctx.now,
|
||||
livemode: ctx.mode === "live",
|
||||
quota: camp.stammmodelQuota,
|
||||
lock: false,
|
||||
});
|
||||
voucher = evaluation.ok ? { state: "applied" } : { state: "rejected", message: PUBLIC_VOUCHER_REJECTION };
|
||||
}
|
||||
|
||||
const price = phase ? calculatePrice(phase, voucher.state === "applied") : null;
|
||||
const plans: QuotePlan[] = PLAN_TYPES.map((type) => {
|
||||
const base = { type, label: PLAN_LABEL[type] };
|
||||
if (!price) return { ...base, available: false, reason: "Die Buchung ist noch nicht geöffnet.", installments: [] };
|
||||
if (type !== "FULL" && !state.installmentsAllowed) {
|
||||
const reason = camp.campStartDate ? SCHEDULE_REASON_TEXT.NOT_APPROVED : SCHEDULE_REASON_TEXT.CAMP_DATE_MISSING;
|
||||
return { ...base, available: false, reason, installments: [] };
|
||||
}
|
||||
const schedule = buildSchedule({
|
||||
planType: type,
|
||||
totalCents: price.totalCents,
|
||||
now: ctx.now,
|
||||
campStartDate: campStartIso(camp),
|
||||
finalInstallmentLeadDays: camp.finalInstallmentLeadDays,
|
||||
minInstallmentGapDays: camp.minInstallmentGapDays,
|
||||
});
|
||||
if (!schedule.ok) return { ...base, available: false, reason: SCHEDULE_REASON_TEXT[schedule.reason], installments: [] };
|
||||
return {
|
||||
...base,
|
||||
available: true,
|
||||
installments: schedule.installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueDate: i.dueDate })),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
checkoutOpen: state.checkoutOpen,
|
||||
closedReason: state.closedReason,
|
||||
mode: ctx.mode,
|
||||
phase: state.effectivePhase,
|
||||
phaseSimulated: state.phaseSimulated,
|
||||
price: price
|
||||
? {
|
||||
basePriceCents: price.basePriceCents,
|
||||
phaseDiscountCents: price.phaseDiscountCents,
|
||||
voucherDiscountCents: price.voucherDiscountCents,
|
||||
totalCents: price.totalCents,
|
||||
}
|
||||
: null,
|
||||
voucher,
|
||||
plans,
|
||||
priceLockMinutes: camp.reservationMinutes,
|
||||
taxNote: publicCampFacts(camp).taxNote,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Rate Limiting ueber PostgreSQL statt Prozessspeicher: die Anwendung laeuft
|
||||
* mit mehreren Replikaten, ein Zaehler pro Pod liesse sich durch
|
||||
* Lastverteilung vervielfachen.
|
||||
*
|
||||
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
|
||||
*/
|
||||
import type { Db } from "./db";
|
||||
import { hmacHex } from "./tokens";
|
||||
|
||||
export type RateLimitRule = { action: string; limit: number; windowSeconds: number };
|
||||
|
||||
export const RATE_LIMITS = {
|
||||
quote: { action: "quote", limit: 60, windowSeconds: 600 },
|
||||
voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
|
||||
checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
|
||||
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
|
||||
} satisfies Record<string, RateLimitRule>;
|
||||
|
||||
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
|
||||
|
||||
export async function consumeRateLimit(
|
||||
db: Db,
|
||||
rule: RateLimitRule,
|
||||
identifier: string,
|
||||
secret: string,
|
||||
): Promise<RateLimitResult> {
|
||||
const key = `${rule.action}:${hmacHex(secret, identifier).slice(0, 40)}`;
|
||||
const rows = await db.$queryRaw<{ count: number; windowStart: Date }[]>`
|
||||
INSERT INTO "RateLimitBucket" ("key", "windowStart", "count")
|
||||
VALUES (${key}, now(), 1)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"count" = CASE
|
||||
WHEN "RateLimitBucket"."windowStart" < now() - make_interval(secs => ${rule.windowSeconds}) THEN 1
|
||||
ELSE "RateLimitBucket"."count" + 1 END,
|
||||
"windowStart" = CASE
|
||||
WHEN "RateLimitBucket"."windowStart" < now() - make_interval(secs => ${rule.windowSeconds}) THEN now()
|
||||
ELSE "RateLimitBucket"."windowStart" END
|
||||
RETURNING "count", "windowStart"`;
|
||||
const row = rows[0]!;
|
||||
const elapsed = (Date.now() - row.windowStart.getTime()) / 1000;
|
||||
return {
|
||||
allowed: row.count <= rule.limit,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil(rule.windowSeconds - elapsed)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Veraltete Zaehler entfernen (Worker). */
|
||||
export async function purgeRateLimits(db: Db): Promise<number> {
|
||||
return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-IP hinter Traefik. Traefik setzt X-Real-Ip selbst und ersetzt
|
||||
* X-Forwarded-For von nicht vertrauenswuerdigen Absendern (EntryPoint-
|
||||
* Einstellung forwardedHeaders.trustedIPs, siehe docs/BETRIEB.md).
|
||||
*/
|
||||
export function clientIp(headers: Headers): string {
|
||||
const realIp = headers.get("x-real-ip")?.trim();
|
||||
if (realIp) return realIp;
|
||||
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||
return forwarded || "unknown";
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Serverseitige Verkaufsfreigabe (Briefing Abschnitt 11).
|
||||
*
|
||||
* Jeder Checkout-Endpunkt fragt diese Funktion — nicht nur die Oberflaeche.
|
||||
*
|
||||
* - Stripe-Testmodus (sk_test_…): Checkout erlaubt, sobald eine Preisphase
|
||||
* gilt (echt oder per testPhaseOverride simuliert). Die Checkliste wird
|
||||
* angezeigt, blockiert aber nicht.
|
||||
* - Stripe-Livemodus (sk_live_…): Checkout nur, wenn die echte Preisphase
|
||||
* laeuft, salesEnabled gesetzt ist UND jede Freigabe bestaetigt wurde.
|
||||
* testPhaseOverride wird ignoriert.
|
||||
*/
|
||||
import { pricePhaseAt, type PricePhase, type SellablePhase } from "./pricing";
|
||||
import { berlinStartOfDay } from "./time";
|
||||
|
||||
export type StripeMode = "test" | "live";
|
||||
|
||||
export type CampGateFields = {
|
||||
salesEnabled: boolean;
|
||||
capacityConfirmed: boolean;
|
||||
stammmodelQuotaConfirmed: boolean;
|
||||
campStartDate: string | null;
|
||||
dateConfirmed: boolean;
|
||||
locationName: string | null;
|
||||
locationConfirmed: boolean;
|
||||
accommodationText: string | null;
|
||||
accommodationConfirmed: boolean;
|
||||
servicesConfirmed: boolean;
|
||||
organizerName: string | null;
|
||||
organizerAddress: string | null;
|
||||
organizerEmail: string | null;
|
||||
organizerConfirmed: boolean;
|
||||
taxNote: string | null;
|
||||
taxConfirmed: boolean;
|
||||
termsVersion: string | null;
|
||||
termsApproved: boolean;
|
||||
travelLawChecked: boolean;
|
||||
installmentsApproved: boolean;
|
||||
installmentRuleApproved: boolean;
|
||||
testPhaseOverride: PricePhase | null;
|
||||
};
|
||||
|
||||
export type ReleaseBlocker =
|
||||
| "SALES_DISABLED"
|
||||
| "DATE"
|
||||
| "LOCATION"
|
||||
| "ACCOMMODATION"
|
||||
| "SERVICES"
|
||||
| "ORGANIZER"
|
||||
| "TAX"
|
||||
| "TERMS"
|
||||
| "TRAVEL_LAW"
|
||||
| "CAPACITY"
|
||||
| "STAMMMODEL_QUOTA";
|
||||
|
||||
export const BLOCKER_TEXT: Record<ReleaseBlocker, string> = {
|
||||
SALES_DISABLED: "Live-Verkauf ist im Adminbereich nicht eingeschaltet.",
|
||||
DATE: "Camp-Termin ist nicht bestätigt.",
|
||||
LOCATION: "Ort ist nicht bestätigt.",
|
||||
ACCOMMODATION: "Unterkunft inkl. Zimmer- und Bettbeschreibung ist nicht bestätigt.",
|
||||
SERVICES: "Enthaltene Leistungen sind nicht bestätigt.",
|
||||
ORGANIZER: "Veranstalter- und Kontaktdaten fehlen oder sind nicht bestätigt.",
|
||||
TAX: "Steuerlich korrekte Preisangabe ist nicht konfiguriert.",
|
||||
TERMS: "Buchungs-, Storno- und Zahlungsbedingungen sind nicht freigegeben.",
|
||||
TRAVEL_LAW: "Reiserechtliche Einordnung (inkl. ggf. Absicherung) ist nicht geprüft.",
|
||||
CAPACITY: "Verkaufbare Platzzahl ist nicht bestätigt.",
|
||||
STAMMMODEL_QUOTA: "Kontingent für Stammmodel-Codes ist nicht bestätigt.",
|
||||
};
|
||||
|
||||
export type SalesState = {
|
||||
mode: StripeMode;
|
||||
/** Tatsaechliche Phase laut Kalender. */
|
||||
phase: PricePhase;
|
||||
/** Fuer Preise massgebliche Phase (im Testmodus ggf. simuliert). */
|
||||
effectivePhase: PricePhase;
|
||||
phaseSimulated: boolean;
|
||||
checkoutOpen: boolean;
|
||||
/** Oeffentlich zeigbarer Grund, wenn nicht buchbar. */
|
||||
closedReason: "PREVIEW" | "NOT_RELEASED" | "CAMP_STARTED" | null;
|
||||
releaseBlockers: ReleaseBlocker[];
|
||||
installmentsAllowed: boolean;
|
||||
};
|
||||
|
||||
export function releaseBlockers(camp: CampGateFields): ReleaseBlocker[] {
|
||||
const blockers: ReleaseBlocker[] = [];
|
||||
if (!camp.salesEnabled) blockers.push("SALES_DISABLED");
|
||||
if (!camp.campStartDate || !camp.dateConfirmed) blockers.push("DATE");
|
||||
if (!camp.locationName || !camp.locationConfirmed) blockers.push("LOCATION");
|
||||
if (!camp.accommodationText || !camp.accommodationConfirmed) blockers.push("ACCOMMODATION");
|
||||
if (!camp.servicesConfirmed) blockers.push("SERVICES");
|
||||
if (!camp.organizerName || !camp.organizerAddress || !camp.organizerEmail || !camp.organizerConfirmed) {
|
||||
blockers.push("ORGANIZER");
|
||||
}
|
||||
if (!camp.taxNote || !camp.taxConfirmed) blockers.push("TAX");
|
||||
if (!camp.termsVersion || !camp.termsApproved) blockers.push("TERMS");
|
||||
if (!camp.travelLawChecked) blockers.push("TRAVEL_LAW");
|
||||
if (!camp.capacityConfirmed) blockers.push("CAPACITY");
|
||||
if (!camp.stammmodelQuotaConfirmed) blockers.push("STAMMMODEL_QUOTA");
|
||||
return blockers;
|
||||
}
|
||||
|
||||
export function evaluateSales(now: Date, camp: CampGateFields, mode: StripeMode): SalesState {
|
||||
const phase = pricePhaseAt(now);
|
||||
const simulate = mode === "test" && camp.testPhaseOverride !== null && camp.testPhaseOverride !== "PREVIEW";
|
||||
const effectivePhase = simulate ? camp.testPhaseOverride! : phase;
|
||||
const blockers = releaseBlockers(camp);
|
||||
const campStarted = camp.campStartDate !== null && now.getTime() >= berlinStartOfDay(camp.campStartDate).getTime();
|
||||
|
||||
let closedReason: SalesState["closedReason"] = null;
|
||||
if (campStarted) closedReason = "CAMP_STARTED";
|
||||
else if (effectivePhase === "PREVIEW") closedReason = "PREVIEW";
|
||||
else if (mode === "live" && blockers.length > 0) closedReason = "NOT_RELEASED";
|
||||
|
||||
const installmentsAllowed =
|
||||
mode === "test"
|
||||
? camp.campStartDate !== null
|
||||
: camp.installmentsApproved && camp.installmentRuleApproved && camp.dateConfirmed && camp.campStartDate !== null;
|
||||
|
||||
return {
|
||||
mode,
|
||||
phase,
|
||||
effectivePhase,
|
||||
phaseSimulated: simulate,
|
||||
checkoutOpen: closedReason === null,
|
||||
closedReason,
|
||||
releaseBlockers: blockers,
|
||||
installmentsAllowed,
|
||||
};
|
||||
}
|
||||
|
||||
export function sellablePhase(state: SalesState): SellablePhase | null {
|
||||
return state.effectivePhase === "PREVIEW" ? null : state.effectivePhase;
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Zeitrechnung in Europe/Berlin ohne Fremdbibliothek.
|
||||
*
|
||||
* Fachliche Grenzen (Preisphasen, Faelligkeiten) sind als Berliner Ortszeit
|
||||
* definiert. Gespeichert und verglichen wird immer in UTC; diese Helfer
|
||||
* uebersetzen zwischen beiden Welten und beruecksichtigen Sommer-/Winterzeit.
|
||||
*/
|
||||
|
||||
export const TIME_ZONE = "Europe/Berlin";
|
||||
|
||||
const partsFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: TIME_ZONE,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
type WallClock = { year: number; month: number; day: number; hour: number; minute: number; second: number };
|
||||
|
||||
function wallClockOf(date: Date): WallClock {
|
||||
const parts = Object.fromEntries(partsFormatter.formatToParts(date).map((p) => [p.type, p.value]));
|
||||
return {
|
||||
year: Number(parts.year),
|
||||
month: Number(parts.month),
|
||||
day: Number(parts.day),
|
||||
hour: Number(parts.hour),
|
||||
minute: Number(parts.minute),
|
||||
second: Number(parts.second),
|
||||
};
|
||||
}
|
||||
|
||||
/** Versatz Berlin gegenueber UTC in Millisekunden zum gegebenen Zeitpunkt. */
|
||||
function offsetMs(date: Date): number {
|
||||
const w = wallClockOf(date);
|
||||
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
|
||||
return asUtc - Math.floor(date.getTime() / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** Berliner Ortszeit -> UTC-Zeitpunkt. */
|
||||
export function berlinToUtc(year: number, month: number, day: number, hour = 0, minute = 0): Date {
|
||||
const naive = Date.UTC(year, month - 1, day, hour, minute);
|
||||
let result = naive - offsetMs(new Date(naive));
|
||||
// Zweiter Durchlauf korrigiert Tage, an denen die Zeitumstellung zwischen
|
||||
// Schaetzung und Ergebnis liegt.
|
||||
result = naive - offsetMs(new Date(result));
|
||||
return new Date(result);
|
||||
}
|
||||
|
||||
/** Kalenderdatum (YYYY-MM-DD) des Zeitpunkts in Berlin. */
|
||||
export function berlinDateString(date: Date): string {
|
||||
const w = wallClockOf(date);
|
||||
return `${w.year}-${pad(w.month)}-${pad(w.day)}`;
|
||||
}
|
||||
|
||||
/** Beginn (00:00 Berlin) eines Kalenderdatums als UTC-Zeitpunkt. */
|
||||
export function berlinStartOfDay(isoDate: string, hour = 0): Date {
|
||||
const [y, m, d] = parseIsoDate(isoDate);
|
||||
return berlinToUtc(y, m, d, hour);
|
||||
}
|
||||
|
||||
export function addDays(isoDate: string, days: number): string {
|
||||
const [y, m, d] = parseIsoDate(isoDate);
|
||||
const t = new Date(Date.UTC(y, m - 1, d + days));
|
||||
return t.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Ganze Kalendertage von a nach b (b - a). */
|
||||
export function daysBetween(a: string, b: string): number {
|
||||
const [ay, am, ad] = parseIsoDate(a);
|
||||
const [by, bm, bd] = parseIsoDate(b);
|
||||
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Datumsspalte aus PostgreSQL (UTC-Mitternacht) -> YYYY-MM-DD. */
|
||||
export function dbDateToIso(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function isoToDbDate(isoDate: string): Date {
|
||||
const [y, m, d] = parseIsoDate(isoDate);
|
||||
return new Date(Date.UTC(y, m - 1, d));
|
||||
}
|
||||
|
||||
export function parseIsoDate(isoDate: string): [number, number, number] {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
||||
if (!match) throw new Error(`Ungueltiges Datum: ${isoDate}`);
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: TIME_ZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: TIME_ZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const longDateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: "UTC",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return dateFormatter.format(date);
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date): string {
|
||||
return `${dateTimeFormatter.format(date)} Uhr`;
|
||||
}
|
||||
|
||||
/** Fuer reine Datumswerte (YYYY-MM-DD), z. B. den Camp-Termin. */
|
||||
export function formatIsoDateLong(isoDate: string): string {
|
||||
return longDateFormatter.format(isoToDbDate(isoDate));
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createHash, createHmac, randomInt } from "node:crypto";
|
||||
|
||||
export function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function hmacHex(secret: string, value: string): string {
|
||||
return createHmac("sha256", secret).update(value).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zugangstoken fuer die Buchungsstatusseite.
|
||||
*
|
||||
* Deterministisch aus Buchungs-ID und APP_SECRET abgeleitet: so laesst es
|
||||
* sich fuer jede E-Mail neu erzeugen, ohne es speichern zu muessen. In der
|
||||
* Datenbank liegt nur sein SHA-256 (Booking.statusTokenHash) fuer die Suche.
|
||||
* Ohne APP_SECRET ist es nicht zu erraten; ein Wechsel des Secrets macht alle
|
||||
* bisherigen Statuslinks ungueltig.
|
||||
*/
|
||||
export function statusTokenFor(bookingId: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(`booking-status:${bookingId}`).digest("base64url");
|
||||
}
|
||||
|
||||
export function isWellFormedStatusToken(token: string): boolean {
|
||||
return /^[A-Za-z0-9_-]{43}$/.test(token);
|
||||
}
|
||||
|
||||
// Ohne leicht verwechselbare Zeichen (0/O, 1/I/L).
|
||||
const REFERENCE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
|
||||
export function generateReference(): string {
|
||||
let suffix = "";
|
||||
for (let i = 0; i < 6; i++) suffix += REFERENCE_ALPHABET[randomInt(REFERENCE_ALPHABET.length)];
|
||||
return `AMC27-${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Stammmodel-Codes (Briefing Abschnitt 6).
|
||||
*
|
||||
* Oeffentlich gibt es nur eine einzige, neutrale Ablehnung — ob ein Code
|
||||
* unbekannt, abgelaufen oder ausgeschoepft ist, erfaehrt nur der
|
||||
* Adminbereich. So laesst sich die Existenz gueltiger Codes nicht
|
||||
* durchprobieren.
|
||||
*/
|
||||
import type { Voucher } from "@/generated/prisma/client";
|
||||
import type { Db, Tx } from "./db";
|
||||
|
||||
export const PUBLIC_VOUCHER_REJECTION = "Dieser Gutscheincode kann leider nicht angewendet werden.";
|
||||
|
||||
export type VoucherRejection =
|
||||
| "MALFORMED"
|
||||
| "UNKNOWN"
|
||||
| "INACTIVE"
|
||||
| "NOT_YET_VALID"
|
||||
| "EXPIRED"
|
||||
| "EMAIL_MISMATCH"
|
||||
| "EXHAUSTED"
|
||||
| "QUOTA_EXHAUSTED";
|
||||
|
||||
/** Grossbuchstaben, ohne Leerzeichen; null bei offensichtlich ungueltiger Form. */
|
||||
export function normalizeVoucherCode(input: string): string | null {
|
||||
const code = input.replace(/\s+/g, "").toUpperCase();
|
||||
return /^[A-Z0-9-]{4,40}$/.test(code) ? code : null;
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
type StaticVoucherFields = Pick<Voucher, "active" | "validFrom" | "validUntil" | "boundEmail">;
|
||||
|
||||
export function checkVoucherStatic(
|
||||
voucher: StaticVoucherFields,
|
||||
ctx: { now: Date; email: string | null },
|
||||
): VoucherRejection | null {
|
||||
if (!voucher.active) return "INACTIVE";
|
||||
if (voucher.validFrom && ctx.now < voucher.validFrom) return "NOT_YET_VALID";
|
||||
if (voucher.validUntil && ctx.now >= voucher.validUntil) return "EXPIRED";
|
||||
if (voucher.boundEmail) {
|
||||
// Ohne E-Mail (reine Preisvorschau) laesst sich die Bindung nicht
|
||||
// pruefen — die verbindliche Pruefung passiert beim Checkout.
|
||||
if (ctx.email !== null && normalizeEmail(ctx.email) !== normalizeEmail(voucher.boundEmail)) {
|
||||
return "EMAIL_MISMATCH";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaehlt belegte Einloesungen: abgeschlossene plus reservierte, deren
|
||||
* Buchung ihren Platz noch haelt. Ohne voucherId: Gesamtkontingent ueber
|
||||
* alle Stammmodel-Codes.
|
||||
*/
|
||||
export async function countUsedRedemptions(
|
||||
db: Db | Tx,
|
||||
params: { livemode: boolean; now: Date; voucherId?: string },
|
||||
): Promise<number> {
|
||||
const rows = await db.$queryRaw<{ n: number }[]>`
|
||||
SELECT count(*)::int AS n
|
||||
FROM "VoucherRedemption" r
|
||||
JOIN "SeatReservation" s ON s."bookingId" = r."bookingId"
|
||||
WHERE r."livemode" = ${params.livemode}
|
||||
AND (${params.voucherId ?? null}::text IS NULL OR r."voucherId" = ${params.voucherId ?? null})
|
||||
AND (
|
||||
r."status" = 'COMPLETED'
|
||||
OR (r."status" = 'RESERVED' AND (
|
||||
s."status" IN ('HELD', 'CONVERTED')
|
||||
OR (s."status" = 'ACTIVE' AND s."expiresAt" > ${params.now})
|
||||
))
|
||||
)`;
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
export type VoucherEvaluation =
|
||||
| { ok: true; voucher: Voucher }
|
||||
| { ok: false; reason: VoucherRejection };
|
||||
|
||||
/**
|
||||
* Vollstaendige Pruefung eines Codes inklusive Einloeselimit und
|
||||
* Gesamtkontingent. Mit lock=true wird die Gutscheinzeile gesperrt (nur
|
||||
* innerhalb einer Transaktion, die bereits die Camp-Zeile haelt).
|
||||
*/
|
||||
export async function evaluateVoucher(
|
||||
db: Db | Tx,
|
||||
params: { code: string; email: string | null; now: Date; livemode: boolean; quota: number; lock: boolean },
|
||||
): Promise<VoucherEvaluation> {
|
||||
const code = normalizeVoucherCode(params.code);
|
||||
if (!code) return { ok: false, reason: "MALFORMED" };
|
||||
|
||||
if (params.lock) {
|
||||
await db.$queryRaw`SELECT "id" FROM "Voucher" WHERE "code" = ${code} FOR UPDATE`;
|
||||
}
|
||||
const voucher = await db.voucher.findUnique({ where: { code } });
|
||||
if (!voucher) return { ok: false, reason: "UNKNOWN" };
|
||||
|
||||
const rejection = checkVoucherStatic(voucher, { now: params.now, email: params.email });
|
||||
if (rejection) return { ok: false, reason: rejection };
|
||||
|
||||
const used = await countUsedRedemptions(db, { livemode: params.livemode, now: params.now, voucherId: voucher.id });
|
||||
if (used >= voucher.maxRedemptions) return { ok: false, reason: "EXHAUSTED" };
|
||||
|
||||
const quotaUsed = await countUsedRedemptions(db, { livemode: params.livemode, now: params.now });
|
||||
if (quotaUsed >= params.quota) return { ok: false, reason: "QUOTA_EXHAUSTED" };
|
||||
|
||||
return { ok: true, voucher };
|
||||
}
|
||||
|
||||
export const VOUCHER_REJECTION_ADMIN_TEXT: Record<VoucherRejection, string> = {
|
||||
MALFORMED: "Format ungültig",
|
||||
UNKNOWN: "Unbekannter Code",
|
||||
INACTIVE: "Code ist deaktiviert",
|
||||
NOT_YET_VALID: "Gültigkeit hat noch nicht begonnen",
|
||||
EXPIRED: "Gültigkeit abgelaufen",
|
||||
EMAIL_MISMATCH: "An andere E-Mail-Adresse gebunden",
|
||||
EXHAUSTED: "Maximale Einlösungen erreicht",
|
||||
QUOTA_EXHAUSTED: "Gesamtkontingent für Stammmodel-Buchungen erreicht",
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Verarbeitung verifizierter Stripe-Events.
|
||||
*
|
||||
* - Idempotenz: Jede Event-ID wird in StripeEvent festgehalten; ein bereits
|
||||
* verarbeitetes Event wird beim erneuten Zustellen uebersprungen.
|
||||
* - Reihenfolge: Die Handler in payment-events.ts sind monoton und sperren
|
||||
* die Buchung — ungeordnete Events (z. B. "expired" nach "completed")
|
||||
* richten keinen Schaden an.
|
||||
* - Wahrheit: Fuer Zahlungen wird der PaymentIntent bei Stripe nachgeladen,
|
||||
* statt dem ggf. veralteten Event-Inhalt zu vertrauen.
|
||||
*/
|
||||
import type Stripe from "stripe";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { STRIPE_ACTOR, type Actor } from "./audit";
|
||||
import type { Db } from "./db";
|
||||
import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
|
||||
import {
|
||||
applyCheckoutExpired,
|
||||
applyDispute,
|
||||
applyFirstPaymentFailed,
|
||||
applyFirstPaymentProcessing,
|
||||
applyFirstPaymentSucceeded,
|
||||
applyInstallmentFailed,
|
||||
applyInstallmentPaid,
|
||||
applyRefundState,
|
||||
type EventContext,
|
||||
} from "./payment-events";
|
||||
|
||||
export type HandlerContext = { now: Date; secret: string; adminEmail: string | null };
|
||||
export type WebhookResult = { outcome: "processed" | "ignored" | "duplicate"; bookingIds: string[] };
|
||||
|
||||
function idOf(value: string | { id: string } | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
return typeof value === "string" ? value : value.id;
|
||||
}
|
||||
|
||||
function eventCtx(ctx: HandlerContext, actor: Actor = STRIPE_ACTOR): EventContext {
|
||||
return { now: ctx.now, secret: ctx.secret, adminEmail: ctx.adminEmail, actor };
|
||||
}
|
||||
|
||||
export function sessionInfoFromStripe(s: Stripe.Checkout.Session): CheckoutSessionInfo {
|
||||
return {
|
||||
id: s.id,
|
||||
status: (s.status ?? "open") as CheckoutSessionInfo["status"],
|
||||
paymentStatus: normalizePaymentStatus(s.payment_status),
|
||||
paymentIntentId: idOf(s.payment_intent),
|
||||
customerId: idOf(s.customer),
|
||||
url: s.url ?? null,
|
||||
metadata: (s.metadata ?? {}) as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt eine abgeschlossene Checkout-Session in die Datenbank. Wird vom
|
||||
* Webhook UND vom Worker (Abgleich bei verlorenem Webhook) genutzt.
|
||||
*/
|
||||
export async function settleCheckoutSession(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
session: CheckoutSessionInfo,
|
||||
ctx: HandlerContext,
|
||||
actor: Actor = STRIPE_ACTOR,
|
||||
): Promise<string | null> {
|
||||
const { kind, bookingId, installmentId } = session.metadata;
|
||||
if (!bookingId || (kind !== "booking" && kind !== "installment")) return null;
|
||||
const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { id: true, stripeCheckoutSessionId: true } });
|
||||
if (!booking) return null;
|
||||
// Eine Buchungs-Session muss zur gespeicherten Session passen (oder es ist
|
||||
// noch keine gespeichert, weil das Speichern nach Stripe scheiterte).
|
||||
if (kind === "booking" && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
|
||||
|
||||
const paid = session.paymentStatus === "paid";
|
||||
if (paid) {
|
||||
if (!session.paymentIntentId) throw new Error(`Bezahlte Session ${session.id} ohne PaymentIntent.`);
|
||||
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
|
||||
const info = {
|
||||
paymentIntentId: pi.id,
|
||||
chargeId: pi.chargeId,
|
||||
paymentMethodId: pi.paymentMethodId,
|
||||
customerId: session.customerId,
|
||||
amountCents: pi.amountReceived,
|
||||
};
|
||||
if (kind === "booking") {
|
||||
await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor)));
|
||||
} else if (installmentId) {
|
||||
await db.$transaction((tx) => applyInstallmentPaid(tx, installmentId, info, eventCtx(ctx, actor)));
|
||||
}
|
||||
return bookingId;
|
||||
}
|
||||
|
||||
if (session.status === "complete") {
|
||||
if (kind === "booking") {
|
||||
await db.$transaction((tx) =>
|
||||
applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)),
|
||||
);
|
||||
} else if (installmentId) {
|
||||
await db.installment.updateMany({
|
||||
where: { id: installmentId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
||||
data: { status: "PROCESSING", stripePaymentIntentId: session.paymentIntentId, lastAttemptAt: ctx.now },
|
||||
});
|
||||
}
|
||||
}
|
||||
return bookingId;
|
||||
}
|
||||
|
||||
async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
case "checkout.session.async_payment_succeeded": {
|
||||
const id = await settleCheckoutSession(db, gateway, sessionInfoFromStripe(event.data.object), ctx);
|
||||
return id ? [id] : [];
|
||||
}
|
||||
case "checkout.session.async_payment_failed": {
|
||||
const s = sessionInfoFromStripe(event.data.object);
|
||||
const { kind, bookingId, installmentId } = s.metadata;
|
||||
if (kind === "booking" && bookingId) {
|
||||
await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx)));
|
||||
return [bookingId];
|
||||
}
|
||||
if (kind === "installment" && installmentId) {
|
||||
await db.installment.updateMany({
|
||||
where: { id: installmentId, status: "PROCESSING" },
|
||||
data: { status: "FAILED", failureCode: "async_payment_failed", checkoutSessionId: null, checkoutExpiresAt: null },
|
||||
});
|
||||
return bookingId ? [bookingId] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
case "checkout.session.expired": {
|
||||
const s = event.data.object;
|
||||
const { kind, bookingId, installmentId } = (s.metadata ?? {}) as Record<string, string>;
|
||||
if (kind === "booking" && bookingId) {
|
||||
await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx)));
|
||||
return [bookingId];
|
||||
}
|
||||
if (kind === "installment" && installmentId) {
|
||||
await db.installment.updateMany({
|
||||
where: { id: installmentId, checkoutSessionId: s.id },
|
||||
data: { checkoutSessionId: null, checkoutExpiresAt: null },
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
case "payment_intent.succeeded": {
|
||||
const pi = event.data.object;
|
||||
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
|
||||
const outcome = await db.$transaction((tx) =>
|
||||
applyInstallmentPaid(
|
||||
tx,
|
||||
pi.metadata.installmentId!,
|
||||
{
|
||||
paymentIntentId: pi.id,
|
||||
chargeId: idOf(pi.latest_charge),
|
||||
paymentMethodId: idOf(pi.payment_method),
|
||||
customerId: idOf(pi.customer),
|
||||
amountCents: pi.amount_received,
|
||||
},
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
void outcome;
|
||||
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
|
||||
}
|
||||
case "payment_intent.payment_failed": {
|
||||
const pi = event.data.object;
|
||||
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
|
||||
const code = pi.last_payment_error?.code ?? null;
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentFailed(
|
||||
tx,
|
||||
pi.metadata.installmentId!,
|
||||
{
|
||||
paymentIntentId: pi.id,
|
||||
failureCode: pi.last_payment_error?.decline_code ?? code,
|
||||
requiresAction: code === "authentication_required" || pi.status === "requires_action",
|
||||
},
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
|
||||
}
|
||||
case "charge.refunded": {
|
||||
const charge = event.data.object;
|
||||
const piId = idOf(charge.payment_intent);
|
||||
if (!piId) return [];
|
||||
await db.$transaction((tx) =>
|
||||
applyRefundState(tx, { paymentIntentId: piId, amountRefundedCents: charge.amount_refunded }, eventCtx(ctx)),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
case "refund.created":
|
||||
case "refund.updated": {
|
||||
const refund = event.data.object;
|
||||
await db.refund.updateMany({ where: { stripeRefundId: refund.id }, data: { status: refund.status ?? "unknown" } });
|
||||
return [];
|
||||
}
|
||||
case "charge.dispute.created": {
|
||||
const piId = idOf(event.data.object.payment_intent);
|
||||
if (piId) await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
|
||||
return [];
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const HANDLED = new Set<string>([
|
||||
"checkout.session.completed",
|
||||
"checkout.session.async_payment_succeeded",
|
||||
"checkout.session.async_payment_failed",
|
||||
"checkout.session.expired",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.payment_failed",
|
||||
"charge.refunded",
|
||||
"refund.created",
|
||||
"refund.updated",
|
||||
"charge.dispute.created",
|
||||
]);
|
||||
|
||||
export async function handleStripeEvent(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
event: Stripe.Event,
|
||||
ctx: HandlerContext,
|
||||
): Promise<WebhookResult> {
|
||||
const livemode = gateway.mode === "live";
|
||||
const relevant = HANDLED.has(event.type) && event.livemode === livemode;
|
||||
|
||||
try {
|
||||
await db.stripeEvent.create({
|
||||
data: { id: event.id, type: event.type, livemode: event.livemode, status: relevant ? "PROCESSING" : "IGNORED" },
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) throw error;
|
||||
const existing = await db.stripeEvent.findUniqueOrThrow({ where: { id: event.id } });
|
||||
if (existing.status === "PROCESSED" || existing.status === "IGNORED") return { outcome: "duplicate", bookingIds: [] };
|
||||
// PROCESSING (paralleler/abgebrochener Versuch) oder FAILED: erneut
|
||||
// verarbeiten — die Handler sind idempotent.
|
||||
await db.stripeEvent.update({ where: { id: event.id }, data: { attempts: { increment: 1 } } });
|
||||
}
|
||||
if (!relevant) return { outcome: "ignored", bookingIds: [] };
|
||||
|
||||
try {
|
||||
const bookingIds = await dispatch(db, gateway, event, ctx);
|
||||
await db.stripeEvent.update({ where: { id: event.id }, data: { status: "PROCESSED", processedAt: new Date(), error: null } });
|
||||
return { outcome: "processed", bookingIds };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.slice(0, 500) : "unbekannter Fehler";
|
||||
await db.stripeEvent.update({ where: { id: event.id }, data: { status: "FAILED", error: message } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Hintergrundaufgaben, ausgefuehrt vom Kubernetes-CronJob (scripts/worker.ts).
|
||||
*
|
||||
* Kein In-Process-Timer: Faelligkeiten stehen in der Datenbank, jeder Lauf
|
||||
* arbeitet den aktuellen Stand ab. Mehrere gleichzeitige Laeufe (z. B. ein
|
||||
* manueller Start waehrend des CronJobs) sind unschaedlich, weil jede Rate
|
||||
* vor dem Einzug atomar beansprucht wird und Stripe-Aufrufe
|
||||
* Idempotenzschluessel tragen.
|
||||
*/
|
||||
import { audit, WORKER } from "./audit";
|
||||
import type { Db } from "./db";
|
||||
import { flushEmails } from "./email";
|
||||
import { MAX_OFF_SESSION_ATTEMPTS, applyCheckoutExpired, applyFirstPaymentFailed, applyInstallmentFailed, applyInstallmentPaid, type EventContext } from "./payment-events";
|
||||
import type { PaymentGateway } from "./payment-gateway";
|
||||
import { purgeRateLimits } from "./rate-limit";
|
||||
import { settleCheckoutSession, type HandlerContext } from "./webhook";
|
||||
|
||||
/** Stripe-Idempotenzschluessel gelten 24 Stunden; ein haengender Einzug
|
||||
* wird nur innerhalb dieser Frist automatisch mit demselben Schluessel
|
||||
* wiederholt, danach entscheidet ein Mensch. */
|
||||
const SAFE_RETRY_WINDOW_MS = 20 * 60 * 60_000;
|
||||
const STUCK_AFTER_MS = 2 * 60 * 60_000;
|
||||
|
||||
function eventCtx(ctx: HandlerContext): EventContext {
|
||||
return { ...ctx, actor: WORKER };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt abgelaufene Reservierungen frei. Vorher wird immer Stripe gefragt:
|
||||
* wurde die Session doch bezahlt (Webhook verloren), wird die Zahlung
|
||||
* verbucht statt der Platz freigegeben.
|
||||
*/
|
||||
export async function releaseExpiredReservations(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const expired = await db.seatReservation.findMany({
|
||||
where: { status: "ACTIVE", expiresAt: { lt: ctx.now } },
|
||||
include: { booking: { select: { id: true, livemode: true, stripeCheckoutSessionId: true } } },
|
||||
take: 100,
|
||||
});
|
||||
let released = 0;
|
||||
let settled = 0;
|
||||
for (const reservation of expired) {
|
||||
const booking = reservation.booking;
|
||||
const sameMode = booking.livemode === (gateway.mode === "live");
|
||||
if (booking.stripeCheckoutSessionId && sameMode) {
|
||||
let session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId);
|
||||
if (session.status === "open") {
|
||||
await gateway.expireCheckoutSession(session.id);
|
||||
session = await gateway.retrieveCheckoutSession(session.id);
|
||||
}
|
||||
if (session.status === "complete") {
|
||||
await settleCheckoutSession(db, gateway, session, ctx, WORKER);
|
||||
settled++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const result = await db.$transaction((tx) =>
|
||||
applyCheckoutExpired(tx, booking.id, null, { ...eventCtx(ctx), reason: "reservation_expired" }),
|
||||
);
|
||||
if (result === "released") released++;
|
||||
}
|
||||
return { released, settled };
|
||||
}
|
||||
|
||||
/** Zieht faellige Folgeraten off-session ein. */
|
||||
export async function chargeDueInstallments(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const livemode = gateway.mode === "live";
|
||||
const due = await db.installment.findMany({
|
||||
where: {
|
||||
seq: { gt: 1 },
|
||||
booking: { status: "CONFIRMED", livemode },
|
||||
OR: [
|
||||
{ status: "PENDING", dueAt: { lte: ctx.now } },
|
||||
{ status: "FAILED", nextAttemptAt: { lte: ctx.now }, attemptCount: { lt: MAX_OFF_SESSION_ATTEMPTS } },
|
||||
],
|
||||
AND: [{ OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }] }],
|
||||
},
|
||||
orderBy: { dueAt: "asc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const summary = { charged: 0, failed: 0, requiresAction: 0, processing: 0, skipped: 0 };
|
||||
for (const installment of due) {
|
||||
// Atomar beanspruchen: nur ein Lauf darf diese Rate einziehen.
|
||||
const claimed = await db.installment.updateMany({
|
||||
where: {
|
||||
id: installment.id,
|
||||
status: installment.status,
|
||||
OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }],
|
||||
},
|
||||
data: { status: "PROCESSING", lastAttemptAt: ctx.now },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
summary.skipped++;
|
||||
continue;
|
||||
}
|
||||
const booking = await db.booking.findUniqueOrThrow({ where: { id: installment.bookingId } });
|
||||
|
||||
// Schutz gegen jede Abbuchung ueber den vereinbarten Gesamtpreis hinaus,
|
||||
// insbesondere nach der letzten Rate.
|
||||
if (booking.paidCents + installment.amountCents > booking.totalCents) {
|
||||
await db.installment.update({ where: { id: installment.id }, data: { status: "CANCELED", failureCode: "plan_already_paid" } });
|
||||
await audit(db, WORKER, "installment.skipped_overpayment_guard", "Installment", installment.id, {
|
||||
paidCents: booking.paidCents,
|
||||
totalCents: booking.totalCents,
|
||||
});
|
||||
summary.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!booking.stripeCustomerId || !booking.stripePaymentMethodId || booking.paymentMethodDetachedAt) {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentFailed(tx, installment.id, { paymentIntentId: null, failureCode: "no_saved_payment_method", requiresAction: true }, eventCtx(ctx)),
|
||||
);
|
||||
summary.requiresAction++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await gateway.chargeOffSession(
|
||||
{
|
||||
customerId: booking.stripeCustomerId,
|
||||
paymentMethodId: booking.stripePaymentMethodId,
|
||||
amountCents: installment.amountCents,
|
||||
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Rate ${installment.seq}`,
|
||||
metadata: {
|
||||
kind: "installment_offsession",
|
||||
bookingId: booking.id,
|
||||
installmentId: installment.id,
|
||||
reference: booking.reference,
|
||||
},
|
||||
},
|
||||
// Gleicher Schluessel bei Wiederholung nach Netzwerkfehler: Stripe
|
||||
// liefert dann das erste Ergebnis statt ein zweites Mal abzubuchen.
|
||||
`offsession:${installment.id}:${installment.attemptCount}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[worker] Einzug Rate ${installment.id} unterbrochen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, status: "PROCESSING" },
|
||||
data: { status: installment.status, nextAttemptAt: new Date(ctx.now.getTime() + 60 * 60_000) },
|
||||
});
|
||||
summary.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.outcome === "succeeded" && result.paymentIntentId) {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentPaid(
|
||||
tx,
|
||||
installment.id,
|
||||
{
|
||||
paymentIntentId: result.paymentIntentId!,
|
||||
chargeId: result.chargeId,
|
||||
paymentMethodId: booking.stripePaymentMethodId,
|
||||
customerId: booking.stripeCustomerId,
|
||||
amountCents: installment.amountCents,
|
||||
},
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
summary.charged++;
|
||||
} else if (result.outcome === "processing") {
|
||||
await db.installment.update({ where: { id: installment.id }, data: { stripePaymentIntentId: result.paymentIntentId } });
|
||||
summary.processing++;
|
||||
} else {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentFailed(
|
||||
tx,
|
||||
installment.id,
|
||||
{
|
||||
paymentIntentId: result.paymentIntentId,
|
||||
failureCode: result.failureCode,
|
||||
requiresAction: result.outcome === "requires_action",
|
||||
},
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
if (result.outcome === "requires_action") summary.requiresAction++;
|
||||
else summary.failed++;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Faengt haengende Einzuege und verlorene Webhooks auf. */
|
||||
export async function reconcileStuck(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const stuck = await db.installment.findMany({
|
||||
where: { status: "PROCESSING", lastAttemptAt: { lt: new Date(ctx.now.getTime() - STUCK_AFTER_MS) }, seq: { gt: 1 } },
|
||||
include: { booking: { select: { livemode: true } } },
|
||||
take: 50,
|
||||
});
|
||||
let resolved = 0;
|
||||
for (const installment of stuck) {
|
||||
if (installment.booking.livemode !== (gateway.mode === "live")) continue;
|
||||
if (installment.stripePaymentIntentId) {
|
||||
const pi = await gateway.retrievePaymentIntent(installment.stripePaymentIntentId);
|
||||
if (pi.status === "succeeded") {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentPaid(
|
||||
tx,
|
||||
installment.id,
|
||||
{ paymentIntentId: pi.id, chargeId: pi.chargeId, paymentMethodId: pi.paymentMethodId, customerId: null, amountCents: pi.amountReceived },
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
resolved++;
|
||||
} else if (pi.status === "requires_payment_method" || pi.status === "canceled" || pi.status === "requires_action") {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentFailed(
|
||||
tx,
|
||||
installment.id,
|
||||
{ paymentIntentId: pi.id, failureCode: `stuck_${pi.status}`, requiresAction: pi.status === "requires_action" },
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
resolved++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Kein Ergebnis gespeichert (z. B. Absturz waehrend des Aufrufs).
|
||||
if (installment.lastAttemptAt && ctx.now.getTime() - installment.lastAttemptAt.getTime() < SAFE_RETRY_WINDOW_MS) {
|
||||
// attemptCount bleibt gleich -> naechster Lauf nutzt denselben
|
||||
// Idempotenzschluessel und kann nicht doppelt abbuchen.
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, status: "PROCESSING" },
|
||||
data: { status: "FAILED", nextAttemptAt: ctx.now, failureCode: "worker_interrupted" },
|
||||
});
|
||||
} else {
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, status: "PROCESSING" },
|
||||
data: { status: "REQUIRES_ACTION", failureCode: "manual_check_required" },
|
||||
});
|
||||
await audit(db, WORKER, "installment.manual_check_required", "Installment", installment.id);
|
||||
}
|
||||
resolved++;
|
||||
}
|
||||
|
||||
// Verzoegerte erste Zahlungen (SEPA), deren Ergebnis-Webhook ausblieb.
|
||||
const processing = await db.booking.findMany({
|
||||
where: {
|
||||
status: "PENDING",
|
||||
paymentStatus: "PROCESSING",
|
||||
livemode: gateway.mode === "live",
|
||||
updatedAt: { lt: new Date(ctx.now.getTime() - 24 * 60 * 60_000) },
|
||||
stripeCheckoutSessionId: { not: null },
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
for (const booking of processing) {
|
||||
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
|
||||
if (session.paymentStatus === "paid") {
|
||||
await settleCheckoutSession(db, gateway, session, ctx, WORKER);
|
||||
resolved++;
|
||||
} else if (session.paymentIntentId) {
|
||||
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
|
||||
if (pi.status === "requires_payment_method" || pi.status === "canceled") {
|
||||
await db.$transaction((tx) => applyFirstPaymentFailed(tx, booking.id, eventCtx(ctx)));
|
||||
resolved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trennt die gespeicherte Zahlungsmethode, sobald keine Rate mehr offen ist
|
||||
* (vollstaendig bezahlt oder storniert). Danach ist technisch keine weitere
|
||||
* Abbuchung mehr moeglich.
|
||||
*/
|
||||
export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGateway, bookingIds?: string[]) {
|
||||
const candidates = await db.booking.findMany({
|
||||
where: {
|
||||
stripePaymentMethodId: { not: null },
|
||||
paymentMethodDetachedAt: null,
|
||||
livemode: gateway.mode === "live",
|
||||
...(bookingIds ? { id: { in: bookingIds } } : {}),
|
||||
installments: { none: { status: { in: ["PENDING", "PROCESSING", "FAILED", "REQUIRES_ACTION"] } } },
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
for (const booking of candidates) {
|
||||
await gateway.detachPaymentMethod(booking.stripePaymentMethodId!);
|
||||
await db.booking.update({ where: { id: booking.id }, data: { paymentMethodDetachedAt: new Date() } });
|
||||
await audit(db, WORKER, "payment_method.detached", "Booking", booking.id, { paymentMethodId: booking.stripePaymentMethodId });
|
||||
}
|
||||
return { detached: candidates.length };
|
||||
}
|
||||
|
||||
export async function runWorker(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const reservations = await releaseExpiredReservations(db, gateway, ctx);
|
||||
const reconcile = await reconcileStuck(db, gateway, ctx);
|
||||
const installments = await chargeDueInstallments(db, gateway, ctx);
|
||||
const detach = await detachFinishedPaymentMethods(db, gateway);
|
||||
const emails = await flushEmails(db, { limit: 200 });
|
||||
const purged = await purgeRateLimits(db);
|
||||
return { reservations, reconcile, installments, detach, emails, purgedRateLimits: purged };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Deutsche Standard-Fehlermeldungen fuer Zod. Wird von allen Modulen mit
|
||||
* Eingabevalidierung importiert, damit auch nicht eigens formulierte Fehler
|
||||
* verstaendlich auf Deutsch erscheinen.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { de } from "zod/v4/locales";
|
||||
|
||||
z.config(de());
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Pro Request: Content-Security-Policy mit Nonce, noindex fuer nicht
|
||||
* oeffentliche Bereiche, und eine schnelle Vorab-Umleitung fuer den
|
||||
* Adminbereich ohne Sitzungscookie.
|
||||
*
|
||||
* Die Vorab-Umleitung ist KEINE Sicherheitsgrenze — die eigentliche Pruefung
|
||||
* (Sitzung + 2FA) machen Admin-Layout, jede Server-Action und jeder
|
||||
* Admin-Endpunkt selbst (src/lib/admin-auth.ts).
|
||||
*/
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const VIDEO_FRAMES: Record<string, string> = {
|
||||
"youtube-nocookie": "https://www.youtube-nocookie.com",
|
||||
vimeo: "https://player.vimeo.com",
|
||||
};
|
||||
|
||||
function buildCsp(nonce: string): string {
|
||||
const frame = VIDEO_FRAMES[process.env.EXTERNAL_VIDEO_PROVIDER ?? "none"];
|
||||
const https = (process.env.APP_URL ?? "").startsWith("https://");
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`,
|
||||
// Inline-Styles (React-style-Attribute, Motion) sind ein deutlich
|
||||
// schwaecheres Angriffsprimitiv als Skripte; Skripte bleiben per Nonce
|
||||
// streng.
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
"media-src 'self' blob:",
|
||||
"connect-src 'self'",
|
||||
`frame-src ${frame ?? "'none'"}`,
|
||||
"form-action 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
...(https ? ["upgrade-insecure-requests"] : []),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
const SESSION_COOKIES = ["better-auth.session_token", "__Secure-better-auth.session_token"];
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (pathname.startsWith("/admin") && !pathname.startsWith("/admin/anmelden")) {
|
||||
const hasSession = SESSION_COOKIES.some((name) => request.cookies.has(name));
|
||||
if (!hasSession) {
|
||||
if (request.method !== "GET") return new NextResponse("Nicht angemeldet.", { status: 401 });
|
||||
return NextResponse.redirect(new URL("/admin/anmelden", request.url));
|
||||
}
|
||||
}
|
||||
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
const csp = buildCsp(nonce);
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
requestHeaders.set("Content-Security-Policy", csp);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
if (pathname.startsWith("/admin") || pathname.startsWith("/buchung/")) {
|
||||
response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
{
|
||||
source: "/((?!api|_next/static|_next/image|media|favicon.ico).*)",
|
||||
missing: [
|
||||
{ type: "header", key: "next-router-prefetch" },
|
||||
{ type: "header", key: "purpose", value: "prefetch" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user