/* global React, Icon, chf, SpecPills */ // ACN-Cars · Vehicle card // v2.2.13 — Deal-Badge zeigt konsistent den höchsten Rabatt über alle Tiers // (vorher: tier-aware, sprang zwischen Karte und Detail-Seite hin // und her). Streich-Preis-Zeile bleibt tier-aware. // v2.2.12 — Info-Card-Felder per Backend-Toggle ein-/ausblendbar, Custom-Felder // pro Fahrzeug, Tier-Aktionspreise (alter / neuer Preis pro Tier), // Backend-Hinweis aus Footer entfernt (war nur für Admin gedacht). function VehicleCard({ vehicle, fav, onFav, onCompare, comparing, onBook, onOpen, contact, infoCardSettings }) { const v = vehicle; const [showInfo, setShowInfo] = React.useState(false); // v2.2.45 — Info-Popup-Singleton: nur EIN Info-Popup darf gleichzeitig offen // sein. Wenn ein Auto in zwei Sektionen gleichzeitig erscheint (z.B. Aktion // oben + alle-Fahrzeuge unten bei Package-Deals), oder einfach mehrere // Karten im Grid sichtbar sind, schliesst das Öffnen des einen Popups alle // anderen. Click-Outside (Klick auf irgendwo ausserhalb der Karte) schliesst // ebenfalls. Implementiert via Window-Event ohne Parent-State-Änderung. const cardInstanceId = React.useRef( typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : "vc-" + Math.random().toString(36).slice(2) ).current; const cardRef = React.useRef(null); React.useEffect(() => { const onOtherOpen = (e) => { if (e && e.detail && e.detail.id !== cardInstanceId) setShowInfo(false); }; const onDocClick = (e) => { if (cardRef.current && !cardRef.current.contains(e.target)) setShowInfo(false); }; window.addEventListener("acn-vcard-info-open", onOtherOpen); document.addEventListener("click", onDocClick, true); return () => { window.removeEventListener("acn-vcard-info-open", onOtherOpen); document.removeEventListener("click", onDocClick, true); }; }, [cardInstanceId]); const toggleInfo = (e) => { if (e && e.stopPropagation) e.stopPropagation(); setShowInfo((prev) => { const next = !prev; if (next) { try { window.dispatchEvent(new CustomEvent("acn-vcard-info-open", { detail: { id: cardInstanceId } })); } catch (err) { /* IE-Fallback nicht nötig — alle modernen Browser */ } } return next; }); }; // v2.2.12 — Info-Card-Toggles aus Backend (mit sicheren Defaults wenn nicht // gesetzt). Bevorzugt Prop, dann window.ACN_DATA.boot.infoCard, dann all-on. const ics = infoCardSettings || (typeof window !== "undefined" && window.ACN_DATA && window.ACN_DATA.boot && window.ACN_DATA.boot.infoCard) || {}; const showKm = ics.showKm !== false; // default true const showHourRate = ics.showHourRate !== false; // default true const showMinHours = ics.showMinHours !== false; // default true const showVat = ics.showVat !== false; // default true // v2.2.6 — Tier-Picker auf der Karte. Default-Auswahl = 1 Tag (24h) const tp = v.tierPrices || {}; const tierDefs = [ { hours: 3, label: "3 Std", short: "3h", per: "/3 Std", price: tp["3"] }, { hours: 6, label: "6 Std", short: "6h", per: "/6 Std", price: tp["6"] }, { hours: 24, label: "1 Tag", short: "1d", per: "/Tag", price: tp["24"] || v.pricePerDay }, { hours: 48, label: "2 Tage", short: "2d", per: "/2 Tage", price: tp["48"] }, // v2.2.37 — Wochenend-Tier { hours: 168, label: "1 Woche", short: "1w", per: "/Woche", price: tp["168"] }, ].filter(t => t.price && t.price > 0); // v2.2.11 — Default-Auswahl: GÜNSTIGSTER absoluter Preis (statt 1 Tag) // damit Kund:innen den niedrigsten Einstiegspreis sofort sehen. const defaultIdx = tierDefs.length ? tierDefs.reduce((minI, t, i, arr) => (t.price < arr[minI].price ? i : minI), 0) : 0; const [tierIdx, setTierIdx] = React.useState(defaultIdx); const activeTierDef = tierDefs[tierIdx] || null; const activeTier = activeTierDef ? activeTierDef.hours : null; const showTierPicker = tierDefs.length > 1; // Live-Preis für den grossen Preis-Block (folgt dem Slider, sonst Standard /Tag) const displayPrice = activeTierDef ? activeTierDef.price : v.pricePerDay; const displayPer = activeTierDef ? activeTierDef.per : "/Tag"; // v2.2.12 — Tier-aware Aktionspreise: pro Tier kann ein eigener "alter Preis" // hinterlegt werden (tierOldPrices[hours]). Default: globaler oldPrice nur // für 1-Tag-Tier (Backwards-Kompatibilität). const tierOldPrices = v.tierOldPrices || {}; const activeOldPrice = activeTier ? (tierOldPrices[String(activeTier)] && tierOldPrices[String(activeTier)] > 0 ? Number(tierOldPrices[String(activeTier)]) : (activeTier === 24 ? (v.oldPrice || 0) : 0)) : (v.oldPrice || 0); const tierHasDeal = activeOldPrice > 0 && activeOldPrice > displayPrice; const tierDealPct = tierHasDeal ? Math.round((1 - displayPrice / activeOldPrice) * 100) : 0; // v2.2.13 — Deal-Badge zeigt IMMER den HÖCHSTEN verfügbaren Rabatt über alle // Tiers. Damit ist die Anzeige konsistent: Liste-Karte und Detail-Seite // zeigen denselben Wert, egal welche Tier-Pille gerade aktiv ist. Die // Streich-Preis-Zeile darunter (price-old) bleibt tier-aware und folgt // weiterhin der ausgewählten Pille. const allDealPcts = []; if (v.oldPrice && v.oldPrice > v.pricePerDay && v.pricePerDay > 0) { allDealPcts.push(Math.round((1 - v.pricePerDay / v.oldPrice) * 100)); } if (typeof v.dealPct === "number" && v.dealPct > 0) { allDealPcts.push(v.dealPct); } tierDefs.forEach(td => { const op = tierOldPrices[String(td.hours)]; if (op && Number(op) > 0 && Number(op) > td.price && td.price > 0) { allDealPcts.push(Math.round((1 - td.price / Number(op)) * 100)); } }); const dealPct = allDealPcts.length ? Math.max.apply(null, allDealPcts) : 0; const hasDeal = dealPct > 0; // v2.2.11 — Inkl.-km dynamisch für die AKTIVE Tier-Stunde berechnen const activeHoursForKm = activeTier || 24; const kmActive = (typeof window !== "undefined" && window.ACN_kmIncludedFor) ? window.ACN_kmIncludedFor(v, activeHoursForKm) : (v.kmIncluded && v.kmIncluded[String(activeHoursForKm)] ? parseInt(v.kmIncluded[String(activeHoursForKm)], 10) : null); const kmLabel = activeTier === 168 ? "Inkl. Kilometer pro Woche" : activeTier === 24 ? "Inkl. Kilometer pro Tag" : activeTier ? `Inkl. Kilometer für ${activeTierDef ? activeTierDef.label : activeTier + " Std"}` : "Inkl. Kilometer pro Tag"; // v2.2.29 — Backend-konfigurierbare WhatsApp-Vorlage mit {placeholder}-Substitution const waTemplate = (contact && contact.whatsappTemplateShort) ? contact.whatsappTemplateShort : "Hallo ACN-Cars, ich interessiere mich für den {brand} {model} (CHF {pricePerDay}/Tag). Bitte um Verfügbarkeitsprüfung."; const waFilled = waTemplate .replace(/\{brand\}/g, v.brand || "") .replace(/\{model\}/g, v.model || "") .replace(/\{pricePerDay\}/g, chf(v.pricePerDay)) .replace(/\{price\}/g, "CHF " + chf(v.pricePerDay) + "/Tag"); const waMsg = encodeURIComponent(waFilled); const waBase = (contact && contact.whatsappLink) ? contact.whatsappLink.split("?")[0] : "https://wa.me/41789480304"; const waUrl = `${waBase}?text=${waMsg}`; return (
onOpen && onOpen(v)} >
e.stopPropagation()}>
{hasDeal && (
−{dealPct}%
)}
{v.brand}
{v.model}
{v.tagline}
CHF {chf(displayPrice)} {displayPer}
{tierHasDeal && (
CHF {chf(activeOldPrice)}{displayPer} · Sie sparen CHF {chf(activeOldPrice - displayPrice)}
)} {showInfo && (
{showKm && kmActive != null && (
{kmLabel} {kmActive} km
)} {showHourRate && (activeTier === 3 || activeTier === 6) && v.pricePerHour > 0 && (
Stundentarif CHF {chf(v.pricePerHour)} / Std
)} {showMinHours && v.minHours > 0 && (
Mindestmietdauer {v.minHours} Std
)} {/* v2.2.12 — Custom-Felder pro Fahrzeug (Backend → Fahrzeug → Info-Card-Felder) */} {Array.isArray(v.infoCardCustom) && v.infoCardCustom.map((row, idx) => ( row && row.label && row.value ? (
{row.label} {row.value}
) : null ))} {showVat && (
Alle Preise inkl. MwSt.
)}
)}
{/* v2.2.9 — Tier-Pills auf der Karte: kein Slider mehr, nur 4 gleich breite Pills (3H / 6H / 1 Tag / 1 Woche). Big-Price oben + Buchen- Button folgen weiterhin live der ausgewählten Pille. */} {showTierPicker && (
e.stopPropagation()}>
Mietdauer
{tierDefs.map((t, i) => ( ))}
)}
); } window.VehicleCard = VehicleCard;