329 lines
20 KiB
TypeScript
329 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|