Collapse long feature lists behind a per-card "+X weitere Leistungen" toggle

Cards show the first 5 bullet points; anything beyond that sits behind an
accent-colored toggle whose count itself signals package depth (pricier
packages carry longer lists). Expanding affects only the clicked card -
the row aligns items-start instead of stretching every neighbor to the
tallest card. The darkening gradient behind the list holds its opacity
almost to the bottom edge so the toggle and deep list entries stay
readable over the bright road glow, and the expanded state uses a denser
scrim for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-09 04:19:50 +02:00
parent 31bc890688
commit 96be810cc9
3 changed files with 184 additions and 138 deletions

View file

@ -155,6 +155,8 @@ export default {
productLoading: 'Produkt wird geladen…', productLoading: 'Produkt wird geladen…',
requestContact: 'Kontakt anfragen', requestContact: 'Kontakt anfragen',
featuredBadge: 'Empfohlen', featuredBadge: 'Empfohlen',
moreBullets: (n) => (n === 1 ? '+ 1 weitere Leistung' : `+ ${n} weitere Leistungen`),
lessBullets: 'Weniger anzeigen',
}, },
leistungen: { leistungen: {
metaTitle: 'Leistungen', metaTitle: 'Leistungen',

View file

@ -155,6 +155,8 @@ export default {
productLoading: 'Loading product…', productLoading: 'Loading product…',
requestContact: 'Request contact', requestContact: 'Request contact',
featuredBadge: 'Recommended', featuredBadge: 'Recommended',
moreBullets: (n) => (n === 1 ? '+ 1 more feature' : `+ ${n} more features`),
lessBullets: 'Show less',
}, },
leistungen: { leistungen: {
metaTitle: 'Services', metaTitle: 'Services',

View file

@ -85,6 +85,172 @@ const PRICE_COLOR = '#f2f2f2';
const LABEL_COLOR = '#8a8a8a'; const LABEL_COLOR = '#8a8a8a';
const BULLET_COLOR = '#c4c4c4'; 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, isStripLayout, 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={
isStripLayout
? '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]'
: '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"
/>
</>
)}
{pkg.is_featured && (
<span className="absolute -top-3 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">
<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,
}}
>
{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() { export default function ModelPage() {
const { brandSlug, modelSlug } = useParams(); const { brandSlug, modelSlug } = useParams();
const [data, setData] = useState(null); const [data, setData] = useState(null);
@ -189,150 +355,26 @@ export default function ModelPage() {
<div <div
className={ className={
isStripLayout isStripLayout
? 'flex items-stretch gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:thin] [scrollbar-color:rgba(0,0,0,0.3)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.25)_transparent] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/30 dark:[&::-webkit-scrollbar-thumb]:bg-white/25' ? // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst
: 'grid gap-[18px] sm:grid-cols-2 xl:grid-cols-3' // nur DIESE in die Hoehe - die Nachbarn behalten ihre Referenzhoehe.
'flex items-start gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:thin] [scrollbar-color:rgba(0,0,0,0.3)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.25)_transparent] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/30 dark:[&::-webkit-scrollbar-thumb]:bg-white/25'
: 'grid items-start gap-[18px] sm:grid-cols-2 xl:grid-cols-3'
} }
> >
{packages.map((pkg) => { {packages.map((pkg) => {
const { tier, tierT } = tierOf(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 ( return (
<div <PackageCard
key={pkg.id} key={pkg.id}
style={{ pkg={pkg}
borderColor: tier.border, tier={tier}
background: `linear-gradient(180deg, ${tier.bgTop} 0%, #0b0b0b 55%, #080808 100%)`, tierT={tierT}
}} isStripLayout={isStripLayout}
className={ bullets={bulletsOf(pkg)}
isStripLayout formatPrice={formatPrice}
? '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]' contactUrl={contactUrl}
: 'relative flex min-h-[640px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40' t={t}
} />
>
{/* 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"
/>
</>
)}
{pkg.is_featured && (
<span className="absolute -top-3 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 (von oben nach unten), leicht
abgedunkelter Hintergrund fuer Lesbarkeit ueber dem Straßenbild. */}
{bullets.length > 0 && (
<ul
className="relative z-10 space-y-2 text-left"
style={{
padding: '18px 20px 14px',
background: 'linear-gradient(180deg, rgba(8,8,8,0.55) 0%, rgba(8,8,8,0.35) 70%, rgba(8,8,8,0) 100%)',
}}
>
{bullets.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>
)}
{/* 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">
<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,
}}
>
{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>
); );
})} })}
</div> </div>