/* global React, Icon, chf */
// ACN-Cars · Page: Booking (multi-step) + live calculator + smart hints
const DOW_LABELS_DE = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
// Findet das nächste Datum mit Wochentag = targetDow (0..6) ab Startdatum (inkl. heute)
function nextDateForDow(start, targetDow, hour) {
const d = new Date(start.getTime());
d.setHours(hour || 9, 0, 0, 0);
// Wenn Startdatum bereits dieser Wochentag ist UND Zeit >= jetzt: nimm es. Sonst suche das nächste Vorkommen.
let diff = (targetDow - d.getDay() + 7) % 7;
if (diff === 0 && d.getTime() < start.getTime()) diff = 7;
d.setDate(d.getDate() + diff);
return d;
}
function toLocalIso(d) {
const off = d.getTimezoneOffset();
const local = new Date(d.getTime() - off * 60 * 1000);
return local.toISOString().slice(0, 16);
}
function BookingPage({ vehicles, addons, locations, initialVehicleId, dealSeed, tierHours, contact, onComplete, onHome, onFleet }) {
// v2.2.29 — Fallback wenn contact-prop fehlt (alte Aufrufe).
contact = contact || (window.ACN_DATA && window.ACN_DATA.contact) || {};
const boot = (window.ACN_DATA && window.ACN_DATA.boot) || {};
const vatRate = (typeof boot.vatRate === "number" ? boot.vatRate : 8.1) / 100;
const showLocations = !!boot.showLocations;
const smartHints = boot.smartHints || {};
// v2.2.4 — Aktive Wochenend-Aktion (über Aktions-CTA reingekommen)
const weekendDeal = (dealSeed && dealSeed.isWeekend && dealSeed.weekend) ? dealSeed : null;
const w = weekendDeal ? weekendDeal.weekend : null;
const bonusEnabled = !!(w && w.bonusEnabled && w.bonusDay !== w.returnDay);
// Default-Termine: morgen + 24h. Bei Aktion: nächster passender Wochentag/Stunde.
// v2.2.6 — Tier-Hint aus VehicleCard: tierHours bestimmt initialReturn = pickup + tierHours
const tomorrow = new Date(Date.now() + 24 * 3600 * 1000);
const initialDurationH = (tierHours && tierHours > 0) ? tierHours : 24;
const tPlusInitial = new Date(tomorrow.getTime() + initialDurationH * 3600 * 1000);
let initialPickup = tomorrow;
let initialReturn = tPlusInitial;
if (weekendDeal && w) {
const pStart = new Date(Date.now() + 24 * 3600 * 1000); // ab morgen
const pickup = nextDateForDow(pStart, w.pickupDay, w.pickupHour || 9);
const ret = nextDateForDow(pickup, w.returnDay, w.returnHour || 18);
if (ret.getTime() <= pickup.getTime()) ret.setDate(ret.getDate() + 7);
initialPickup = pickup;
initialReturn = ret;
}
// Wenn ein Fahrzeug bereits vorausgewählt wurde (z.B. aus Fahrzeug-Karte heraus), Step 2 (Fahrzeug-Wahl) überspringen
// → Nutzer startet trotzdem bei Step 1 (Daten), Stepper zeigt nur 3 Schritte (Daten → Extras → Bestätigung)
const hasPreselected = !!initialVehicleId && vehicles.some(v => v.id === initialVehicleId);
const [step, setStep] = React.useState(1);
// v2.2.4 — Bonus-Tag-Toggle (verlängert die Aktion um den optionalen Bonus-Tag, z. B. Mo)
const [useBonusDay, setUseBonusDay] = React.useState(false);
const [data, setData] = React.useState({
pickupLoc: locations[0],
returnLoc: locations[0],
pickupAt: toLocalIso(initialPickup),
returnAt: toLocalIso(initialReturn),
vehicleId: initialVehicleId || vehicles[0].id,
addons: addons.filter(a => a.default || a.locked).map(a => a.id),
extraKm: 0,
name: "",
email: "",
phone: "",
license: "",
message: "",
consent: false,
website: "", // Honeypot
});
// v2.2.4 — Bonus-Tag An/Aus → returnAt entsprechend setzen
React.useEffect(() => {
if (!weekendDeal || !w || !bonusEnabled) return;
const pickupDate = new Date(data.pickupAt);
if (isNaN(pickupDate.getTime())) return;
const targetDow = useBonusDay ? w.bonusDay : w.returnDay;
const targetHour = useBonusDay ? (w.returnHour || 18) : (w.returnHour || 18);
const newReturn = nextDateForDow(pickupDate, targetDow, targetHour);
if (newReturn.getTime() <= pickupDate.getTime()) {
newReturn.setDate(newReturn.getDate() + 7);
}
const newReturnIso = toLocalIso(newReturn);
if (newReturnIso !== data.returnAt) {
setData(d => ({ ...d, returnAt: newReturnIso }));
}
// eslint-disable-next-line
}, [useBonusDay]);
// Server-Quote (mit Smart-Rabatten) — wird bei jedem Datums/Fahrzeug/Addon-Wechsel neu geholt
const [serverQuote, setServerQuote] = React.useState(null);
const [quoting, setQuoting] = React.useState(false);
React.useEffect(() => {
if (!boot.apiBase) return;
let cancelled = false;
setQuoting(true);
const controller = new AbortController();
const t = setTimeout(() => {
fetch(boot.apiBase + "/quote", {
method: "POST",
headers: { "Content-Type": "application/json", "X-WP-Nonce": boot.nonce },
body: JSON.stringify({
vehicle_id: data.vehicleId,
pickup_at: data.pickupAt,
return_at: data.returnAt,
addons: data.addons,
extra_km: parseInt(data.extraKm || 0, 10),
// v2.2.37 — Aktion-Kontext mitsenden, damit Weekend-Pauschale auch
// bei abweichenden Wochentagen greift (z. B. Fr→So obwohl Aktion
// Fr→Mo konfiguriert ist).
deal_id: weekendDeal ? weekendDeal.id : "",
}),
signal: controller.signal,
})
.then(r => r.json())
.then(j => { if (!cancelled) setServerQuote(j); })
.catch(() => { /* silent */ })
.finally(() => { if (!cancelled) setQuoting(false); });
}, 250); // Debounce
return () => { cancelled = true; clearTimeout(t); controller.abort(); };
}, [data.vehicleId, data.pickupAt, data.returnAt, data.addons.join(","), data.extraKm, weekendDeal && weekendDeal.id, boot.apiBase]);
const update = (k, v) => setData(d => ({ ...d, [k]: v }));
const toggleAddon = (id) => {
setData(d => {
const a = addons.find(x => x.id === id);
if (a && a.locked) return d; // locked Addons können nicht abgewählt werden
const has = d.addons.includes(id);
return { ...d, addons: has ? d.addons.filter(x => x !== id) : [...d.addons, id] };
});
};
const vehicle = vehicles.find(v => v.id === data.vehicleId) || vehicles[0];
const minHours = Math.max(1, parseInt(vehicle.minHours || (vehicle.minDays ? vehicle.minDays * 24 : 24), 10));
const minDays = Math.max(1, Math.ceil(minHours / 24));
const rawHours = Math.round((new Date(data.returnAt) - new Date(data.pickupAt)) / (1000 * 60 * 60));
const hours = Math.max(1, rawHours);
const days = Math.max(1, Math.ceil(hours / 24));
const tooShort = hours < minHours;
// Lokale Berechnung als Fallback / Sofortige Anzeige
// Auto-pick: günstigeres Modell aus (Stunden × Stundenpreis) vs (Tage × Tagespreis)
const pricePerHour = vehicle.pricePerHour || 0;
const baseDayTotal = vehicle.pricePerDay * days;
const baseHourTotal = pricePerHour > 0 ? pricePerHour * hours : 0;
let priceMode = "day";
let baseTotal = baseDayTotal;
if (pricePerHour > 0 && baseHourTotal > 0 && baseHourTotal < baseDayTotal) {
priceMode = "hour";
baseTotal = baseHourTotal;
}
const addonTotal = data.addons.reduce((sum, id) => {
const a = addons.find(x => x.id === id);
if (!a) return sum;
return sum + (a.per === "Tag" ? a.price * days : a.price);
}, 0);
// Server-Werte bevorzugen, wenn vorhanden
const sq = serverQuote && !serverQuote.code ? serverQuote : null;
const extraKmCost = sq ? (sq.extraKmCost || 0) : 0;
const kmIncluded = sq ? (sq.kmIncluded || 0) : 0;
const extraKmPrice= sq ? (sq.extraKmPrice || 1.5) : (vehicle.kmExtraPrice || 1.5);
const subtotal_pre = sq ? (sq.baseTotal + sq.addonTotal + extraKmCost) : (baseTotal + addonTotal);
const discountAmount = sq ? (sq.discountAmount || 0) : 0;
const discountPct = sq ? (sq.discountPct || 0) : 0;
const appliedDisc = sq ? (sq.appliedDiscounts || []) : [];
const hourTip = sq ? (sq.hourTip || null) : null;
const weekendApplied = sq ? !!sq.weekendApplied : false;
const weekendLabel = sq ? (sq.weekendLabel || "") : "";
const weekendBonusUsed = sq ? !!sq.weekendBonusUsed : false;
const weekendBonusSurcharge = sq ? (sq.weekendBonusSurcharge || 0) : 0;
const afterHoursCost = sq ? (sq.afterHoursCost || 0) : 0;
const afterHoursReasons = sq ? (sq.afterHoursReasons || []) : [];
// Preise sind INKL. MwSt — Total = Brutto, MwSt wird daraus extrahiert.
const total = sq ? sq.total : (baseTotal + addonTotal);
const subtotal = sq ? sq.subtotal : Math.round(total / (1 + vatRate));
const vat = sq ? sq.vat : (total - subtotal);
const sqHours = sq && typeof sq.hours === "number" ? sq.hours : hours;
const sqMode = sq && sq.priceMode ? sq.priceMode : priceMode;
// v2.2.6 — Tier / Inquiry-Mode aus Server-Quote
const isInquiry = sq ? !!sq.isInquiry : false;
const inquiryThresholdH= sq ? (sq.inquiryThresholdH || 168) : (vehicle.inquiryThresholdH || 168);
const tierMatched = sq ? !!sq.tierMatched : false;
// Achtung: NICHT tierHours nennen — Kollision mit dem Prop weiter oben würde
// einen SyntaxError („Identifier already declared") werfen und das ganze Bundle killen.
const matchedTierHours = sq ? (sq.tierHours || null) : null;
const summary = {
vehicle, days, hours: sqHours, priceMode: sqMode,
pricePerHour,
baseTotal: sq ? sq.baseTotal : baseTotal,
addonTotal: sq ? sq.addonTotal : addonTotal,
extraKm: parseInt(data.extraKm || 0, 10),
extraKmCost, kmIncluded, extraKmPrice,
subtotal_pre, discountAmount, discountPct, appliedDisc, hourTip,
weekendApplied, weekendLabel, weekendBonusUsed, weekendBonusSurcharge,
afterHoursCost, afterHoursReasons,
subtotal, vat, total, vatRate, minDays, minHours, tooShort, quoting,
// v2.2.6
isInquiry, inquiryThresholdH, tierMatched, tierHours: matchedTierHours,
};
// Stepper — Fahrzeug nur anzeigen wenn nicht vorausgewählt (oder wenn Nutzer dort schon war)
const stepperItems = hasPreselected
? [
{ n: 1, l: "Daten" },
{ n: 3, l: "Extras" },
{ n: 4, l: "Bestätigung" },
]
: [
{ n: 1, l: "Daten" },
{ n: 2, l: "Fahrzeug" },
{ n: 3, l: "Extras" },
{ n: 4, l: "Bestätigung" },
];
return (
{/* Back-Navigation oben — schnell zurück zur Startseite oder Flotte */}
onHome && onHome()}
aria-label="Zurück zur Startseite"
>
Zurück zur Startseite
{onFleet && (
onFleet()}
aria-label="Zurück zur Flotte"
>
Andere Fahrzeuge ansehen
)}
{/* v2.2.3 — Buchungs-Hero: Auto-Bild + Marke/Modell + Specs + Trust-Badges */}
{hasPreselected ? (
Reservierung
{vehicle.dealPct > 0 && −{vehicle.dealPct}% Deal }
{vehicle.brand}
{vehicle.model}
{vehicle.tagline &&
{vehicle.tagline}
}
{vehicle.power} PS
{vehicle.transmission}
{vehicle.fuel}
{vehicle.seats} Plätze
ab CHF {chf(vehicle.pricePerDay)}/Tag
Sofort-Bestätigung
innerhalb von 2 Std
Vollkasko inkl.
Versicherung enthalten
Persönliche Übergabe
am Standort Neuhausen
) : (
<>
Buchung
Reservierung in 4 Schritten.
>
)}
{/* v2.2.4 — Aktions-Banner (Weekend-Pauschale aus Aktions-CTA) */}
{weekendDeal && w && (
Aktion aktiv: {weekendDeal.title}
Diese Pauschale gilt für Abholung am {DOW_LABELS_DE[w.pickupDay] || "—"}
{" "}({String(w.pickupHour || 9).padStart(2, "0")}:00 Uhr) und Rückgabe am
{" "}{DOW_LABELS_DE[w.returnDay] || "—"}
{" "}({String(w.returnHour || 18).padStart(2, "0")}:00 Uhr).
{" "}Wir haben die Daten passend vorausgefüllt – Sie können sie aber jederzeit anpassen.
{bonusEnabled && (() => {
// v2.2.5 — Saldo: After-Hours-Fee fällt durch späteren Werktag-Rückgabe weg
// Saldo (negativ = Kunde spart). Wir zeigen ihn nur, wenn After-Hours aktiv ist
// UND der Bonus-Tag wirklich innerhalb der Öffnungszeiten liegt.
const ahEnabled = !!(boot.afterHours && boot.afterHours.enabled);
const ahFee = ahEnabled ? (boot.afterHours.fee || 0) : 0;
const surcharge = parseInt(w.bonusSurcharge || 0, 10);
const showSaldo = ahFee > 0;
const saved = ahFee;
const net = saved - surcharge;
return (
<>
setUseBonusDay(e.target.checked)}
/>
Bonus-Tag dazu: Rückgabe erst {DOW_LABELS_DE[w.bonusDay] || "—"}
{surcharge > 0
? <> (+CHF {chf(surcharge)} Aufpreis)>
: <> (ohne Aufpreis)>}
{showSaldo && (
{net > 0 ? (
<>
Effektiv CHF {chf(net)} günstiger:
{" "}Sonntag-Rückgabe wäre After-Hours (+CHF {chf(saved)})
{surcharge > 0 ? <> – Bonus-Aufpreis CHF {chf(surcharge)}> : null}
{" "}= Sie sparen CHF {chf(net)}.
>
) : net === 0 ? (
<>
Bonus-Tag ist preisneutral: After-Hours fällt weg
{" "}(−CHF {chf(saved)}), Aufpreis CHF {chf(surcharge)} gleicht das aus.
>
) : (
<>
Bonus-Tag kostet effektiv CHF {chf(-net)} mehr
{" "}(After-Hours wäre −CHF {chf(saved)}, Aufpreis +CHF {chf(surcharge)}).
>
)}
)}
>
);
})()}
)}
{/* Stepper */}
{stepperItems.map((s, i, arr) => (
s.n ? "done" : ""}
onClick={() => s.n <= step && setStep(s.n)}
style={{ background: "none", border: 0, padding: 0, cursor: s.n <= step ? "pointer" : "default" }}
>
{step > s.n ? : (i + 1)}
{s.l}
{i < arr.length - 1 &&
}
))}
{step === 1 && (
setStep(hasPreselected ? 3 : 2)}
weekendActive={!!weekendDeal}
/>
)}
{step === 2 && !hasPreselected && (
setStep(3)} onBack={() => setStep(1)}
/>
)}
{step === 3 && (
setStep(4)}
onBack={() => setStep(hasPreselected ? 1 : 2)}
/>
)}
{step === 4 && (
setStep(3)}
/>
)}
{/* Summary-Sidebar wird unten gerendert (wegen Reihenfolge im DOM/CSS) */}
);
}
// v2.2.5 — Doppel-Slider für Mietdauer (Tage 0-30 + Stunden 0-23)
// onChangeHours(totalHours) — totalHours = days*24 + hours, mindestens 1
function DurationSlider({ hours, onChangeHours, maxDays }) {
const safeMax = Math.max(1, parseInt(maxDays || 30, 10));
const total = Math.max(1, parseInt(hours || 1, 10));
const days = Math.floor(total / 24);
const extraH = total - days * 24;
const setDays = (d) => {
const next = Math.max(0, Math.min(safeMax, parseInt(d, 10)));
onChangeHours && onChangeHours(Math.max(1, next * 24 + extraH));
};
const setHours = (h) => {
const next = Math.max(0, Math.min(23, parseInt(h, 10)));
onChangeHours && onChangeHours(Math.max(1, days * 24 + next));
};
// Schöne Label-Anzeige
const label = days > 0
? (extraH > 0 ? `${days} Tag${days === 1 ? "" : "e"} ${extraH} Std` : `${days} Tag${days === 1 ? "" : "e"}`)
: `${extraH || 1} Std`;
return (
);
}
window.DurationSlider = DurationSlider;
function Step1({ data, update, locations, showLocations, summary, onNext, weekendActive }) {
// Mietdauer-Buckets für Schnellauswahl-Pills (kürzere Auswahl als Dropdown — die typischen Fälle)
const buckets = [
{ hours: 3, label: "3 Std" },
{ hours: 6, label: "6 Std" },
{ hours: 24, label: "1 Tag" },
{ hours: 48, label: "2 Tage" },
{ hours: 168, label: "1 Woche" },
];
// v2.2.20 — Mietdauer-Dropdown (statt Doppel-Slider). Optionen wie SC Sportcars:
// 3H / 6H / 12H / 24H (1 Tag) / 48H (2 Tage) / 72H (3 Tage) / 96H (4 Tage) /
// 120H (5 Tage) / Länger (Anfrage)
const durationOptions = [
{ value: "3", hours: 3, label: "3 Stunden" },
{ value: "6", hours: 6, label: "6 Stunden" },
{ value: "12", hours: 12, label: "12 Stunden" },
{ value: "24", hours: 24, label: "24 Stunden (1 Tag)" },
{ value: "48", hours: 48, label: "48 Stunden (2 Tage)" },
{ value: "72", hours: 72, label: "72 Stunden (3 Tage)" },
{ value: "96", hours: 96, label: "96 Stunden (4 Tage)" },
{ value: "120", hours: 120, label: "120 Stunden (5 Tage)" },
{ value: "longer", hours: 192, label: "Länger – auf Anfrage" },
];
const currentHours = Math.max(1, Math.round((new Date(data.returnAt) - new Date(data.pickupAt)) / 3600000));
const setHours = (h) => {
const start = new Date(data.pickupAt);
if (isNaN(start.getTime())) return;
const end = new Date(start.getTime() + h * 3600 * 1000);
const off = end.getTimezoneOffset();
const local = new Date(end.getTime() - off * 60 * 1000);
update("returnAt", local.toISOString().slice(0, 16));
};
const setBucket = (h) => setHours(h);
// Aktuell gewählten Wert auf eine Dropdown-Option matchen, sonst "custom" (zeigt aktuellen Wert an)
const matchedDuration = durationOptions.find(o => o.hours === currentHours);
const durationSelectValue = matchedDuration
? matchedDuration.value
: (currentHours > 120 ? "longer" : "custom");
const handleDurationChange = (val) => {
if (val === "custom") return;
const opt = durationOptions.find(o => o.value === val);
if (opt) setHours(opt.hours);
};
// v2.2.6 — Tier-Preise des aktuell gewählten Fahrzeugs
const v = summary && summary.vehicle ? summary.vehicle : null;
const tp = (v && v.tierPrices) ? v.tierPrices : {};
const tierDefs = [
{ hours: 3, label: "3 Std", price: tp["3"] },
{ hours: 6, label: "6 Std", price: tp["6"] },
{ hours: 24, label: "1 Tag", price: tp["24"] },
{ hours: 168, label: "1 Woche", price: tp["168"] },
].filter(t => t.price && t.price > 0);
const hasTiers = tierDefs.length > 0;
const inquiryThresholdH = (v && v.inquiryThresholdH) ? v.inquiryThresholdH : 168;
const isInquiry = inquiryThresholdH > 0 && currentHours > inquiryThresholdH;
return (
Wann{showLocations ? " und wo" : ""}?
{showLocations && (
<>
Abholort
update("pickupLoc", e.target.value)}>
{locations.map(l => {l} )}
Rückgabeort
update("returnLoc", e.target.value)}>
{locations.map(l => {l} )}
>
)}
{!showLocations && (
Standort
{locations[0]}
· Abholung & Rückgabe am gleichen Standort
)}
{/* v2.2.21 — Datum + Zeit prominenter (eyebrow-Label + grössere Box,
konsistent mit dem Mietdauer-Feld darunter)
v2.2.41 — Im Aktion-Slot-Modus (weekendActive) sind die Felder
readOnly und visuell als „fixiert" markiert. Der Slot wurde von
der Aktion-Karte vorgegeben und darf nicht editiert werden. */}
Abholung — Datum & Zeit
{weekendActive && Aktion-Slot }
update("pickupAt", e.target.value)}
/>
Rückgabe — Datum & Zeit
{weekendActive && Aktion-Slot }
update("returnAt", e.target.value)}
/>
{weekendActive && (
Aktion-Slot fixiert. Diese Daten kommen aus der Aktion und sind nicht änderbar.
Brauchst du einen anderen Zeitraum? Buche das Fahrzeug regulär ohne Aktion.
)}
{/* v2.2.5 — Im Weekend-Modus: keine Mietdauer-Auswahl (Pauschalpreis ist fix) */}
{!weekendActive && (
{/* v2.2.7 — Tier-Pauschalen-Pills entfernt: nur noch Slider + Schnellauswahl,
um die UI klarer zu halten. Der Slider rastet auf den Tier-Stunden ein
(siehe DurationSlider) und der Server-Quote zeigt den passenden Tier-Preis an. */}
{/* v2.2.6 — Inquiry-Mode: über Schwelle = Anfrage statt Live-Preis */}
{isInquiry ? (
Längere Mieten — auf Anfrage
Über {inquiryThresholdH} Std. ({Math.round(inquiryThresholdH/24)} Tage) erstellen wir gerne ein
individuelles Angebot. Senden Sie uns Ihre Anfrage über das Formular — wir melden uns
innerhalb von 2 Stunden mit einer persönlichen Offerte.
) : null}
{/* v2.2.20 — Mietdauer-Dropdown (ersetzt den Doppel-Slider)
v2.2.21 — Box-Wrapper mit prominentem Eyebrow-Header für bessere
Lesbarkeit und Konsistenz mit den Datum-Feldern. */}
Mietdauer
Wie lange möchtest du fahren?
handleDurationChange(e.target.value)}
aria-label="Mietdauer"
>
{durationSelectValue === "custom" && (
{currentHours} Std. (individuell)
)}
{durationOptions.map(o => (
{o.label}
))}
Brauchst du etwas dazwischen? Wähle „Länger – auf Anfrage" und wir
erstellen dir ein individuelles Angebot.
Schnellauswahl:
{buckets.map(b => {
const active = currentHours === b.hours;
return (
setBucket(b.hours)}
>
{b.label}
);
})}
)}
{/* Mindestmietdauer-Warnung */}
{summary && summary.tooShort && (
Für dieses Fahrzeug beträgt die Mindestmietdauer {summary.minHours} Std.
{" "}Aktuell sind nur {summary.hours} Std. ausgewählt.
)}
{/* Inkl. km + Extra-km kaufen */}
update("extraKm", Math.max(0, parseInt(n || 0, 10)))}
/>
{/* Stundenmodus-Hint: günstigerer Stundentarif aktiv */}
{summary && summary.priceMode === "hour" && summary.pricePerHour > 0 && (
Stundentarif aktiv: {summary.hours} Std. × CHF {chf(summary.pricePerHour)}
{" "}— günstiger als der Tagestarif für diese Mietdauer.
)}
{/* Smart-Hint: Stunden-Tipp (weniger als 24h gewählt → Tag günstiger) */}
{summary && summary.hourTip && (
{summary.hourTip.message}
)}
{/* Smart-Hint: Aktive Rabatte */}
{summary && summary.appliedDisc && summary.appliedDisc.length > 0 && !summary.weekendApplied && summary.discountAmount > 0 && (
Smart-Rabatt aktiv: {" "}
{summary.appliedDisc.filter(d => d.kind !== 'weekend_deal').map((d, i) => (
{i > 0 && ", "}
{d.label} (−{d.pct}%)
))}
{" — Sie sparen CHF "}{chf(summary.discountAmount)}.
)}
{/* Weekend-Pauschale-Hint */}
{summary && summary.weekendApplied && (
Weekend-Pauschale aktiv
{summary.weekendLabel ? : {summary.weekendLabel} : null}
{" — alles inklusive für Ihr Wochenende. Pauschalpreis: CHF "}{chf(summary.baseTotal)}.
)}
{/* After-Hours-Warnung */}
{summary && summary.afterHoursReasons && summary.afterHoursReasons.length > 0 && (
Hinweis: {" "}
{summary.afterHoursReasons.map((r, i) => (
{i > 0 ? ", " : ""}{r.label}
))}
{" — Aufschlag CHF "}{chf(summary.afterHoursCost)}{" wird der Buchung automatisch hinzugefügt."}
)}
Weiter
);
}
function Step2({ data, update, vehicles, onNext, onBack }) {
return (
Welches Fahrzeug?
{vehicles.map(v => (
update("vehicleId", v.id)}
style={{
textAlign: "left",
padding: 0,
cursor: "pointer",
border: data.vehicleId === v.id ? "2px solid var(--accent)" : "1px solid var(--line)",
background: "var(--bg-elev)",
overflow: "hidden",
}}
>
{v.brand}
{v.model}
CHF {chf(v.pricePerDay)} /Tag
))}
Zurück
Weiter
);
}
function Step3({ data, addons, toggleAddon, onNext, onBack }) {
return (
Extras & Versicherung
{addons.map(a => {
const active = data.addons.includes(a.id);
return (
!a.locked && toggleAddon(a.id)}
className="card addon-row"
style={{
padding: "16px 20px",
display: "flex",
alignItems: "center",
gap: 16,
cursor: a.locked ? "default" : "pointer",
borderColor: active ? "var(--accent)" : "var(--line)",
}}
>
{active && }
{a.label}
{a.note &&
{a.note}
}
{a.price === 0 ? "Inklusive" : `CHF ${chf(a.price)} / ${a.per}`}
);
})}
Zurück
Weiter
);
}
function Step4({ data, update, summary, locations, contact, onConfirm, onBack }) {
// v2.2.29 — WhatsApp-Vorlage mit Buchungs-Daten füllen
const fmtDt = (iso) => {
if (!iso) return "—";
try {
const d = new Date(iso);
const pad = n => String(n).padStart(2, "0");
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`;
} catch { return iso; }
};
const fmtTm = (iso) => {
if (!iso) return "—";
try {
const d = new Date(iso);
const pad = n => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch { return ""; }
};
const totalH = Math.max(1, Math.round((new Date(data.returnAt) - new Date(data.pickupAt)) / 3600000));
const days = Math.floor(totalH / 24);
const extraH = totalH - days * 24;
const durationStr = days > 0
? (extraH > 0 ? `${days} Tag${days === 1 ? "" : "e"} ${extraH} Std` : `${days} Tag${days === 1 ? "" : "e"}`)
: `${totalH} Std`;
const locName = (() => {
if (!data.pickupLoc) return "Nach Absprache";
const lst = Array.isArray(locations) ? locations : [];
const m = lst.find(l => String(l.id) === String(data.pickupLoc));
return m ? (m.name || m.label || m.title || "Nach Absprache") : "Nach Absprache";
})();
const v = summary.vehicle || {};
const waTpl = (contact && contact.whatsappTemplateBooking)
? contact.whatsappTemplateBooking
: "Hallo ACN-Cars, ich möchte den {brand} {model} buchen. Abholung {pickup_date} bis {return_date}. Total CHF {total_price}. Name: {customer_name}";
const waText = waTpl
.replace(/\{brand\}/g, v.brand || "")
.replace(/\{model\}/g, v.model || "")
.replace(/\{pricePerDay\}/g, chf(v.pricePerDay))
.replace(/\{price\}/g, "CHF " + chf(v.pricePerDay) + "/Tag")
.replace(/\{pickup_date\}/g, fmtDt(data.pickupAt))
.replace(/\{pickup_time\}/g, fmtTm(data.pickupAt))
.replace(/\{return_date\}/g, fmtDt(data.returnAt))
.replace(/\{return_time\}/g, fmtTm(data.returnAt))
.replace(/\{duration\}/g, durationStr)
.replace(/\{pickup_location\}/g, locName)
.replace(/\{total_price\}/g, chf(summary.total))
.replace(/\{customer_name\}/g, data.name || "—");
const waBase = (contact && contact.whatsappLink)
? contact.whatsappLink.split("?")[0]
: "https://wa.me/41789480304";
const waUrl = `${waBase}?text=${encodeURIComponent(waText)}`;
// v2.2.6 — Im Inquiry-Mode ist tooShort egal (kein Live-Preis nötig)
const can = data.name && data.email && data.phone && data.consent && (summary.isInquiry || !summary.tooShort);
return (
Letzter Schritt
Ihre Kontaktdaten
Wir bestätigen Ihre Anfrage innerhalb von 2 Stunden per E-Mail oder WhatsApp.
{/* Honeypot — unsichtbar für Menschen */}
Website
update("website", e.target.value)} />
Unverbindlich
Keine Vorauszahlung
Antwort in 2 Std
update("consent", e.target.checked)}
/>
Ich akzeptiere, dass meine Angaben zur Bearbeitung meiner Anfrage gespeichert und per E-Mail übermittelt werden.
Es entstehen keine Kosten — die Buchung ist erst nach schriftlicher Bestätigung verbindlich.
{summary.tooShort && (
Bitte passen Sie zuerst die Mietdauer an (mindestens {summary.minHours} Std.).
)}
{/* v2.2.29 — Alternative WhatsApp-Anfrage mit vorgefüllter Buchungs-Übersicht */}
Lieber per WhatsApp anfragen
Zurück
onConfirm({ ...data, total: summary.total })}
style={{ opacity: can ? 1 : 0.5, cursor: can ? "pointer" : "not-allowed" }}
>
{summary.isInquiry
? <>Anfrage senden >
: <>Unverbindlich anfragen · CHF {chf(summary.total)}>
}
);
}
function Summary({ summary, addons, selectedAddons, pickupLoc, pickupAt, returnAt }) {
const v = summary.vehicle;
return (
Ihre Reservierung
{v.brand}
{v.model}
{pickupLoc}
{fmtDate(pickupAt)} → {fmtDate(returnAt)}
· {summary.hours} Std. ({summary.days} Tag{summary.days !== 1 ? "e" : ""})
{/* v2.2.6 — Inquiry-Mode: kein Preis sondern "Auf Anfrage" */}
{summary.isInquiry ? (
Auf Anfrage
Persönliches Angebot
Bei Mietdauern über {summary.inquiryThresholdH} Std. erstellen wir Ihnen ein
individuelles Angebot. Senden Sie das Formular ab — wir melden uns innerhalb
von 2 Stunden mit der Offerte.
) : (
<>
{summary.priceMode === "weekend"
?
Weekend-Pauschale
{summary.weekendLabel ? · {summary.weekendLabel} : null}
}
r={`CHF ${chf(summary.baseTotal)}`}
/>
: (summary.priceMode && summary.priceMode.indexOf("tier_") === 0
? (() => {
// v2.2.44 — „Pauschale" statt „Tier-Pauschale" + Streichpreis bei Aktion
const tierKey = summary.priceMode === "tier_3h" ? "3"
: summary.priceMode === "tier_6h" ? "6"
: summary.priceMode === "tier_24h" ? "24"
: summary.priceMode === "tier_48h" ? "48"
: "168";
const tierLbl = summary.priceMode === "tier_3h" ? "3 Std"
: summary.priceMode === "tier_6h" ? "6 Std"
: summary.priceMode === "tier_24h" ? "1 Tag"
: summary.priceMode === "tier_48h" ? "2 Tage"
: "1 Woche";
const top = (summary.vehicle && summary.vehicle.tierOldPrices) || {};
const struck = parseInt(top[tierKey], 10);
const dealActive = struck > 0 && struck > summary.baseTotal;
return (
Pauschale
· {tierLbl}
}
r={
dealActive ? (
CHF {chf(struck)}
CHF {chf(summary.baseTotal)}
) : `CHF ${chf(summary.baseTotal)}`
}
/>
);
})()
: (summary.priceMode === "hour" && summary.pricePerHour > 0
?
:
)
)
}
{selectedAddons.map(id => {
const a = addons.find(x => x.id === id);
if (!a || a.price === 0) return null;
const cost = a.per === "Tag" ? a.price * summary.days : a.price;
return
;
})}
{summary.kmIncluded > 0 && (
)}
{summary.extraKm > 0 && (
)}
{summary.discountAmount > 0 && (
Smart-Rabatt (−{summary.discountPct}%)}
r={− CHF {chf(summary.discountAmount)} }
small
/>
)}
{summary.weekendBonusUsed && summary.weekendBonusSurcharge > 0 && (
Bonus-Tag (Aktion verlängert)}
r={+ CHF {chf(summary.weekendBonusSurcharge)} }
small
/>
)}
{(summary.afterHoursReasons || []).map((reason, i) => (
{reason.label}
(After-Hours)
}
r={+ CHF {chf(reason.fee)} }
small
/>
))}
Gesamt (inkl. MwSt)
CHF {chf(summary.total)}
{summary.quoting && … }
>
)}
);
}
function Row({ l, r, small }) {
return (
{l} {r}
);
}
function fmtDate(s) {
const d = new Date(s);
return d.toLocaleString("de-CH", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
}
// Wiederverwendbare KM-Komponente — wird auch im Inline-Calc / Calc-Page genutzt
function KmBlock({ kmIncluded, extraKm, extraKmPrice, extraKmCost, onChange, compact }) {
const km = parseInt(extraKm || 0, 10);
const price = parseFloat(extraKmPrice || 1.5);
const cost = parseInt(extraKmCost || Math.round(km * price), 10);
const dec = () => onChange && onChange(Math.max(0, km - 50));
const inc = () => onChange && onChange(km + 50);
return (
Inklusive Kilometer
{kmIncluded > 0
? {kmIncluded} km enthalten
: — für gewählte Dauer keine km-Pauschale
}
Extra-km: CHF {price.toFixed(2).replace('.', ',')} /km
Zusätzliche km kaufen:
−
onChange && onChange(Math.max(0, parseInt(e.target.value || 0, 10)))}
aria-label="Extra-Kilometer"
/>
+
km
{km > 0 && (
= CHF {cost}
)}
);
}
window.KmBlock = KmBlock;
window.BookingPage = BookingPage;