From a19b703416fc69e24a3535b3fbb54cf35e1b39f0 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 22 Jun 2026 11:48:19 +0200 Subject: [PATCH] feat: manage winner detail chrome via payload --- package.json | 1 + src/collections/Preistraeger.ts | 89 +++++++++++++++++++ src/payload-types.ts | 84 +++++++++++++++++ src/scripts/migrate-spa-pages.ts | 10 +++ .../preload-preistraeger-detail-cms.ts | 57 ++++++++++++ src/spa/pages/PreistraegerDetail.tsx | 53 +++++++---- src/spa/preistraegerDetailContent.ts | 34 +++++++ 7 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 src/scripts/preload-preistraeger-detail-cms.ts create mode 100644 src/spa/preistraegerDetailContent.ts diff --git a/package.json b/package.json index c2f5238..99f285b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "preload:mitglied-werden-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-mitglied-werden-page-cms.ts", "preload:netzwerk-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-netzwerk-page-cms.ts", "preload:participation-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-participation-page-cms.ts", + "preload:preistraeger-detail-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-preistraeger-detail-cms.ts", "preload:preistraeger-index-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-preistraeger-index-page-cms.ts", "preload:press-index-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-press-index-page-cms.ts", "reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install", diff --git a/src/collections/Preistraeger.ts b/src/collections/Preistraeger.ts index 7e2a56e..168e4d6 100644 --- a/src/collections/Preistraeger.ts +++ b/src/collections/Preistraeger.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 { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { slugField } from 'payload' @@ -110,6 +111,94 @@ export const Preistraeger: CollectionConfig = { name: 'hasMedia', type: 'checkbox', }, + { + name: 'detailPage', + label: 'Detail page chrome', + type: 'group', + admin: { + description: + 'Shared labels, explanatory copy, CTAs, and supporting media for the Preisträger detail route.', + }, + fields: [ + { + name: 'breadcrumb', + label: 'Breadcrumb', + type: 'group', + fields: [ + { name: 'url', type: 'text', label: 'URL', defaultValue: preistraegerDetailContent.breadcrumb.url }, + { name: 'label', type: 'text', label: 'Label', defaultValue: preistraegerDetailContent.breadcrumb.label }, + ], + }, + { + name: 'sections', + label: 'Section copy', + type: 'group', + fields: [ + { name: 'companyHeading', type: 'text', defaultValue: preistraegerDetailContent.sections.companyHeading }, + { name: 'juryHeading', type: 'text', defaultValue: preistraegerDetailContent.sections.juryHeading }, + { + name: 'juryTextBeforeName', + type: 'textarea', + defaultValue: preistraegerDetailContent.sections.juryTextBeforeName, + }, + { + name: 'juryTextAfterNameBeforeCategory', + type: 'textarea', + defaultValue: preistraegerDetailContent.sections.juryTextAfterNameBeforeCategory, + }, + { + name: 'juryTextAfterCategory', + type: 'textarea', + defaultValue: preistraegerDetailContent.sections.juryTextAfterCategory, + }, + ], + }, + { + name: 'sideImage', + label: 'Side image', + type: 'group', + fields: [ + { + name: 'image', + type: 'upload', + relationTo: 'media', + admin: { + description: `Current frontend image: /${preistraegerDetailContent.sideImage.imageFilename}`, + }, + }, + { name: 'imageAlt', type: 'text', defaultValue: preistraegerDetailContent.sideImage.imageAlt }, + ], + }, + { + name: 'facts', + label: 'Facts box labels', + type: 'group', + fields: [ + { name: 'heading', type: 'text', defaultValue: preistraegerDetailContent.facts.heading }, + { name: 'categoryLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.categoryLabel }, + { name: 'awardLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.awardLabel }, + { name: 'yearLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.yearLabel }, + { name: 'locationLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.locationLabel }, + { name: 'industryLabel', type: 'text', defaultValue: preistraegerDetailContent.facts.industryLabel }, + ], + }, + { + name: 'websiteCta', + label: 'Website CTA', + type: 'group', + fields: [{ name: 'label', type: 'text', defaultValue: preistraegerDetailContent.websiteCta.label }], + }, + { + name: 'backLink', + label: 'Back link', + type: 'group', + fields: [ + { name: 'url', type: 'text', label: 'URL', defaultValue: preistraegerDetailContent.backLink.url }, + { name: 'label', type: 'text', label: 'Label', defaultValue: preistraegerDetailContent.backLink.label }, + ], + }, + ], + }, { name: 'spaPath', type: 'text', diff --git a/src/payload-types.ts b/src/payload-types.ts index 3a620b8..8ecccfd 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -2460,6 +2460,44 @@ export interface Preistraeger { industry?: string | null; website?: string | null; hasMedia?: boolean | null; + /** + * Shared labels, explanatory copy, CTAs, and supporting media for the Preisträger detail route. + */ + detailPage?: { + breadcrumb?: { + url?: string | null; + label?: string | null; + }; + sections?: { + companyHeading?: string | null; + juryHeading?: string | null; + juryTextBeforeName?: string | null; + juryTextAfterNameBeforeCategory?: string | null; + juryTextAfterCategory?: string | null; + }; + sideImage?: { + /** + * Current frontend image: /gala-preistraeger.jpg + */ + image?: (number | null) | Media; + imageAlt?: string | null; + }; + facts?: { + heading?: string | null; + categoryLabel?: string | null; + awardLabel?: string | null; + yearLabel?: string | null; + locationLabel?: string | null; + industryLabel?: string | null; + }; + websiteCta?: { + label?: string | null; + }; + backLink?: { + url?: string | null; + label?: string | null; + }; + }; /** * Route in the legacy SPA used for previewing the current migrated Preisträger state. */ @@ -4451,6 +4489,52 @@ export interface PreistraegerSelect { industry?: T; website?: T; hasMedia?: T; + detailPage?: + | T + | { + breadcrumb?: + | T + | { + url?: T; + label?: T; + }; + sections?: + | T + | { + companyHeading?: T; + juryHeading?: T; + juryTextBeforeName?: T; + juryTextAfterNameBeforeCategory?: T; + juryTextAfterCategory?: T; + }; + sideImage?: + | T + | { + image?: T; + imageAlt?: T; + }; + facts?: + | T + | { + heading?: T; + categoryLabel?: T; + awardLabel?: T; + yearLabel?: T; + locationLabel?: T; + industryLabel?: T; + }; + websiteCta?: + | T + | { + label?: T; + }; + backLink?: + | T + | { + url?: T; + label?: T; + }; + }; spaPath?: T; publishedAt?: T; meta?: diff --git a/src/scripts/migrate-spa-pages.ts b/src/scripts/migrate-spa-pages.ts index db624d6..fe08947 100644 --- a/src/scripts/migrate-spa-pages.ts +++ b/src/scripts/migrate-spa-pages.ts @@ -6,6 +6,7 @@ import { getPayload } from 'payload' import { articles } from '@/spa/data/blog' import { events } from '@/spa/data/events' +import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' import { WINNERS } from '@/spa/data/winners' import { spaPages, type SpaPageRegistryEntry } from '@/spa/pageRegistry' @@ -159,6 +160,7 @@ function eventData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { function preistraegerData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { const winner = WINNERS.find((item) => item.slug === targetSlug(page)) + const { imageFilename: _sideImageFilename, ...sideImage } = preistraegerDetailContent.sideImage return { ...baseData(page), image: mediaID(mediaMap, winner?.img), @@ -175,6 +177,13 @@ function preistraegerData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { industry: winner?.industry, website: winner?.website, hasMedia: winner?.hasMedia, + detailPage: { + ...preistraegerDetailContent, + sideImage: { + ...sideImage, + image: mediaID(mediaMap, `/${preistraegerDetailContent.sideImage.imageFilename}`), + }, + }, } } @@ -265,6 +274,7 @@ async function upsertPage(payload: Payload, page: SpaPageRegistryEntry, mediaMap async function loadMediaMap(payload: Payload): Promise { const sources = new Set([ ...WINNERS.map((winner) => winner.img), + `/${preistraegerDetailContent.sideImage.imageFilename}`, ...events.map((event) => event.img), ...articles.map((article) => article.img), ]) diff --git a/src/scripts/preload-preistraeger-detail-cms.ts b/src/scripts/preload-preistraeger-detail-cms.ts new file mode 100644 index 0000000..42e2efc --- /dev/null +++ b/src/scripts/preload-preistraeger-detail-cms.ts @@ -0,0 +1,57 @@ +import 'dotenv/config' + +import config from '@payload-config' +import { getPayload, type Payload } from 'payload' + +import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' + +const mediaByFilename = async (payload: Payload, filename: string) => { + const result = await payload.find({ + collection: 'media', + limit: 1, + pagination: false, + where: { filename: { equals: filename } }, + }) + return result.docs[0]?.id +} + +async function main() { + const payload = await getPayload({ config }) + const sideImage = await mediaByFilename(payload, preistraegerDetailContent.sideImage.imageFilename) + const { imageFilename: _sideImageFilename, ...sideImageContent } = preistraegerDetailContent.sideImage + + const winners = await payload.find({ + collection: 'preistraeger', + draft: true, + overrideAccess: true, + pagination: false, + limit: 1000, + }) + + await Promise.all( + winners.docs.map((winner) => + payload.update({ + collection: 'preistraeger', + id: winner.id, + overrideAccess: true, + context: { disableRevalidate: true }, + data: { + detailPage: { + ...preistraegerDetailContent, + sideImage: { + ...sideImageContent, + image: sideImage, + }, + }, + } as never, + }), + ), + ) + + payload.logger.info(`Preloaded Preisträger detail CMS fields for ${winners.docs.length} records`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/src/spa/pages/PreistraegerDetail.tsx b/src/spa/pages/PreistraegerDetail.tsx index da1dc87..c8a1650 100644 --- a/src/spa/pages/PreistraegerDetail.tsx +++ b/src/spa/pages/PreistraegerDetail.tsx @@ -3,8 +3,9 @@ import { Link, useParams, Navigate } from '@/spa/router'; import { Trophy, ArrowLeft, MapPin, Building2, Globe, ChevronRight } from 'lucide-react'; import { WINNERS } from '@/spa/data/winners'; import { useCmsRoute } from '@/spa/cmsRoute'; -import { docImageUrl } from '@/spa/cmsMediaField'; +import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'; import Image from '@/spa/components/ui/UnoptimizedImage' const FF = '"IBM Plex Sans", sans-serif'; @@ -15,6 +16,12 @@ const BORDER = '#D0D5DD'; const GRAY = '#666666'; const BG_ALT = '#E4E2E3'; +type PreistraegerDetailCms = Partial & { + sideImage?: Partial & { image?: unknown } +} + +const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback; + export default function PreistraegerDetail() { const { slug } = useParams<{ slug: string }>(); const route = useCmsRoute(); @@ -36,10 +43,22 @@ export default function PreistraegerDetail() { industry: String(cmsWinner.industry || 'Mittelstand'), website: String(cmsWinner.website || '#'), hasMedia: Boolean(cmsWinner.hasMedia), + detailPage: (cmsWinner.detailPage || {}) as PreistraegerDetailCms, } : WINNERS.find(w => w.slug === slug); const isMobile = useIsMobile(); - if (!winner) return ; + const detail = { + ...preistraegerDetailContent, + ...((winner && 'detailPage' in winner ? winner.detailPage : {}) || {}), + } as PreistraegerDetailCms; + const breadcrumb = { ...preistraegerDetailContent.breadcrumb, ...(detail.breadcrumb || {}) }; + const sections = { ...preistraegerDetailContent.sections, ...(detail.sections || {}) }; + const sideImage = { ...preistraegerDetailContent.sideImage, ...(detail.sideImage || {}) }; + const facts = { ...preistraegerDetailContent.facts, ...(detail.facts || {}) }; + const websiteCta = { ...preistraegerDetailContent.websiteCta, ...(detail.websiteCta || {}) }; + const backLink = { ...preistraegerDetailContent.backLink, ...(detail.backLink || {}) }; + + if (!winner) return ; return (
@@ -55,8 +74,8 @@ export default function PreistraegerDetail() { {/* Breadcrumb */}
- - Preisträger:innen + + {fallbackText(breadcrumb.label, preistraegerDetailContent.breadcrumb.label)} {winner.name} @@ -92,17 +111,17 @@ export default function PreistraegerDetail() { {/* Left: Text */}

- Über das Unternehmen + {fallbackText(sections.companyHeading, preistraegerDetailContent.sections.companyHeading)}

{winner.longDesc}

- Warum ausgezeichnet? + {fallbackText(sections.juryHeading, preistraegerDetailContent.sections.juryHeading)}

- Die Jury des Bayerischen Mittelstandspreises überzeugte {winner.name} durch außergewöhnliche Leistungen im Bereich {winner.category}. Das Unternehmen steht exemplarisch für die Stärken des bayerischen Mittelstands: unternehmerischen Mut, wertebasierte Führung und nachhaltige Wirkung weit über den eigenen Betrieb hinaus. + {fallbackText(sections.juryTextBeforeName, preistraegerDetailContent.sections.juryTextBeforeName)} {winner.name} {fallbackText(sections.juryTextAfterNameBeforeCategory, preistraegerDetailContent.sections.juryTextAfterNameBeforeCategory)} {winner.category}. {fallbackText(sections.juryTextAfterCategory, preistraegerDetailContent.sections.juryTextAfterCategory)}

{/* Quote */} @@ -122,21 +141,21 @@ export default function PreistraegerDetail() {
{/* Photo */}
- Gala Preisverleihung + {mediaAlt(sideImage.image,
{/* Kenndaten */}

- Auf einen Blick + {fallbackText(facts.heading, preistraegerDetailContent.facts.heading)}

{[ - { label: 'Kategorie', value: winner.category, icon: }, - { label: 'Auszeichnung', value: winner.type, icon: }, - { label: 'Jahr', value: String(winner.year), icon: null }, - { label: 'Standort', value: winner.location, icon: }, - { label: 'Branche', value: winner.industry, icon: }, + { label: fallbackText(facts.categoryLabel, preistraegerDetailContent.facts.categoryLabel), value: winner.category, icon: }, + { label: fallbackText(facts.awardLabel, preistraegerDetailContent.facts.awardLabel), value: winner.type, icon: }, + { label: fallbackText(facts.yearLabel, preistraegerDetailContent.facts.yearLabel), value: String(winner.year), icon: null }, + { label: fallbackText(facts.locationLabel, preistraegerDetailContent.facts.locationLabel), value: winner.location, icon: }, + { label: fallbackText(facts.industryLabel, preistraegerDetailContent.facts.industryLabel), value: winner.industry, icon: }, ].map(row => (
{row.label} @@ -162,7 +181,7 @@ export default function PreistraegerDetail() { onMouseEnter={e => { const el = e.currentTarget as HTMLElement; el.style.background = NAVY; el.style.color = '#fff'; }} onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.background = 'transparent'; el.style.color = NAVY; }} > - Website besuchen ↗ + {fallbackText(websiteCta.label, preistraegerDetailContent.websiteCta.label)}
@@ -170,12 +189,12 @@ export default function PreistraegerDetail() { {/* Back link */}
((e.currentTarget as HTMLElement).style.color = GOLD)} onMouseLeave={e => ((e.currentTarget as HTMLElement).style.color = NAVY)} > - Alle Preisträger + {fallbackText(backLink.label, preistraegerDetailContent.backLink.label)}
diff --git a/src/spa/preistraegerDetailContent.ts b/src/spa/preistraegerDetailContent.ts new file mode 100644 index 0000000..326eaec --- /dev/null +++ b/src/spa/preistraegerDetailContent.ts @@ -0,0 +1,34 @@ +export const preistraegerDetailContent = { + notFoundRedirect: '/preistraeger', + breadcrumb: { + url: '/preistraeger', + label: 'Preisträger:innen', + }, + sections: { + companyHeading: 'Über das Unternehmen', + juryHeading: 'Warum ausgezeichnet?', + juryTextBeforeName: 'Die Jury des Bayerischen Mittelstandspreises überzeugte', + juryTextAfterNameBeforeCategory: 'durch außergewöhnliche Leistungen im Bereich', + juryTextAfterCategory: + 'Das Unternehmen steht exemplarisch für die Stärken des bayerischen Mittelstands: unternehmerischen Mut, wertebasierte Führung und nachhaltige Wirkung weit über den eigenen Betrieb hinaus.', + }, + sideImage: { + imageFilename: 'gala-preistraeger.jpg', + imageAlt: 'Gala Preisverleihung', + }, + facts: { + heading: 'Auf einen Blick', + categoryLabel: 'Kategorie', + awardLabel: 'Auszeichnung', + yearLabel: 'Jahr', + locationLabel: 'Standort', + industryLabel: 'Branche', + }, + websiteCta: { + label: 'Website besuchen ↗', + }, + backLink: { + url: '/preistraeger', + label: 'Alle Preisträger', + }, +}