From 43f85dfea2d93422e4e58d82147d1786cc756156 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 22 Jun 2026 11:51:47 +0200 Subject: [PATCH] feat: manage event detail chrome via payload --- package.json | 1 + src/collections/Events.ts | 92 ++++++++++++++++++ src/payload-types.ts | 118 ++++++++++++++++++++++++ src/scripts/migrate-spa-pages.ts | 5 + src/scripts/preload-event-detail-cms.ts | 42 +++++++++ src/spa/eventDetailContent.ts | 49 ++++++++++ src/spa/pages/EventDetail.tsx | 107 +++++++++++++-------- 7 files changed, 373 insertions(+), 41 deletions(-) create mode 100644 src/scripts/preload-event-detail-cms.ts create mode 100644 src/spa/eventDetailContent.ts diff --git a/package.json b/package.json index 99f285b..501529c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "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: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: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", diff --git a/src/collections/Events.ts b/src/collections/Events.ts index b5c9ef0..19ef165 100644 --- a/src/collections/Events.ts +++ b/src/collections/Events.ts @@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload' import { authenticated } from '@/access/authenticated' import { authenticatedOrPublished } from '@/access/authenticatedOrPublished' import { populatePublishedAt } from '@/hooks/populatePublishedAt' +import { eventDetailContent } from '@/spa/eventDetailContent' import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { slugField } from 'payload' @@ -120,6 +121,97 @@ export const Events: CollectionConfig = { { 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', type: 'text', diff --git a/src/payload-types.ts b/src/payload-types.ts index 8ecccfd..e6cbd02 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -2549,6 +2549,73 @@ export interface Event { id?: string | 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. */ @@ -4455,6 +4522,57 @@ export interface EventsSelect { body?: 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; publishedAt?: T; meta?: diff --git a/src/scripts/migrate-spa-pages.ts b/src/scripts/migrate-spa-pages.ts index fe08947..a4208c4 100644 --- a/src/scripts/migrate-spa-pages.ts +++ b/src/scripts/migrate-spa-pages.ts @@ -5,6 +5,7 @@ import 'dotenv/config' import { getPayload } from 'payload' import { articles } from '@/spa/data/blog' +import { eventDetailContent } from '@/spa/eventDetailContent' import { events } from '@/spa/data/events' import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' import { WINNERS } from '@/spa/data/winners' @@ -155,6 +156,10 @@ function eventData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { status: event?.status || 'geplant', eckdaten: event?.eckdaten || [], updates: event?.updates?.map(({ id: _id, ...update }) => update) || [], + detailPage: { + ...eventDetailContent, + updatesCopy: eventDetailContent.updates, + }, } } diff --git a/src/scripts/preload-event-detail-cms.ts b/src/scripts/preload-event-detail-cms.ts new file mode 100644 index 0000000..9d3592a --- /dev/null +++ b/src/scripts/preload-event-detail-cms.ts @@ -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) +}) diff --git a/src/spa/eventDetailContent.ts b/src/spa/eventDetailContent.ts new file mode 100644 index 0000000..9b18016 --- /dev/null +++ b/src/spa/eventDetailContent.ts @@ -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', + }, +} diff --git a/src/spa/pages/EventDetail.tsx b/src/spa/pages/EventDetail.tsx index b47e2ce..c07b240 100644 --- a/src/spa/pages/EventDetail.tsx +++ b/src/spa/pages/EventDetail.tsx @@ -2,44 +2,61 @@ import React, { useState } from 'react' import { useParams, Link } from '@/spa/router' import { motion } from 'framer-motion' 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 { docImageUrl } 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 CREAM = '#EFE5E3' +type EventDetailCms = Omit, 'updateStyles' | 'statusStyles'> & { + updateStyles?: unknown + statusStyles?: unknown + updatesCopy?: Partial +} +type StyleEntry = { bg: string; color: string; label: string } +type StyleMap = Record + +const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback + // ── Timestamp formatter ────────────────────────────────────────────────────── -const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'] -function formatTs(iso: string): string { +function formatTs(iso: string, detail: EventDetailCms): 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` + 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 UPDATE_STYLE: Record = { - 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' }, +const asStyleMap = (value: unknown, fallback: StyleMap): StyleMap => { + if (!value || typeof value !== 'object') return fallback + return { ...fallback, ...(value as StyleMap) } } -// ── Status badge ───────────────────────────────────────────────────────────── -const STATUS_LABEL: Record = { - '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' }, +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 }, + sections: { ...eventDetailContent.sections, ...detail.sections }, + updatesCopy: { ...eventDetailContent.updates, ...(detail.updatesCopy || detail.updates) }, + bottomCta: { ...eventDetailContent.bottomCta, ...detail.bottomCta }, + } } // ── Update Card ─────────────────────────────────────────────────────────────── -function UpdateCard({ update, index }: { update: EventUpdate; index: number }) { - const style = UPDATE_STYLE[update.type] ?? UPDATE_STYLE.update +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 ( {style.label} - {formatTs(update.timestamp)} + {formatTs(update.timestamp, detail)}
{update.title}
{update.body}
@@ -94,7 +111,14 @@ export default function EventDetail() { 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[] : [], updates: Array.isArray(cmsEvent.updates) ? cmsEvent.updates as EventUpdate[] : [], + detailPage: cmsEvent.detailPage, } 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 isMobile = useIsMobile() @@ -102,15 +126,16 @@ export default function EventDetail() { return (
404
-
Event nicht gefunden
- - Zurück zur Presse-Seite +
{fallbackText(notFound.title, eventDetailContent.notFound.title)}
+ + {fallbackText(notFound.backLabel, eventDetailContent.notFound.backLabel)}
) } - const statusStyle = STATUS_LABEL[event.status] ?? STATUS_LABEL['geplant'] + const statusStyles = asStyleMap(detail.statusStyles, eventDetailContent.statusStyles) + const statusStyle = statusStyles[event.status] ?? statusStyles.geplant return (
@@ -126,11 +151,11 @@ export default function EventDetail() { {/* Back link */}
- (e.currentTarget.style.color = '#fff')} onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.7)')} > - Alle Events + {fallbackText(heroBackLink.label, eventDetailContent.heroBackLink.label)}
@@ -182,7 +207,7 @@ export default function EventDetail() { {/* Description */}

- Über die Veranstaltung + {fallbackText(sections.aboutEyebrow, eventDetailContent.sections.aboutEyebrow)}

{event.longDescription.split('\n\n').map((para, i) => (

{para}

@@ -192,7 +217,7 @@ export default function EventDetail() { {/* Eckdaten */}

- Eckdaten + {fallbackText(sections.factsEyebrow, eventDetailContent.sections.factsEyebrow)}

{event.eckdaten.map((item, i) => ( @@ -222,22 +247,22 @@ export default function EventDetail() { {/* CTA */}
{event.status === 'anmeldung-offen' && ( - - Jetzt anmelden + {fallbackText(sections.registerLabel, eventDetailContent.sections.registerLabel)} )} - - Frage stellen + {fallbackText(sections.questionLabel, eventDetailContent.sections.questionLabel)}
@@ -249,7 +274,7 @@ export default function EventDetail() {
- Live Updates + {fallbackText(updatesCopy.heading, eventDetailContent.updates.heading)} - {subscribed ? <> Abgemeldet : <> Abonnieren} + {subscribed ? <> {fallbackText(updatesCopy.unsubscribeLabel, eventDetailContent.updates.unsubscribeLabel)} : <> {fallbackText(updatesCopy.subscribeLabel, eventDetailContent.updates.subscribeLabel)}}
@@ -278,14 +303,14 @@ export default function EventDetail() { 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. + {fallbackText(updatesCopy.subscribedMessage, eventDetailContent.updates.subscribedMessage)} )} {/* Timeline */}
{event.updates.map((update, i) => ( - + ))}
@@ -297,14 +322,14 @@ export default function EventDetail() {

- Mehr entdecken + {fallbackText(bottomCta.eyebrow, eventDetailContent.bottomCta.eyebrow)}

- Weitere Veranstaltungen + {fallbackText(bottomCta.heading, eventDetailContent.bottomCta.heading)}

{ 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 + {fallbackText(bottomCta.label, eventDetailContent.bottomCta.label)}