diff --git a/hifi-src/src/assets/roads/apex.webp b/hifi-src/src/assets/roads/apex.webp new file mode 100644 index 0000000..cab5223 Binary files /dev/null and b/hifi-src/src/assets/roads/apex.webp differ diff --git a/hifi-src/src/assets/roads/base.webp b/hifi-src/src/assets/roads/base.webp new file mode 100644 index 0000000..93afff2 Binary files /dev/null and b/hifi-src/src/assets/roads/base.webp differ diff --git a/hifi-src/src/assets/roads/clear.webp b/hifi-src/src/assets/roads/clear.webp new file mode 100644 index 0000000..fd718d3 Binary files /dev/null and b/hifi-src/src/assets/roads/clear.webp differ diff --git a/hifi-src/src/assets/roads/drive.webp b/hifi-src/src/assets/roads/drive.webp new file mode 100644 index 0000000..761a193 Binary files /dev/null and b/hifi-src/src/assets/roads/drive.webp differ diff --git a/hifi-src/src/assets/roads/elite_r.webp b/hifi-src/src/assets/roads/elite_r.webp new file mode 100644 index 0000000..c5b5aa6 Binary files /dev/null and b/hifi-src/src/assets/roads/elite_r.webp differ diff --git a/hifi-src/src/assets/roads/limitless.webp b/hifi-src/src/assets/roads/limitless.webp new file mode 100644 index 0000000..0fb9c49 Binary files /dev/null and b/hifi-src/src/assets/roads/limitless.webp differ diff --git a/hifi-src/src/assets/roads/prime.webp b/hifi-src/src/assets/roads/prime.webp new file mode 100644 index 0000000..a83e2f0 Binary files /dev/null and b/hifi-src/src/assets/roads/prime.webp differ diff --git a/hifi-src/src/assets/roads/statement.webp b/hifi-src/src/assets/roads/statement.webp new file mode 100644 index 0000000..9a0ba80 Binary files /dev/null and b/hifi-src/src/assets/roads/statement.webp differ diff --git a/hifi-src/src/i18n/de.js b/hifi-src/src/i18n/de.js index 74eae14..d40ed8a 100644 --- a/hifi-src/src/i18n/de.js +++ b/hifi-src/src/i18n/de.js @@ -155,8 +155,6 @@ export default { productLoading: 'Produkt wird geladen…', requestContact: 'Kontakt anfragen', featuredBadge: 'Empfohlen', - tiersHeading: (count) => `${count} Stufen. Ein Ziel: perfekter Klang.`, - tiersSubheading: 'Von der soliden Basis bis zum kompromisslosen Meisterwerk – finde dein Paket.', }, leistungen: { metaTitle: 'Leistungen', diff --git a/hifi-src/src/i18n/en.js b/hifi-src/src/i18n/en.js index 8ce2308..1af9c9c 100644 --- a/hifi-src/src/i18n/en.js +++ b/hifi-src/src/i18n/en.js @@ -155,8 +155,6 @@ export default { productLoading: 'Loading product…', requestContact: 'Request contact', featuredBadge: 'Recommended', - tiersHeading: (count) => `${count} tiers. One goal: perfect sound.`, - tiersSubheading: 'From a solid foundation to an uncompromising masterpiece – find your package.', }, leistungen: { metaTitle: 'Services', diff --git a/hifi-src/src/pages/public/ModelPage.jsx b/hifi-src/src/pages/public/ModelPage.jsx index 6fc5288..09fbb13 100644 --- a/hifi-src/src/pages/public/ModelPage.jsx +++ b/hifi-src/src/pages/public/ModelPage.jsx @@ -4,23 +4,22 @@ 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 textureGraphite from '../../assets/textures/metal-graphite.webp'; -import textureDeepBlue from '../../assets/textures/metal-deep-blue.webp'; -import textureWarmBronze from '../../assets/textures/metal-warm-bronze.webp'; import roadGlowTexture from '../../assets/textures/road-glow.webp'; - -// Eine generierte, gebuerstete Metall-Oberflaechenstruktur je Theme (statt Fotos pro -// Paket, die es fuer echte Kundenpakete nicht gibt) - wird per "overlay"-Blendmodus mit -// dem bestehenden Farbverlauf kombiniert, sodass die Preis-Staffelung weiterhin die -// Farbe/Helligkeit steuert, die Textur nur die Material-Oberflaeche liefert. -const PACKAGE_TEXTURES = { - graphite: textureGraphite, - 'deep-blue': textureDeepBlue, - 'warm-bronze': textureWarmBronze, -}; +// 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; @@ -30,77 +29,61 @@ const hslToRgb = (h, s, 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 mixRgb = (c1, c2, t) => [0, 1, 2].map((i) => c1[i] + (c2[i] - c1[i]) * t); -// Fuer die Themes ohne eigene Kunden-Referenzfarben wird der Rahmen aus der Flaechenfarbe -// abgeleitet (etwas heller) statt von Hand 8 weitere Werte zu pflegen - im Referenzdesign -// liegt der Rahmenton durchgaengig ca. 15-25 Helligkeitsstufen ueber der Flaeche. -const deriveBorder = (bg) => bg.map((c) => Math.min(c + 18, 255)); -const hslStops = (stops) => - stops.map(({ bg, accent }) => { - const bgRgb = hslToRgb(...bg); - return { bg: bgRgb, accent: hslToRgb(...accent), border: deriveBorder(bgRgb) }; - }); +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, + })); -// Material-Stufen statt einfarbigem Verlauf: jede Preisstufe durchlaeuft eine eigene -// "Wertigkeit" wie bei Kreditkarten-/Loyalty-Stufen. Die Karten bleiben durchgehend dunkel -// (wie eine Fahrzeugkonfigurator-Buehne) - die Preis-Staffelung zeigt sich nicht in -// Hell/Dunkel, sondern im Farbton. "graphite" uebernimmt die exakten 8 Referenzfarben aus -// dem vom Kunden gelieferten Kachel-Design (Base/Clear/Drive/Prime/Elite R/Apex/The -// Statement/Limitless: kuehles Grau -> Petrol -> Gruen -> Gold); die anderen beiden Themes -// sind eigene Farbfamilien im selben Aufbau. Jede Stufe hat eine Flaechenfarbe (bg), eine -// Akzentfarbe (Name/Unterstrich/Lichtweg) und eine Rahmenfarbe (border) - nur der "Kontakt -// anfragen"-Button bleibt ueberall einheitlich markengruen. Zwischen zwei Nachbar- -// Materialien wird in RGB linear interpoliert (siehe materialRgbAt), sodass JEDES Paket -// (nicht nur die Materialien selbst) eine eigene Abstufung bekommt, egal ob ein Modell 2 -// oder 12 Pakete hat. Welches Farbschema verwendet wird, waehlt der Kunde selbst unter -// Admin -> Einstellungen -> Website ("Paket-Kachel-Design"). +// "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: [ - { bg: [28, 28, 28], accent: [182, 182, 182], border: [42, 42, 42] }, - { bg: [33, 33, 33], accent: [211, 211, 211], border: [46, 46, 46] }, - { bg: [22, 40, 43], accent: [143, 184, 189], border: [38, 56, 59] }, - { bg: [18, 41, 22], accent: [111, 191, 115], border: [31, 58, 36] }, - { bg: [18, 51, 18], accent: [99, 211, 79], border: [32, 74, 28] }, - { bg: [23, 58, 15], accent: [139, 234, 60], border: [43, 85, 24] }, - { bg: [51, 39, 13], accent: [217, 168, 60], border: [74, 58, 22] }, - { bg: [61, 47, 12], accent: [242, 193, 78], border: [90, 71, 24] }, + { 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': hslStops([ - { bg: [210, 8, 22], accent: [210, 12, 58] }, - { bg: [195, 30, 19], accent: [195, 45, 58] }, - { bg: [210, 45, 17], accent: [210, 60, 58] }, - { bg: [220, 55, 15], accent: [220, 65, 60] }, - { bg: [230, 58, 12], accent: [230, 68, 62] }, - { bg: [240, 62, 10], accent: [240, 72, 65] }, + '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': hslStops([ - { bg: [30, 8, 22], accent: [30, 12, 58] }, - { bg: [38, 35, 20], accent: [38, 55, 60] }, - { bg: [30, 50, 18], accent: [30, 65, 60] }, - { bg: [20, 50, 15], accent: [20, 65, 60] }, - { bg: [10, 48, 13], accent: [10, 62, 60] }, - { bg: [0, 45, 11], accent: [0, 58, 62] }, + '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], ]), }; -// 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 Materialien -// selbst. 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 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(materials[idx][key], materials[idx + 1][key], frac); -}; - -// Feste Preis-/Label-Farbe unabhaengig von der Preisstufe (wie im Referenzdesign) - nur -// der Name/Unterstrich/Lichtweg wandert farblich mit der Preisstufe, der Preis selbst -// bleibt auf jeder Karte gleich lesbar in neutralem Hell. -const PRICE_COLOR = 'rgb(242, 242, 242)'; -const LABEL_COLOR = 'rgb(138, 138, 138)'; +// 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'; export default function ModelPage() { const { brandSlug, modelSlug } = useParams(); @@ -109,8 +92,7 @@ export default function ModelPage() { const maintenance = useMaintenance(); const { t, language } = useLanguage(); const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings(); - const MATERIALS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite; - const cardTexture = PACKAGE_TEXTURES[packageCardTheme] || PACKAGE_TEXTURES.graphite; + 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); @@ -143,77 +125,33 @@ export default function ModelPage() { 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. + // 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 styleOf = (pkg) => { + const tierOf = (pkg) => { const n = packages.length; const rank = rankById.get(pkg.id) ?? 0; const tierT = n <= 1 ? 0 : rank / (n - 1); - - const bgRgb = materialRgbAt(MATERIALS, tierT, 'bg').map(Math.round); - const accentRgb = materialRgbAt(MATERIALS, tierT, 'accent').map(Math.round); - const borderRgb = materialRgbAt(MATERIALS, tierT, 'border').map(Math.round); - // Der "Lichtweg" im Kartenhintergrund ist ein generiertes, fotografisches Glow-Motiv - // (weisser Lichtstreif auf Schwarz) statt einer duennen Vektorlinie - per CSS-Maske - // wird die weisse Kurve zur Alphamaske fuer die jeweilige Akzentfarbe, sodass das - // Motiv bei JEDER Preisstufe/JEDEM Theme automatisch in der richtigen Farbe leuchtet. - // Schon die Einstiegsstufe bleibt sichtbar praesent statt fast unsichtbar - die - // Preis-Staffelung zeigt sich in der Intensitaet, nicht im "Ob ueberhaupt sichtbar". - const roadOpacity = (0.55 + tierT * 0.45).toFixed(2); - const roadGlowOpacity = (0.2 + tierT * 0.45).toFixed(2); - const roadMaskStyle = { - backgroundColor: `rgb(${accentRgb.join(', ')})`, - WebkitMaskImage: `url(${roadGlowTexture})`, - maskImage: `url(${roadGlowTexture})`, - // WebP-Dateien bringen einen (durchgehend deckenden) Alphakanal mit - ohne explizites - // "luminance" bevorzugt CSS per Default den Alphakanal statt der Helligkeit, wodurch - // die Maske komplett deckend (= volle Flaeche statt duenner Kurve) gerendert wuerde. - maskMode: 'luminance', - WebkitMaskSize: 'cover', - maskSize: 'cover', - WebkitMaskPosition: 'center', - maskPosition: 'center', - WebkitMaskRepeat: 'no-repeat', - maskRepeat: 'no-repeat', - }; - // Die Metall-Textur wird als eigene Ebene mit eigener Deckkraft gefahren (statt fest - // per background-blend-mode verschmolzen) - bei der hellen Einstiegsstufe bleibt sie - // kaum sichtbar, damit die Karte wirklich hell/weiss bleibt statt durch die dunkle - // Textur grau anzulaufen; zur teuersten Stufe hin wird sie praesenter. - const textureOpacity = (0.05 + tierT * 0.32).toFixed(2); - - return { - accentColor: `rgb(${accentRgb.join(', ')})`, - roadOpacity, - roadGlowOpacity, - roadMaskStyle, - textureStyle: { - backgroundImage: `url(${cardTexture})`, - backgroundSize: '480px 480px', - mixBlendMode: 'overlay', - opacity: textureOpacity, - }, - // Duenne Haarlinie unter dem Namen statt dickerem Rahmen - wie im Referenzdesign: - // ein 44px breiter Verlauf, der zu beiden Seiten in Transparenz auslaeuft. - glowLineStyle: { - background: `linear-gradient(90deg, transparent, rgb(${accentRgb.join(', ')}), transparent)`, - }, - style: { - // Vertikaler Verlauf wie im Referenzdesign: oben die Materialfarbe der Preisstufe, - // darunter schnell in Richtung Schwarz - so bleibt der untere Kartenbereich (Preis) - // unabhaengig vom Theme immer gut lesbar dunkel. - backgroundImage: `linear-gradient(180deg, rgb(${bgRgb.join(', ')}) 0%, #0b0b0b 55%, #080808 100%)`, - borderColor: `rgb(${borderRgb.join(', ')})`, - }, - }; + 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, @@ -243,68 +181,73 @@ export default function ModelPage() { {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. -
-
-
-

{t('modelPage.tiersHeading')(packages.length)}

-

{t('modelPage.tiersSubheading')}

-
-
- - {/* Eigener, deutlich breiterer Rahmen fuer die Kartenreihe im Scroll-Band-Modus: - die restliche Seite haelt sich an die normale Lesebreite (max-w-6xl), aber - die Kacheln sollen auf breiten Bildschirmen wirklich die volle Breite nutzen - koennen, statt unnoetig frueh in den Scroll-Modus zu wechseln. */} -
+ // 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. +
+
{packages.map((pkg) => { - const { style, accentColor, glowLineStyle, roadOpacity, roadGlowOpacity, roadMaskStyle, textureStyle } = - styleOf(pkg); + const { tier, tierT } = tierOf(pkg); + const bullets = bulletsOf(pkg); + // 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 (
- {/* Abstrakter "Lichtweg" im Hintergrund - ein generiertes Foto-Glow-Motiv - (weisser Lichtstreif auf Schwarz), per CSS-Maske in die jeweilige - Akzentfarbe eingefaerbt. Zwei Ebenen: eine weich verwischte Halo-Ebene - fuer die Tiefe darunter, eine scharfe Ebene fuer die Kontur darueber - - je teurer die Stufe, desto praesenter/leuchtender beide. */} -