import { useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import DynamicIcon from '../../components/DynamicIcon.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx';
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
import roadGlowTexture from '../../assets/textures/road-glow.webp';
// Die 8 fertigen "Straßen"-Glow-Bilder aus dem Kunden-Referenzdesign (Kacheln.html) -
// je Preisstufe ein eigenes Bild (Grau -> Petrol -> Gruen -> Gold). Wurden 1:1 aus der
// Referenzdatei extrahiert und nur PNG->WebP optimiert (Motiv unveraendert).
import roadBase from '../../assets/roads/base.webp';
import roadClear from '../../assets/roads/clear.webp';
import roadDrive from '../../assets/roads/drive.webp';
import roadPrime from '../../assets/roads/prime.webp';
import roadEliteR from '../../assets/roads/elite_r.webp';
import roadApex from '../../assets/roads/apex.webp';
import roadStatement from '../../assets/roads/statement.webp';
import roadLimitless from '../../assets/roads/limitless.webp';
const hslToRgb = (h, s, l) => {
s /= 100;
l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
};
const hslCss = (h, s, l) => `rgb(${hslToRgb(h, s, l).join(', ')})`;
// Baut aus [bgH,bgS,bgL, acH,acS,acL]-Zeilen eine Stufenliste ohne eigenes Straßenbild
// (fuer die beiden nicht im Referenzdesign enthaltenen Themes) - Rahmenfarbe = Flaeche
// etwas heller, wie im Referenzdesign.
const gen = (rows) =>
rows.map(([bh, bs, bl, ah, as, al]) => ({
bgTop: hslCss(bh, bs, bl),
border: hslCss(bh, bs, bl + 8),
accent: hslCss(ah, as, al),
img: null,
}));
// "graphite" = exakt die 8 Referenz-Kacheln (Farben + Straßenbild) aus Kacheln.html:
// Base / Clear / Drive / Prime / Elite / Apex / The Statement / Limitless. Jede Stufe
// hat eine Flaechenfarbe oben im Verlauf (bgTop), eine Rahmenfarbe (border) und eine
// Akzentfarbe (Name + Trennlinie). Welche Stufe ein Paket bekommt, ergibt sich aus seinem
// Preis-Rang (siehe tierOf). Die beiden anderen Themes sind eigene Farbfamilien im selben
// Aufbau, nutzen aber (mangels eigener Referenzbilder) das neutrale, umfaerbbare Glow-Motiv.
const PACKAGE_THEMES = {
graphite: [
{ accent: '#b6b6b6', border: '#2a2a2a', bgTop: '#1c1c1c', img: roadBase },
{ accent: '#d3d3d3', border: '#2e2e2e', bgTop: '#212121', img: roadClear },
{ accent: '#8fb8bd', border: '#26383b', bgTop: '#16282b', img: roadDrive },
{ accent: '#6fbf73', border: '#1f3a24', bgTop: '#122916', img: roadPrime },
{ accent: '#63d34f', border: '#204a1c', bgTop: '#123312', img: roadEliteR },
{ accent: '#8bea3c', border: '#2b5518', bgTop: '#173a0f', img: roadApex },
{ accent: '#d9a83c', border: '#4a3a16', bgTop: '#33270d', img: roadStatement },
{ accent: '#f2c14e', border: '#5a4718', bgTop: '#3d2f0c', img: roadLimitless },
],
'deep-blue': gen([
[215, 18, 11, 210, 15, 60],
[212, 25, 11, 208, 32, 60],
[210, 38, 12, 206, 46, 60],
[212, 46, 12, 205, 56, 61],
[214, 52, 12, 205, 63, 62],
[216, 58, 12, 205, 68, 63],
[220, 62, 12, 212, 73, 65],
[226, 66, 12, 216, 78, 68],
]),
'warm-bronze': gen([
[30, 15, 11, 34, 22, 60],
[34, 32, 11, 34, 46, 60],
[32, 46, 12, 30, 58, 60],
[26, 52, 12, 24, 65, 60],
[20, 55, 12, 18, 68, 60],
[14, 55, 12, 12, 68, 61],
[8, 52, 12, 6, 64, 62],
[2, 50, 12, 358, 60, 63],
]),
};
// Referenz-Werte fuer die Preis-Sektion (unabhaengig von der Preisstufe, wie im Design):
const PRICE_COLOR = '#f2f2f2';
const LABEL_COLOR = '#8a8a8a';
const BULLET_COLOR = '#c4c4c4';
// Mehr sichtbare Stichpunkte wuerden das Straßenbild verdraengen - gerade bei den teuren
// Paketen (die laut Kunde die laengsten Listen haben) soll die Kachel ruhig bleiben. Der
// Rest steckt hinter einem "+X weitere Leistungen"-Toggle, dessen Zaehler selbst als
// Wertigkeitssignal wirkt.
const VISIBLE_BULLETS = 5;
function PackageCard({ pkg, tier, tierT, layout, bullets, formatPrice, contactUrl, t }) {
const [expanded, setExpanded] = useState(false);
const hiddenCount = bullets.length - VISIBLE_BULLETS;
const shownBullets = expanded ? bullets : bullets.slice(0, VISIBLE_BULLETS);
// Fuer die Themes ohne eigenes Straßenbild: neutrales Glow-Motiv, per Maske
// in die Akzentfarbe eingefaerbt (zwei Ebenen: weicher Halo + scharfe Kontur).
const glowMask = tier.img
? null
: {
backgroundColor: tier.accent,
WebkitMaskImage: `url(${roadGlowTexture})`,
maskImage: `url(${roadGlowTexture})`,
maskMode: 'luminance',
WebkitMaskSize: 'cover',
maskSize: 'cover',
WebkitMaskPosition: 'center',
maskPosition: 'center',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
};
return (
{/* Vollflaechiges Straßen-Glow-Bild der Preisstufe (Referenzdesign). */}
{tier.img ? (
) : (
<>
>
)}
{/* top-2 statt -top-3: die Karte hat overflow-hidden (runde Ecken + Straßenbild),
ein halb herausragender Badge wuerde oben abgeschnitten. */}
{pkg.is_featured && (
{t('modelPage.featuredBadge')}
)}
{/* Kopf: Name + Trennlinie (exakt Referenz). */}
{pkg.name}
{/* Stichpunkte direkt unter dem Kopf, leicht abgedunkelter Hintergrund fuer
Lesbarkeit ueber dem Straßenbild - aufgeklappt deckender, da die Liste dann
weiter ins helle Bildzentrum hineinreicht. */}
{bullets.length > 0 && (
{shownBullets.map((line, i) => (
{line}
))}
{hiddenCount > 0 && (
)}
)}
{/* Freiraum, damit das Straßenbild zur Geltung kommt. */}
{/* Preis-Sektion mit dunklem Verlauf (Referenz): "ab ca." + Preis + CTA. */}
);
}
export default function ModelPage() {
const { brandSlug, modelSlug } = useParams();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const maintenance = useMaintenance();
const { t, language } = useLanguage();
const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings();
const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
const layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
const scrollerRef = useRef(null);
const trackRef = useRef(null);
const thumbRef = useRef(null);
const hintPlayedRef = useRef(false);
const formatPrice = (value) =>
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
useEffect(() => {
if (maintenance.vehicles.enabled && !maintenance.bypass) return;
api
.get(`/models/${brandSlug}/${modelSlug}/packages`)
.then(setData)
.catch((e) => setError(e.message));
}, [brandSlug, modelSlug, maintenance.vehicles.enabled, maintenance.bypass]);
// 3D-Coverflow: pro Scroll-Frame bekommt jede Karte abhaengig vom Abstand ihrer Mitte
// zur Containermitte eine Drehung/Skalierung/Tiefe - die mittlere Karte steht frontal,
// die Nachbarn drehen raeumlich weg. rAF-gedrosselt; der ResizeObserver deckt auch
// Hoehenaenderungen ab (z. B. wenn eine Karte ihre Stichpunkte aufklappt).
useEffect(() => {
const el = scrollerRef.current;
if (layout !== 'coverflow' || !el) return undefined;
let rafId = 0;
const apply = () => {
rafId = 0;
const mid = el.scrollLeft + el.clientWidth / 2;
for (const card of el.children) {
const center = card.offsetLeft + card.offsetWidth / 2;
const d = Math.max(-1, Math.min(1, (center - mid) / (card.offsetWidth * 1.4)));
const abs = Math.abs(d);
card.style.transform = `rotateY(${(-d * 35).toFixed(2)}deg) scale(${(1 - abs * 0.13).toFixed(3)}) translateZ(${(-abs * 60).toFixed(1)}px)`;
card.style.zIndex = String(100 - Math.round(abs * 50));
}
};
const schedule = () => {
if (!rafId) rafId = requestAnimationFrame(apply);
};
apply();
el.addEventListener('scroll', schedule, { passive: true });
const ro = new ResizeObserver(schedule);
ro.observe(el);
return () => {
el.removeEventListener('scroll', schedule);
ro.disconnect();
cancelAnimationFrame(rafId);
};
}, [layout, data]);
// Kein Karten-Drag per Maus mehr (Kundenfeedback: fuehlte sich in Kombination mit der
// 3D-Transformation im Coverflow nicht gut an). Auf dem Desktop navigiert man
// stattdessen ueber die eigene Scroll-Leiste unten; Touch-Geraete behalten ohnehin
// das native, fluessige Wisch-Scrolling der scroll-snap-Kartenreihe.
//
// Stattdessen: einmaliger kurzer Wisch-Hinweis beim ersten Laden auf Touch-Geraeten
// (Kundenwunsch) - die Reihe faehrt kurz ein Stueck nach rechts und wieder zurueck,
// damit sofort klar ist, dass sich die Kacheln wischen lassen. Nur einmal pro
// Seitenaufruf, nur auf echten Touch-Geraeten, nicht bei reduzierter Bewegung.
useEffect(() => {
const el = scrollerRef.current;
if (layout === 'grid' || !el || hintPlayedRef.current) return undefined;
const isTouch = window.matchMedia('(pointer: coarse)').matches;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isTouch || reducedMotion) return undefined;
hintPlayedRef.current = true;
const peek = Math.min(el.clientWidth * 0.4, 160);
const t1 = setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500);
const t2 = setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [layout, data]);
// Eigene, zum Design passende Scroll-Leiste unter der Kartenreihe (Strip + Coverflow):
// die native Browser-Leiste ist ausgeblendet, stattdessen ein schmaler zentrierter
// Track mit markengruenem, leuchtendem Daumen. Daumen ist ziehbar, Klick auf den
// Track springt an die Stelle. Bei zu wenig Inhalt (kein Overflow) blendet sie aus.
useEffect(() => {
const el = scrollerRef.current;
const track = trackRef.current;
const thumb = thumbRef.current;
if (layout === 'grid' || !el || !track || !thumb) return undefined;
let rafId = 0;
const update = () => {
rafId = 0;
const maxScroll = el.scrollWidth - el.clientWidth;
if (maxScroll <= 1) {
track.style.opacity = '0';
track.style.pointerEvents = 'none';
return;
}
track.style.opacity = '1';
track.style.pointerEvents = 'auto';
const trackW = track.clientWidth;
const thumbW = Math.max(32, (el.clientWidth / el.scrollWidth) * trackW);
thumb.style.width = `${thumbW}px`;
thumb.style.transform = `translateX(${((el.scrollLeft / maxScroll) * (trackW - thumbW)).toFixed(1)}px)`;
};
const schedule = () => {
if (!rafId) rafId = requestAnimationFrame(update);
};
update();
el.addEventListener('scroll', schedule, { passive: true });
const ro = new ResizeObserver(schedule);
ro.observe(el);
let dragging = false;
let grabOffset = 0;
const scrollToPointer = (clientX) => {
const rect = track.getBoundingClientRect();
const thumbW = thumb.clientWidth;
const x = Math.min(Math.max(clientX - rect.left - grabOffset, 0), rect.width - thumbW);
el.scrollLeft = (x / (rect.width - thumbW)) * (el.scrollWidth - el.clientWidth);
};
const onDown = (e) => {
dragging = true;
track.setPointerCapture(e.pointerId);
el.style.scrollSnapType = 'none';
const tr = thumb.getBoundingClientRect();
// Auf dem Daumen: relative Griffposition merken; daneben: Daumen unter den Zeiger springen.
grabOffset = e.clientX >= tr.left && e.clientX <= tr.right ? e.clientX - tr.left : thumb.clientWidth / 2;
if (!(e.clientX >= tr.left && e.clientX <= tr.right)) scrollToPointer(e.clientX);
};
const onMove = (e) => {
if (dragging) scrollToPointer(e.clientX);
};
const onUp = (e) => {
if (!dragging) return;
dragging = false;
if (track.hasPointerCapture?.(e.pointerId)) track.releasePointerCapture(e.pointerId);
el.style.scrollSnapType = '';
};
track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onUp);
return () => {
el.removeEventListener('scroll', schedule);
ro.disconnect();
cancelAnimationFrame(rafId);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onUp);
};
}, [layout, data]);
usePageMeta({
title: data ? t('modelPage.metaTitle')(data.model.brand_name, data.model.name) : t('modelPage.metaTitleFallback'),
description: data ? t('modelPage.metaDescription')(data.model.brand_name, data.model.name) : undefined,
path: `/fahrzeuge/${brandSlug}/${modelSlug}`,
});
if (maintenance.vehicles.enabled && !maintenance.bypass) {
return ;
}
if (error) {
return
{error}
;
}
if (!data) {
return
{t('modelPage.loading')}
;
}
const { model, packages } = data;
// Preis-Rang innerhalb dieses Modells waehlt die Referenz-Stufe: die guenstigste Option
// bekommt die erste Kachel (Base), die teuerste die letzte (Limitless), der Rest wird
// gleichmaessig auf die dazwischenliegenden Stufen verteilt. Da jede Stufe ihr eigenes
// Straßenbild hat (und Bilder sich nicht mischen lassen), wird die naechstgelegene Stufe
// gewaehlt statt zwischen zwei Stufen zu interpolieren - so passen Bild und Farbe immer
// exakt zur Referenz, egal ob ein Modell 2, 8 oder 12 Pakete hat.
const rankById = new Map(
[...packages].sort((a, b) => a.total_price - b.total_price).map((p, i) => [p.id, i])
);
const tierOf = (pkg) => {
const n = packages.length;
const rank = rankById.get(pkg.id) ?? 0;
const tierT = n <= 1 ? 0 : rank / (n - 1);
const idx = Math.round(tierT * (TIERS.length - 1));
return { tier: TIERS[idx], tierT };
};
// Stichpunkte einer Kachel = die Produkte des Pakets plus die Freitext-Zeilen der
// Beschreibung (je Zeile ein Punkt). Das ist der einzige Zusatz zum Referenzdesign.
const bulletsOf = (pkg) => [
...pkg.products.map((p) => p.name_override || p.scraped_name || t('modelPage.productLoading')),
...(pkg.description || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
];
const contactUrl = (pkg) => {
const params = new URLSearchParams({
brand: model.brand_name,
model: model.name,
package: pkg.name,
packageId: pkg.id,
total: pkg.total_price,
});
return `/kontakt?${params.toString()}`;
};
return (
<>
{packages.length > 0 && (
// Die Sektion folgt dem hellen/dunklen Seiten-Theme - nur die Kacheln selbst
// bleiben dunkel (Referenzdesign), wodurch sie auf heller Flaeche als eigenstaendige
// "Karten" wirken. Der Scrollbalken-Stil unten passt sich per dark: mit an.