HifiPlanet-Website/hifi-src/src/pages/public/ModelPage.jsx
Maaxxs 3868a258e4 Replace the tier legend with a glowing road visual inside each card
Removed the bottom legend row per feedback - the ask was for a visual
inside the tiles, not an explanatory key below them. Each card now has an
abstract flowing light-path drawn as SVG (no stock photography), colored
with that tier's accent and escalating from barely visible at the
cheapest tier to a bright glow at the priciest, matching the reference
mockup. Content sits in its own stacking context above the path so text
stays legible regardless of the background art.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 10:58:52 +02:00

367 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
// Material-Stufen statt einfarbigem Verlauf: jede Preisstufe durchlaeuft eine eigene
// "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen. Die Paket-Sektion sitzt bewusst IMMER
// auf einer dunklen Buehne (unabhaengig vom Hell/Dunkel-Modus der Seite, wie ein
// Fahrzeugkonfigurator) - das ist der groesste Hebel fuer den edlen/luxurioesen Eindruck,
// den flache helle Kacheln nicht liefern konnten. Jede Stufe hat eine Flaechenfarbe (bg)
// und eine dazu passende Akzentfarbe (Rahmen/Leucht-Schatten/Preis/Icon) - nur der
// "Kontakt anfragen"-Button bleibt ueberall einheitlich markengruen, damit die Kernaktion
// auf jeder Karte wiedererkennbar bleibt. Zwischen zwei Nachbar-Materialien wird in RGB
// linear interpoliert (siehe materialRgbAt), sodass JEDES Paket (nicht nur die Materialien
// selbst) eine eigene Abstufung bekommt. Welches Farbschema verwendet wird, waehlt der
// Kunde selbst unter Admin -> Einstellungen -> Website ("Paket-Kachel-Design") - alle drei
// laufen bewusst auf dieselbe Onyx+Gold-Kroenung bei der teuersten Stufe zu.
const PACKAGE_THEMES = {
graphite: [
{ bg: [0, 0, 14], accent: [88, 45, 55] }, // Graphit (Einstieg)
{ bg: [24, 32, 22], accent: [24, 55, 58] }, // Bronze
{ bg: [212, 10, 24], accent: [212, 18, 68] }, // Silber
{ bg: [45, 38, 24], accent: [42, 68, 58] }, // Gold
{ bg: [196, 12, 27], accent: [200, 20, 72] }, // Platin
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // Onyx
],
'deep-blue': [
{ bg: [212, 30, 16], accent: [205, 45, 62] }, // Eisblau (Einstieg)
{ bg: [212, 35, 20], accent: [205, 50, 64] }, // Tiefblau
{ bg: [190, 30, 22], accent: [190, 45, 62] }, // Petrol
{ bg: [160, 30, 20], accent: [150, 45, 58] }, // Seegruen
{ bg: [95, 30, 20], accent: [95, 50, 58] }, // Markengruen
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // Onyx
],
'warm-bronze': [
{ bg: [28, 22, 16], accent: [32, 40, 55] }, // Warmes Graphit (Einstieg)
{ bg: [26, 38, 22], accent: [28, 58, 55] }, // Bronze
{ bg: [32, 42, 24], accent: [34, 62, 56] }, // Kupfer
{ bg: [38, 45, 24], accent: [38, 66, 58] }, // Amber
{ bg: [42, 48, 24], accent: [42, 70, 60] }, // Reiches Gold
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // Onyx
],
};
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 rgbToHsl = (r, g, b) => {
(r /= 255), (g /= 255), (b /= 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l * 100];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
const h = max === r ? ((g - b) / d + (g < b ? 6 : 0)) * 60 : max === g ? ((b - r) / d + 2) * 60 : ((r - g) / d + 4) * 60;
return [h, s * 100, l * 100];
};
const mixRgb = (c1, c2, t) => [0, 1, 2].map((i) => c1[i] + (c2[i] - c1[i]) * t);
// Findet die zwei Nachbar-Materialien fuer einen Preis-Rang t (0..1) und mischt linear
// dazwischen - dadurch bekommt jedes Paket eine eigene Abstufung, nicht nur die 6
// Materialien selbst, egal ob ein Modell 2 oder 12 Pakete hat. Gemischt wird in RGB statt
// HSL: der Farbton zweier benachbarter Materialien (z.B. Bronze 24° -> Silber 212°) liegt
// oft weit auseinander, eine HSL-Interpolation liefe dann mitten durch fremde Farbtoene
// (Gelb/Gruen) statt sauber zwischen den beiden Materialien zu vermitteln.
const materialRgbAt = (materials, t, key) => {
const n = materials.length;
const pos = t * (n - 1);
const idx = Math.min(Math.floor(pos), n - 2);
const frac = pos - idx;
return mixRgb(hslToRgb(...materials[idx][key]), hslToRgb(...materials[idx + 1][key]), frac);
};
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 } = useSiteSettings();
const MATERIALS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
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 bestimmt die optische Wucht der Kachel: die
// guenstigste Option bleibt schlichtes Graphit, jede weitere Preisstufe durchlaeuft
// eine eigene Materialstufe (Bronze/Silber/Gold/Platin) bis zur Onyx-Kroenung ganz
// oben. Das braucht keine zusaetzliche Admin-Einstellung und passt sich automatisch
// an jede Paketanzahl an.
const tierLabels = t('modelPage.tierLabels');
const rankById = new Map(
[...packages].sort((a, b) => a.total_price - b.total_price).map((p, i) => [p.id, i])
);
const styleOf = (pkg) => {
const n = packages.length;
const rank = rankById.get(pkg.id) ?? 0;
const tierT = n <= 1 ? 0 : rank / (n - 1);
const tierNumber = String(rank + 1).padStart(2, '0');
const tierLabel = tierLabels[Math.min(Math.floor(tierT * tierLabels.length), tierLabels.length - 1)];
const bgRgb = materialRgbAt(MATERIALS, tierT, 'bg').map(Math.round);
const accentRgbFloat = materialRgbAt(MATERIALS, tierT, 'accent');
const accentRgb = accentRgbFloat.map(Math.round);
const bgHsl = rgbToHsl(...bgRgb);
const accentHsl = rgbToHsl(...accentRgbFloat);
const bgL = bgHsl[2];
// Ab hier reicht der Hintergrund nicht mehr zum Kontrastieren mit dunklem Text -
// Bronze/Gold-Mitteltoene brauchen (wie die Buttons) helle statt dunkle Schrift.
// Diese Entscheidung haengt bewusst an der Flaechenfarbe, nicht am Lichtschein
// unten, damit der Text unabhaengig vom Material IMMER gut lesbar bleibt.
const isDarkCard = bgL < 60;
// Garantiert einen Mindestabstand zur tatsaechlichen Hintergrund-Helligkeit statt
// sich auf einen festen Hell/Dunkel-Klassenwechsel zu verlassen - sonst kann ein
// Hintergrund GENAU im Grenzbereich (z.B. ein kuehles Mittelgrau nahe der 60%-Schwelle)
// eine "eigentlich helle" Textfarbe bekommen, die kaum noch Kontrast zum Hintergrund hat.
const contrastingL = (delta) => (isDarkCard ? Math.min(bgL + delta, 92) : Math.max(bgL - delta, 10));
// Preis/Icon brauchen den kraeftigsten Kontrast, da sie die Kernaussage der Karte sind.
const priceRgb = hslToRgb(accentHsl[0], Math.min(accentHsl[1] + 10, 90), contrastingL(48));
// Dezente Texte (Label/Slogan/Liste) bleiben neutral-grau, aber ebenfalls mit
// garantiertem Abstand zur Flaeche - kein fixes Tailwind-Grau mehr, das zufaellig auf
// gleicher Helligkeit wie der Hintergrund landen und unsichtbar werden koennte.
const mutedColor = `rgb(${hslToRgb(0, 0, contrastingL(38)).join(', ')})`;
const glowAlpha = (0.1 + tierT * 0.3).toFixed(2);
// Der "Lichtweg" im Kartenhintergrund faengt bei der Einstiegsstufe kaum sichtbar an
// und wird zur teuersten Stufe hin kraeftiger/leuchtender - genau wie der Rahmen-Glow.
const roadOpacity = 0.14 + tierT * 0.22;
const roadGlowOpacity = 0.15 + tierT * 0.55;
// Leichter "Lichtschein" von oben rechts statt flacher Flaeche - kommt von oben
// rechts, weil dort nie Text steht (Titel/Preis/Liste sind linksbuendig), damit die
// Aufhellung die Lesbarkeit nirgends beeintraechtigt.
const highlight = hslToRgb(bgHsl[0], Math.max(bgHsl[1] - 8, 0), Math.min(bgL + 16, 99));
return {
mutedColor,
tierNumber,
tierLabel,
accentColor: `rgb(${accentRgb.join(', ')})`,
priceColor: `rgb(${priceRgb.join(', ')})`,
roadOpacity,
roadGlowOpacity,
iconChipStyle: {
backgroundColor: `rgba(${accentRgb.join(', ')}, 0.16)`,
color: `rgb(${priceRgb.join(', ')})`,
boxShadow: `inset 0 0 0 1px rgba(${accentRgb.join(', ')}, 0.45)`,
},
// Duenne, gleichbleibende Haarlinie statt dickerem Rahmen - die zunehmende Wertigkeit
// zeigt sich in Farbe/Leucht-Schatten, nicht in der Strichstaerke. Wirkt ruhiger/edler.
glowLineStyle: {
background: `linear-gradient(90deg, transparent, rgba(${accentRgb.join(', ')}, 0.9), transparent)`,
boxShadow: `0 0 ${Math.round(6 + tierT * 14)}px rgba(${accentRgb.join(', ')}, ${(0.25 + tierT * 0.45).toFixed(2)})`,
},
style: {
backgroundColor: `rgb(${bgRgb.join(', ')})`,
backgroundImage: `radial-gradient(135% 160% at 88% -20%, rgb(${highlight.join(', ')}) 0%, rgb(${bgRgb.join(', ')}) 55%)`,
borderColor: `rgb(${accentRgb.join(', ')})`,
boxShadow:
tierT > 0.04
? `0 ${Math.round(6 + tierT * 14)}px ${Math.round(20 + tierT * 45)}px -10px rgba(${accentRgb.join(', ')}, ${glowAlpha})`
: undefined,
},
};
};
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 Paket-Sektion bekommt bewusst eine eigene, immer dunkle Buehne (wie ein
// Fahrzeugkonfigurator) statt der hellen/dunklen Seiten-Textur zu folgen - das
// ist der Haupthebel fuer den edlen Eindruck, den flache Kacheln nicht liefern.
<section className="bg-neutral-950 py-14 sm:py-20">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mb-10 text-center sm:mb-14">
<h2 className="text-2xl font-bold text-white sm:text-3xl">{t('modelPage.tiersHeading')(packages.length)}</h2>
<p className="mx-auto mt-2 max-w-xl text-sm text-neutral-400">{t('modelPage.tiersSubheading')}</p>
</div>
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{packages.map((pkg) => {
const {
style,
priceColor,
mutedColor,
iconChipStyle,
tierNumber,
tierLabel,
accentColor,
glowLineStyle,
roadOpacity,
roadGlowOpacity,
} = styleOf(pkg);
return (
<div key={pkg.id} style={style} className="relative flex flex-col overflow-hidden rounded-xl border p-8">
{/* Abstrakter "Lichtweg" im Hintergrund - je teurer die Stufe, desto praesenter/leuchtender.
Fixe Aspect-Ratio + "slice" statt "none", damit Strich/Kurve bei jeder Kartenhoehe
gleichmaessig aussieht statt verzerrt gestreckt zu werden. */}
<svg
viewBox="0 0 200 260"
preserveAspectRatio="xMidYMid slice"
className="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden="true"
>
<path
d="M 20 250 C 60 250 70 190 100 150 C 130 110 160 130 175 90 C 185 65 190 40 195 10"
fill="none"
stroke={accentColor}
strokeWidth="14"
strokeLinecap="round"
style={{ opacity: roadGlowOpacity, filter: 'blur(7px)' }}
/>
<path
d="M 20 250 C 60 250 70 190 100 150 C 130 110 160 130 175 90 C 185 65 190 40 195 10"
fill="none"
stroke={accentColor}
strokeWidth="2.5"
strokeLinecap="round"
style={{ opacity: roadOpacity }}
/>
</svg>
{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>
)}
{/* Eigener Stapelkontext ueber dem Lichtweg-SVG, damit der Text IMMER lesbar
oben liegt statt vom absolut positionierten Hintergrund verdeckt zu werden. */}
<div className="relative z-10 flex flex-1 flex-col">
<div className="mb-4 flex items-baseline gap-3">
<span style={{ color: mutedColor }} className="text-3xl font-extralight leading-none">
{tierNumber}
</span>
<span style={{ color: accentColor }} className="text-xs font-semibold uppercase tracking-[0.2em]">
{tierLabel}
</span>
</div>
{pkg.icon_name && (
<div style={iconChipStyle} className="mb-4 flex h-11 w-11 items-center justify-center rounded-full">
<DynamicIcon name={pkg.icon_name} className="h-6 w-6" />
</div>
)}
<h3 className="text-lg font-bold text-white">{pkg.name}</h3>
{pkg.tagline && (
<p style={{ color: mutedColor }} className="mt-1 text-sm">
{pkg.tagline}
</p>
)}
<div style={glowLineStyle} className="my-5 h-px w-full" />
<div className="mb-4">
<p style={{ color: mutedColor }} className="text-xs uppercase tracking-wide">
{t('modelPage.totalPrice')}
</p>
<p style={{ color: priceColor }} className="text-2xl font-extrabold">
{formatPrice(pkg.total_price)}
</p>
</div>
<ul className="mb-6 flex-1 space-y-2.5 text-sm">
{pkg.products.map((product) => (
<li key={product.id} className="flex items-start gap-2.5">
<DynamicIcon name="check" className="mt-0.5 h-4 w-4 shrink-0" style={{ color: accentColor }} />
<span style={{ color: mutedColor }} className="leading-snug">
{product.name_override || product.scraped_name || t('modelPage.productLoading')}
</span>
</li>
))}
{pkg.description
?.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, i) => (
<li key={`desc-${i}`} className="flex items-start gap-2.5">
<DynamicIcon name="check" className="mt-0.5 h-4 w-4 shrink-0" style={{ color: accentColor }} />
<span style={{ color: mutedColor }} className="leading-snug">
{line}
</span>
</li>
))}
</ul>
<Link
to={contactUrl(pkg)}
className="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>
</div>
</section>
)}
</>
);
}