feat: manage event detail chrome via payload

This commit is contained in:
syntaxbullet
2026-06-22 11:51:47 +02:00
parent a19b703416
commit 43f85dfea2
7 changed files with 373 additions and 41 deletions

View File

@@ -20,6 +20,7 @@
"preload:about-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-about-page-cms.ts", "preload:about-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-about-page-cms.ts",
"preload:contact-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-contact-page-cms.ts", "preload:contact-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-contact-page-cms.ts",
"preload:datenschutz-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-datenschutz-page-cms.ts", "preload:datenschutz-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-datenschutz-page-cms.ts",
"preload:event-detail-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-event-detail-cms.ts",
"preload:formular-upload-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-formular-upload-page-cms.ts", "preload:formular-upload-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-formular-upload-page-cms.ts",
"preload:home-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-home-page-cms.ts", "preload:home-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-home-page-cms.ts",
"preload:impressum-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-impressum-page-cms.ts", "preload:impressum-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-impressum-page-cms.ts",

View File

@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
import { authenticated } from '@/access/authenticated' import { authenticated } from '@/access/authenticated'
import { authenticatedOrPublished } from '@/access/authenticatedOrPublished' import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
import { populatePublishedAt } from '@/hooks/populatePublishedAt' import { populatePublishedAt } from '@/hooks/populatePublishedAt'
import { eventDetailContent } from '@/spa/eventDetailContent'
import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { generatePreviewPath } from '@/utilities/generatePreviewPath'
import { slugField } from 'payload' import { slugField } from 'payload'
@@ -120,6 +121,97 @@ export const Events: CollectionConfig = {
{ name: 'body', type: 'textarea', required: true }, { name: 'body', type: 'textarea', required: true },
], ],
}, },
{
name: 'detailPage',
label: 'Detail page chrome',
type: 'group',
admin: {
description:
'Shared labels, status/update labels, subscription copy, CTAs, and formatting copy for the Event detail route.',
},
fields: [
{
name: 'notFound',
label: 'Not found state',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: eventDetailContent.notFound.title },
{ name: 'backLabel', type: 'text', defaultValue: eventDetailContent.notFound.backLabel },
{ name: 'backUrl', type: 'text', defaultValue: eventDetailContent.notFound.backUrl },
],
},
{
name: 'dateFormat',
label: 'Date format',
type: 'group',
fields: [
{
name: 'monthNames',
type: 'json',
defaultValue: eventDetailContent.dateFormat.monthNames,
admin: { description: 'Array of 12 month names used when formatting update timestamps.' },
},
{ name: 'timeSuffix', type: 'text', defaultValue: eventDetailContent.dateFormat.timeSuffix },
],
},
{
name: 'updateStyles',
label: 'Update type labels/styles JSON',
type: 'json',
defaultValue: eventDetailContent.updateStyles,
},
{
name: 'statusStyles',
label: 'Status labels/styles JSON',
type: 'json',
defaultValue: eventDetailContent.statusStyles,
},
{
name: 'heroBackLink',
label: 'Hero back link',
type: 'group',
fields: [
{ name: 'label', type: 'text', defaultValue: eventDetailContent.heroBackLink.label },
{ name: 'url', type: 'text', defaultValue: eventDetailContent.heroBackLink.url },
],
},
{
name: 'sections',
label: 'Section labels and CTAs',
type: 'group',
fields: [
{ name: 'aboutEyebrow', type: 'text', defaultValue: eventDetailContent.sections.aboutEyebrow },
{ name: 'factsEyebrow', type: 'text', defaultValue: eventDetailContent.sections.factsEyebrow },
{ name: 'registerLabel', type: 'text', defaultValue: eventDetailContent.sections.registerLabel },
{ name: 'registerUrl', type: 'text', defaultValue: eventDetailContent.sections.registerUrl },
{ name: 'questionLabel', type: 'text', defaultValue: eventDetailContent.sections.questionLabel },
{ name: 'questionUrl', type: 'text', defaultValue: eventDetailContent.sections.questionUrl },
],
},
{
name: 'updatesCopy',
label: 'Updates panel',
type: 'group',
fields: [
{ name: 'heading', type: 'text', defaultValue: eventDetailContent.updates.heading },
{ name: 'subscribeLabel', type: 'text', defaultValue: eventDetailContent.updates.subscribeLabel },
{ name: 'unsubscribeLabel', type: 'text', defaultValue: eventDetailContent.updates.unsubscribeLabel },
{ name: 'subscribedMessage', type: 'text', defaultValue: eventDetailContent.updates.subscribedMessage },
],
},
{
name: 'bottomCta',
label: 'Bottom CTA',
type: 'group',
fields: [
{ name: 'eyebrow', type: 'text', defaultValue: eventDetailContent.bottomCta.eyebrow },
{ name: 'heading', type: 'text', defaultValue: eventDetailContent.bottomCta.heading },
{ name: 'label', type: 'text', defaultValue: eventDetailContent.bottomCta.label },
{ name: 'url', type: 'text', defaultValue: eventDetailContent.bottomCta.url },
],
},
],
},
{ {
name: 'spaPath', name: 'spaPath',
type: 'text', type: 'text',

View File

@@ -2549,6 +2549,73 @@ export interface Event {
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
/**
* Shared labels, status/update labels, subscription copy, CTAs, and formatting copy for the Event detail route.
*/
detailPage?: {
notFound?: {
title?: string | null;
backLabel?: string | null;
backUrl?: string | null;
};
dateFormat?: {
/**
* Array of 12 month names used when formatting update timestamps.
*/
monthNames?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
timeSuffix?: string | null;
};
updateStyles?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
statusStyles?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
heroBackLink?: {
label?: string | null;
url?: string | null;
};
sections?: {
aboutEyebrow?: string | null;
factsEyebrow?: string | null;
registerLabel?: string | null;
registerUrl?: string | null;
questionLabel?: string | null;
questionUrl?: string | null;
};
updatesCopy?: {
heading?: string | null;
subscribeLabel?: string | null;
unsubscribeLabel?: string | null;
subscribedMessage?: string | null;
};
bottomCta?: {
eyebrow?: string | null;
heading?: string | null;
label?: string | null;
url?: string | null;
};
};
/** /**
* Route in the legacy SPA used for previewing the current migrated event state. * Route in the legacy SPA used for previewing the current migrated event state.
*/ */
@@ -4455,6 +4522,57 @@ export interface EventsSelect<T extends boolean = true> {
body?: T; body?: T;
id?: T; id?: T;
}; };
detailPage?:
| T
| {
notFound?:
| T
| {
title?: T;
backLabel?: T;
backUrl?: T;
};
dateFormat?:
| T
| {
monthNames?: T;
timeSuffix?: T;
};
updateStyles?: T;
statusStyles?: T;
heroBackLink?:
| T
| {
label?: T;
url?: T;
};
sections?:
| T
| {
aboutEyebrow?: T;
factsEyebrow?: T;
registerLabel?: T;
registerUrl?: T;
questionLabel?: T;
questionUrl?: T;
};
updatesCopy?:
| T
| {
heading?: T;
subscribeLabel?: T;
unsubscribeLabel?: T;
subscribedMessage?: T;
};
bottomCta?:
| T
| {
eyebrow?: T;
heading?: T;
label?: T;
url?: T;
};
};
spaPath?: T; spaPath?: T;
publishedAt?: T; publishedAt?: T;
meta?: meta?:

View File

@@ -5,6 +5,7 @@ import 'dotenv/config'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { articles } from '@/spa/data/blog' import { articles } from '@/spa/data/blog'
import { eventDetailContent } from '@/spa/eventDetailContent'
import { events } from '@/spa/data/events' import { events } from '@/spa/data/events'
import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'
import { WINNERS } from '@/spa/data/winners' import { WINNERS } from '@/spa/data/winners'
@@ -155,6 +156,10 @@ function eventData(page: SpaPageRegistryEntry, mediaMap: MediaMap) {
status: event?.status || 'geplant', status: event?.status || 'geplant',
eckdaten: event?.eckdaten || [], eckdaten: event?.eckdaten || [],
updates: event?.updates?.map(({ id: _id, ...update }) => update) || [], updates: event?.updates?.map(({ id: _id, ...update }) => update) || [],
detailPage: {
...eventDetailContent,
updatesCopy: eventDetailContent.updates,
},
} }
} }

View File

@@ -0,0 +1,42 @@
import 'dotenv/config'
import config from '@payload-config'
import { getPayload } from 'payload'
import { eventDetailContent } from '@/spa/eventDetailContent'
async function main() {
const payload = await getPayload({ config })
const events = await payload.find({
collection: 'events',
draft: true,
overrideAccess: true,
pagination: false,
limit: 1000,
})
await Promise.all(
events.docs.map((event) =>
payload.update({
collection: 'events',
id: event.id,
overrideAccess: true,
context: { disableRevalidate: true },
data: {
detailPage: {
...eventDetailContent,
updatesCopy: eventDetailContent.updates,
},
} as never,
}),
),
)
payload.logger.info(`Preloaded Event detail CMS fields for ${events.docs.length} records`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -0,0 +1,49 @@
export const eventDetailContent = {
notFound: {
title: 'Event nicht gefunden',
backLabel: 'Zurück zur Presse-Seite',
backUrl: '/presse',
},
dateFormat: {
monthNames: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
timeSuffix: 'Uhr',
},
updateStyles: {
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: '#111D5512', color: '#111D55', label: 'Highlight' },
ergebnis: { bg: '#ECFDF5', color: '#065F46', label: 'Ergebnis' },
wichtig: { bg: '#FEF2F2', color: '#991B1B', label: 'Wichtig' },
},
statusStyles: {
geplant: { label: 'In Planung', bg: '#111D5518', color: '#111D55' },
'anmeldung-offen': { label: 'Anmeldung offen', bg: '#ECFDF5', color: '#065F46' },
intern: { label: 'Intern', bg: '#FEF9C3', color: '#713F12' },
abgeschlossen: { label: 'Abgeschlossen', bg: '#F3F4F6', color: '#6B7280' },
},
heroBackLink: {
label: 'Alle Events',
url: '/presse',
},
sections: {
aboutEyebrow: 'Über die Veranstaltung',
factsEyebrow: 'Eckdaten',
registerLabel: 'Jetzt anmelden',
registerUrl: '/teilnahme',
questionLabel: 'Frage stellen',
questionUrl: '/kontakt',
},
updates: {
heading: 'Live Updates',
subscribeLabel: 'Abonnieren',
unsubscribeLabel: 'Abgemeldet',
subscribedMessage: '✓ Sie erhalten Benachrichtigungen für dieses Event.',
},
bottomCta: {
eyebrow: 'Mehr entdecken',
heading: 'Weitere Veranstaltungen',
label: 'Alle Events',
url: '/presse',
},
}

View File

@@ -2,44 +2,61 @@ import React, { useState } from 'react'
import { useParams, Link } from '@/spa/router' import { useParams, Link } from '@/spa/router'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Calendar, MapPin, Clock, Bell, BellOff, ArrowLeft, ChevronRight } from 'lucide-react' import { Calendar, MapPin, Clock, Bell, BellOff, ArrowLeft, ChevronRight } from 'lucide-react'
import { getEventBySlug, EventUpdate, UpdateType, type BmpEvent, type EventEckdaten } from '@/spa/data/events' import { getEventBySlug, EventUpdate, type BmpEvent, type EventEckdaten } from '@/spa/data/events'
import { useCmsRoute } from '@/spa/cmsRoute' import { useCmsRoute } from '@/spa/cmsRoute'
import { docImageUrl } from '@/spa/cmsMediaField' import { docImageUrl } from '@/spa/cmsMediaField'
import { useIsMobile } from '@/spa/hooks/useIsMobile' import { useIsMobile } from '@/spa/hooks/useIsMobile'
import { eventDetailContent } from '@/spa/eventDetailContent'
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55' const NAVY = '#111D55'
const GOLD = '#EFBF04' const GOLD = '#EFBF04'
const CREAM = '#EFE5E3' const CREAM = '#EFE5E3'
type EventDetailCms = Omit<Partial<typeof eventDetailContent>, 'updateStyles' | 'statusStyles'> & {
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 ────────────────────────────────────────────────────── // ── Timestamp formatter ──────────────────────────────────────────────────────
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'] function formatTs(iso: string, detail: EventDetailCms): string {
function formatTs(iso: string): string {
const d = new Date(iso) 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` 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)}`
} }
// ── Update type styling ────────────────────────────────────────────────────── const asStyleMap = (value: unknown, fallback: StyleMap): StyleMap => {
const UPDATE_STYLE: Record<UpdateType, { bg: string; color: string; label: string }> = { if (!value || typeof value !== 'object') return fallback
ankündigung: { bg: '#EBF4FF', color: '#1D4ED8', label: 'Ankündigung' }, return { ...fallback, ...(value as StyleMap) }
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 getDetailPage = (value: unknown): EventDetailCms => {
const STATUS_LABEL: Record<string, { label: string; bg: string; color: string }> = { const detail = (value && typeof value === 'object' ? value : {}) as EventDetailCms
'geplant': { label: 'In Planung', bg: `${NAVY}18`, color: NAVY }, return {
'anmeldung-offen': { label: 'Anmeldung offen', bg: '#ECFDF5', color: '#065F46' }, ...eventDetailContent,
'intern': { label: 'Intern', bg: '#FEF9C3', color: '#713F12' }, ...detail,
'abgeschlossen': { label: 'Abgeschlossen', bg: '#F3F4F6', color: '#6B7280' }, 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 },
sections: { ...eventDetailContent.sections, ...detail.sections },
updatesCopy: { ...eventDetailContent.updates, ...(detail.updatesCopy || detail.updates) },
bottomCta: { ...eventDetailContent.bottomCta, ...detail.bottomCta },
}
} }
// ── Update Card ─────────────────────────────────────────────────────────────── // ── Update Card ───────────────────────────────────────────────────────────────
function UpdateCard({ update, index }: { update: EventUpdate; index: number }) { function UpdateCard({ update, index, detail }: { update: EventUpdate; index: number; detail: EventDetailCms }) {
const style = UPDATE_STYLE[update.type] ?? UPDATE_STYLE.update const styles = asStyleMap(detail.updateStyles, eventDetailContent.updateStyles)
const style = styles[update.type] ?? styles.update
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, x: 24 }} initial={{ opacity: 0, x: 24 }}
@@ -65,7 +82,7 @@ function UpdateCard({ update, index }: { update: EventUpdate; index: number }) {
padding: '3px 8px', borderRadius: 20, padding: '3px 8px', borderRadius: 20,
background: style.bg, color: style.color, background: style.bg, color: style.color,
}}>{style.label}</span> }}>{style.label}</span>
<span style={{ fontSize: 11, color: 'rgba(58,58,58,0.45)' }}>{formatTs(update.timestamp)}</span> <span style={{ fontSize: 11, color: 'rgba(58,58,58,0.45)' }}>{formatTs(update.timestamp, detail)}</span>
</div> </div>
<div style={{ fontSize: 15, fontWeight: 700, color: NAVY, marginBottom: 4, lineHeight: 1.35 }}>{update.title}</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 style={{ fontSize: 14, color: 'rgba(58,58,58,0.65)', lineHeight: 1.65 }}>{update.body}</div>
@@ -94,7 +111,14 @@ export default function EventDetail() {
longDescription: String(cmsEvent.longDescription || cmsEvent.description || cmsEvent.meta?.description || 'Dieses Event wird aus Payload CMS geladen.'), longDescription: String(cmsEvent.longDescription || cmsEvent.description || cmsEvent.meta?.description || 'Dieses Event wird aus Payload CMS geladen.'),
eckdaten: Array.isArray(cmsEvent.eckdaten) ? cmsEvent.eckdaten as EventEckdaten[] : [], eckdaten: Array.isArray(cmsEvent.eckdaten) ? cmsEvent.eckdaten as EventEckdaten[] : [],
updates: Array.isArray(cmsEvent.updates) ? cmsEvent.updates as EventUpdate[] : [], updates: Array.isArray(cmsEvent.updates) ? cmsEvent.updates as EventUpdate[] : [],
detailPage: cmsEvent.detailPage,
} as BmpEvent : undefined) } as BmpEvent : undefined)
const detail = getDetailPage(event && 'detailPage' in event ? event.detailPage : undefined)
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 [subscribed, setSubscribed] = useState(false) const [subscribed, setSubscribed] = useState(false)
const isMobile = useIsMobile() const isMobile = useIsMobile()
@@ -102,15 +126,16 @@ export default function EventDetail() {
return ( return (
<div style={{ paddingTop: 60, minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: CREAM, flexDirection: 'column', gap: 24 }}> <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: 64, fontWeight: 900, color: `${NAVY}20`, letterSpacing: '-0.04em' }}>404</div>
<div style={{ fontSize: 20, fontWeight: 700, color: NAVY }}>Event nicht gefunden</div> <div style={{ fontSize: 20, fontWeight: 700, color: NAVY }}>{fallbackText(notFound.title, eventDetailContent.notFound.title)}</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 }}> <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} /> Zurück zur Presse-Seite <ArrowLeft size={14} /> {fallbackText(notFound.backLabel, eventDetailContent.notFound.backLabel)}
</Link> </Link>
</div> </div>
) )
} }
const statusStyle = STATUS_LABEL[event.status] ?? STATUS_LABEL['geplant'] const statusStyles = asStyleMap(detail.statusStyles, eventDetailContent.statusStyles)
const statusStyle = statusStyles[event.status] ?? statusStyles.geplant
return ( return (
<div style={{ paddingTop: 60, background: CREAM, minHeight: '100vh', fontFamily: '"Inter", sans-serif' }}> <div style={{ paddingTop: 60, background: CREAM, minHeight: '100vh', fontFamily: '"Inter", sans-serif' }}>
@@ -126,11 +151,11 @@ export default function EventDetail() {
{/* Back link */} {/* Back link */}
<div style={{ position: 'absolute', top: 28, left: 0, right: 0, padding: isMobile ? '0 24px' : '0 80px', zIndex: 10 }}> <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' }} <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')} onMouseEnter={e => (e.currentTarget.style.color = '#fff')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.7)')} onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.7)')}
> >
<ArrowLeft size={14} /> Alle Events <ArrowLeft size={14} /> {fallbackText(heroBackLink.label, eventDetailContent.heroBackLink.label)}
</Link> </Link>
</div> </div>
@@ -182,7 +207,7 @@ export default function EventDetail() {
{/* Description */} {/* Description */}
<div style={{ marginBottom: 56 }}> <div style={{ marginBottom: 56 }}>
<p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 16 }}> <p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 16 }}>
Über die Veranstaltung {fallbackText(sections.aboutEyebrow, eventDetailContent.sections.aboutEyebrow)}
</p> </p>
{event.longDescription.split('\n\n').map((para, i) => ( {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> <p key={i} style={{ fontSize: 17, color: 'rgba(58,58,58,0.75)', lineHeight: 1.8, marginBottom: 20 }}>{para}</p>
@@ -192,7 +217,7 @@ export default function EventDetail() {
{/* Eckdaten */} {/* Eckdaten */}
<div> <div>
<p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 24 }}> <p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 24 }}>
Eckdaten {fallbackText(sections.factsEyebrow, eventDetailContent.sections.factsEyebrow)}
</p> </p>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 2 }}> <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 2 }}>
{event.eckdaten.map((item, i) => ( {event.eckdaten.map((item, i) => (
@@ -222,22 +247,22 @@ export default function EventDetail() {
{/* CTA */} {/* CTA */}
<div style={{ marginTop: 40, display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ marginTop: 40, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{event.status === 'anmeldung-offen' && ( {event.status === 'anmeldung-offen' && (
<Link to="/teilnahme" style={{ <Link to={fallbackText(sections.registerUrl, eventDetailContent.sections.registerUrl)} style={{
display: 'inline-flex', alignItems: 'center', gap: 8, display: 'inline-flex', alignItems: 'center', gap: 8,
background: NAVY, color: GOLD, textDecoration: 'none', background: NAVY, color: GOLD, textDecoration: 'none',
padding: '14px 28px', borderRadius: 10, fontWeight: 700, fontSize: 15, padding: '14px 28px', borderRadius: 10, fontWeight: 700, fontSize: 15,
fontFamily: '"IBM Plex Sans", sans-serif', fontFamily: '"IBM Plex Sans", sans-serif',
}}> }}>
Jetzt anmelden <ChevronRight size={16} /> {fallbackText(sections.registerLabel, eventDetailContent.sections.registerLabel)} <ChevronRight size={16} />
</Link> </Link>
)} )}
<Link to="/kontakt" style={{ <Link to={fallbackText(sections.questionUrl, eventDetailContent.sections.questionUrl)} style={{
display: 'inline-flex', alignItems: 'center', gap: 8, display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'transparent', color: NAVY, textDecoration: 'none', background: 'transparent', color: NAVY, textDecoration: 'none',
padding: '14px 28px', borderRadius: 10, fontWeight: 700, fontSize: 15, padding: '14px 28px', borderRadius: 10, fontWeight: 700, fontSize: 15,
border: `2px solid ${NAVY}`, border: `2px solid ${NAVY}`,
}}> }}>
Frage stellen {fallbackText(sections.questionLabel, eventDetailContent.sections.questionLabel)}
</Link> </Link>
</div> </div>
</div> </div>
@@ -249,7 +274,7 @@ export default function EventDetail() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Bell size={18} color={NAVY} /> <Bell size={18} color={NAVY} />
<span style={{ fontSize: 17, fontWeight: 800, color: NAVY, fontFamily: '"IBM Plex Sans", sans-serif' }}> <span style={{ fontSize: 17, fontWeight: 800, color: NAVY, fontFamily: '"IBM Plex Sans", sans-serif' }}>
Live Updates {fallbackText(updatesCopy.heading, eventDetailContent.updates.heading)}
</span> </span>
<span style={{ <span style={{
fontSize: 11, fontWeight: 800, background: NAVY, color: GOLD, fontSize: 11, fontWeight: 800, background: NAVY, color: GOLD,
@@ -268,7 +293,7 @@ export default function EventDetail() {
transition: 'all 0.2s', fontFamily: 'inherit', transition: 'all 0.2s', fontFamily: 'inherit',
}} }}
> >
{subscribed ? <><BellOff size={13} /> Abgemeldet</> : <><Bell size={13} /> Abonnieren</>} {subscribed ? <><BellOff size={13} /> {fallbackText(updatesCopy.unsubscribeLabel, eventDetailContent.updates.unsubscribeLabel)}</> : <><Bell size={13} /> {fallbackText(updatesCopy.subscribeLabel, eventDetailContent.updates.subscribeLabel)}</>}
</button> </button>
</div> </div>
@@ -278,14 +303,14 @@ export default function EventDetail() {
animate={{ opacity: 1, y: 0 }} 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 }} 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. {fallbackText(updatesCopy.subscribedMessage, eventDetailContent.updates.subscribedMessage)}
</motion.div> </motion.div>
)} )}
{/* Timeline */} {/* Timeline */}
<div style={{ maxHeight: isMobile ? 'none' : 560, overflowY: isMobile ? 'visible' : 'auto', paddingRight: 4 }}> <div style={{ maxHeight: isMobile ? 'none' : 560, overflowY: isMobile ? 'visible' : 'auto', paddingRight: 4 }}>
{event.updates.map((update, i) => ( {event.updates.map((update, i) => (
<UpdateCard key={update.id} update={update} index={i} /> <UpdateCard key={update.id} update={update} index={i} detail={detail} />
))} ))}
</div> </div>
</div> </div>
@@ -297,14 +322,14 @@ export default function EventDetail() {
<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 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> <div>
<p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 10 }}> <p style={{ fontSize: 11, fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.25em', color: GOLD, marginBottom: 10 }}>
Mehr entdecken {fallbackText(bottomCta.eyebrow, eventDetailContent.bottomCta.eyebrow)}
</p> </p>
<h3 style={{ fontSize: 28, fontWeight: 900, color: '#fff', letterSpacing: '-0.03em', fontFamily: '"IBM Plex Sans", sans-serif' }}> <h3 style={{ fontSize: 28, fontWeight: 900, color: '#fff', letterSpacing: '-0.03em', fontFamily: '"IBM Plex Sans", sans-serif' }}>
Weitere Veranstaltungen {fallbackText(bottomCta.heading, eventDetailContent.bottomCta.heading)}
</h3> </h3>
</div> </div>
<Link <Link
to="/presse" to={fallbackText(bottomCta.url, eventDetailContent.bottomCta.url)}
style={{ style={{
display: 'inline-flex', alignItems: 'center', gap: 8, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, textDecoration: 'none',
background: GOLD, color: NAVY, padding: '14px 28px', borderRadius: 10, background: GOLD, color: NAVY, padding: '14px 28px', borderRadius: 10,
@@ -314,7 +339,7 @@ export default function EventDetail() {
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)'; }} 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'; }} onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.background = GOLD; el.style.boxShadow = 'none'; }}
> >
Alle Events <ChevronRight size={16} /> {fallbackText(bottomCta.label, eventDetailContent.bottomCta.label)} <ChevronRight size={16} />
</Link> </Link>
</div> </div>
</section> </section>