Files
bmp-website-2026/src/spa/pages/Preistraeger.tsx
2026-07-31 14:25:11 +02:00

533 lines
24 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, ArrowRight, Medal } from 'lucide-react';
import 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';
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder';
const FF = '"IBM Plex Sans", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const BORDER = '#D0D5DD';
const GRAY = '#666666';
const BG_ALT = '#E4E2E3';
const ARCHIVE_START_YEAR = 2007;
const ARCHIVE_END_YEAR = 2023;
const ARCHIVE_PERIOD = 'archive-2007-2023' as const;
const ARCHIVE_RANGE_LABEL = `${ARCHIVE_START_YEAR}–${ARCHIVE_END_YEAR}`;
const ARCHIVE_TAB_LABEL = 'Bis 2023';
const GOLDENE_BAVARIA_GROUP = 'goldene-bavaria';
const PREISTRAEGER_GROUP = 'preistraeger';
type WinnerPeriod = number | typeof ARCHIVE_PERIOD;
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 optionalNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const positiveNumber = (value: unknown, fallback: number) => {
const parsed = optionalNumber(value);
return parsed && parsed > 0 ? Math.floor(parsed) : fallback;
};
const normalizeAwardGroup = (value: unknown): Winner['awardGroup'] =>
value === GOLDENE_BAVARIA_GROUP || value === PREISTRAEGER_GROUP ? value : undefined;
const isGoldeneBavariaWinner = (winner: Winner) =>
winner.awardGroup === GOLDENE_BAVARIA_GROUP || winner.type.toLowerCase().includes('goldene bavaria');
const sortWinnersForDisplay = (winners: Winner[]) =>
winners
.map((winner, index) => ({ winner, index }))
.sort((a, b) => {
const rankDiff = (a.winner.winnerRank ?? Number.POSITIVE_INFINITY) - (b.winner.winnerRank ?? Number.POSITIVE_INFINITY);
if (rankDiff !== 0) return rankDiff;
const sortDiff = (a.winner.sortOrder ?? Number.POSITIVE_INFINITY) - (b.winner.sortOrder ?? Number.POSITIVE_INFINITY);
if (sortDiff !== 0) return sortDiff;
return a.index - b.index;
})
.map(({ winner }) => winner);
const isArchiveWinner = (winner: Winner) =>
winner.year === undefined || (winner.year >= ARCHIVE_START_YEAR && winner.year <= ARCHIVE_END_YEAR);
const sortArchiveWinners = (winners: Winner[]) =>
winners
.map((winner, index) => ({ winner, index }))
.sort((a, b) => {
const sortDiff = (a.winner.sortOrder ?? Number.POSITIVE_INFINITY) - (b.winner.sortOrder ?? Number.POSITIVE_INFINITY);
return sortDiff !== 0 ? sortDiff : a.index - b.index;
})
.map(({ winner }) => winner);
const splitWinnersForYear = (winners: Winner[], totalWinners: number, goldeneBavariaCount: number) => {
const visibleWinners = sortWinnersForDisplay(winners.filter((winner) => winner.showOnIndex !== false));
const maxGoldeneBavaria = Math.min(goldeneBavariaCount, totalWinners);
const goldeneBavariaWinners = visibleWinners
.filter((winner) => isGoldeneBavariaWinner(winner) || (winner.winnerRank || Number.POSITIVE_INFINITY) <= maxGoldeneBavaria)
.slice(0, maxGoldeneBavaria);
const goldIds = new Set(goldeneBavariaWinners.map((winner) => winner.id));
for (const winner of visibleWinners) {
if (goldeneBavariaWinners.length >= maxGoldeneBavaria) break;
if (goldIds.has(winner.id) || winner.awardGroup === PREISTRAEGER_GROUP) continue;
goldeneBavariaWinners.push(winner);
goldIds.add(winner.id);
}
const remainingWinners = visibleWinners
.filter((winner) => !goldIds.has(winner.id))
.slice(0, Math.max(totalWinners - goldeneBavariaWinners.length, 0));
return {
goldeneBavariaWinners,
remainingWinners,
displayedWinners: [...goldeneBavariaWinners, ...remainingWinners],
};
};
const normalizeWinner = (
doc: CmsRouteDoc,
placeholderImage: 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: optionalNumber(doc.year),
type: String(
doc.awardType ||
(normalizeAwardGroup(doc.awardGroup) === GOLDENE_BAVARIA_GROUP ? 'Goldene Bavaria' : fallbackWinner.awardType) ||
preistraegerIndexContent.card.fallbackWinner.awardType,
),
awardGroup: normalizeAwardGroup(doc.awardGroup),
winnerRank: optionalNumber(doc.winnerRank),
sortOrder: optionalNumber(doc.sortOrder),
showOnIndex: doc.showOnIndex !== false,
img: docImageUrl(doc, placeholderImage),
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 placeholderImage = usePreistraegerPlaceholderImage();
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
const cmsPreistraeger = useCmsCollection('preistraeger');
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) };
const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) };
const rules = { ...preistraegerIndexContent.rules, ...(cms.rules || {}) };
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
const goldeneBavaria = { ...preistraegerIndexContent.goldeneBavaria, ...(cms.goldeneBavaria || {}) };
const winnersSection = { ...preistraegerIndexContent.winners, ...(cms.winners || {}) };
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
const cta = { ...preistraegerIndexContent.cta, ...(cms.cta || {}) };
const totalWinners = positiveNumber(rules.totalWinners, preistraegerIndexContent.rules.totalWinners);
const goldeneBavariaCount = Math.min(
positiveNumber(rules.goldeneBavariaCount, preistraegerIndexContent.rules.goldeneBavariaCount),
totalWinners,
);
const winners = useMemo(() => {
const fallbackWinner = card.fallbackWinner || preistraegerIndexContent.card.fallbackWinner;
return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
}, [card, cmsPreistraeger, placeholderImage]);
const periods = useMemo<WinnerPeriod[]>(() => {
const annualYears = [...new Set(
winners
.map((winner) => winner.year)
.filter((year): year is number => typeof year === 'number' && year > ARCHIVE_END_YEAR),
)].sort((a, b) => b - a);
return winners.some(isArchiveWinner) ? [...annualYears, ARCHIVE_PERIOD] : annualYears;
}, [winners]);
const [activePeriod, setActivePeriod] = useState<WinnerPeriod | undefined>(undefined);
const selectedPeriod = activePeriod ?? periods[0] ?? ARCHIVE_PERIOD;
const isArchivePeriod = selectedPeriod === ARCHIVE_PERIOD;
const selectedPeriodLabel = isArchivePeriod ? ARCHIVE_RANGE_LABEL : String(selectedPeriod);
const filtered = useMemo(
() => winners.filter((winner) => isArchivePeriod ? isArchiveWinner(winner) : winner.year === selectedPeriod),
[isArchivePeriod, selectedPeriod, winners],
);
const winnerGroups = useMemo(
() => {
if (isArchivePeriod) {
const archiveWinners = sortArchiveWinners(filtered.filter((winner) => winner.showOnIndex !== false));
return {
goldeneBavariaWinners: [],
remainingWinners: archiveWinners,
displayedWinners: archiveWinners,
};
}
return splitWinnersForYear(filtered, totalWinners, goldeneBavariaCount);
},
[filtered, goldeneBavariaCount, isArchivePeriod, totalWinners],
);
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>
{/* Page title */}
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '18px 24px' : '18px 80px' }}>
<h1 className="type-card-title text-role-brand-light" style={{ margin: 0 }}>
{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}
</h1>
</div>
{/* Year Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 20px' : '48px 80px 24px' }}>
{/* #93: Headline entfernt – Seitenname steht bereits oben im Breadcrumb. */}
{/* Year Tabs */}
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
{periods.map(period => (
<button
key={period}
onClick={() => setActivePeriod(period)}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700,
padding: isMobile ? '12px 20px' : '12px 32px',
flexShrink: 0,
background: 'none', border: 'none', cursor: 'pointer',
color: selectedPeriod === period ? NAVY : GRAY,
borderBottom: selectedPeriod === period ? `2px solid ${GOLD}` : '2px solid transparent',
marginBottom: -2,
letterSpacing: '0.04em',
transition: 'color 0.15s, border-color 0.15s',
}}
>
{period === ARCHIVE_PERIOD ? ARCHIVE_TAB_LABEL : period}
</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' }}>{selectedPeriodLabel}</strong> – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
</span>
</div>
{winnerGroups.displayedWinners.length === 0 ? (
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
</div>
) : isArchivePeriod ? (
<SimpleWinnerGrid winners={winnerGroups.displayedWinners} />
) : (
<>
{winnerGroups.goldeneBavariaWinners.length > 0 ? (
<GoldeneBavariaSection
copy={goldeneBavaria}
hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)}
winners={winnerGroups.goldeneBavariaWinners}
/>
) : null}
{winnerGroups.remainingWinners.length > 0 ? (
<section style={{ background: '#fff' }}>
<SectionIntro copy={winnersSection} isMobile={isMobile} />
<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',
}}>
{winnerGroups.remainingWinners.map(w => (
<WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
))}
</div>
</section>
) : null}
</>
)}
{/* Bottom CTA */}
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', borderTop: `1px solid ${BORDER}`, textAlign: 'center' }}>
<p className="type-body text-role-secondary" style={{ 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>
</div>
);
}
function SectionIntro({ copy, isMobile }: { copy: typeof preistraegerIndexContent.winners; isMobile: boolean }) {
return (
<div style={{ padding: isMobile ? '40px 24px 22px' : '56px 80px 28px', maxWidth: 900 }}>
<span className="type-eyebrow text-role-brand-light" style={{ textTransform: 'uppercase' }}>
{fallbackText(copy.eyebrow, preistraegerIndexContent.winners.eyebrow)}
</span>
<h2 className="type-section-title text-role-heading" style={{ margin: '10px 0 12px', textTransform: 'uppercase' }}>
{fallbackText(copy.heading, preistraegerIndexContent.winners.heading)}
</h2>
<p className="type-body text-role-secondary" style={{ margin: 0, maxWidth: 680 }}>
{fallbackText(copy.description, preistraegerIndexContent.winners.description)}
</p>
</div>
);
}
function GoldeneBavariaSection({
copy,
hoverLabel,
winners,
}: {
copy: typeof preistraegerIndexContent.goldeneBavaria;
hoverLabel: string;
winners: Winner[];
}) {
const isMobile = useIsMobile();
return (
<section style={{ background: NAVY, color: '#fff', borderTop: `1px solid ${BORDER}` }}>
<div style={{ padding: isMobile ? '44px 24px 26px' : '64px 80px 34px', maxWidth: 980 }}>
<span className="type-eyebrow text-role-inverse" style={{ color: GOLD, textTransform: 'uppercase' }}>
{fallbackText(copy.eyebrow, preistraegerIndexContent.goldeneBavaria.eyebrow)}
</span>
<h2 className="type-section-title text-role-inverse" style={{ margin: '10px 0 14px', textTransform: 'uppercase' }}>
{fallbackText(copy.heading, preistraegerIndexContent.goldeneBavaria.heading)}
</h2>
<p className="type-body text-role-inverse-secondary" style={{ margin: 0, maxWidth: 760 }}>
{fallbackText(copy.description, preistraegerIndexContent.goldeneBavaria.description)}
</p>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
gap: isMobile ? 14 : 16,
padding: isMobile ? '0 24px 48px' : '0 80px 72px',
}}>
{winners.map((winner, index) => (
<GoldeneBavariaCard key={winner.id} winner={winner} rank={winner.winnerRank || index + 1} hoverLabel={hoverLabel} />
))}
</div>
</section>
);
}
function GoldeneBavariaCard({ winner, rank, hoverLabel }: { winner: Winner; rank: number; hoverLabel: string }) {
const [hovered, setHovered] = useState(false);
const isMobile = useIsMobile();
return (
<Link
to={`/preistraeger/${winner.slug}`}
style={{ background: '#fff', color: '#101828', display: 'block', minHeight: isMobile ? 0 : 430, overflow: 'hidden', position: 'relative', textDecoration: 'none' }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ height: isMobile ? 250 : 300, overflow: 'hidden', position: 'relative' }}>
<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.05)' : 'scale(1)' }}
/>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.62), transparent 62%)' }} />
<div style={{ position: 'absolute', left: 18, bottom: 18, display: 'inline-flex', alignItems: 'center', gap: 8, background: GOLD, color: NAVY, padding: '8px 12px', fontFamily: FF, fontSize: 13, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0 }}>
<Medal size={16} /> #{rank} Goldene Bavaria
</div>
</div>
<div style={{ padding: isMobile ? '20px' : '24px' }}>
<span className="type-eyebrow text-role-brand-light" style={{ textTransform: 'uppercase' }}>
{winner.year} · {winner.category}
</span>
<h3 className="type-card-title text-role-heading" style={{ margin: '10px 0 10px', overflowWrap: 'anywhere' }}>
{winner.name}
</h3>
<p className="type-small text-role-secondary" style={{ margin: 0, overflowWrap: 'anywhere' }}>
{winner.shortDesc}
</p>
<span style={{ marginTop: 18, display: 'inline-flex', alignItems: 'center', gap: 7, color: NAVY, fontFamily: FF, fontSize: 14, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
<Trophy size={14} /> {hoverLabel}
</span>
</div>
</Link>
);
}
// ── 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 className="type-card-title text-role-inverse" style={{ margin: 0, textShadow: '0 1px 4px rgba(0,0,0,0.5)' }}>
{winner.name}
</h3>
<p className="type-small text-role-inverse-secondary" style={{ margin: '4px 0 0' }}>
{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>
);
}
function SimpleWinnerGrid({ winners }: { winners: Winner[] }) {
const isMobile = useIsMobile();
return (
<div style={{
background: '#F7F7F8',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
gap: isMobile ? 12 : 16,
padding: isMobile ? '24px' : '40px 80px 56px',
}}>
{winners.map(winner => (
<SimpleWinnerCard key={winner.id} winner={winner} />
))}
</div>
);
}
function SimpleWinnerCard({ winner }: { winner: Winner }) {
const isMobile = useIsMobile();
const hasDescription = winner.shortDesc.trim().length > 0;
const winnerMeta = [winner.year, winner.type].filter(Boolean).join(' · ');
return (
<article
style={{
background: '#fff',
border: `1px solid ${BORDER}`,
color: '#101828',
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 0 : 220,
padding: isMobile ? '20px' : '24px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 18 }}>
{winnerMeta ? (
<span className="type-eyebrow text-role-brand-light" style={{ textTransform: 'uppercase' }}>
{winnerMeta}
</span>
) : null}
{winner.category ? (
<span style={{
border: `1px solid ${BORDER}`,
color: GRAY,
fontFamily: FF,
fontSize: 12,
fontWeight: 600,
lineHeight: 1.2,
padding: '5px 8px',
}}>
{winner.category}
</span>
) : null}
</div>
<h3 className="type-card-title text-role-heading" style={{
margin: 0,
overflowWrap: 'anywhere',
}}>
{winner.name}
</h3>
{hasDescription ? (
<p className="type-small text-role-secondary" style={{
margin: '16px 0 0',
overflowWrap: 'anywhere',
}}>
{winner.shortDesc}
</p>
) : null}
</article>
);
}