Files
bmp-website-2026/src/spa/pages/EventDetail.tsx

329 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 from 'react'
import { useParams, Link } from '@/spa/router'
import { motion } from 'framer-motion'
import { Calendar, MapPin, Clock, Bell, ArrowLeft, ChevronRight } from 'lucide-react'
import { getEventBySlug, EventUpdate, type BmpEvent, type EventEckdaten } from '@/spa/data/events'
import { useCmsRoute } from '@/spa/cmsRoute'
import { docImageUrl, mediaUrl } from '@/spa/cmsMediaField'
import { useIsMobile } from '@/spa/hooks/useIsMobile'
import { eventDetailContent } from '@/spa/eventDetailContent'
import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55'
const GOLD = '#EFBF04'
const WHITE = '#fff'
type EventDetailCms = Omit<Partial<typeof eventDetailContent>, 'updateStyles' | 'statusStyles'> & {
fallbackImage?: unknown
updateStyles?: unknown
statusStyles?: unknown
updatesCopy?: Partial<typeof eventDetailContent.updates>
}
type StyleEntry = { bg: string; color: string; label: string }
type StyleMap = Record<string, StyleEntry>
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback
// ── Timestamp formatter ──────────────────────────────────────────────────────
function formatTs(iso: string, detail: EventDetailCms): string {
const d = new Date(iso)
const monthNames = Array.isArray(detail.dateFormat?.monthNames) && detail.dateFormat.monthNames.length >= 12
? detail.dateFormat.monthNames
: eventDetailContent.dateFormat.monthNames
return `${d.getDate()}. ${monthNames[d.getMonth()]} ${d.getFullYear()}, ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')} ${fallbackText(detail.dateFormat?.timeSuffix, eventDetailContent.dateFormat.timeSuffix)}`
}
const asStyleMap = (value: unknown, fallback: StyleMap): StyleMap => {
if (!value || typeof value !== 'object') return fallback
return { ...fallback, ...(value as StyleMap) }
}
const getDetailPage = (value: unknown): EventDetailCms => {
const detail = (value && typeof value === 'object' ? value : {}) as EventDetailCms
return {
...eventDetailContent,
...detail,
notFound: { ...eventDetailContent.notFound, ...detail.notFound },
dateFormat: { ...eventDetailContent.dateFormat, ...detail.dateFormat },
updateStyles: asStyleMap(detail.updateStyles, eventDetailContent.updateStyles),
statusStyles: asStyleMap(detail.statusStyles, eventDetailContent.statusStyles),
heroBackLink: { ...eventDetailContent.heroBackLink, ...detail.heroBackLink },
fallbackEvent: { ...eventDetailContent.fallbackEvent, ...detail.fallbackEvent },
sections: { ...eventDetailContent.sections, ...detail.sections },
updatesCopy: { ...eventDetailContent.updates, ...(detail.updatesCopy || detail.updates) },
bottomCta: { ...eventDetailContent.bottomCta, ...detail.bottomCta },
}
}
// ── Update Card ───────────────────────────────────────────────────────────────
function UpdateCard({ update, index, detail }: { update: EventUpdate; index: number; detail: EventDetailCms }) {
const styles = asStyleMap(detail.updateStyles, eventDetailContent.updateStyles)
const style = styles[update.type] ?? styles.update
return (
<motion.div
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.35, delay: index * 0.07, ease: 'easeOut' }}
style={{ display: 'flex', gap: 16, position: 'relative' }}
>
{/* Timeline dot */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: GOLD, border: `2px solid ${GOLD}`, marginTop: 6, flexShrink: 0 }} />
<div style={{ flex: 1, width: 1, background: `rgba(3,9,58,0.1)`, marginTop: 6 }} />
</div>
{/* Card */}
<div style={{
flex: 1, background: '#fff', borderRadius: 12, padding: '16px 20px',
marginBottom: 12, border: '1px solid rgba(3,9,58,0.08)',
boxShadow: '0 1px 8px rgba(3,9,58,0.04)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<span style={{
fontSize: 10, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.1em',
padding: '3px 8px', borderRadius: 20,
background: style.bg, color: style.color,
}}>{style.label}</span>
<span style={{ fontSize: 11, color: 'rgba(58,58,58,0.45)' }}>{formatTs(update.timestamp, detail)}</span>
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: NAVY, marginBottom: 4, lineHeight: 1.35 }}>{update.title}</div>
<div style={{ fontSize: 14, color: 'rgba(58,58,58,0.65)', lineHeight: 1.65 }}>{update.body}</div>
</div>
</motion.div>
)
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function EventDetail() {
const { slug } = useParams<{ slug: string }>()
const route = useCmsRoute()
const cmsEvent = route?.collection === 'events' ? route.doc : undefined
const cmsDetail = getDetailPage(cmsEvent?.detailPage)
const fallbackEvent = cmsDetail.fallbackEvent || eventDetailContent.fallbackEvent
const eventFallbackImage = mediaUrl(cmsDetail.fallbackImage, `/images/${eventDetailContent.fallbackImageFilename}`)
const event = getEventBySlug(slug ?? '') || (cmsEvent ? {
slug: String(cmsEvent.slug || slug),
title: String(cmsEvent.title || fallbackEvent.title || eventDetailContent.fallbackEvent.title),
subtitle: String(cmsEvent.subtitle || cmsEvent.description || cmsEvent.meta?.description || ''),
date: String(cmsEvent.date || fallbackEvent.date || eventDetailContent.fallbackEvent.date),
time: String(cmsEvent.time || fallbackEvent.time || eventDetailContent.fallbackEvent.time),
location: String(cmsEvent.location || fallbackEvent.location || eventDetailContent.fallbackEvent.location),
venue: String(cmsEvent.venue || fallbackEvent.venue || eventDetailContent.fallbackEvent.venue),
category: String(cmsEvent.category || fallbackEvent.category || eventDetailContent.fallbackEvent.category),
status: ((cmsEvent.status || fallbackEvent.status || eventDetailContent.fallbackEvent.status) as BmpEvent['status']),
img: docImageUrl(cmsEvent, eventFallbackImage),
description: String(cmsEvent.description || cmsEvent.meta?.description || ''),
longDescription: String(cmsEvent.longDescription || cmsEvent.description || cmsEvent.meta?.description || fallbackEvent.longDescription || eventDetailContent.fallbackEvent.longDescription),
eckdaten: Array.isArray(cmsEvent.eckdaten) ? cmsEvent.eckdaten as EventEckdaten[] : [],
updates: Array.isArray(cmsEvent.updates) ? cmsEvent.updates as EventUpdate[] : [],
detailPage: cmsEvent.detailPage,
} as BmpEvent : undefined)
const detail = getDetailPage(event && 'detailPage' in event ? event.detailPage : cmsDetail)
const notFound = detail.notFound || eventDetailContent.notFound
const heroBackLink = detail.heroBackLink || eventDetailContent.heroBackLink
const sections = detail.sections || eventDetailContent.sections
const updatesCopy = detail.updatesCopy || eventDetailContent.updates
const bottomCta = detail.bottomCta || eventDetailContent.bottomCta
const isMobile = useIsMobile()
if (!event) {
return (
<div style={{ paddingTop: 60, minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: WHITE, flexDirection: 'column', gap: 24 }}>
<div style={{ fontSize: 64, fontWeight: 900, color: `${NAVY}20`, letterSpacing: '-0.04em' }}>404</div>
<div style={{ fontSize: 20, fontWeight: 700, color: NAVY }}>{fallbackText(notFound.title, eventDetailContent.notFound.title)}</div>
<Link to={fallbackText(notFound.backUrl, eventDetailContent.notFound.backUrl)} style={{ fontSize: 15, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, borderBottom: `2px solid ${GOLD}`, paddingBottom: 2 }}>
<ArrowLeft size={14} /> {fallbackText(notFound.backLabel, eventDetailContent.notFound.backLabel)}
</Link>
</div>
)
}
const statusStyles = asStyleMap(detail.statusStyles, eventDetailContent.statusStyles)
const statusStyle = statusStyles[event.status] ?? statusStyles.geplant
return (
<div style={{ background: WHITE, minHeight: '100vh', fontFamily: '"Inter", sans-serif' }}>
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<section style={{ position: 'relative', height: isMobile ? 'auto' : '65vh', minHeight: isMobile ? 520 : 480, overflow: 'hidden' }}>
<Image unoptimized
src={event.img}
alt={event.title}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center' }}
/>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(3,9,58,0.25) 0%, rgba(3,9,58,0.92) 100%)' }} />
{/* Back link */}
<div style={{ position: 'absolute', top: 28, left: 0, right: 0, padding: isMobile ? '0 24px' : '0 80px', zIndex: 10 }}>
<Link to={fallbackText(heroBackLink.url, eventDetailContent.heroBackLink.url)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, color: 'rgba(255,255,255,0.7)', textDecoration: 'none', fontSize: 14, fontWeight: 600, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget.style.color = '#fff')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.7)')}
>
<ArrowLeft size={14} /> {fallbackText(heroBackLink.label, eventDetailContent.heroBackLink.label)}
</Link>
</div>
{/* Hero content */}
<div style={{ position: isMobile ? 'relative' : 'absolute', bottom: 0, left: 0, right: 0, padding: isMobile ? '88px 24px 48px' : '40px 80px', zIndex: 10, display: 'flex', flexDirection: isMobile ? 'column' : 'row', justifyContent: isMobile ? 'flex-end' : 'space-between', alignItems: isMobile ? 'flex-start' : 'flex-end', gap: isMobile ? 20 : 40, minHeight: isMobile ? 520 : undefined, boxSizing: 'border-box' }}>
{/* Left */}
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14, flexWrap: 'wrap' }}>
<span style={{ fontSize: 10, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.2em', background: statusStyle.bg, color: statusStyle.color, padding: '4px 10px', borderRadius: 20 }}>
{statusStyle.label}
</span>
<span style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.15em', color: GOLD }}>
{event.category}
</span>
</div>
<h1 className="type-page-title text-role-inverse" style={{ marginBottom: 12 }}>
{event.title}
</h1>
<p className="type-small text-role-inverse-secondary" style={{ maxWidth: 520 }}>
{event.subtitle}
</p>
</div>
{/* Right – key facts */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flexShrink: 0, background: 'rgba(3,9,58,0.55)', backdropFilter: 'blur(12px)', borderRadius: 16, padding: isMobile ? '16px 20px' : '24px 28px', border: '1px solid rgba(255,255,255,0.1)', width: isMobile ? '100%' : undefined, boxSizing: 'border-box' }}>
{[
{ icon: Calendar, label: event.date },
{ icon: Clock, label: event.time },
{ icon: MapPin, label: `${event.venue}, ${event.location}` },
].map(({ icon: Icon, label }, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<Icon size={15} color={GOLD} style={{ flexShrink: 0 }} />
<span style={{ fontSize: 14, color: '#fff', fontWeight: 500, overflowWrap: 'anywhere' }}>{label}</span>
</div>
))}
</div>
</div>
{/* Gold bottom line */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 3, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.3), transparent)` }} />
</section>
{/* ── Main Content ─────────────────────────────────────────────────── */}
<section style={{ padding: isMobile ? '48px 24px' : '64px 80px', maxWidth: 1400, margin: '0 auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 400px', gap: isMobile ? 48 : 64, alignItems: 'start' }}>
{/* Left – Description + Eckdaten */}
<div>
{/* Description */}
<div style={{ marginBottom: 56 }}>
<p className="type-eyebrow text-role-brand-light" style={{ marginBottom: 16 }}>
{fallbackText(sections.aboutEyebrow, eventDetailContent.sections.aboutEyebrow)}
</p>
{event.longDescription.split('\n\n').map((para, i) => (
<p key={i} className="type-body text-role-body" style={{ marginBottom: 20 }}>{para}</p>
))}
</div>
{/* Eckdaten */}
<div>
<p className="type-eyebrow text-role-brand-light" style={{ marginBottom: 24 }}>
{fallbackText(sections.factsEyebrow, eventDetailContent.sections.factsEyebrow)}
</p>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 2 }}>
{event.eckdaten.map((item, i) => (
<div key={i} style={{
padding: '20px 24px',
background: i % 2 === 0 ? '#fff' : 'rgba(255,255,255,0.6)',
borderRadius: isMobile
? (i === 0
? '12px 12px 0 0'
: i === event.eckdaten.length - 1
? '0 0 12px 12px'
: '0')
: (i === 0 ? '12px 0 0 0' : i === 1 ? '0 12px 0 0' : i === event.eckdaten.length - 2 ? '0 0 0 12px' : i === event.eckdaten.length - 1 ? '0 0 12px 0' : '0'),
border: '1px solid rgba(3,9,58,0.06)',
}}>
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(58,58,58,0.4)', marginBottom: 6 }}>
{item.label}
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: NAVY, lineHeight: 1.35 }}>
{item.value}
</div>
</div>
))}
</div>
</div>
{/* CTA */}
<div style={{ marginTop: 40, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{event.status === 'anmeldung-offen' && (
<Link to={fallbackText(sections.registerUrl, eventDetailContent.sections.registerUrl)} style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: NAVY, color: GOLD, textDecoration: 'none',
padding: '14px 28px', borderRadius: 10, fontWeight: 700, fontSize: 15,
fontFamily: '"IBM Plex Sans", sans-serif',
}}>
{fallbackText(sections.registerLabel, eventDetailContent.sections.registerLabel)} <ChevronRight size={16} />
</Link>
)}
<Link to={fallbackText(sections.questionUrl, eventDetailContent.sections.questionUrl)} style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'transparent', color: NAVY, textDecoration: 'none',
padding: '14px 28px', borderRadius: 10, fontWeight: 700, fontSize: 15,
border: `2px solid ${NAVY}`,
}}>
{fallbackText(sections.questionLabel, eventDetailContent.sections.questionLabel)}
</Link>
</div>
</div>
{/* Right – Live Updates Feed (sticky on desktop, static on mobile) */}
<div style={isMobile ? undefined : { position: 'sticky', top: 80 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Bell size={18} color={NAVY} />
<span style={{ fontSize: 17, fontWeight: 800, color: NAVY, fontFamily: '"IBM Plex Sans", sans-serif' }}>
{fallbackText(updatesCopy.heading, eventDetailContent.updates.heading)}
</span>
<span style={{
fontSize: 11, fontWeight: 800, background: NAVY, color: GOLD,
borderRadius: 20, padding: '2px 8px',
}}>{event.updates.length}</span>
</div>
</div>
{/* Timeline */}
<div style={{ maxHeight: isMobile ? 'none' : 560, overflowY: isMobile ? 'visible' : 'auto', paddingRight: 4 }}>
{event.updates.map((update, i) => (
<UpdateCard key={update.id} update={update} index={i} detail={detail} />
))}
</div>
</div>
</div>
</section>
{/* ── Bottom CTA ───────────────────────────────────────────────────── */}
<section style={{ background: NAVY, padding: isMobile ? '48px 24px' : '64px 80px', marginTop: 40 }}>
<div style={{ maxWidth: 1400, margin: '0 auto', display: 'flex', flexDirection: isMobile ? 'column' : 'row', justifyContent: 'space-between', alignItems: isMobile ? 'flex-start' : 'center', gap: isMobile ? 24 : 0 }}>
<div>
<p className="type-eyebrow text-role-brand-dark" style={{ marginBottom: 10 }}>
{fallbackText(bottomCta.eyebrow, eventDetailContent.bottomCta.eyebrow)}
</p>
<h2 className="type-section-title text-role-inverse">
{fallbackText(bottomCta.heading, eventDetailContent.bottomCta.heading)}
</h2>
</div>
<Link
to={fallbackText(bottomCta.url, eventDetailContent.bottomCta.url)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8, textDecoration: 'none',
background: GOLD, color: NAVY, padding: '14px 28px', borderRadius: 10,
fontWeight: 800, fontSize: 15, fontFamily: '"IBM Plex Sans", sans-serif',
transition: 'background 0.15s, box-shadow 0.2s',
}}
onMouseEnter={e => { const el = e.currentTarget as HTMLElement; el.style.background = '#FFD130'; el.style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }}
onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.background = GOLD; el.style.boxShadow = 'none'; }}
>
{fallbackText(bottomCta.label, eventDetailContent.bottomCta.label)} <ChevronRight size={16} />
</Link>
</div>
</section>
</div>
)
}