Compare commits
2
Commits
5c2878875b
...
20adfaa97e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20adfaa97e | ||
|
|
7a3e37f96f |
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>
|
||||||
|
|||||||
@@ -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,10 +326,11 @@ 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"}`}
|
||||||
>
|
>
|
||||||
|
<label className={`flex gap-4 ${plan.available ? "cursor-pointer" : "cursor-not-allowed"}`}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="planType"
|
name="planType"
|
||||||
@@ -341,15 +344,11 @@ export function BookingDialog({
|
|||||||
/>
|
/>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
<span className="block font-semibold">{plan.label}</span>
|
<span className="block font-semibold">{plan.label}</span>
|
||||||
{plan.available ? (
|
{!plan.available && <span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>}
|
||||||
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{plan.available && planType === plan.type && quote.price && <PlanDetails plan={plan} quote={quote} />}
|
||||||
|
</div>
|
||||||
)) ?? <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 (
|
||||||
|
|||||||
Reference in new issue
Block a user