diff --git a/hifi-src/src/pages/public/ModelPage.jsx b/hifi-src/src/pages/public/ModelPage.jsx index 2dbdf96..aff661b 100644 --- a/hifi-src/src/pages/public/ModelPage.jsx +++ b/hifi-src/src/pages/public/ModelPage.jsx @@ -7,6 +7,56 @@ 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'; + +// Farbverlauf fuer die Paket-Kacheln: von schlicht/hell (guenstigste Option) bis +// dunkel/leuchtend (teuerste Option). Interpoliert wird in HSL statt RGB, mit dem +// Farbton (Hue) konstant nahe "Markengruen" - eine direkte RGB-Mischung von hellem +// Mintgruen zu Schwarz liefe sonst mittendrin durch ein schmutziges Graugruen statt +// gleichmaessig satter zu wirken. Drei Stuetzpunkte (t=0/0.5/1), dazwischen linear +// interpoliert - dadurch bekommt JEDES Paket eine eigene Abstufung statt nur 2-3 +// fester Stile, egal ob ein Modell 2 oder 12 Pakete hat. +const LIGHT_STOPS = { + bg: [ + [0, 0, 100], + [88, 55, 92], + [100, 45, 7], + ], + border: [ + [0, 0, 90], + [83, 60, 65], + [88, 63, 40], + ], +}; +const DARK_STOPS = { + bg: [ + [0, 0, 9], + [95, 35, 14], + [105, 55, 5], + ], + border: [ + [0, 0, 15], + [95, 45, 35], + [92, 65, 60], + ], +}; + +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 mixHsl = (c1, c2, t) => [0, 1, 2].map((i) => c1[i] + (c2[i] - c1[i]) * t); +// Liefert sowohl die interpolierte HSL-Lightness (fuer die Textfarben-Entscheidung - +// verlaesslicher als ein RGB-Luminanz-Naeherungswert, da satte Mitteltoene sonst +// faelschlich als "hell genug fuer dunklen Text" gelten wuerden) als auch die RGB-Werte. +const stopColor = (stops, t) => { + const [h, s, l] = t <= 0.5 ? mixHsl(stops[0], stops[1], t / 0.5) : mixHsl(stops[1], stops[2], (t - 0.5) / 0.5); + return { rgb: hslToRgb(h, s, l), lightness: l }; +}; export default function ModelPage() { const { brandSlug, modelSlug } = useParams(); @@ -14,6 +64,7 @@ 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); @@ -46,18 +97,37 @@ export default function ModelPage() { const { model, packages } = data; // Preis-Rang innerhalb dieses Modells bestimmt die optische Wucht der Kachel: - // die guenstigste Option bleibt schlicht/hell, dazwischen liegende Pakete - // bekommen einen Akzent-Rahmen, das teuerste Paket eine dunkle "Premium"-Karte. - // Das braucht keine zusaetzliche Admin-Einstellung und passt sich automatisch an. + // die guenstigste Option bleibt schlicht/hell, jede weitere Preisstufe wird + // kontinuierlich dunkler/leuchtender bis zur teuersten Option. Das braucht keine + // zusaetzliche Admin-Einstellung und passt sich automatisch an jede Paketanzahl an. + const stops = theme === 'dark' ? DARK_STOPS : LIGHT_STOPS; const rankById = new Map( [...packages].sort((a, b) => a.total_price - b.total_price).map((p, i) => [p.id, i]) ); - const tierOf = (pkg) => { - if (packages.length <= 1) return 'plain'; - const rank = rankById.get(pkg.id); - if (rank === 0) return 'plain'; - if (rank === packages.length - 1) return 'premium'; - return 'accent'; + const styleOf = (pkg) => { + const n = packages.length; + const rank = rankById.get(pkg.id) ?? 0; + const tierT = n <= 1 ? 0 : rank / (n - 1); + + const bg = stopColor(stops.bg, tierT); + const border = stopColor(stops.border, tierT); + // Ab hier reicht der Hintergrund nicht mehr zum Kontrastieren mit dunklem Text - + // satte gruene Mitteltoene brauchen (wie die Buttons) helle statt dunkle Schrift. + const isDarkCard = bg.lightness < 60; + const glowAlpha = (0.05 + tierT * 0.35).toFixed(2); + + return { + isDarkCard, + style: { + backgroundColor: `rgb(${bg.rgb.join(', ')})`, + borderColor: `rgb(${border.rgb.join(', ')})`, + borderWidth: tierT > 0.12 ? 2 : 1, + boxShadow: + tierT > 0.08 + ? `0 ${Math.round(6 + tierT * 14)}px ${Math.round(20 + tierT * 45)}px -10px rgba(107, 166, 38, ${glowAlpha})` + : undefined, + }, + }; }; const contactUrl = (pkg) => { @@ -88,20 +158,10 @@ export default function ModelPage() {
{packages.map((pkg) => { - const tier = tierOf(pkg); - const isPremium = tier === 'premium'; + const { isDarkCard, style } = styleOf(pkg); return ( -
+
{pkg.is_featured && ( {t('modelPage.featuredBadge')} @@ -111,36 +171,36 @@ export default function ModelPage() { {pkg.icon_name && (
)} -

+

{pkg.name}

{pkg.tagline && ( -

+

{pkg.tagline}

)}
-

+

{t('modelPage.totalPrice')}

-

+

{formatPrice(pkg.total_price)}

    {pkg.products.map((product) => ( @@ -158,11 +218,7 @@ export default function ModelPage() { {t('modelPage.requestContact')}