Files
bmp-website-2026/src/spa/pages/Preistraeger.tsx
2026-06-22 12:48:54 +02:00

380 lines
20 KiB
TypeScript
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 React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router';
import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react';
import { WINNERS, type Winner } from '@/spa/data/winners';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent';
const FF = '"IBM Plex Sans", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const BORDER = '#D0D5DD';
const GRAY = '#666666';
const BG_ALT = '#E4E2E3';
type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
hero?: Partial<typeof preistraegerIndexContent.hero> & { image?: unknown }
card?: Partial<typeof preistraegerIndexContent.card> & { fallbackImage?: unknown }
}
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const normalizeWinner = (
doc: CmsRouteDoc,
fallbackImage: string,
fallbackWinner: typeof preistraegerIndexContent.card.fallbackWinner,
): Winner => ({
id: String(doc.id),
slug: String(doc.slug || ''),
name: String(doc.title || fallbackWinner.name || preistraegerIndexContent.card.fallbackWinner.name),
category: String(doc.category || fallbackWinner.category || preistraegerIndexContent.card.fallbackWinner.category),
year: Number(doc.year || new Date().getFullYear()),
type: String(doc.awardType || fallbackWinner.awardType || preistraegerIndexContent.card.fallbackWinner.awardType),
img: docImageUrl(doc, fallbackImage),
shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''),
longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''),
quote: String(doc.quote || ''),
quotePerson: String(doc.quotePerson || ''),
quoteRole: String(doc.quoteRole || ''),
location: String(doc.location || fallbackWinner.location || preistraegerIndexContent.card.fallbackWinner.location),
industry: String(doc.industry || fallbackWinner.industry || preistraegerIndexContent.card.fallbackWinner.industry),
website: String(doc.website || '#'),
hasMedia: Boolean(doc.hasMedia),
});
export default function Preistraeger() {
const isMobile = useIsMobile();
const [filterCategory, setFilterCategory] = useState('');
const [search, setSearch] = useState('');
const [mediaOnly, setMediaOnly] = useState(false);
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
const cmsPreistraeger = useCmsCollection('preistraeger');
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) };
const filters = { ...preistraegerIndexContent.filters, ...(cms.filters || {}) };
const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) };
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
const cta = { ...preistraegerIndexContent.cta, ...(cms.cta || {}) };
const cardFallbackImage = useMemo(
() => mediaUrl(card.fallbackImage, `/images/${preistraegerIndexContent.card.fallbackImageFilename}`),
[card],
);
const winners = useMemo(() => {
const fallbackWinner = card.fallbackWinner || preistraegerIndexContent.card.fallbackWinner;
if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, cardFallbackImage, fallbackWinner)).filter((winner) => winner.slug);
return WINNERS;
}, [card, cardFallbackImage, cmsPreistraeger]);
const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
const categories = useMemo(() => [...new Set(winners.map((winner) => winner.category))].sort((a, b) => a.localeCompare(b, 'de')), [winners]);
const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const filtered = useMemo(() => winners.filter(w => {
if (w.year !== selectedYear) return false;
if (filterCategory && w.category !== filterCategory) return false;
if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false;
if (mediaOnly && !w.hasMedia) return false;
return true;
}), [selectedYear, filterCategory, search, mediaOnly, winners]);
const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); };
const hasFilter = !!(filterCategory || search || mediaOnly);
return (
<div style={{ background: '#fff', minHeight: '100vh' }}>
{/* Hero */}
<div style={{ width: '100%', height: '50vh', overflow: 'hidden', position: 'relative' }}>
<Image unoptimized
src={mediaUrl(hero.image, fallbackText(hero.imageUrl, preistraegerIndexContent.hero.imageUrl))}
alt={mediaAlt(hero.image, fallbackText(hero.imageAlt, preistraegerIndexContent.hero.imageAlt))}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, transparent 40%, rgba(3,9,58,0.82) 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.3), transparent)' }} />
</div>
{/* Breadcrumb */}
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '18px 24px' : '18px 80px' }}>
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
</div>
{/* Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 24px' : '48px 80px 32px' }}>
{/* #93: Headline entfernt – Seitenname steht bereits oben im Breadcrumb. */}
{/* Year Tabs */}
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, marginBottom: 32, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
{years.map(year => (
<button
key={year}
onClick={() => { setActiveYear(year); reset(); }}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700,
padding: isMobile ? '12px 20px' : '12px 32px',
flexShrink: 0,
background: 'none', border: 'none', cursor: 'pointer',
color: selectedYear === year ? NAVY : GRAY,
borderBottom: selectedYear === year ? `2px solid ${GOLD}` : '2px solid transparent',
marginBottom: -2,
letterSpacing: '0.04em',
transition: 'color 0.15s, border-color 0.15s',
}}
>
{year}
</button>
))}
</div>
{/* Filter Row */}
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: 'stretch', marginBottom: 20, flexWrap: 'wrap' }}>
<SelectControl
value={filterCategory}
onChange={setFilterCategory}
placeholder={fallbackText(filters.categoryPlaceholder, preistraegerIndexContent.filters.categoryPlaceholder)}
options={categories.map(c => ({ label: c, value: c }))}
width={isMobile ? undefined : 280}
/>
<SearchControl value={search} onChange={setSearch} placeholder={fallbackText(filters.searchPlaceholder, preistraegerIndexContent.filters.searchPlaceholder)} />
</div>
{/* Toggle row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828' }}>
{fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 15 }}>{fallbackText(filters.mediaIcon, preistraegerIndexContent.filters.mediaIcon)}</span>
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>{fallbackText(filters.mediaLabel, preistraegerIndexContent.filters.mediaLabel)}</span>
<button
onClick={() => setMediaOnly(v => !v)}
style={{
width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
background: mediaOnly ? NAVY : BORDER,
position: 'relative', transition: 'background 0.2s',
}}
>
<span style={{
position: 'absolute', top: 3, left: mediaOnly ? 20 : 3, width: 16, height: 16,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
}} />
</button>
</div>
</div>
{hasFilter && (
<button
onClick={reset}
style={{ fontFamily: FF, fontSize: 16, color: GRAY, background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
onMouseEnter={e => (e.currentTarget.style.color = GOLD)}
onMouseLeave={e => (e.currentTarget.style.color = GRAY)}
>
<X size={14} /> {fallbackText(filters.resetLabel, preistraegerIndexContent.filters.resetLabel)}
</button>
)}
</div>
</div>
{/* Count bar */}
<div style={{ padding: isMobile ? '12px 24px' : '12px 80px', background: BG_ALT, borderTop: `1px solid ${BORDER}`, borderBottom: `1px solid ${BORDER}`, display: 'flex', alignItems: 'center', gap: 12 }}>
<Trophy size={13} style={{ color: GOLD }} fill={GOLD} />
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>
<strong style={{ color: '#101828' }}>{selectedYear}</strong> – {filtered.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
</span>
</div>
{/* Grid */}
{filtered.length === 0 ? (
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)',
gridAutoFlow: isMobile ? 'column' : undefined,
gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined,
gridAutoColumns: isMobile ? '74vw' : undefined,
overflowX: isMobile ? 'auto' : undefined,
scrollSnapType: isMobile ? 'x mandatory' : undefined,
gap: isMobile ? 8 : 0,
padding: isMobile ? '0 16px 8px' : 0,
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
}}>
{filtered.map(w => (
<WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
))}
</div>
)}
{/* Bottom CTA */}
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', borderTop: `1px solid ${BORDER}`, textAlign: 'center' }}>
<p style={{ fontFamily: FF, fontSize: 17, color: GRAY, marginBottom: 20 }}>{fallbackText(cta.text, preistraegerIndexContent.cta.text)}</p>
<Link
to={fallbackText(cta.primaryCta?.url, preistraegerIndexContent.cta.primaryCta.url)}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.12em', color: '#101828', background: GOLD,
padding: '15px 36px', textDecoration: 'none',
display: 'inline-flex', alignItems: 'center', gap: 8, transition: 'background 0.15s, box-shadow 0.2s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = '#FFD130'; (e.currentTarget as HTMLElement).style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
>
{fallbackText(cta.primaryCta?.label, preistraegerIndexContent.cta.primaryCta.label)} <ArrowRight size={14} />
</Link>
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : undefined, flexWrap: isMobile ? 'wrap' : 'nowrap', gap: 8 }}>
<span style={{ width: 20, height: 1, background: 'rgba(239,191,4,0.3)', display: 'inline-block' }} />
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 11, color: 'rgba(16,24,40,0.4)' }}>{fallbackText(cta.secondaryPrefix, preistraegerIndexContent.cta.secondaryPrefix)}</span>
<Link
to={fallbackText(cta.secondaryCta?.url, preistraegerIndexContent.cta.secondaryCta.url)}
style={{
fontFamily: '"IBM Plex Sans", sans-serif',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'rgba(239,191,4,0.7)',
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderBottom: '1px solid rgba(239,191,4,0.3)',
paddingBottom: 1,
transition: 'color 0.15s, borderColor 0.15s',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.color = '#EFBF04';
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
}}
>
{fallbackText(cta.secondaryCta?.label, preistraegerIndexContent.cta.secondaryCta.label)} <ChevronRight size={10} />
</Link>
</div>
</div>
</div>
);
}
// ── WinnerCard ───────────────────────────────────────────────────────────────
function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string }) {
const [hovered, setHovered] = useState(false);
const isMobile = useIsMobile();
return (
<Link
to={`/preistraeger/${winner.slug}`}
style={{ textDecoration: 'none', display: 'block', position: 'relative', height: isMobile ? 260 : 340, overflow: 'hidden', boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.07)', scrollSnapAlign: isMobile ? 'start' : undefined }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<Image unoptimized
src={winner.img}
alt={winner.name}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', transition: 'transform 0.5s ease', transform: hovered ? 'scale(1.06)' : 'scale(1)' }}
/>
{/* base gradient */}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.15) 55%, transparent 100%)' }} />
{/* hover overlay */}
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.22)', opacity: hovered ? 1 : 0, transition: 'opacity 0.25s' }} />
{/* Text */}
<div style={{ position: 'absolute', bottom: 0, left: 0, padding: '18px 22px', width: '100%', boxSizing: 'border-box' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<Trophy size={12} style={{ color: GOLD, flexShrink: 0 }} fill={GOLD} />
<span style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, color: GOLD, letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{winner.year} · {winner.type}
</span>
</div>
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 700, color: '#fff', margin: 0, lineHeight: 1.3, textShadow: '0 1px 4px rgba(0,0,0,0.5)' }}>
{winner.name}
</h3>
<p style={{ fontFamily: FF, fontSize: 16, color: 'rgba(255,255,255,0.82)', margin: '4px 0 0', lineHeight: 1.3 }}>
{winner.category}
</p>
{/* Pill button */}
<div style={{
marginTop: 12,
display: 'inline-flex', alignItems: 'center', gap: 7,
background: NAVY, color: '#fff',
padding: '9px 18px', borderRadius: 999,
fontSize: 16, fontWeight: 700, fontFamily: FF,
textTransform: 'uppercase', letterSpacing: '0.06em',
opacity: hovered ? 1 : 0,
transform: hovered ? 'translateY(0)' : 'translateY(6px)',
transition: 'opacity 0.25s, transform 0.25s',
}}>
<Trophy size={14} /> {hoverLabel}
</div>
</div>
</Link>
);
}
// ── SelectControl ────────────────────────────────────────────────────────────
function SelectControl({ value, onChange, placeholder, options, width }: {
value: string; onChange: (v: string) => void;
placeholder: string; options: { label: string; value: string }[];
width?: number;
}) {
const active = !!value;
return (
<div style={{ position: 'relative', width: width ?? '100%', flexShrink: width ? 0 : 1 }}>
<select
value={value}
onChange={e => onChange(e.target.value)}
style={{
width: '100%', height: 44, padding: '0 36px 0 14px',
fontFamily: FF, fontSize: 16,
border: `1px solid ${active ? NAVY : BORDER}`,
color: active ? NAVY : GRAY,
background: '#fff', borderRadius: 0,
appearance: 'none', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
onBlur={e => (e.currentTarget.style.borderColor = active ? NAVY : BORDER)}
>
<option value="">{placeholder}</option>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown size={16} style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: '#101828' }} />
</div>
);
}
// ── SearchControl ────────────────────────────────────────────────────────────
function SearchControl({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<div style={{ position: 'relative', flex: 1, minWidth: 200 }}>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
style={{
width: '100%', height: 44, padding: '0 40px 0 14px',
fontFamily: FF, fontSize: 16,
border: `1px solid ${BORDER}`, borderRadius: 0,
background: '#fff', outline: 'none', boxSizing: 'border-box',
transition: 'border-color 0.15s', color: '#101828',
}}
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
onBlur={e => (e.currentTarget.style.borderColor = BORDER)}
/>
<Search size={16} style={{ position: 'absolute', right: 14, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: GRAY }} />
</div>
);
}