HifiPlanet-Website/hifi-src/src/pages/public/ModelPage.jsx
Maaxxs fa862d936a Rename the "Elite R" package tier to "Elite"
Client wants the tier called just "Elite". Only the admin checkbox list
(PACKAGE_TIERS) carries tier names; public card theming is price-rank
based, so nothing else changes. An already-created "Elite R" package
would show up under "Weitere Pakete (alte Namen)" in the editor and can
be deleted and recreated as Elite there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 07:03:27 +02:00

560 lines
24 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, useRef, 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';
import roadGlowTexture from '../../assets/textures/road-glow.webp';
// 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;
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 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,
}));
// "graphite" = exakt die 8 Referenz-Kacheln (Farben + Straßenbild) aus Kacheln.html:
// Base / Clear / Drive / Prime / Elite / 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: [
{ 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': 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': 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],
]),
};
// 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';
// Mehr sichtbare Stichpunkte wuerden das Straßenbild verdraengen - gerade bei den teuren
// Paketen (die laut Kunde die laengsten Listen haben) soll die Kachel ruhig bleiben. Der
// Rest steckt hinter einem "+X weitere Leistungen"-Toggle, dessen Zaehler selbst als
// Wertigkeitssignal wirkt.
const VISIBLE_BULLETS = 5;
function PackageCard({ pkg, tier, tierT, layout, bullets, formatPrice, contactUrl, t }) {
const [expanded, setExpanded] = useState(false);
const hiddenCount = bullets.length - VISIBLE_BULLETS;
const shownBullets = expanded ? bullets : bullets.slice(0, VISIBLE_BULLETS);
// 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 (
<div
style={{
borderColor: tier.border,
background: `linear-gradient(180deg, ${tier.bgTop} 0%, #0b0b0b 55%, #080808 100%)`,
}}
className={
layout === 'strip'
? 'relative flex min-h-[640px] grow shrink basis-[190px] min-w-[190px] max-w-[240px] snap-start flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40 sm:basis-[210px]'
: layout === 'coverflow'
? // Feste Breite + snap-center: die 3D-Transformation (rotateY/scale/translateZ)
// setzt der Scroll-Handler in ModelPage direkt per style.transform.
'relative flex min-h-[640px] w-[230px] shrink-0 snap-center flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 will-change-transform dark:shadow-black/40'
: 'relative flex min-h-[640px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40'
}
>
{/* Vollflaechiges Straßen-Glow-Bild der Preisstufe (Referenzdesign). */}
{tier.img ? (
<img
src={tier.img}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : (
<>
<div
className="pointer-events-none absolute inset-0"
style={{ ...glowMask, opacity: 0.2 + tierT * 0.45, filter: 'blur(18px)' }}
aria-hidden="true"
/>
<div
className="pointer-events-none absolute inset-0"
style={{ ...glowMask, opacity: 0.55 + tierT * 0.45 }}
aria-hidden="true"
/>
</>
)}
{/* top-2 statt -top-3: die Karte hat overflow-hidden (runde Ecken + Straßenbild),
ein halb herausragender Badge wuerde oben abgeschnitten. */}
{pkg.is_featured && (
<span className="absolute top-2 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>
)}
{/* Kopf: Name + Trennlinie (exakt Referenz). */}
<div className="relative z-10 text-center" style={{ padding: '34px 20px 0' }}>
<div
style={{
fontFamily: "'Barlow Condensed', sans-serif",
fontSize: '30px',
letterSpacing: '3px',
fontWeight: 600,
color: tier.accent,
textTransform: 'uppercase',
lineHeight: 1.1,
}}
>
{pkg.name}
</div>
<div
style={{
width: '44px',
height: '2px',
margin: '14px auto 0',
background: `linear-gradient(90deg, rgba(255,255,255,0) 0%, ${tier.accent} 50%, rgba(255,255,255,0) 100%)`,
}}
/>
</div>
{/* Stichpunkte direkt unter dem Kopf, leicht abgedunkelter Hintergrund fuer
Lesbarkeit ueber dem Straßenbild - aufgeklappt deckender, da die Liste dann
weiter ins helle Bildzentrum hineinreicht. */}
{bullets.length > 0 && (
<div
className="relative z-10"
style={{
padding: '18px 20px 14px',
background: expanded
? 'linear-gradient(180deg, rgba(8,8,8,0.7) 0%, rgba(8,8,8,0.62) 94%, rgba(8,8,8,0) 100%)'
: 'linear-gradient(180deg, rgba(8,8,8,0.55) 0%, rgba(8,8,8,0.48) 92%, rgba(8,8,8,0) 100%)',
}}
>
<ul className="space-y-1.5 text-left">
{shownBullets.map((line, i) => (
<li key={i} className="flex items-start gap-2" style={{ fontSize: '13px', color: BULLET_COLOR }}>
<DynamicIcon name="check" className="mt-0.5 h-3.5 w-3.5 shrink-0" style={{ color: tier.accent }} />
<span className="leading-snug">{line}</span>
</li>
))}
</ul>
{hiddenCount > 0 && (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="mt-2.5 flex items-center gap-1 hover:opacity-80"
style={{ fontSize: '13px', color: tier.accent }}
>
{expanded ? t('modelPage.lessBullets') : t('modelPage.moreBullets')(hiddenCount)}
<DynamicIcon name={expanded ? 'chevron-up' : 'chevron-down'} className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{/* Freiraum, damit das Straßenbild zur Geltung kommt. */}
<div className="relative z-10 flex-1" />
{/* Preis-Sektion mit dunklem Verlauf (Referenz): "ab ca." + Preis + CTA. */}
<div
className="relative z-10"
style={{
padding: '20px 20px 28px',
background: 'linear-gradient(180deg, rgba(8,8,8,0) 0%, rgba(8,8,8,0.85) 45%, rgba(8,8,8,0.95) 100%)',
}}
>
<div className="text-center">
{/* Optionaler Admin-Freitext (z. B. "Coming soon") ersetzt "ab ca." + Preis. */}
{!pkg.price_text && <div style={{ fontSize: '13px', color: LABEL_COLOR }}>{t('modelPage.totalPrice')}</div>}
<div
style={{
marginTop: '4px',
fontFamily: "'Barlow Condensed', sans-serif",
fontSize: '30px',
fontWeight: 600,
letterSpacing: '1px',
color: PRICE_COLOR,
}}
>
{pkg.price_text || formatPrice(pkg.total_price)}
</div>
<Link
to={contactUrl(pkg)}
className="mt-4 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>
);
}
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, package_card_layout: packageCardLayout } = useSiteSettings();
const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
const layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
const scrollerRef = useRef(null);
const trackRef = useRef(null);
const thumbRef = useRef(null);
const hintPlayedRef = useRef(false);
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]);
// 3D-Coverflow: pro Scroll-Frame bekommt jede Karte abhaengig vom Abstand ihrer Mitte
// zur Containermitte eine Drehung/Skalierung/Tiefe - die mittlere Karte steht frontal,
// die Nachbarn drehen raeumlich weg. rAF-gedrosselt; der ResizeObserver deckt auch
// Hoehenaenderungen ab (z. B. wenn eine Karte ihre Stichpunkte aufklappt).
useEffect(() => {
const el = scrollerRef.current;
if (layout !== 'coverflow' || !el) return undefined;
let rafId = 0;
const apply = () => {
rafId = 0;
const mid = el.scrollLeft + el.clientWidth / 2;
for (const card of el.children) {
const center = card.offsetLeft + card.offsetWidth / 2;
const d = Math.max(-1, Math.min(1, (center - mid) / (card.offsetWidth * 1.4)));
const abs = Math.abs(d);
card.style.transform = `rotateY(${(-d * 35).toFixed(2)}deg) scale(${(1 - abs * 0.13).toFixed(3)}) translateZ(${(-abs * 60).toFixed(1)}px)`;
card.style.zIndex = String(100 - Math.round(abs * 50));
}
};
const schedule = () => {
if (!rafId) rafId = requestAnimationFrame(apply);
};
apply();
el.addEventListener('scroll', schedule, { passive: true });
const ro = new ResizeObserver(schedule);
ro.observe(el);
return () => {
el.removeEventListener('scroll', schedule);
ro.disconnect();
cancelAnimationFrame(rafId);
};
}, [layout, data]);
// Kein Karten-Drag per Maus mehr (Kundenfeedback: fuehlte sich in Kombination mit der
// 3D-Transformation im Coverflow nicht gut an). Auf dem Desktop navigiert man
// stattdessen ueber die eigene Scroll-Leiste unten; Touch-Geraete behalten ohnehin
// das native, fluessige Wisch-Scrolling der scroll-snap-Kartenreihe.
//
// Stattdessen: einmaliger kurzer Wisch-Hinweis beim ersten Laden auf Touch-Geraeten
// (Kundenwunsch) - die Reihe faehrt kurz ein Stueck nach rechts und wieder zurueck,
// damit sofort klar ist, dass sich die Kacheln wischen lassen. Nur einmal pro
// Seitenaufruf, nur auf echten Touch-Geraeten, nicht bei reduzierter Bewegung.
useEffect(() => {
const el = scrollerRef.current;
if (layout === 'grid' || !el || hintPlayedRef.current) return undefined;
const isTouch = window.matchMedia('(pointer: coarse)').matches;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isTouch || reducedMotion) return undefined;
hintPlayedRef.current = true;
const peek = Math.min(el.clientWidth * 0.4, 160);
const t1 = setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500);
const t2 = setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [layout, data]);
// Eigene, zum Design passende Scroll-Leiste unter der Kartenreihe (Strip + Coverflow):
// die native Browser-Leiste ist ausgeblendet, stattdessen ein schmaler zentrierter
// Track mit markengruenem, leuchtendem Daumen. Daumen ist ziehbar, Klick auf den
// Track springt an die Stelle. Bei zu wenig Inhalt (kein Overflow) blendet sie aus.
useEffect(() => {
const el = scrollerRef.current;
const track = trackRef.current;
const thumb = thumbRef.current;
if (layout === 'grid' || !el || !track || !thumb) return undefined;
let rafId = 0;
const update = () => {
rafId = 0;
const maxScroll = el.scrollWidth - el.clientWidth;
if (maxScroll <= 1) {
track.style.opacity = '0';
track.style.pointerEvents = 'none';
return;
}
track.style.opacity = '1';
track.style.pointerEvents = 'auto';
const trackW = track.clientWidth;
const thumbW = Math.max(32, (el.clientWidth / el.scrollWidth) * trackW);
thumb.style.width = `${thumbW}px`;
thumb.style.transform = `translateX(${((el.scrollLeft / maxScroll) * (trackW - thumbW)).toFixed(1)}px)`;
};
const schedule = () => {
if (!rafId) rafId = requestAnimationFrame(update);
};
update();
el.addEventListener('scroll', schedule, { passive: true });
const ro = new ResizeObserver(schedule);
ro.observe(el);
let dragging = false;
let grabOffset = 0;
const scrollToPointer = (clientX) => {
const rect = track.getBoundingClientRect();
const thumbW = thumb.clientWidth;
const x = Math.min(Math.max(clientX - rect.left - grabOffset, 0), rect.width - thumbW);
el.scrollLeft = (x / (rect.width - thumbW)) * (el.scrollWidth - el.clientWidth);
};
const onDown = (e) => {
dragging = true;
track.setPointerCapture(e.pointerId);
el.style.scrollSnapType = 'none';
const tr = thumb.getBoundingClientRect();
// Auf dem Daumen: relative Griffposition merken; daneben: Daumen unter den Zeiger springen.
grabOffset = e.clientX >= tr.left && e.clientX <= tr.right ? e.clientX - tr.left : thumb.clientWidth / 2;
if (!(e.clientX >= tr.left && e.clientX <= tr.right)) scrollToPointer(e.clientX);
};
const onMove = (e) => {
if (dragging) scrollToPointer(e.clientX);
};
const onUp = (e) => {
if (!dragging) return;
dragging = false;
if (track.hasPointerCapture?.(e.pointerId)) track.releasePointerCapture(e.pointerId);
el.style.scrollSnapType = '';
};
track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onUp);
return () => {
el.removeEventListener('scroll', schedule);
ro.disconnect();
cancelAnimationFrame(rafId);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onUp);
};
}, [layout, data]);
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 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 tierOf = (pkg) => {
const n = packages.length;
const rank = rankById.get(pkg.id) ?? 0;
const tierT = n <= 1 ? 0 : rank / (n - 1);
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,
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 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.
<section className="py-10 sm:py-14" style={{ fontFamily: "'Barlow', sans-serif" }}>
<div className={layout === 'grid' ? 'mx-auto max-w-6xl px-4 sm:px-6' : 'mx-auto max-w-[1880px] px-4 sm:px-6'}>
<div
ref={layout === 'grid' ? undefined : scrollerRef}
className={
layout === 'grid'
? 'grid items-start gap-[18px] sm:grid-cols-2 xl:grid-cols-3'
: // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst
// nur DIESE in die Hoehe - die Nachbarn behalten ihre Referenzhoehe.
// Die native Scroll-Leiste ist ausgeblendet - darunter sitzt die eigene
// Track/Daumen-Leiste. select-none, damit Drag keinen Text markiert.
'flex select-none items-start gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
}
style={
layout === 'coverflow'
? // Perspektive fuer die 3D-Drehung; das seitliche Padding erlaubt es,
// auch die erste/letzte Karte in die Mitte zu scrollen (Kartenbreite
// 230px -> halbe Breite 115px). pt schafft Luft fuer die Skalierung.
{ perspective: '1200px', paddingLeft: 'calc(50% - 115px)', paddingRight: 'calc(50% - 115px)', paddingTop: '10px' }
: undefined
}
>
{packages.map((pkg) => {
const { tier, tierT } = tierOf(pkg);
return (
<PackageCard
key={pkg.id}
pkg={pkg}
tier={tier}
tierT={tierT}
layout={layout}
bullets={bulletsOf(pkg)}
formatPrice={formatPrice}
contactUrl={contactUrl}
t={t}
/>
);
})}
</div>
{/* Eigene Scroll-Leiste: zentrierter Track, markengruener leuchtender Daumen.
Ziehbar + klickbar (Logik im Scrollbar-Effekt); blendet aus, wenn alle
Karten ohne Scrollen passen. touch-action none fuer sauberes Pointer-Ziehen. */}
{layout !== 'grid' && (
<div
ref={trackRef}
className="relative mx-auto mt-5 h-2 w-56 max-w-full cursor-pointer rounded-full bg-neutral-900/10 transition-opacity duration-200 dark:bg-white/10"
style={{ touchAction: 'none' }}
aria-hidden="true"
>
<div
ref={thumbRef}
className="absolute left-0 top-0 h-full rounded-full bg-brand-500 shadow-[0_0_10px_rgba(107,166,38,0.7)]"
style={{ width: '48px' }}
/>
</div>
)}
</div>
</section>
)}
</>
);
}