feat: add JuryMitglieder collection and migration for jury members

- Created a new collection `JuryMitglieder` with fields for name, image, quote, role, organization, bio, website, LinkedIn, sort order, active status, and published date.
- Implemented access control for creating, reading, updating, and deleting jury members.
- Added migration script to create necessary database tables and columns for jury members, including versioning support.
- Updated existing `preistraeger` table to include new columns for award group, winner rank, sort order, and visibility settings.
- Introduced a temporary global switch to control the visibility of testimonials in the SPA.
This commit is contained in:
syntaxbullet
2026-07-07 14:57:15 +02:00
parent 4167730fc2
commit d2fdf8e3c5
25 changed files with 1139 additions and 595 deletions

View File

@@ -401,6 +401,48 @@ const Home: React.FC = () => {
{/* Status Phase Slider */}
<StatusSlider data={applicationPhase} />
{/* Winners Grid – neue Preisträger-Übersicht */}
<section style={{ background: '#111D55' }}>
<div style={{ padding: isMobile ? '48px 24px 32px' : '72px 80px 48px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'flex-end', justifyContent: 'space-between', gap: isMobile ? 16 : 0 }}>
<div>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: '#EFBF04', display: 'block', marginBottom: 12 }}>{fallbackText(winnersSection.eyebrow, homeContent.winners.eyebrow)}</span>
<h2 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(2rem, 4vw, 3rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', margin: 0, lineHeight: 1 }}>
<Lines text={fallbackText(winnersSection.heading, homeContent.winners.heading)} />
</h2>
</div>
<Link
to={fallbackText(winnersSection.cta?.url, homeContent.winners.cta.url)}
style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: '#EFBF04', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 4, borderBottom: '1px solid #EFBF04', whiteSpace: 'nowrap' }}
>
{fallbackText(winnersSection.cta?.label, homeContent.winners.cta.label)} <ChevronRight size={14} />
</Link>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)',
gridAutoFlow: isMobile ? 'column' : undefined,
gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined,
gridAutoColumns: isMobile ? '66vw' : undefined,
overflowX: isMobile ? 'auto' : undefined,
scrollSnapType: isMobile ? 'x mandatory' : undefined,
gap: isMobile ? 8 : 0,
padding: isMobile ? '0 16px 8px' : 0,
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
}}>
{homeWinners.map((w) => (
<HomeWinnerCard
key={(w as WinnerCardData).id || (w as WinnerCardData).slug}
winner={w as WinnerCardData}
fallbackTitle={fallbackText(winnersSection.cardFallbackTitle, homeContent.winners.cardFallbackTitle)}
fallbackImage={winnerFallbackImage}
hoverLabel={fallbackText(winnersSection.hoverLabel, homeContent.winners.hoverLabel)}
/>
))}
</div>
</section>
{/* Schnell-Check Section */}
<section style={{ overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
@@ -550,48 +592,6 @@ const Home: React.FC = () => {
</div>
</section>
{/* Winners Grid – neue Preisträger-Übersicht */}
<section style={{ background: '#111D55' }}>
<div style={{ padding: isMobile ? '48px 24px 32px' : '72px 80px 48px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'flex-end', justifyContent: 'space-between', gap: isMobile ? 16 : 0 }}>
<div>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: '#EFBF04', display: 'block', marginBottom: 12 }}>{fallbackText(winnersSection.eyebrow, homeContent.winners.eyebrow)}</span>
<h2 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(2rem, 4vw, 3rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', margin: 0, lineHeight: 1 }}>
<Lines text={fallbackText(winnersSection.heading, homeContent.winners.heading)} />
</h2>
</div>
<Link
to={fallbackText(winnersSection.cta?.url, homeContent.winners.cta.url)}
style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: '#EFBF04', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 4, borderBottom: '1px solid #EFBF04', whiteSpace: 'nowrap' }}
>
{fallbackText(winnersSection.cta?.label, homeContent.winners.cta.label)} <ChevronRight size={14} />
</Link>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)',
gridAutoFlow: isMobile ? 'column' : undefined,
gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined,
gridAutoColumns: isMobile ? '66vw' : undefined,
overflowX: isMobile ? 'auto' : undefined,
scrollSnapType: isMobile ? 'x mandatory' : undefined,
gap: isMobile ? 8 : 0,
padding: isMobile ? '0 16px 8px' : 0,
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
}}>
{homeWinners.map((w) => (
<HomeWinnerCard
key={(w as WinnerCardData).id || (w as WinnerCardData).slug}
winner={w as WinnerCardData}
fallbackTitle={fallbackText(winnersSection.cardFallbackTitle, homeContent.winners.cardFallbackTitle)}
fallbackImage={winnerFallbackImage}
hoverLabel={fallbackText(winnersSection.hoverLabel, homeContent.winners.hoverLabel)}
/>
))}
</div>
</section>
{/* Testimonials Section */}
<TestimonialsSection data={home.testimonials} />

View File

@@ -7,6 +7,7 @@ import MembershipWizard from '@/spa/components/membership/MembershipWizard';
import { useCmsRoute } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { mitgliedWerdenContent } from '@/spa/mitgliedWerdenContent';
import { SHOW_TESTIMONIALS } from '@/spa/testimonialsVisibility';
import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55';
@@ -1242,52 +1243,53 @@ const MitgliedWerden: React.FC = () => {
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section>
{/* ── SECTION 7 – Testimonials ────────────────────────────────────────── */}
<section style={{ background: '#fff', position: 'relative', overflow: 'hidden' }}>
<div style={{ padding: isMobile ? '48px 24px' : '80px' }}>
<span
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.32em',
color: BLUE,
display: 'block',
marginBottom: 16,
}}
>
{fallbackText(testimonials.eyebrow, mitgliedWerdenContent.testimonials.eyebrow)}
</span>
<h2
style={{
fontFamily: FF,
fontSize: 'clamp(2rem, 4vw, 3rem)',
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
lineHeight: 1.03,
margin: '0 0 20px',
}}
>
{fallbackText(testimonials.heading, mitgliedWerdenContent.testimonials.heading)}
</h2>
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 48 }} />
{SHOW_TESTIMONIALS && (
<section style={{ background: '#fff', position: 'relative', overflow: 'hidden' }}>
<div style={{ padding: isMobile ? '48px 24px' : '80px' }}>
<span
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.32em',
color: BLUE,
display: 'block',
marginBottom: 16,
}}
>
{fallbackText(testimonials.eyebrow, mitgliedWerdenContent.testimonials.eyebrow)}
</span>
<h2
style={{
fontFamily: FF,
fontSize: 'clamp(2rem, 4vw, 3rem)',
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
lineHeight: 1.03,
margin: '0 0 20px',
}}
>
{fallbackText(testimonials.heading, mitgliedWerdenContent.testimonials.heading)}
</h2>
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 48 }} />
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)',
gap: 24,
}}
>
{testimonialItems.map((item, index) => (
<QuoteCard key={`${item.name}-${index}`} quote={item.quote} initials={item.initials} name={item.name} role={item.role} />
))}
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)',
gap: 24,
}}
>
{testimonialItems.map((item, index) => (
<QuoteCard key={`${item.name}-${index}`} quote={item.quote} initials={item.initials} name={item.name} role={item.role} />
))}
</div>
</div>
</div>
</section>
</section>
)}
{/* ── FAQ ─────────────────────────────────────────────────────────────── */}
<FAQSection isMobile={isMobile} content={faq} />

View File

@@ -4,7 +4,7 @@ import { ChevronRight, X, ArrowRight, Globe, Linkedin, Building2, MapPin, Extern
import SponsoringForm from '@/spa/components/forms/SponsoringForm';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { homeContent } from '@/spa/homeContent';
import { netzwerkContent } from '@/spa/netzwerkContent';
@@ -18,14 +18,6 @@ const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
const HIDE_MEMBERSHIP_NETWORK_CTA = true;
const ROLE_COLOR: Record<string, string> = {
'Hauptsponsor': '#EFBF04',
'Sponsor': '#2563EB',
'Finanzpartner': '#16A34A',
'Partner': '#111D55',
'Medienpartner': '#DC2626',
};
type NetzwerkCms = Partial<typeof netzwerkContent> & {
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown }
@@ -44,7 +36,14 @@ type HomeVideoCms = {
}
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
type JuryMember = (typeof netzwerkContent.jury.members)[number] & { imageUpload?: unknown }
type JuryMember = (typeof netzwerkContent.jury.members)[number] & {
active?: boolean | null
imageAlt?: string | null
imageUpload?: unknown
organization?: string | null
quote?: string | null
sortOrder?: number | null
}
type Partner = (typeof netzwerkContent.partners.items)[number] &
PartnerLogoData & {
active?: boolean | null
@@ -65,11 +64,40 @@ type PartnerModalCopy = typeof netzwerkContent.partners.modal
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
const managedImage = (upload: unknown, fallback: unknown) => mediaUrl(upload, fallbackText(fallback, ''))
const deprecatedPartnerDescription = 'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.'
const visiblePartnerDescription = (value: unknown) => {
const text = fallbackText(value, netzwerkContent.partners.description)
return text.trim() === deprecatedPartnerDescription ? netzwerkContent.partners.description : text
}
const juryImageFallback = '/images/preistraeger-placeholder.svg'
const sortedPartners = <T extends PartnerLogoData>(partners: T[]) =>
partners
.map((partner, index) => ({ partner, index }))
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner)
const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
const organization = fallbackText(member.organization, '')
const rawRole = fallbackText(member.role, '')
const role = rawRole && organization && rawRole !== organization ? `${rawRole} / ${organization}` : fallbackText(rawRole, organization)
return {
name: fallbackText(member.name, ''),
role,
bio: fallbackText(member.bio, fallbackText(member.quote, '')),
image: mediaUrl(member.image, juryImageFallback),
imageAlt: fallbackText(member.imageAlt, fallbackText(member.name, 'Jury-Mitglied')),
imageUpload: member.image,
organization,
quote: fallbackText(member.quote, ''),
sortOrder: typeof member.sortOrder === 'number' ? member.sortOrder : undefined,
active: member.active !== false,
}
}
const sortedJuryMembers = (members: JuryMember[]) =>
members
.map((member, index) => ({ member, index }))
.sort((a, b) => Number(a.member.sortOrder ?? a.index) - Number(b.member.sortOrder ?? b.index))
.map(({ member }) => member)
const isMembershipCta = (cta?: { label?: string | null; url?: string | null } | null) => {
const normalizedLabel = cta?.label?.trim().toLowerCase();
@@ -138,24 +166,30 @@ function PersonCard({ imgSrc, imgAlt, name, titleLine1, titleLine2, institution,
// ── JuryCard ──────────────────────────────────────────────────────────────────
const JuryCard: React.FC<{ member: JuryMember; idx: number }> = ({ member, idx }) => {
const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
const [hovered, setHovered] = useState(false);
const isMobileCard = useIsMobile();
const quote = fallbackText(member.quote, '');
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ borderLeft: !isMobileCard && idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none', display: 'flex', flexDirection: 'column', cursor: 'default', background: hovered ? 'rgba(255,255,255,0.03)' : 'transparent', transition: 'background 0.2s', ...(isMobileCard ? { minWidth: '74vw', maxWidth: '74vw', flexShrink: 0, scrollSnapAlign: 'start', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 16, overflow: 'hidden' } : {}) }}
style={{ display: 'flex', flexDirection: 'column', height: '100%', cursor: 'default', background: hovered ? 'rgba(255,255,255,0.04)' : NAVY, transition: 'background 0.2s', ...(isMobileCard ? { minWidth: '58vw', maxWidth: '58vw', flexShrink: 0, scrollSnapAlign: 'start', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 16, overflow: 'hidden' } : { minWidth: 0, border: '1px solid rgba(255,255,255,0.07)' }) }}
>
<div style={{ position: 'relative', overflow: 'hidden', aspectRatio: '3/4' }}>
<Image unoptimized src={managedImage(member.imageUpload, member.image)} alt={member.name} style={{ width: '100%', height: '100%', objectFit: 'cover', filter: hovered ? 'none' : 'grayscale(100%)', transition: 'filter 0.5s', display: 'block' }} />
<div style={{ position: 'relative', overflow: 'hidden', height: isMobileCard ? 142 : 170, flexShrink: 0 }}>
<Image unoptimized src={managedImage(member.imageUpload, member.image)} alt={fallbackText(member.imageAlt, member.name)} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center top', filter: hovered ? 'none' : 'grayscale(100%)', transition: 'filter 0.5s', display: 'block' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '40%', background: 'linear-gradient(to top, rgba(3,9,58,0.65), transparent)' }} />
</div>
<div style={{ padding: isMobileCard ? '28px 28px 32px' : '32px 32px 36px' }}>
<div style={{ fontFamily: FF, fontSize: 20, fontWeight: 700, color: '#fff', marginBottom: 5 }}>{member.name}</div>
<div style={{ fontFamily: FF, fontSize: 12, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.16em', fontWeight: 700, marginBottom: 14 }}>{member.role}</div>
<div style={{ width: hovered ? '100%' : 24, height: 1, background: GOLD, opacity: 0.4, transition: 'width 0.4s ease', marginBottom: 14 }} />
<div style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.55)', lineHeight: 1.7 }}>{member.bio}</div>
<div style={{ padding: isMobileCard ? '18px 18px 22px' : '20px 20px 24px', flex: 1, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 16 : 17, fontWeight: 700, color: '#fff', lineHeight: 1.15, marginBottom: 5, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.name}</div>
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 9 : 10, color: GOLD, textTransform: 'uppercase', letterSpacing: isMobileCard ? '0.1em' : '0.14em', fontWeight: 700, lineHeight: 1.35, marginBottom: 10, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.role}</div>
<div style={{ width: hovered ? '100%' : 20, height: 1, background: GOLD, opacity: 0.4, transition: 'width 0.4s ease', marginBottom: 10 }} />
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 14 : 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.55, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.bio}</div>
{quote ? (
<div style={{ borderLeft: `2px solid ${GOLD}`, color: 'rgba(255,255,255,0.72)', fontFamily: FB, fontSize: isMobileCard ? 13 : 14, fontStyle: 'italic', lineHeight: 1.5, marginTop: 14, paddingLeft: 10, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
{quote}
</div>
) : null}
</div>
</div>
);
@@ -166,6 +200,7 @@ const JuryCard: React.FC<{ member: JuryMember; idx: number }> = ({ member, idx }
const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member, badge }) => {
const [hovered, setHovered] = useState(false);
const isMobileCard = useIsMobile();
const quote = fallbackText(member.quote, '');
return (
<div
onMouseEnter={() => setHovered(true)}
@@ -181,7 +216,7 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
}}
>
<div style={{ position: 'relative', overflow: 'hidden', aspectRatio: isMobileCard ? '16/10' : '3/4', minHeight: isMobileCard ? 240 : 'auto' }}>
<Image unoptimized src={managedImage(member.imageUpload, member.image)} alt={member.name} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center top', filter: hovered ? 'none' : 'grayscale(100%)', transition: 'filter 0.5s', display: 'block' }} />
<Image unoptimized src={managedImage(member.imageUpload, member.image)} alt={fallbackText(member.imageAlt, member.name)} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center top', filter: hovered ? 'none' : 'grayscale(100%)', transition: 'filter 0.5s', display: 'block' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '45%', background: 'linear-gradient(to top, rgba(3,9,58,0.7), transparent)' }} />
</div>
<div style={{ padding: isMobileCard ? '32px 28px 36px' : '56px 56px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
@@ -192,6 +227,11 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
<div style={{ fontFamily: FF, fontSize: 13, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.18em', fontWeight: 700, marginBottom: 24 }}>{member.role}</div>
<div style={{ width: 48, height: 2, background: GOLD, opacity: 0.5, marginBottom: 24 }} />
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 18 : 21, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, maxWidth: 620 }}>{member.bio}</div>
{quote ? (
<div style={{ borderLeft: `3px solid ${GOLD}`, color: 'rgba(255,255,255,0.78)', fontFamily: FB, fontSize: isMobileCard ? 17 : 19, fontStyle: 'italic', lineHeight: 1.65, marginTop: 24, maxWidth: 620, paddingLeft: 18 }}>
{quote}
</div>
) : null}
</div>
</div>
);
@@ -201,35 +241,35 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
// ── SponsorCell (tiered) ──────────────────────────────────────────────────────
const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx: number; cols: number; detailLabel: string; premiumLabel: string; onClick: () => void }> = ({ partner, variant, idx, cols, detailLabel, premiumLabel, onClick }) => {
const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx: number; cols: number; detailLabel: string; onClick: () => void }> = ({ partner, variant, idx, cols, detailLabel, onClick }) => {
const [hovered, setHovered] = useState(false);
const isMobileCell = useIsMobile();
const lg = variant === 'lg';
const sm = variant === 'sm';
const lastCol = (idx % cols) === cols - 1;
const logoHeight = isMobileCell ? (lg ? 76 : sm ? 54 : 62) : lg ? 92 : sm ? 62 : 76;
const logoScale = lg ? 1.55 : sm ? 1.22 : 1.38;
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
padding: isMobileCell ? '24px 20px' : lg ? '40px 36px' : sm ? '28px 28px' : '32px 32px',
padding: isMobileCell ? '26px 20px' : lg ? '44px 38px 40px' : sm ? '30px 28px' : '36px 32px',
borderRight: !lastCol && !isMobileCell ? '1px solid rgba(3,9,58,0.08)' : 'none',
borderBottom: '1px solid rgba(3,9,58,0.08)',
display: 'flex', flexDirection: 'column', gap: 14,
display: 'flex', flexDirection: 'column', gap: lg ? 18 : 16,
cursor: 'pointer',
background: hovered ? '#E8E5E4' : 'transparent',
transition: 'background 0.15s',
position: 'relative',
}}
>
{lg && (
<span style={{ position: 'absolute', top: 18, right: 18, fontFamily: FF, fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', color: '#101828', background: GOLD, padding: '4px 10px', borderRadius: 100, zIndex: 1 }}>{premiumLabel}</span>
)}
<div style={{ height: lg ? 52 : 44, display: 'flex', alignItems: 'center' }}><PartnerLogoMark partner={partner} /></div>
<div style={{ width: hovered ? 48 : 20, height: 1, background: GOLD, transition: 'width 0.3s ease' }} />
<div style={{ height: logoHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minWidth: 0 }}>
<PartnerLogoMark partner={partner} scale={logoScale} />
</div>
<div style={{ width: hovered ? 56 : 24, height: 1, background: GOLD, transition: 'width 0.3s ease' }} />
<div style={{ fontFamily: FF, fontSize: lg ? 22 : 18, fontWeight: 700, color: '#101828', letterSpacing: '-0.01em', lineHeight: 1.15 }}>{partner.name}</div>
<div style={{ fontFamily: FF, fontSize: sm ? 10 : 11, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.2em', fontWeight: 700 }}>{partner.role}</div>
{!sm && <div style={{ fontFamily: FB, fontSize: lg ? 17 : 16, color: 'rgba(16,24,40,0.45)', lineHeight: 1.6, flexGrow: 1 }}>{partner.desc}</div>}
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}>
{detailLabel} <ArrowRight size={10} />
@@ -241,7 +281,7 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
// ── PartnerModal ─────────────────────────────────────────────────────────────
function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: PartnerModalCopy; onClose: () => void }) {
const accentColor = ROLE_COLOR[partner.role] ?? GOLD;
const accentColor = GOLD;
useEffect(() => {
document.body.style.overflow = 'hidden';
@@ -279,21 +319,6 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
{/* Gradient to white */}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0) 35%, rgba(255,255,255,0.6) 75%, #fff 100%)' }} />
{/* Category badge – top left */}
<div style={{ position: 'absolute', top: 20, left: 24 }}>
<span style={{
background: 'rgba(255,255,255,0.15)',
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.25)',
color: '#fff', padding: '5px 13px',
fontFamily: FF, fontSize: 9, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.22em',
display: 'inline-block',
}}>
{partner.role}
</span>
</div>
{/* Social + Close – top right */}
<div style={{ position: 'absolute', top: 16, right: 20, display: 'flex', gap: 8, alignItems: 'center' }}>
{partner.linkedin && (
@@ -432,9 +457,6 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(16,24,40,0.3)' }}>
{fallbackText(copy.footerTitle, netzwerkContent.partners.modal.footerTitle)}
</span>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: accentColor }}>
{fallbackText(copy.footerRolePrefix, netzwerkContent.partners.modal.footerRolePrefix)} {partner.role}
</span>
</div>
</div>
</div>
@@ -451,6 +473,7 @@ const Netzwerk: React.FC = () => {
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
const cmsPartners = useCmsCollection('partners') as unknown as Partner[];
const cmsJuryMembers = useCmsCollection('jury-mitglieder');
const home = (homePage?.home || {}) as HomeVideoCms;
const homeVideoModal = home.videoModal || {};
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
@@ -462,7 +485,10 @@ const Netzwerk: React.FC = () => {
const partnerModal = { ...netzwerkContent.partners.modal, ...((cms.partners as typeof netzwerkContent.partners | undefined)?.modal || {}) };
const sponsoring = { ...netzwerkContent.sponsoring, ...(cms.sponsoring || {}) };
const patronPeople = fallbackArray<PatronPerson>(patronage.people, netzwerkContent.patronage.people);
const juryMembers = fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members);
const juryCollectionMembers = sortedJuryMembers(cmsJuryMembers.map(normalizeJuryMember).filter((member) => member.active !== false && member.name));
const juryMembers = juryCollectionMembers.length
? juryCollectionMembers
: fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members);
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
const defaultLogoColor = fallbackText(partnerSection.defaultLogoColor, netzwerkContent.partners.defaultLogoColor);
const partnerSource = cmsPartners.length ? cmsPartners : (netzwerkContent.partners.items as Partner[]);
@@ -686,9 +712,9 @@ const Netzwerk: React.FC = () => {
</div>
{/* ── Übrige Jury-Mitglieder ──────────────────────────────────────── */}
<div style={{ display: isMobile ? 'flex' : 'grid', gridTemplateColumns: isMobile ? undefined : 'repeat(3, 1fr)', overflowX: isMobile ? 'auto' : undefined, scrollSnapType: isMobile ? 'x mandatory' : undefined, gap: isMobile ? 14 : 0, padding: isMobile ? '24px 24px 12px' : '0', WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none', borderTop: isMobile ? 'none' : '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ display: isMobile ? 'flex' : 'grid', gridTemplateColumns: isMobile ? undefined : 'repeat(auto-fill, minmax(210px, 1fr))', overflowX: isMobile ? 'auto' : undefined, scrollSnapType: isMobile ? 'x mandatory' : undefined, gap: isMobile ? 12 : 0, padding: isMobile ? '24px 24px 12px' : '0', WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none', borderTop: isMobile ? 'none' : '1px solid rgba(255,255,255,0.07)' }}>
{juryMembers.slice(1).map((member, idx) => (
<JuryCard key={idx} member={member} idx={idx} />
<JuryCard key={idx} member={member} />
))}
</div>
@@ -741,7 +767,7 @@ const Netzwerk: React.FC = () => {
<h2 style={{ fontFamily: FF, fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', fontSize: 'clamp(2rem, 3.5vw, 3rem)', lineHeight: 0.95, margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
</div>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.4)', maxWidth: isMobile ? '100%' : 220, textAlign: isMobile ? 'left' : 'right', lineHeight: 1.6, margin: 0 }}>
{fallbackText(partnerSection.description, netzwerkContent.partners.description)}
{visiblePartnerDescription(partnerSection.description)}
</p>
</div>
@@ -755,7 +781,7 @@ const Netzwerk: React.FC = () => {
return (
<div key={t.tier} style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
{items.map((p, i) => (
<SponsorCell key={p.id} partner={p} variant={variant} idx={i} cols={cols} detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)} premiumLabel={fallbackText(partnerSection.premiumLabel, netzwerkContent.partners.premiumLabel)} onClick={() => setSelectedPartner(p)} />
<SponsorCell key={p.id} partner={p} variant={variant} idx={i} cols={cols} detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)} onClick={() => setSelectedPartner(p)} />
))}
</div>
);

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router';
import { Trophy, ArrowRight } from 'lucide-react';
import { Trophy, ArrowRight, Medal } 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';
@@ -16,6 +16,8 @@ const BORDER = '#D0D5DD';
const GRAY = '#666666';
const BG_ALT = '#E4E2E3';
const SIMPLE_LIST_YEAR = 2023;
const GOLDENE_BAVARIA_GROUP = 'goldene-bavaria';
const PREISTRAEGER_GROUP = 'preistraeger';
type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
hero?: Partial<typeof preistraegerIndexContent.hero> & { image?: unknown }
@@ -23,6 +25,60 @@ type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
}
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const optionalNumber = (value: unknown) => {
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 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,
@@ -34,7 +90,15 @@ const normalizeWinner = (
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),
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 || ''),
@@ -55,9 +119,17 @@ export default function 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;
if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
@@ -68,6 +140,10 @@ export default function Preistraeger() {
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
const winnerGroups = useMemo(
() => splitWinnersForYear(filtered, totalWinners, goldeneBavariaCount),
[filtered, goldeneBavariaCount, totalWinners],
);
const usesSimpleWinnerGrid = selectedYear === SIMPLE_LIST_YEAR;
return (
@@ -120,35 +196,51 @@ export default function Preistraeger() {
<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)}
<strong style={{ color: '#101828' }}>{selectedYear}</strong> – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
</span>
</div>
{/* Grid */}
{filtered.length === 0 ? (
{winnerGroups.displayedWinners.length === 0 ? (
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
</div>
) : usesSimpleWinnerGrid ? (
<SimpleWinnerGrid winners={filtered} />
) : (
<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>
<>
{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} />
{usesSimpleWinnerGrid ? (
<SimpleWinnerGrid winners={winnerGroups.remainingWinners} />
) : (
<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 */}
@@ -172,6 +264,101 @@ export default function Preistraeger() {
);
}
function SectionIntro({ copy, isMobile }: { copy: typeof preistraegerIndexContent.winners; isMobile: boolean }) {
return (
<div style={{ padding: isMobile ? '40px 24px 22px' : '56px 80px 28px', maxWidth: 900 }}>
<span style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
{fallbackText(copy.eyebrow, preistraegerIndexContent.winners.eyebrow)}
</span>
<h2 style={{ color: NAVY, fontFamily: FF, fontSize: isMobile ? 28 : 42, fontWeight: 900, lineHeight: 1.05, margin: '10px 0 12px', textTransform: 'uppercase', letterSpacing: 0 }}>
{fallbackText(copy.heading, preistraegerIndexContent.winners.heading)}
</h2>
<p style={{ color: GRAY, fontFamily: FF, fontSize: 17, lineHeight: 1.6, 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 style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
{fallbackText(copy.eyebrow, preistraegerIndexContent.goldeneBavaria.eyebrow)}
</span>
<h2 style={{ color: '#fff', fontFamily: FF, fontSize: isMobile ? 30 : 48, fontWeight: 900, lineHeight: 1.02, margin: '10px 0 14px', textTransform: 'uppercase', letterSpacing: 0 }}>
{fallbackText(copy.heading, preistraegerIndexContent.goldeneBavaria.heading)}
</h2>
<p style={{ color: 'rgba(255,255,255,0.74)', fontFamily: FF, fontSize: 18, lineHeight: 1.6, 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 style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
{winner.year} · {winner.category}
</span>
<h3 style={{ color: NAVY, fontFamily: FF, fontSize: isMobile ? 23 : 26, fontWeight: 900, lineHeight: 1.12, margin: '10px 0 10px', overflowWrap: 'anywhere' }}>
{winner.name}
</h3>
<p style={{ color: GRAY, fontFamily: FF, fontSize: 15, lineHeight: 1.55, 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 }) {

View File

@@ -54,13 +54,14 @@ export default function PreistraegerDetail() {
...((cmsWinner?.detailPage || {}) as PreistraegerDetailCms),
} as PreistraegerDetailCms;
const fallbackWinner = cmsDetail.fallbackWinner || preistraegerDetailContent.fallbackWinner;
const cmsWinnerIsGoldeneBavaria = cmsWinner?.awardGroup === 'goldene-bavaria';
const winner = cmsWinner ? {
id: String(cmsWinner.id),
slug: String(cmsWinner.slug || slug),
name: String(cmsWinner.title || fallbackWinner.name || preistraegerDetailContent.fallbackWinner.name),
category: String(cmsWinner.category || fallbackWinner.category || preistraegerDetailContent.fallbackWinner.category),
year: Number(cmsWinner.year || new Date().getFullYear()),
type: String(cmsWinner.awardType || fallbackWinner.awardType || preistraegerDetailContent.fallbackWinner.awardType),
type: String(cmsWinner.awardType || (cmsWinnerIsGoldeneBavaria ? 'Goldene Bavaria' : fallbackWinner.awardType) || preistraegerDetailContent.fallbackWinner.awardType),
img: docImageUrl(cmsWinner, placeholderImage),
shortDesc: String(cmsWinner.shortDesc || cmsWinner.description || cmsWinner.meta?.description || ''),
longDesc: String(cmsWinner.longDesc || cmsWinner.description || cmsWinner.meta?.description || fallbackWinner.longDesc || preistraegerDetailContent.fallbackWinner.longDesc),

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { Link } from '@/spa/router';
import { Calendar, MapPin, Download, ArrowRight, FileText } from 'lucide-react';
import React from 'react';
import { Calendar, MapPin } from 'lucide-react';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
@@ -75,20 +74,17 @@ function Lines({ text }: { text: string }) {
const fallbackCardImage = (item: PressEventItem | PressNewsItem) => mediaUrl(item.image, fallbackText(item.img, ''))
function NewsCard({ item, idx, isMobile, readMoreLabel, total }: { item: PressNewsItem; idx: number; isMobile: boolean; readMoreLabel: string; total: number }) {
function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: number; isMobile: boolean; total: number }) {
const [hovered, setHovered] = React.useState(false);
return (
<Link
to={item.slug}
<article
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
borderLeft: isMobile ? 'none' : (idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none'),
borderBottom: isMobile && idx < total - 1 ? '1px solid rgba(255,255,255,0.07)' : 'none',
textDecoration: 'none',
}}
>
{/* image */}
@@ -166,54 +162,8 @@ function NewsCard({ item, idx, isMobile, readMoreLabel, total }: { item: PressNe
>
{item.excerpt}
</p>
<span
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.12em',
color: GOLD,
display: 'inline-flex',
alignItems: 'center',
gap: 5,
}}
>
{readMoreLabel} <ArrowRight size={12} />
</span>
</div>
</Link>
);
}
function DarkInput(props: React.InputHTMLAttributes<HTMLInputElement>) {
const [f, setF] = React.useState(false);
return (
<input
{...props}
onFocus={(e) => {
setF(true);
props.onFocus?.(e);
}}
onBlur={(e) => {
setF(false);
props.onBlur?.(e);
}}
style={{
width: '100%',
height: 44,
padding: '0 14px',
fontFamily: FF,
fontSize: 18,
color: '#fff',
border: `1px solid ${f ? 'rgba(239,191,4,0.8)' : 'rgba(255,255,255,0.12)'}`,
background: f ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.05)',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.15s, background 0.15s',
...props.style,
}}
/>
</article>
);
}
@@ -224,7 +174,6 @@ const Press: React.FC = () => {
const eventsSection = React.useMemo(() => ({ ...pressIndexContent.events, ...(cms.events || {}) }), [cms.events]);
const newsSection = React.useMemo(() => ({ ...pressIndexContent.news, ...(cms.news || {}) }), [cms.news]);
const downloads = React.useMemo(() => ({ ...pressIndexContent.downloads, ...(cms.downloads || {}) }), [cms.downloads]);
const accreditation = React.useMemo(() => ({ ...pressIndexContent.accreditation, ...(cms.accreditation || {}) }), [cms.accreditation]);
const contactLines = fallbackText(downloads.contactLines, pressIndexContent.downloads.contactLines).split('\n');
const cmsEvents = useCmsCollection('events');
const cmsPosts = useCmsCollection('posts');
@@ -248,22 +197,6 @@ const Press: React.FC = () => {
if (cmsPosts.length) return cmsPosts.map((post) => postFromCms(post, newsCollectionFallbackImage, newsSection));
return fallbackArray<PressNewsItem>(newsSection.fallbackItems, pressIndexContent.news.fallbackItems);
}, [cmsPosts, newsCollectionFallbackImage, newsSection]);
const [hoveredEvent, setHoveredEvent] = useState<number | null>(null);
const [dlHovered, setDlHovered] = useState(false);
const [submitHovered, setSubmitHovered] = useState(false);
// Form state
const [medium, setMedium] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = () => {
if (medium.trim() && name.trim() && email.trim()) {
setSubmitted(true);
}
};
return (
<div className="animate-fade-in">
@@ -411,15 +344,11 @@ const Press: React.FC = () => {
{combinedEvents.map((event, idx) => (
<div
key={idx}
onMouseEnter={() => setHoveredEvent(idx)}
onMouseLeave={() => setHoveredEvent(null)}
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '320px 1fr',
borderBottom: '1px solid rgba(3,9,58,0.08)',
background: hoveredEvent === idx ? '#EAE7E6' : 'transparent',
transition: 'background 0.15s',
cursor: 'pointer',
background: 'transparent',
}}
>
{/* Left – image cell */}
@@ -444,9 +373,6 @@ const Press: React.FC = () => {
</div>
<div style={{ width: 24, height: 1, background: 'rgba(239,191,4,0.4)', marginBottom: 14 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.7, flex: 1 }}>{event.desc}</p>
<Link to={event.slug} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16, fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: '#101828', textDecoration: 'none' }}>
{fallbackText(eventsSection.detailLabel, pressIndexContent.events.detailLabel)} <ArrowRight size={12} />
</Link>
</div>
</div>
))}
@@ -511,7 +437,7 @@ const Press: React.FC = () => {
{/* 3-column news grid */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)' }}>
{combinedNews.map((item, idx) => (
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} readMoreLabel={fallbackText(newsSection.readMoreLabel, pressIndexContent.news.readMoreLabel)} total={combinedNews.length} />
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
))}
</div>
</section>
@@ -519,7 +445,7 @@ const Press: React.FC = () => {
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
<section id="downloads" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 600 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', minHeight: isMobile ? 'auto' : 600 }}>
{/* Left col */}
<div
@@ -529,8 +455,6 @@ const Press: React.FC = () => {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
borderRight: isMobile ? 'none' : '1px solid rgba(3,9,58,0.1)',
borderBottom: isMobile ? '1px solid rgba(3,9,58,0.1)' : 'none',
}}
>
<div
@@ -571,62 +495,12 @@ const Press: React.FC = () => {
color: 'rgba(16,24,40,0.5)',
lineHeight: 1.8,
maxWidth: 380,
marginBottom: 48,
marginBottom: 0,
}}
>
{fallbackText(downloads.description, pressIndexContent.downloads.description)}
</p>
{/* Download button */}
<a
href={mediaUrl(downloads.kitFile, fallbackText(downloads.kitUrl, pressIndexContent.downloads.kitUrl))}
download={fallbackText(downloads.kitDownloadName, pressIndexContent.downloads.kitDownloadName)}
onMouseEnter={() => setDlHovered(true)}
onMouseLeave={() => setDlHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 20,
padding: '24px 28px',
border: `1px solid ${dlHovered ? GOLD : 'rgba(3,9,58,0.12)'}`,
background: '#fff',
cursor: 'pointer',
marginBottom: 20,
transition: 'border-color 0.15s',
textDecoration: 'none',
}}
>
<div
style={{
width: 48,
height: 48,
background: NAVY,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Download size={20} color={GOLD} />
</div>
<div>
<div
style={{
fontFamily: FF,
fontSize: 18,
fontWeight: 700,
color: '#101828',
marginBottom: 3,
}}
>
{fallbackText(downloads.kitLabel, pressIndexContent.downloads.kitLabel)}
</div>
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.4)' }}>
{fallbackText(downloads.kitMeta, pressIndexContent.downloads.kitMeta)}
</div>
</div>
</a>
{/* Press contact block */}
<div style={{ borderLeft: `3px solid ${GOLD}`, paddingLeft: 24, marginTop: 32 }}>
<div
@@ -671,204 +545,6 @@ const Press: React.FC = () => {
</div>
</div>
</div>
{/* Right col */}
<div
style={{
background: NAVY,
padding: isMobile ? '48px 24px' : '72px 64px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{submitted ? (
/* Success state */
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
}}
>
<div
style={{
width: 56,
height: 56,
background: GOLD,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 28,
flexShrink: 0,
}}
>
<FileText size={24} color={NAVY} />
</div>
<h3
style={{
fontFamily: FF,
fontSize: 'clamp(1.4rem, 2.2vw, 1.8rem)',
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.02em',
margin: '0 0 16px',
}}
>
{fallbackText(accreditation.successHeading, pressIndexContent.accreditation.successHeading)}
</h3>
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.45)',
lineHeight: 1.8,
margin: 0,
}}
>
{fallbackText(accreditation.successMessage, pressIndexContent.accreditation.successMessage)}
</p>
</div>
) : (
<>
<div
style={{
fontFamily: FF,
fontSize: 10,
color: GOLD,
textTransform: 'uppercase',
letterSpacing: '0.32em',
fontWeight: 700,
marginBottom: 20,
}}
>
{fallbackText(accreditation.eyebrow, pressIndexContent.accreditation.eyebrow)}
</div>
<h3
style={{
fontFamily: FF,
fontSize: 'clamp(1.4rem, 2.2vw, 1.8rem)',
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.02em',
margin: '0 0 16px',
}}
>
<Lines text={fallbackText(accreditation.heading, pressIndexContent.accreditation.heading)} />
</h3>
{/* Gold divider */}
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.45)',
lineHeight: 1.8,
marginBottom: 36,
}}
>
{fallbackText(accreditation.description, pressIndexContent.accreditation.description)}
</p>
{/* Form fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.35)',
}}
>
{fallbackText(accreditation.mediumLabel, pressIndexContent.accreditation.mediumLabel)}
</label>
<DarkInput
type="text"
value={medium}
onChange={(e) => setMedium(e.target.value)}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.35)',
}}
>
{fallbackText(accreditation.nameLabel, pressIndexContent.accreditation.nameLabel)}
</label>
<DarkInput
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.35)',
}}
>
{fallbackText(accreditation.emailLabel, pressIndexContent.accreditation.emailLabel)}
</label>
<DarkInput
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
{/* Submit button */}
<button
onClick={handleSubmit}
onMouseEnter={() => setSubmitHovered(true)}
onMouseLeave={() => setSubmitHovered(false)}
style={{
background: submitHovered ? '#FFD130' : GOLD,
boxShadow: submitHovered ? '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)' : 'none',
color: '#101828',
border: 'none',
fontFamily: FF,
fontSize: 16,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.12em',
cursor: 'pointer',
padding: '14px 28px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
width: '100%',
transition: 'background 0.15s, box-shadow 0.2s',
}}
>
{fallbackText(accreditation.submitLabel, pressIndexContent.accreditation.submitLabel)} <ArrowRight size={13} />
</button>
</div>
</>
)}
</div>
</div>
</section>
</div>