- Figtree als einzige Schrift, Logo mittig in fester Kopfzeile - Headerbild ueber volle Breite und Hoehe, ohne Preis, mit Terminleiste - "Was dich erwartet" stellt die Villa mit animierter Bilderfolge vor, darunter Tagesablauf als Karussell (Programm folgt als PDF) - Preis: regulaerer Preis durchgestrichen, kein Verkaufsstartdatum, Ueberschrift "Jetzt Platz sichern mit Early Bird" - Team als "Mit dabei", danach drei Reels im Hochformat - Logo, Headerbild, Portraets und Reels ueber feste Dateinamen im Medienverzeichnis (docs/MEDIEN.md), ohne Neustart austauschbar - Widerrufsbelehrung im Admin pflegbar, Seite /widerruf, Live-Verkauf ohne Freigabe gesperrt - Anreise: Abholung am Bahnhof Firenze Santa Maria Novella - Booking-Link entfernt, Gedankenstriche aus Kundentexten entfernt Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
/**
|
|
* 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;
|
|
withdrawalText: string | null;
|
|
withdrawalApproved: boolean;
|
|
travelLawChecked: boolean;
|
|
installmentsApproved: boolean;
|
|
installmentRuleApproved: boolean;
|
|
testPhaseOverride: PricePhase | null;
|
|
};
|
|
|
|
export type ReleaseBlocker =
|
|
| "SALES_DISABLED"
|
|
| "DATE"
|
|
| "LOCATION"
|
|
| "ACCOMMODATION"
|
|
| "SERVICES"
|
|
| "ORGANIZER"
|
|
| "TAX"
|
|
| "TERMS"
|
|
| "WITHDRAWAL"
|
|
| "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.",
|
|
WITHDRAWAL: "Widerrufsbelehrung ist nicht eingetragen oder 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.withdrawalText || !camp.withdrawalApproved) blockers.push("WITHDRAWAL");
|
|
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;
|
|
}
|