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:
parent
bac6d114a2
commit
d7fdd36662
3 changed files with 129 additions and 108 deletions
|
|
@ -156,6 +156,8 @@ export default {
|
||||||
requestContact: 'Kontakt anfragen',
|
requestContact: 'Kontakt anfragen',
|
||||||
featuredBadge: 'Empfohlen',
|
featuredBadge: 'Empfohlen',
|
||||||
tierLabels: ['Einstieg', 'Upgrade', 'Performance', 'Premium', 'High-End', 'Signature', 'Referenz'],
|
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: {
|
leistungen: {
|
||||||
metaTitle: 'Leistungen',
|
metaTitle: 'Leistungen',
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,8 @@ export default {
|
||||||
requestContact: 'Request contact',
|
requestContact: 'Request contact',
|
||||||
featuredBadge: 'Recommended',
|
featuredBadge: 'Recommended',
|
||||||
tierLabels: ['Entry', 'Upgrade', 'Performance', 'Premium', 'High-End', 'Signature', 'Reference'],
|
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: {
|
leistungen: {
|
||||||
metaTitle: 'Services',
|
metaTitle: 'Services',
|
||||||
|
|
|
||||||
|
|
@ -7,31 +7,25 @@ import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.js
|
||||||
import DynamicIcon from '../../components/DynamicIcon.jsx';
|
import DynamicIcon from '../../components/DynamicIcon.jsx';
|
||||||
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
|
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
|
||||||
import { useLanguage } from '../../context/LanguageContext.jsx';
|
import { useLanguage } from '../../context/LanguageContext.jsx';
|
||||||
import { useTheme } from '../../context/ThemeContext.jsx';
|
|
||||||
|
|
||||||
// Material-Stufen statt einfarbigem Verlauf: jede Preisstufe durchlaeuft eine eigene
|
// Material-Stufen statt einfarbigem Verlauf: jede Preisstufe durchlaeuft eine eigene
|
||||||
// "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen - Basis (weiss) -> Bronze -> Silber
|
// "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen - Graphit (Einstieg) -> Bronze ->
|
||||||
// -> Gold -> Platin -> Onyx (fast schwarz, mit Gold-Glanz als Kroenung ganz oben). Jede
|
// Silber -> Gold -> Platin -> Onyx (fast schwarz, mit Gold-Glanz als Kroenung ganz oben).
|
||||||
// Stufe hat sowohl eine Flaechenfarbe (bg) als auch eine dazu passende Akzentfarbe
|
// Die Paket-Sektion sitzt bewusst IMMER auf einer dunklen Buehne (unabhaengig vom
|
||||||
// (Rahmen/Leucht-Schatten/Preis/Icon) - nur der "Kontakt anfragen"-Button bleibt ueberall
|
// 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.
|
// einheitlich markengruen, damit die Kernaktion auf jeder Karte wiedererkennbar bleibt.
|
||||||
// Zwischen zwei Nachbar-Materialien wird in HSL linear interpoliert, sodass JEDES Paket
|
// Zwischen zwei Nachbar-Materialien wird in RGB linear interpoliert (siehe materialRgbAt),
|
||||||
// (nicht nur die Materialien selbst) eine eigene Abstufung bekommt.
|
// sodass JEDES Paket (nicht nur die Materialien selbst) eine eigene Abstufung bekommt.
|
||||||
const MATERIALS_LIGHT = [
|
const MATERIALS = [
|
||||||
{ bg: [0, 0, 100], accent: [88, 63, 40] }, // Basis
|
{ bg: [0, 0, 14], accent: [88, 45, 55] }, // Graphit (Einstieg)
|
||||||
{ bg: [24, 42, 55], accent: [24, 58, 42] }, // Bronze
|
{ bg: [24, 32, 22], accent: [24, 55, 58] }, // Bronze
|
||||||
{ bg: [212, 10, 80], accent: [212, 15, 48] }, // Silber
|
{ bg: [212, 10, 24], accent: [212, 18, 68] }, // Silber
|
||||||
{ bg: [45, 60, 60], accent: [42, 75, 42] }, // Gold
|
{ bg: [45, 38, 24], accent: [42, 68, 58] }, // Gold
|
||||||
{ bg: [196, 16, 85], accent: [200, 22, 52] }, // Platin
|
{ bg: [196, 12, 27], accent: [200, 20, 72] }, // Platin
|
||||||
{ bg: [225, 20, 6], accent: [45, 70, 55] }, // Onyx
|
{ bg: [225, 22, 5], accent: [45, 78, 68] }, // 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] },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const hslToRgb = (h, s, l) => {
|
const hslToRgb = (h, s, l) => {
|
||||||
|
|
@ -73,7 +67,6 @@ export default function ModelPage() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const maintenance = useMaintenance();
|
const maintenance = useMaintenance();
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
const { theme } = useTheme();
|
|
||||||
const formatPrice = (value) =>
|
const formatPrice = (value) =>
|
||||||
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(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;
|
const { model, packages } = data;
|
||||||
|
|
||||||
// Preis-Rang innerhalb dieses Modells bestimmt die optische Wucht der Kachel:
|
// Preis-Rang innerhalb dieses Modells bestimmt die optische Wucht der Kachel: die
|
||||||
// die guenstigste Option bleibt schlicht/weiss, jede weitere Preisstufe durchlaeuft
|
// guenstigste Option bleibt schlichtes Graphit, jede weitere Preisstufe durchlaeuft
|
||||||
// eine eigene Materialstufe (Bronze/Silber/Gold/Platin) bis zur Onyx-Kroenung ganz
|
// eine eigene Materialstufe (Bronze/Silber/Gold/Platin) bis zur Onyx-Kroenung ganz
|
||||||
// oben. Das braucht keine zusaetzliche Admin-Einstellung und passt sich automatisch
|
// oben. Das braucht keine zusaetzliche Admin-Einstellung und passt sich automatisch
|
||||||
// an jede Paketanzahl an.
|
// an jede Paketanzahl an.
|
||||||
const materials = theme === 'dark' ? MATERIALS_DARK : MATERIALS_LIGHT;
|
|
||||||
const tierLabels = t('modelPage.tierLabels');
|
const tierLabels = t('modelPage.tierLabels');
|
||||||
const rankById = new Map(
|
const rankById = new Map(
|
||||||
[...packages].sort((a, b) => a.total_price - b.total_price).map((p, i) => [p.id, i])
|
[...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 tierNumber = String(rank + 1).padStart(2, '0');
|
||||||
const tierLabel = tierLabels[Math.min(Math.floor(tierT * tierLabels.length), tierLabels.length - 1)];
|
const tierLabel = tierLabels[Math.min(Math.floor(tierT * tierLabels.length), tierLabels.length - 1)];
|
||||||
|
|
||||||
const bgRgb = materialRgbAt(materials, tierT, 'bg').map(Math.round);
|
const bgRgb = materialRgbAt(MATERIALS, tierT, 'bg').map(Math.round);
|
||||||
const accentRgbFloat = materialRgbAt(materials, tierT, 'accent');
|
const accentRgbFloat = materialRgbAt(MATERIALS, tierT, 'accent');
|
||||||
const accentRgb = accentRgbFloat.map(Math.round);
|
const accentRgb = accentRgbFloat.map(Math.round);
|
||||||
const bgHsl = rgbToHsl(...bgRgb);
|
const bgHsl = rgbToHsl(...bgRgb);
|
||||||
const accentHsl = rgbToHsl(...accentRgbFloat);
|
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));
|
const highlight = hslToRgb(bgHsl[0], Math.max(bgHsl[1] - 8, 0), Math.min(bgL + 16, 99));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDarkCard,
|
|
||||||
mutedColor,
|
mutedColor,
|
||||||
tierNumber,
|
tierNumber,
|
||||||
tierLabel,
|
tierLabel,
|
||||||
|
accentColor: `rgb(${accentRgb.join(', ')})`,
|
||||||
priceColor: `rgb(${priceRgb.join(', ')})`,
|
priceColor: `rgb(${priceRgb.join(', ')})`,
|
||||||
iconChipStyle: {
|
iconChipStyle: {
|
||||||
backgroundColor: `rgba(${accentRgb.join(', ')}, 0.16)`,
|
backgroundColor: `rgba(${accentRgb.join(', ')}, 0.16)`,
|
||||||
color: `rgb(${priceRgb.join(', ')})`,
|
color: `rgb(${priceRgb.join(', ')})`,
|
||||||
boxShadow: `inset 0 0 0 1px rgba(${accentRgb.join(', ')}, 0.45)`,
|
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: {
|
style: {
|
||||||
backgroundColor: `rgb(${bgRgb.join(', ')})`,
|
backgroundColor: `rgb(${bgRgb.join(', ')})`,
|
||||||
backgroundImage: `radial-gradient(135% 160% at 88% -20%, rgb(${highlight.join(', ')}) 0%, rgb(${bgRgb.join(', ')}) 55%)`,
|
backgroundImage: `radial-gradient(135% 160% at 88% -20%, rgb(${highlight.join(', ')}) 0%, rgb(${bgRgb.join(', ')}) 55%)`,
|
||||||
borderColor: `rgb(${accentRgb.join(', ')})`,
|
borderColor: `rgb(${accentRgb.join(', ')})`,
|
||||||
borderWidth: tierT > 0.08 ? 2 : 1,
|
|
||||||
boxShadow:
|
boxShadow:
|
||||||
tierT > 0.04
|
tierT > 0.04
|
||||||
? `0 ${Math.round(6 + tierT * 14)}px ${Math.round(20 + tierT * 45)}px -10px rgba(${accentRgb.join(', ')}, ${glowAlpha})`
|
? `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 (
|
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 />}
|
{maintenance.vehicles.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
|
||||||
<p className="mb-1 text-sm text-neutral-500 dark:text-neutral-400">
|
<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" className="hover:text-brand-500">{t('modelPage.breadcrumbVehicles')}</Link> /{' '}
|
||||||
<Link to={`/fahrzeuge/${brandSlug}`} className="hover:text-brand-500">{model.brand_name}</Link> / {model.name}
|
<Link to={`/fahrzeuge/${brandSlug}`} className="hover:text-brand-500">{model.brand_name}</Link> / {model.name}
|
||||||
</p>
|
</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')}
|
{model.brand_name} {model.name} – {t('modelPage.titleSuffix')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{packages.length === 0 && (
|
{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">
|
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{packages.map((pkg) => {
|
{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 (
|
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 && (
|
{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">
|
<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')}
|
{t('modelPage.featuredBadge')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p style={{ color: mutedColor }} className="mb-2 text-xs font-semibold uppercase tracking-wider">
|
<div className="mb-4 flex items-baseline gap-3">
|
||||||
{tierNumber} · {tierLabel}
|
<span style={{ color: mutedColor }} className="text-3xl font-extralight leading-none">
|
||||||
</p>
|
{tierNumber}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: accentColor }} className="text-xs font-semibold uppercase tracking-[0.2em]">
|
||||||
|
{tierLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{pkg.icon_name && (
|
{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" />
|
<DynamicIcon name={pkg.icon_name} className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2 className={`text-lg font-bold ${isDarkCard ? 'text-white' : 'text-neutral-900'}`}>
|
<h3 className="text-lg font-bold text-white">{pkg.name}</h3>
|
||||||
{pkg.name}
|
|
||||||
</h2>
|
|
||||||
{pkg.tagline && (
|
{pkg.tagline && (
|
||||||
<p style={{ color: mutedColor }} className="mt-1 text-sm">
|
<p style={{ color: mutedColor }} className="mt-1 text-sm">
|
||||||
{pkg.tagline}
|
{pkg.tagline}
|
||||||
</p>
|
</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">
|
<p style={{ color: mutedColor }} className="text-xs uppercase tracking-wide">
|
||||||
{t('modelPage.totalPrice')}
|
{t('modelPage.totalPrice')}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -241,7 +257,7 @@ export default function ModelPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => (
|
{pkg.products.map((product) => (
|
||||||
<li key={product.id}>
|
<li key={product.id}>
|
||||||
{product.name_override || product.scraped_name || t('modelPage.productLoading')}
|
{product.name_override || product.scraped_name || t('modelPage.productLoading')}
|
||||||
|
|
@ -256,9 +272,7 @@ export default function ModelPage() {
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to={contactUrl(pkg)}
|
to={contactUrl(pkg)}
|
||||||
className={`inline-block rounded-md px-4 py-2 text-center text-sm font-semibold text-white ${
|
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"
|
||||||
isDarkCard ? 'bg-brand-500 shadow-lg shadow-brand-500/30 hover:bg-brand-400' : 'bg-brand-500 hover:bg-brand-600'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t('modelPage.requestContact')}
|
{t('modelPage.requestContact')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -267,5 +281,8 @@ export default function ModelPage() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue