Files
bmp-website-2026/src/spa/pages/EventDetail.tsx
2026-06-17 17:15:45 +02:00

324 lines
18 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 } from 'react'
import { useParams, Link } from '@/spa/router'
import { motion } from 'framer-motion'
import { Calendar, MapPin, Clock, Bell, BellOff, ArrowLeft, ChevronRight, Users, Tag } from 'lucide-react'
import { getEventBySlug, EventUpdate, UpdateType, type BmpEvent } from '@/spa/data/events'
import { useCmsRoute } from '@/spa/cmsRoute'
import { docImageUrl } from '@/spa/cmsMediaField'
import { useIsMobile } from '@/spa/hooks/useIsMobile'
const NAVY = '#111D55'
const GOLD = '#EFBF04'
const CREAM = '#EFE5E3'
const INK = '#3A3A3A'
// ── Timestamp formatter ──────────────────────────────────────────────────────
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']
function formatTs(iso: string): string {
const d = new Date(iso)
return `${d.getDate()}. ${MONATE[d.getMonth()]} ${d.getFullYear()}, ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')} Uhr`
}
// ── Update type styling ──────────────────────────────────────────────────────
const UPDATE_STYLE: Record<UpdateType, { bg: string; color: string; label: string }> = {
ankündigung: { bg: '#EBF4FF', color: '#1D4ED8', label: 'Ankündigung' },
erinnerung: { bg: '#FFFBEB', color: '#B45309', label: 'Erinnerung' },
update: { bg: '#F3F4F6', color: '#374151', label: 'Update' },
highlight: { bg: `${NAVY}12`, color: NAVY, label: 'Highlight' },
ergebnis: { bg: '#ECFDF5', color: '#065F46', label: 'Ergebnis' },
wichtig: { bg: '#FEF2F2', color: '#991B1B', label: 'Wichtig' },
}
// ── Status badge ─────────────────────────────────────────────────────────────
const STATUS_LABEL: Record<string, { label: string; bg: string; color: string }> = {
'geplant': { label: 'In Planung', bg: `${NAVY}18`, color: NAVY },
'anmeldung-offen': { label: 'Anmeldung offen', bg: '#ECFDF5', color: '#065F46' },
'intern': { label: 'Intern', bg: '#FEF9C3', color: '#713F12' },
'abgeschlossen': { label: 'Abgeschlossen', bg: '#F3F4F6', color: '#6B7280' },
}
// ── Update Card ───────────────────────────────────────────────────────────────
function UpdateCard({ update, index }: { update: EventUpdate; index: number }) {
const style = UPDATE_STYLE[update.type] ?? UPDATE_STYLE.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)}</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 event = getEventBySlug(slug ?? '') || (cmsEvent ? {
slug: String(cmsEvent.slug || slug),
title: String(cmsEvent.title || 'Event'),
subtitle: String(cmsEvent.subtitle || cmsEvent.description || cmsEvent.meta?.description || ''),
date: String(cmsEvent.date || 'Termin folgt'),
time: String(cmsEvent.time || 'Uhrzeit folgt'),
location: String(cmsEvent.location || 'Bayern'),
venue: String(cmsEvent.venue || 'Ort folgt'),
category: String(cmsEvent.category || 'Event'),
status: (cmsEvent.status as any) || 'geplant',
img: docImageUrl(cmsEvent, '/images/buehne-moderatoren.jpg'),
description: String(cmsEvent.description || cmsEvent.meta?.description || ''),
longDescription: String(cmsEvent.longDescription || cmsEvent.description || cmsEvent.meta?.description || 'Dieses Event wird aus Payload CMS geladen.'),
eckdaten: Array.isArray(cmsEvent.eckdaten) ? cmsEvent.eckdaten as any : [],
updates: Array.isArray(cmsEvent.updates) ? cmsEvent.updates as any : [],
} as BmpEvent : undefined)
const [subscribed, setSubscribed] = useState(false)
const isMobile = useIsMobile()
if (!event) {
return (
<div style={{ paddingTop: 60, minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: CREAM, 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 }}>Event nicht gefunden</div>
<Link to="/presse" style={{ fontSize: 15, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, borderBottom: `2px solid ${GOLD}`, paddingBottom: 2 }}>
<ArrowLeft size={14} /> Zurück zur Presse-Seite
</Link>
</div>
)
}
const statusStyle = STATUS_LABEL[event.status] ?? STATUS_LABEL['geplant']
return (
<div style={{ paddingTop: 60, background: CREAM, minHeight: '100vh', fontFamily: '"Inter", sans-serif' }}>
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<section style={{ position: 'relative', height: isMobile ? 'auto' : '65vh', minHeight: isMobile ? 520 : 480, overflow: 'hidden' }}>
<img
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="/presse" 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} /> Alle Events
</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 style={{ fontSize: isMobile ? 'clamp(1.8rem, 8vw, 2.8rem)' : 'clamp(2rem, 5vw, 4rem)', fontWeight: 900, color: '#fff', letterSpacing: '-0.03em', lineHeight: 1.05, marginBottom: 12, fontFamily: '"IBM Plex Sans", sans-serif' }}>
{event.title}
</h1>
<p style={{ fontSize: isMobile ? 14 : 16, color: 'rgba(255,255,255,0.65)', maxWidth: 520, lineHeight: 1.6 }}>
{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 style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 16 }}>
Über die Veranstaltung
</p>
{event.longDescription.split('\n\n').map((para, i) => (
<p key={i} style={{ fontSize: 17, color: 'rgba(58,58,58,0.75)', lineHeight: 1.8, marginBottom: 20 }}>{para}</p>
))}
</div>
{/* Eckdaten */}
<div>
<p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 24 }}>
Eckdaten
</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="/teilnahme" 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',
}}>
Jetzt anmelden <ChevronRight size={16} />
</Link>
)}
<Link to="/kontakt" 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}`,
}}>
Frage stellen
</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', justifyContent: 'space-between', 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' }}>
Live Updates
</span>
<span style={{
fontSize: 11, fontWeight: 800, background: NAVY, color: GOLD,
borderRadius: 20, padding: '2px 8px',
}}>{event.updates.length}</span>
</div>
<button
onClick={() => setSubscribed(s => !s)}
style={{
display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer',
background: subscribed ? NAVY : 'transparent',
color: subscribed ? GOLD : NAVY,
border: `1.5px solid ${NAVY}`, borderRadius: 8,
padding: '8px 14px', fontSize: 12, fontWeight: 700,
transition: 'all 0.2s', fontFamily: 'inherit',
}}
>
{subscribed ? <><BellOff size={13} /> Abgemeldet</> : <><Bell size={13} /> Abonnieren</>}
</button>
</div>
{subscribed && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
style={{ background: '#ECFDF5', border: '1px solid #A7F3D0', borderRadius: 10, padding: '10px 14px', marginBottom: 16, fontSize: 14, color: '#065F46', fontWeight: 500 }}
>
Sie erhalten Benachrichtigungen für dieses Event.
</motion.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} />
))}
</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 style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 10 }}>
Mehr entdecken
</p>
<h3 style={{ fontSize: 28, fontWeight: 900, color: '#fff', letterSpacing: '-0.03em', fontFamily: '"IBM Plex Sans", sans-serif' }}>
Weitere Veranstaltungen
</h3>
</div>
<Link
to="/presse"
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'; }}
>
Alle Events <ChevronRight size={16} />
</Link>
</div>
</section>
</div>
)
}