Cards show the first 5 bullet points; anything beyond that sits behind an accent-colored toggle whose count itself signals package depth (pricier packages carry longer lists). Expanding affects only the clicked card - the row aligns items-start instead of stretching every neighbor to the tallest card. The darkening gradient behind the list holds its opacity almost to the bottom edge so the toggle and deep list entries stay readable over the bright road glow, and the expanded state uses a denser scrim for the same reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
386 lines
16 KiB
JavaScript
386 lines
16 KiB
JavaScript
import { useEffect, 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 R / 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, isStripLayout, 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={
|
||
isStripLayout
|
||
? '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]'
|
||
: '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"
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{pkg.is_featured && (
|
||
<span className="absolute -top-3 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">
|
||
<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,
|
||
}}
|
||
>
|
||
{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 isStripLayout = packageCardLayout !== 'grid';
|
||
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]);
|
||
|
||
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={isStripLayout ? 'mx-auto max-w-[1880px] px-4 sm:px-6' : 'mx-auto max-w-6xl px-4 sm:px-6'}>
|
||
<div
|
||
className={
|
||
isStripLayout
|
||
? // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst
|
||
// nur DIESE in die Hoehe - die Nachbarn behalten ihre Referenzhoehe.
|
||
'flex items-start gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:thin] [scrollbar-color:rgba(0,0,0,0.3)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.25)_transparent] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/30 dark:[&::-webkit-scrollbar-thumb]:bg-white/25'
|
||
: 'grid items-start gap-[18px] sm:grid-cols-2 xl:grid-cols-3'
|
||
}
|
||
>
|
||
{packages.map((pkg) => {
|
||
const { tier, tierT } = tierOf(pkg);
|
||
return (
|
||
<PackageCard
|
||
key={pkg.id}
|
||
pkg={pkg}
|
||
tier={tier}
|
||
tierT={tierT}
|
||
isStripLayout={isStripLayout}
|
||
bullets={bulletsOf(pkg)}
|
||
formatPrice={formatPrice}
|
||
contactUrl={contactUrl}
|
||
t={t}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</>
|
||
);
|
||
}
|