Give the package tier section a dedicated dark configurator-style stage

Flat cards on the regular page background couldn't deliver the
elegance/luxury feel from the reference mockups. The tier grid now sits
on its own always-dark full-bleed section (independent of site light/dark
mode, like a vehicle configurator), with a section headline, larger
elegant tier numbering, glowing per-card divider lines, and thinner
hairline borders instead of thick ones - letting color and glow carry the
escalation instead of stroke weight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-08 10:02:01 +02:00
parent bac6d114a2
commit d7fdd36662
3 changed files with 129 additions and 108 deletions

View file

@ -156,6 +156,8 @@ export default {
requestContact: 'Kontakt anfragen',
featuredBadge: 'Empfohlen',
tierLabels: ['Einstieg', 'Upgrade', 'Performance', 'Premium', 'High-End', 'Signature', 'Referenz'],
tiersHeading: (count) => `${count} Stufen. Ein Ziel: perfekter Klang.`,
tiersSubheading: 'Von der soliden Basis bis zum kompromisslosen Meisterwerk finde dein Paket.',
},
leistungen: {
metaTitle: 'Leistungen',

View file

@ -156,6 +156,8 @@ export default {
requestContact: 'Request contact',
featuredBadge: 'Recommended',
tierLabels: ['Entry', 'Upgrade', 'Performance', 'Premium', 'High-End', 'Signature', 'Reference'],
tiersHeading: (count) => `${count} tiers. One goal: perfect sound.`,
tiersSubheading: 'From a solid foundation to an uncompromising masterpiece find your package.',
},
leistungen: {
metaTitle: 'Services',

View file

@ -7,31 +7,25 @@ import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.js
import DynamicIcon from '../../components/DynamicIcon.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
import { useLanguage } from '../../context/LanguageContext.jsx';
import { useTheme } from '../../context/ThemeContext.jsx';
// Material-Stufen statt einfarbigem Verlauf: jede Preisstufe durchlaeuft eine eigene
// "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen - Basis (weiss) -> Bronze -> Silber
// -> Gold -> Platin -> Onyx (fast schwarz, mit Gold-Glanz als Kroenung ganz oben). Jede
// Stufe hat sowohl eine Flaechenfarbe (bg) als auch eine dazu passende Akzentfarbe
// (Rahmen/Leucht-Schatten/Preis/Icon) - nur der "Kontakt anfragen"-Button bleibt ueberall
// "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen - Graphit (Einstieg) -> Bronze ->
// Silber -> Gold -> Platin -> Onyx (fast schwarz, mit Gold-Glanz als Kroenung ganz oben).
// 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 HSL linear interpoliert, sodass JEDES Paket
// (nicht nur die Materialien selbst) eine eigene Abstufung bekommt.
const MATERIALS_LIGHT = [
{ bg: [0, 0, 100], accent: [88, 63, 40] }, // Basis
{ bg: [24, 42, 55], accent: [24, 58, 42] }, // Bronze
{ bg: [212, 10, 80], accent: [212, 15, 48] }, // Silber
{ bg: [45, 60, 60], accent: [42, 75, 42] }, // Gold
{ bg: [196, 16, 85], accent: [200, 22, 52] }, // Platin
{ bg: [225, 20, 6], accent: [45, 70, 55] }, // Onyx
];
const MATERIALS_DARK = [
{ bg: [0, 0, 9], accent: [88, 55, 48] },
{ bg: [22, 35, 20], accent: [25, 55, 58] },
{ bg: [212, 10, 20], accent: [212, 18, 68] },
{ bg: [45, 42, 22], accent: [42, 68, 58] },
{ bg: [196, 14, 24], accent: [200, 22, 72] },
{ bg: [225, 22, 4], accent: [45, 75, 68] },
// Zwischen zwei Nachbar-Materialien wird in RGB linear interpoliert (siehe materialRgbAt),
// sodass JEDES Paket (nicht nur die Materialien selbst) eine eigene Abstufung bekommt.
const MATERIALS = [
{ 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
];
const hslToRgb = (h, s, l) => {
@ -73,7 +67,6 @@ export default function ModelPage() {
const [error, setError] = useState('');
const maintenance = useMaintenance();
const { t, language } = useLanguage();
const { theme } = useTheme();
const formatPrice = (value) =>
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
@ -105,12 +98,11 @@ export default function ModelPage() {
const { model, packages } = data;
// Preis-Rang innerhalb dieses Modells bestimmt die optische Wucht der Kachel:
// die guenstigste Option bleibt schlicht/weiss, jede weitere Preisstufe durchlaeuft
// 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 materials = theme === 'dark' ? MATERIALS_DARK : MATERIALS_LIGHT;
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])
@ -122,8 +114,8 @@ export default function ModelPage() {
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 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);
@ -152,21 +144,26 @@ export default function ModelPage() {
const highlight = hslToRgb(bgHsl[0], Math.max(bgHsl[1] - 8, 0), Math.min(bgL + 16, 99));
return {
isDarkCard,
mutedColor,
tierNumber,
tierLabel,
accentColor: `rgb(${accentRgb.join(', ')})`,
priceColor: `rgb(${priceRgb.join(', ')})`,
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(', ')})`,
borderWidth: tierT > 0.08 ? 2 : 1,
boxShadow:
tierT > 0.04
? `0 ${Math.round(6 + tierT * 14)}px ${Math.round(20 + tierT * 45)}px -10px rgba(${accentRgb.join(', ')}, ${glowAlpha})`
@ -187,52 +184,71 @@ export default function ModelPage() {
};
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<>
<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="mb-8 text-2xl font-bold text-neutral-900 dark:text-white sm:text-3xl">
<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="text-neutral-500 dark:text-neutral-400">{t('modelPage.empty')}</p>
<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 { isDarkCard, style, priceColor, mutedColor, iconChipStyle, tierNumber, tierLabel } = styleOf(pkg);
const { style, priceColor, mutedColor, iconChipStyle, tierNumber, tierLabel, accentColor, glowLineStyle } =
styleOf(pkg);
return (
<div key={pkg.id} style={style} className="relative flex flex-col rounded-xl border p-6">
<div key={pkg.id} style={style} className="relative flex flex-col rounded-xl border p-8">
{pkg.is_featured && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-brand-500 px-3 py-1 text-xs font-bold text-white shadow">
{t('modelPage.featuredBadge')}
</span>
)}
<p style={{ color: mutedColor }} className="mb-2 text-xs font-semibold uppercase tracking-wider">
{tierNumber} · {tierLabel}
</p>
<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-3 flex h-11 w-11 items-center justify-center rounded-full">
<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>
)}
<h2 className={`text-lg font-bold ${isDarkCard ? 'text-white' : 'text-neutral-900'}`}>
{pkg.name}
</h2>
<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 className="my-4">
<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>
@ -241,7 +257,7 @@ export default function ModelPage() {
</p>
</div>
<ul style={{ color: mutedColor }} className="mb-5 flex-1 list-disc space-y-1 pl-5 text-sm">
<ul style={{ color: mutedColor }} className="mb-6 flex-1 list-disc space-y-1 pl-5 text-sm">
{pkg.products.map((product) => (
<li key={product.id}>
{product.name_override || product.scraped_name || t('modelPage.productLoading')}
@ -256,9 +272,7 @@ export default function ModelPage() {
<Link
to={contactUrl(pkg)}
className={`inline-block rounded-md px-4 py-2 text-center text-sm font-semibold text-white ${
isDarkCard ? 'bg-brand-500 shadow-lg shadow-brand-500/30 hover:bg-brand-400' : 'bg-brand-500 hover:bg-brand-600'
}`}
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>
@ -267,5 +281,8 @@ export default function ModelPage() {
})}
</div>
</div>
</section>
)}
</>
);
}