Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 20adfaa97e feat: Gutschein-Detailseite zeigt, ob der Code gerade einlösbar ist und warum nicht
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m54s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 23:26:14 +02:00
Nick SzaunigandClaude Opus 5.5 7a3e37f96f feat: Buchungsdialog zeigt Preis erst unter der gewählten Zahlungsweise
Keine Vorauswahl mehr. Unter der gewählten Option stehen Preisaufstellung und Raten mit Fälligkeit; Weiter erst nach Auswahl.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 23:26:14 +02:00
2 changed files with 74 additions and 32 deletions

No files matched your search

@@ -3,11 +3,33 @@ import { notFound } from "next/navigation";
import { connection } from "next/server"; import { connection } from "next/server";
import { Flash, PageTitle } from "@/components/admin/AdminUi"; import { Flash, PageTitle } from "@/components/admin/AdminUi";
import { VoucherForm } from "@/components/admin/VoucherForm"; import { VoucherForm } from "@/components/admin/VoucherForm";
import { getCamp, salesStateFor } from "@/lib/camp";
import { getDb } from "@/lib/db"; import { getDb } from "@/lib/db";
import { stripeModeOrNull } from "@/lib/env";
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels"; import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
import { evaluateVoucher, VOUCHER_REJECTION_ADMIN_TEXT } from "@/lib/vouchers";
export const metadata = { title: "Gutschein" }; export const metadata = { title: "Gutschein" };
/**
* Prueft wie der Buchungsdialog, ob der Code gerade einloesbar ist, und nennt
* den Grund, den Kundinnen bewusst nicht sehen (neutrale Ablehnung).
* Eine E-Mail-Bindung prueft erst der Checkout.
*/
async function redeemability(code: string): Promise<{ ok: boolean; text: string }> {
const mode = stripeModeOrNull();
if (!mode) return { ok: false, text: "Nicht prüfbar: STRIPE_SECRET_KEY fehlt oder ist ungültig." };
const db = getDb();
const now = new Date();
const camp = await getCamp(db);
const evaluation = await evaluateVoucher(db, { code, email: null, now, livemode: mode === "live", quota: camp.stammmodelQuota, lock: false });
if (!evaluation.ok) return { ok: false, text: `Derzeit nicht einlösbar: ${VOUCHER_REJECTION_ADMIN_TEXT[evaluation.reason]}.` };
if (!salesStateFor(camp, mode, now).checkoutOpen) {
return { ok: false, text: "Code ist gültig, aber die Buchung ist noch nicht geöffnet. Der Dialog zeigt deshalb keinen Preis und keinen Rabatt." };
}
return { ok: true, text: "Derzeit einlösbar (10 % auf den gültigen Preis)." };
}
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) { export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
await connection(); await connection();
const { id } = await params; const { id } = await params;
@@ -17,11 +39,13 @@ export default async function VoucherPage({ params, searchParams }: { params: Pr
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } }, include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
}); });
if (!voucher) notFound(); if (!voucher) notFound();
const check = await redeemability(voucher.code);
return ( return (
<> <>
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link> <Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
<PageTitle>{voucher.code}</PageTitle> <PageTitle>{voucher.code}</PageTitle>
<Flash ok={flash.ok} fehler={flash.fehler} /> <Flash ok={flash.ok} fehler={flash.fehler} />
<p className={`mb-6 rounded-lg border p-4 ${check.ok ? "border-line" : "border-rosso/40 text-rosso"}`}>{check.text}</p>
<div className="grid gap-10 lg:grid-cols-2"> <div className="grid gap-10 lg:grid-cols-2">
<VoucherForm voucher={voucher} /> <VoucherForm voucher={voucher} />
<section> <section>
+50 -32
View File
@@ -60,7 +60,8 @@ export function BookingDialog({
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [participant, setParticipant] = useState<Participant>(EMPTY); const [participant, setParticipant] = useState<Participant>(EMPTY);
const [chosenPlan, setPlanType] = useState<PlanType>("FULL"); // Keine Vorauswahl: Preise erscheinen erst unter der gewaehlten Zahlungsweise.
const [chosenPlan, setPlanType] = useState<PlanType | null>(null);
const [voucherInput, setVoucherInput] = useState(""); const [voucherInput, setVoucherInput] = useState("");
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null); const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
const [voucherMessage, setVoucherMessage] = useState<string | null>(null); const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
@@ -114,11 +115,11 @@ export function BookingDialog({
}; };
}, [open, quote]); }, [open, quote]);
// Eine nicht (mehr) verfuegbare Ratenvariante faellt auf Einmalzahlung zurueck. // Eine nicht (mehr) verfuegbare Variante gilt als nicht gewaehlt.
const chosen = quote?.plans.find((p) => p.type === chosenPlan); const chosen = quote?.plans.find((p) => p.type === chosenPlan);
const planType: PlanType = chosen && !chosen.available ? "FULL" : chosenPlan; const planType: PlanType | null = chosen?.available ? chosenPlan : null;
const selectedPlan = quote?.plans.find((p) => p.type === planType) ?? null; const selectedPlan = chosen?.available ? chosen : null;
function updateParticipant(field: keyof Participant, value: string) { function updateParticipant(field: keyof Participant, value: string) {
setParticipant((prev) => ({ ...prev, [field]: value })); setParticipant((prev) => ({ ...prev, [field]: value }));
@@ -174,6 +175,7 @@ export function BookingDialog({
async function submit() { async function submit() {
setFormError(null); setFormError(null);
if (!planType) return;
const missing: FieldErrors = {}; const missing: FieldErrors = {};
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist."; 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.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
@@ -324,32 +326,29 @@ export function BookingDialog({
<legend className="field-label">Wie möchtest du bezahlen?</legend> <legend className="field-label">Wie möchtest du bezahlen?</legend>
<div className="mt-2 space-y-3"> <div className="mt-2 space-y-3">
{quote?.plans.map((plan) => ( {quote?.plans.map((plan) => (
<label <div
key={plan.type} 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"}`} className={`rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "opacity-60"}`}
> >
<input <label className={`flex gap-4 ${plan.available ? "cursor-pointer" : "cursor-not-allowed"}`}>
type="radio" <input
name="planType" type="radio"
value={plan.type} name="planType"
checked={planType === plan.type} value={plan.type}
disabled={!plan.available} checked={planType === plan.type}
onChange={() => { disabled={!plan.available}
setPlanType(plan.type); onChange={() => {
}} setPlanType(plan.type);
className="mt-1 h-5 w-5 accent-[var(--color-rosso)]" }}
/> className="mt-1 h-5 w-5 accent-[var(--color-rosso)]"
<span className="flex-1"> />
<span className="block font-semibold">{plan.label}</span> <span className="flex-1">
{plan.available ? ( <span className="block font-semibold">{plan.label}</span>
<span className="mt-1 block text-[0.9375rem] text-ink/80"> {!plan.available && <span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>}
{plan.installments.map((i) => money(i.amountCents)).join(" + ")} </span>
</span> </label>
) : ( {plan.available && planType === plan.type && quote.price && <PlanDetails plan={plan} quote={quote} />}
<span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span> </div>
)}
</span>
</label>
)) ?? <p className="text-stone">Preise werden geladen …</p>} )) ?? <p className="text-stone">Preise werden geladen …</p>}
</div> </div>
</fieldset> </fieldset>
@@ -384,8 +383,6 @@ export function BookingDialog({
{voucherMessage} {voucherMessage}
</p> </p>
</div> </div>
{quote?.price && <PriceBreakdown quote={quote} />}
</div> </div>
)} )}
@@ -462,7 +459,7 @@ export function BookingDialog({
</a>{" "} </a>{" "}
gelesen. gelesen.
</Consent> </Consent>
{planType !== "FULL" && ( {planType !== null && planType !== "FULL" && (
<Consent <Consent
id="installmentMandate" id="installmentMandate"
checked={consents.installmentMandate} checked={consents.installmentMandate}
@@ -494,7 +491,7 @@ export function BookingDialog({
<span /> <span />
)} )}
{step < STEPS.length - 1 ? ( {step < STEPS.length - 1 ? (
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && !quote?.price}> <button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && (!quote?.price || !selectedPlan)}>
Weiter Weiter
</button> </button>
) : ( ) : (
@@ -509,6 +506,27 @@ export function BookingDialog({
); );
} }
/** Preis und Raten unter der gewaehlten Zahlungsweise. */
function PlanDetails({ plan, quote }: { plan: QuoteData["plans"][number]; quote: QuoteData }) {
return (
<div className="mt-3 pl-9">
<PriceBreakdown quote={quote} />
{plan.installments.length > 1 && (
<ul className="mt-3 space-y-1 text-[0.9375rem] text-ink/80">
{plan.installments.map((i) => (
<li key={i.seq} className="flex justify-between gap-4">
<span>
Rate {i.seq}, {i.dueDate ? `fällig ${isoDate(i.dueDate)}` : "heute"}
</span>
<span className="tabular-nums">{money(i.amountCents)}</span>
</li>
))}
</ul>
)}
</div>
);
}
function PriceBreakdown({ quote }: { quote: QuoteData }) { function PriceBreakdown({ quote }: { quote: QuoteData }) {
const p = quote.price!; const p = quote.price!;
return ( return (