Client wants the tier called just "Elite". Only the admin checkbox list (PACKAGE_TIERS) carries tier names; public card theming is price-rank based, so nothing else changes. An already-created "Elite R" package would show up under "Weitere Pakete (alte Namen)" in the editor and can be deleted and recreated as Elite there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
560 lines
24 KiB
JavaScript
560 lines
24 KiB
JavaScript
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 (
|
||
<div
|
||
style={{
|
||
borderColor: tier.border,
|
||
background: `linear-gradient(180deg, ${tier.bgTop} 0%, #0b0b0b 55%, #080808 100%)`,
|
||
}}
|
||
className={
|
||
layout === 'strip'
|
||
? 'relative flex min-h-[640px] grow shrink basis-[190px] min-w-[190px] max-w-[240px] snap-start flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40 sm:basis-[210px]'
|
||
: layout === 'coverflow'
|
||
? // Feste Breite + snap-center: die 3D-Transformation (rotateY/scale/translateZ)
|
||
// setzt der Scroll-Handler in ModelPage direkt per style.transform.
|
||
'relative flex min-h-[640px] w-[230px] shrink-0 snap-center flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 will-change-transform dark:shadow-black/40'
|
||
: 'relative flex min-h-[640px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40'
|
||
}
|
||
>
|
||
{/* Vollflaechiges Straßen-Glow-Bild der Preisstufe (Referenzdesign). */}
|
||
{tier.img ? (
|
||
<img
|
||
src={tier.img}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<>
|
||
<div
|
||
className="pointer-events-none absolute inset-0"
|
||
style={{ ...glowMask, opacity: 0.2 + tierT * 0.45, filter: 'blur(18px)' }}
|
||
aria-hidden="true"
|
||
/>
|
||
<div
|
||
className="pointer-events-none absolute inset-0"
|
||
style={{ ...glowMask, opacity: 0.55 + tierT * 0.45 }}
|
||
aria-hidden="true"
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{/* top-2 statt -top-3: die Karte hat overflow-hidden (runde Ecken + Straßenbild),
|
||
ein halb herausragender Badge wuerde oben abgeschnitten. */}
|
||
{pkg.is_featured && (
|
||
<span className="absolute top-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-brand-500 px-3 py-1 text-xs font-bold text-white shadow">
|
||
{t('modelPage.featuredBadge')}
|
||
</span>
|
||
)}
|
||
|
||
{/* Kopf: Name + Trennlinie (exakt Referenz). */}
|
||
<div className="relative z-10 text-center" style={{ padding: '34px 20px 0' }}>
|
||
<div
|
||
style={{
|
||
fontFamily: "'Barlow Condensed', sans-serif",
|
||
fontSize: '30px',
|
||
letterSpacing: '3px',
|
||
fontWeight: 600,
|
||
color: tier.accent,
|
||
textTransform: 'uppercase',
|
||
lineHeight: 1.1,
|
||
}}
|
||
>
|
||
{pkg.name}
|
||
</div>
|
||
<div
|
||
style={{
|
||
width: '44px',
|
||
height: '2px',
|
||
margin: '14px auto 0',
|
||
background: `linear-gradient(90deg, rgba(255,255,255,0) 0%, ${tier.accent} 50%, rgba(255,255,255,0) 100%)`,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 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 && (
|
||
<div
|
||
className="relative z-10"
|
||
style={{
|
||
padding: '18px 20px 14px',
|
||
background: expanded
|
||
? 'linear-gradient(180deg, rgba(8,8,8,0.7) 0%, rgba(8,8,8,0.62) 94%, rgba(8,8,8,0) 100%)'
|
||
: 'linear-gradient(180deg, rgba(8,8,8,0.55) 0%, rgba(8,8,8,0.48) 92%, rgba(8,8,8,0) 100%)',
|
||
}}
|
||
>
|
||
<ul className="space-y-1.5 text-left">
|
||
{shownBullets.map((line, i) => (
|
||
<li key={i} className="flex items-start gap-2" style={{ fontSize: '13px', color: BULLET_COLOR }}>
|
||
<DynamicIcon name="check" className="mt-0.5 h-3.5 w-3.5 shrink-0" style={{ color: tier.accent }} />
|
||
<span className="leading-snug">{line}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{hiddenCount > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((e) => !e)}
|
||
className="mt-2.5 flex items-center gap-1 hover:opacity-80"
|
||
style={{ fontSize: '13px', color: tier.accent }}
|
||
>
|
||
{expanded ? t('modelPage.lessBullets') : t('modelPage.moreBullets')(hiddenCount)}
|
||
<DynamicIcon name={expanded ? 'chevron-up' : 'chevron-down'} className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Freiraum, damit das Straßenbild zur Geltung kommt. */}
|
||
<div className="relative z-10 flex-1" />
|
||
|
||
{/* Preis-Sektion mit dunklem Verlauf (Referenz): "ab ca." + Preis + CTA. */}
|
||
<div
|
||
className="relative z-10"
|
||
style={{
|
||
padding: '20px 20px 28px',
|
||
background: 'linear-gradient(180deg, rgba(8,8,8,0) 0%, rgba(8,8,8,0.85) 45%, rgba(8,8,8,0.95) 100%)',
|
||
}}
|
||
>
|
||
<div className="text-center">
|
||
{/* Optionaler Admin-Freitext (z. B. "Coming soon") ersetzt "ab ca." + Preis. */}
|
||
{!pkg.price_text && <div style={{ fontSize: '13px', color: LABEL_COLOR }}>{t('modelPage.totalPrice')}</div>}
|
||
<div
|
||
style={{
|
||
marginTop: '4px',
|
||
fontFamily: "'Barlow Condensed', sans-serif",
|
||
fontSize: '30px',
|
||
fontWeight: 600,
|
||
letterSpacing: '1px',
|
||
color: PRICE_COLOR,
|
||
}}
|
||
>
|
||
{pkg.price_text || formatPrice(pkg.total_price)}
|
||
</div>
|
||
<Link
|
||
to={contactUrl(pkg)}
|
||
className="mt-4 inline-block rounded-md bg-brand-500 px-4 py-2 text-center text-sm font-semibold text-white shadow-lg shadow-brand-500/30 hover:bg-brand-400"
|
||
>
|
||
{t('modelPage.requestContact')}
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 <MaintenanceNotice message={maintenance.vehicles.message} />;
|
||
}
|
||
|
||
if (error) {
|
||
return <p className="mx-auto max-w-6xl px-4 py-12 text-red-600 sm:px-6">{error}</p>;
|
||
}
|
||
|
||
if (!data) {
|
||
return <p className="mx-auto max-w-6xl px-4 py-12 text-neutral-500 sm:px-6">{t('modelPage.loading')}</p>;
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<div className="mx-auto max-w-6xl px-4 pt-12 sm:px-6">
|
||
{maintenance.vehicles.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
|
||
<p className="mb-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||
<Link to="/fahrzeuge" className="hover:text-brand-500">{t('modelPage.breadcrumbVehicles')}</Link> /{' '}
|
||
<Link to={`/fahrzeuge/${brandSlug}`} className="hover:text-brand-500">{model.brand_name}</Link> / {model.name}
|
||
</p>
|
||
<h1 className={`text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl ${packages.length ? 'mb-2' : 'mb-8'}`}>
|
||
{model.brand_name} {model.name} – {t('modelPage.titleSuffix')}
|
||
</h1>
|
||
|
||
{packages.length === 0 && (
|
||
<p className="pb-12 text-neutral-500 dark:text-neutral-400">{t('modelPage.empty')}</p>
|
||
)}
|
||
</div>
|
||
|
||
{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.
|
||
<section className="py-10 sm:py-14" style={{ fontFamily: "'Barlow', sans-serif" }}>
|
||
<div className={layout === 'grid' ? 'mx-auto max-w-6xl px-4 sm:px-6' : 'mx-auto max-w-[1880px] px-4 sm:px-6'}>
|
||
<div
|
||
ref={layout === 'grid' ? undefined : scrollerRef}
|
||
className={
|
||
layout === 'grid'
|
||
? 'grid items-start gap-[18px] sm:grid-cols-2 xl:grid-cols-3'
|
||
: // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst
|
||
// nur DIESE in die Hoehe - die Nachbarn behalten ihre Referenzhoehe.
|
||
// Die native Scroll-Leiste ist ausgeblendet - darunter sitzt die eigene
|
||
// Track/Daumen-Leiste. select-none, damit Drag keinen Text markiert.
|
||
'flex select-none items-start gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||
}
|
||
style={
|
||
layout === 'coverflow'
|
||
? // Perspektive fuer die 3D-Drehung; das seitliche Padding erlaubt es,
|
||
// auch die erste/letzte Karte in die Mitte zu scrollen (Kartenbreite
|
||
// 230px -> halbe Breite 115px). pt schafft Luft fuer die Skalierung.
|
||
{ perspective: '1200px', paddingLeft: 'calc(50% - 115px)', paddingRight: 'calc(50% - 115px)', paddingTop: '10px' }
|
||
: undefined
|
||
}
|
||
>
|
||
{packages.map((pkg) => {
|
||
const { tier, tierT } = tierOf(pkg);
|
||
return (
|
||
<PackageCard
|
||
key={pkg.id}
|
||
pkg={pkg}
|
||
tier={tier}
|
||
tierT={tierT}
|
||
layout={layout}
|
||
bullets={bulletsOf(pkg)}
|
||
formatPrice={formatPrice}
|
||
contactUrl={contactUrl}
|
||
t={t}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Eigene Scroll-Leiste: zentrierter Track, markengruener leuchtender Daumen.
|
||
Ziehbar + klickbar (Logik im Scrollbar-Effekt); blendet aus, wenn alle
|
||
Karten ohne Scrollen passen. touch-action none fuer sauberes Pointer-Ziehen. */}
|
||
{layout !== 'grid' && (
|
||
<div
|
||
ref={trackRef}
|
||
className="relative mx-auto mt-5 h-2 w-56 max-w-full cursor-pointer rounded-full bg-neutral-900/10 transition-opacity duration-200 dark:bg-white/10"
|
||
style={{ touchAction: 'none' }}
|
||
aria-hidden="true"
|
||
>
|
||
<div
|
||
ref={thumbRef}
|
||
className="absolute left-0 top-0 h-full rounded-full bg-brand-500 shadow-[0_0_10px_rgba(107,166,38,0.7)]"
|
||
style={{ width: '48px' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</>
|
||
);
|
||
}
|