fix(cms): remove unpublished content fallbacks

This commit is contained in:
syntaxbullet
2026-07-31 14:25:11 +02:00
parent b18bbbe36a
commit 8dc17c0fea
17 changed files with 358 additions and 273 deletions

View File

@@ -29,32 +29,11 @@ const sectionAdmin = (description: string) => ({
initCollapsed: true,
})
const eventFields: Field[] = [
text('title', 'Title'),
text('date', 'Date'),
text('location', 'Location'),
text('cat', 'Category'),
text('status', 'Status'),
uploadField('image', 'Image'),
text('img', 'Image URL or path'),
textarea('desc', 'Description'),
text('slug', 'Route'),
]
const statusLabelFields: Field[] = [
text('value', 'Raw status value'),
text('label', 'Display label'),
]
const newsFields: Field[] = [
text('title', 'Title'),
textarea('excerpt', 'Excerpt'),
text('cat', 'Category'),
uploadField('image', 'Image'),
text('img', 'Image URL or path'),
text('slug', 'Route'),
]
export const pressIndexFields: Field[] = [
{
name: 'pressIndex',
@@ -62,7 +41,7 @@ export const pressIndexFields: Field[] = [
type: 'group',
admin: {
description:
'Edit the Presse listing page chrome. Event and news records are managed through Events and Presse collections; fallback cards remain editable here.',
'Edit the Presse listing page chrome. Event and news records are managed through Events and Presse collections.',
},
fields: [
{
@@ -82,7 +61,7 @@ export const pressIndexFields: Field[] = [
name: 'events',
label: '02 · Events section',
type: 'group',
admin: sectionAdmin('Section header, labels, and fallback event rows.'),
admin: sectionAdmin('Section header, labels, and missing-field presentation fallbacks.'),
fields: [
text('eyebrow', 'Eyebrow', pressIndexContent.events.eyebrow),
textarea('heading', 'Heading', pressIndexContent.events.heading),
@@ -102,21 +81,13 @@ export const pressIndexFields: Field[] = [
defaultValue: pressIndexContent.events.statusLabels,
fields: statusLabelFields,
},
{
name: 'fallbackItems',
label: 'Fallback event rows',
type: 'array',
dbName: 'press_events_fb',
defaultValue: pressIndexContent.events.fallbackItems,
fields: eventFields,
},
],
},
{
name: 'news',
label: '03 · Newsroom section',
type: 'group',
admin: sectionAdmin('Section header, read-more label, and fallback news cards.'),
admin: sectionAdmin('Section header, read-more label, and missing-field presentation fallbacks.'),
fields: [
text('eyebrow', 'Eyebrow', pressIndexContent.news.eyebrow),
textarea('heading', 'Heading', pressIndexContent.news.heading),
@@ -125,14 +96,6 @@ export const pressIndexFields: Field[] = [
text('missingTitleLabel', 'Missing news title label', pressIndexContent.news.missingTitleLabel),
text('missingCategoryLabel', 'Missing news category label', pressIndexContent.news.missingCategoryLabel),
uploadField('collectionFallbackImage', 'Missing news image fallback', `Current frontend image: /images/${pressIndexContent.news.collectionFallbackImageFilename}`),
{
name: 'fallbackItems',
label: 'Fallback news cards',
type: 'array',
dbName: 'press_news_fb',
defaultValue: pressIndexContent.news.fallbackItems,
fields: newsFields,
},
],
},
{

View File

@@ -0,0 +1,114 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
type MigrationDB = MigrateUpArgs['db']
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const fallbackTables = [
'press_events_fb',
'_press_events_fb_v',
'press_news_fb',
'_press_news_fb_v',
] as const
async function tableExists(db: MigrationDB, tableName: string) {
const rows = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function createIndex(db: MigrationDB, tableName: string, suffix: string, columnName: string) {
await db.run(
sql.raw(
`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_${suffix}_idx`)} ON ${ident(tableName)} (${ident(columnName)});`,
),
)
}
async function createLiveFallbackTable(
db: MigrationDB,
tableName: 'press_events_fb' | 'press_news_fb',
itemColumns: string,
) {
if (await tableExists(db, tableName)) return
await db.run(
sql.raw(`
CREATE TABLE ${ident(tableName)} (
${ident('_order')} integer NOT NULL,
${ident('_parent_id')} integer NOT NULL,
${ident('id')} text PRIMARY KEY NOT NULL,
${itemColumns},
FOREIGN KEY (${ident('image_id')}) REFERENCES ${ident('media')}(${ident('id')}) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('pages')}(${ident('id')}) ON UPDATE no action ON DELETE cascade
);
`),
)
await createIndex(db, tableName, 'order', '_order')
await createIndex(db, tableName, 'parent_id', '_parent_id')
await createIndex(db, tableName, 'image', 'image_id')
}
async function createVersionFallbackTable(
db: MigrationDB,
tableName: '_press_events_fb_v' | '_press_news_fb_v',
itemColumns: string,
) {
if (await tableExists(db, tableName)) return
await db.run(
sql.raw(`
CREATE TABLE ${ident(tableName)} (
${ident('_order')} integer NOT NULL,
${ident('_parent_id')} integer NOT NULL,
${ident('id')} integer PRIMARY KEY NOT NULL,
${itemColumns},
${ident('_uuid')} text,
FOREIGN KEY (${ident('image_id')}) REFERENCES ${ident('media')}(${ident('id')}) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('_pages_v')}(${ident('id')}) ON UPDATE no action ON DELETE cascade
);
`),
)
await createIndex(db, tableName, 'order', '_order')
await createIndex(db, tableName, 'parent_id', '_parent_id')
await createIndex(db, tableName, 'image', 'image_id')
}
const eventColumns = [
`${ident('title')} text`,
`${ident('date')} text`,
`${ident('location')} text`,
`${ident('cat')} text`,
`${ident('status')} text`,
`${ident('image_id')} integer`,
`${ident('img')} text`,
`${ident('desc')} text`,
`${ident('slug')} text`,
].join(',\n ')
const newsColumns = [
`${ident('title')} text`,
`${ident('excerpt')} text`,
`${ident('cat')} text`,
`${ident('image_id')} integer`,
`${ident('img')} text`,
`${ident('slug')} text`,
].join(',\n ')
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
for (const tableName of fallbackTables) {
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(tableName)};`))
}
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await createLiveFallbackTable(db, 'press_events_fb', eventColumns)
await createVersionFallbackTable(db, '_press_events_fb_v', eventColumns)
await createLiveFallbackTable(db, 'press_news_fb', newsColumns)
await createVersionFallbackTable(db, '_press_news_fb_v', newsColumns)
}

View File

@@ -15,6 +15,7 @@ import * as migration_20260713_163000_participation_supporting_awards_copy from
import * as migration_20260713_194500_contact_submissions from './20260713_194500_contact_submissions';
import * as migration_20260713_213000_netzwerk_jury_selection from './20260713_213000_netzwerk_jury_selection';
import * as migration_20260713_220000_preistraeger_detail_cleanup from './20260713_220000_preistraeger_detail_cleanup';
import * as migration_20260731_143000_remove_press_record_fallbacks from './20260731_143000_remove_press_record_fallbacks';
export const migrations = [
{
@@ -102,4 +103,9 @@ export const migrations = [
down: migration_20260713_220000_preistraeger_detail_cleanup.down,
name: '20260713_220000_preistraeger_detail_cleanup',
},
{
up: migration_20260731_143000_remove_press_record_fallbacks.up,
down: migration_20260731_143000_remove_press_record_fallbacks.down,
name: '20260731_143000_remove_press_record_fallbacks',
},
];

View File

@@ -1495,7 +1495,7 @@ export interface Page {
};
};
/**
* Edit the Presse listing page chrome. Event and news records are managed through Events and Presse collections; fallback cards remain editable here.
* Edit the Presse listing page chrome. Event and news records are managed through Events and Presse collections.
*/
pressIndex?: {
/**
@@ -1512,7 +1512,7 @@ export interface Page {
description?: string | null;
};
/**
* Section header, labels, and fallback event rows.
* Section header, labels, and missing-field presentation fallbacks.
*/
events?: {
eyebrow?: string | null;
@@ -1536,23 +1536,9 @@ export interface Page {
id?: string | null;
}[]
| null;
fallbackItems?:
| {
title?: string | null;
date?: string | null;
location?: string | null;
cat?: string | null;
status?: string | null;
image?: (number | null) | Media;
img?: string | null;
desc?: string | null;
slug?: string | null;
id?: string | null;
}[]
| null;
};
/**
* Section header, read-more label, and fallback news cards.
* Section header, read-more label, and missing-field presentation fallbacks.
*/
news?: {
eyebrow?: string | null;
@@ -1565,17 +1551,6 @@ export interface Page {
* Current frontend image: /images/gala-saal-overview.jpg
*/
collectionFallbackImage?: (number | null) | Media;
fallbackItems?:
| {
title?: string | null;
excerpt?: string | null;
cat?: string | null;
image?: (number | null) | Media;
img?: string | null;
slug?: string | null;
id?: string | null;
}[]
| null;
};
/**
* Download button and press contact block.
@@ -4176,20 +4151,6 @@ export interface PagesSelect<T extends boolean = true> {
label?: T;
id?: T;
};
fallbackItems?:
| T
| {
title?: T;
date?: T;
location?: T;
cat?: T;
status?: T;
image?: T;
img?: T;
desc?: T;
slug?: T;
id?: T;
};
};
news?:
| T
@@ -4201,17 +4162,6 @@ export interface PagesSelect<T extends boolean = true> {
missingTitleLabel?: T;
missingCategoryLabel?: T;
collectionFallbackImage?: T;
fallbackItems?:
| T
| {
title?: T;
excerpt?: T;
cat?: T;
image?: T;
img?: T;
slug?: T;
id?: T;
};
};
downloads?:
| T

View File

@@ -1,27 +1,11 @@
import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { getPayload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { pressIndexContent } from '@/spa/pressIndexContent'
const filenameFromPath = (source: string) => source.split('?')[0]?.split('/').filter(Boolean).pop()
const fallbackItemsWithImages = async <T extends { img?: string }>(
payload: Payload,
items: T[],
) =>
Promise.all(
items.map(async (item) => {
const filename = item.img ? filenameFromPath(item.img) : undefined
return {
...item,
image: filename ? await mediaByFilename(payload, filename) : undefined,
}
}),
)
async function main() {
const payload = await getPayload({ config })
@@ -48,11 +32,6 @@ async function main() {
const { imageFilename: _heroFilename, ...heroContent } = pressIndexContent.hero
const { collectionFallbackImageFilename: _eventFallbackFilename, ...eventsContent } = pressIndexContent.events
const { collectionFallbackImageFilename: _newsFallbackFilename, ...newsContent } = pressIndexContent.news
const [eventFallbackItems, newsFallbackItems] = await Promise.all([
fallbackItemsWithImages(payload, pressIndexContent.events.fallbackItems),
fallbackItemsWithImages(payload, pressIndexContent.news.fallbackItems),
])
await payload.update({
collection: 'pages',
id: page.id,
@@ -68,12 +47,10 @@ async function main() {
events: {
...eventsContent,
collectionFallbackImage: eventFallbackImage,
fallbackItems: eventFallbackItems,
},
news: {
...newsContent,
collectionFallbackImage: newsFallbackImage,
fallbackItems: newsFallbackItems,
},
downloads: {
...pressIndexContent.downloads,

View File

@@ -102,7 +102,7 @@ export default function HomeWinnersSection({ content }: { content?: HomeWinnersC
const placeholderImage = usePreistraegerPlaceholderImage();
const cmsPreistraeger = useCmsCollection('preistraeger');
const winnersSection = content || {};
const homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger, WINNERS);
const homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger);
const scrollDesktopWinners = !isMobile && homeWinners.length > 8;
const scrollWinnerGrid = isMobile || scrollDesktopWinners;

View File

@@ -3,21 +3,12 @@ import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute'
const FALLBACK_YEAR = 2025
const FALLBACK_LIMIT = 8
type FallbackWinner = {
id: string | number
year?: string | number
}
export function resolveHomeWinners(
featured: unknown,
cmsWinners: CmsRouteDoc[],
fallbackWinners: FallbackWinner[],
) {
export function resolveHomeWinners(featured: unknown, cmsWinners: CmsRouteDoc[]) {
const selectedWinners = Array.isArray(featured) ? featured : []
if (selectedWinners.length) return resolveCmsRelationships(selectedWinners, cmsWinners)
return (cmsWinners.length ? cmsWinners : fallbackWinners)
return cmsWinners
.filter((winner) => Number(winner.year) === FALLBACK_YEAR)
.slice(0, FALLBACK_LIMIT)
}

View File

@@ -483,15 +483,11 @@ const Netzwerk: React.FC = () => {
const sponsoring = { ...netzwerkContent.sponsoring, ...(cms.sponsoring || {}) };
const patronPeople = fallbackArray<PatronPerson>(patronage.people, netzwerkContent.patronage.people);
const cmsJurySelection = cms.jury?.members;
const hasCmsJurySelection = cmsJurySelection !== undefined;
const sourcedJuryDocs = selectJuryDocs(cmsJurySelection, cmsJuryMembers);
const normalizedJuryMembers = sourcedJuryDocs
.map(normalizeJuryMember)
.filter((member) => member.active !== false && member.name);
const jurySourceMembers: JuryMember[] = normalizedJuryMembers.length || hasCmsJurySelection
? normalizedJuryMembers
: netzwerkContent.jury.members;
const juryMembers = sortJuryMembersQuoteFirst(jurySourceMembers);
const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers);
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
const partners = sortedPartners(cmsPartners)
.filter((partner) => partner.active !== false)

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router';
import { Trophy, ArrowRight, Medal } from 'lucide-react';
import { WINNERS, type Winner } from '@/spa/data/winners';
import type { Winner } from '@/spa/data/winners';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
@@ -151,8 +151,7 @@ export default function Preistraeger() {
);
const winners = useMemo(() => {
const fallbackWinner = card.fallbackWinner || preistraegerIndexContent.card.fallbackWinner;
if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
return WINNERS;
return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
}, [card, cmsPreistraeger, placeholderImage]);
const periods = useMemo<WinnerPeriod[]>(() => {
const annualYears = [...new Set(

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { Link, useParams, Navigate } from '@/spa/router';
import { ArrowLeft, Globe, ChevronRight, Download, ChevronLeft, X } from 'lucide-react';
import { WINNERS } from '@/spa/data/winners';
import { useCmsRoute } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
@@ -79,7 +78,7 @@ export default function PreistraegerDetail() {
website: String(cmsWinner.website || '#'),
hasMedia: Boolean(cmsWinner.hasMedia),
detailPage: (cmsWinner.detailPage || {}) as PreistraegerDetailCms,
} : WINNERS.find(w => w.slug === slug);
} : undefined;
const isMobile = useIsMobile();
const detail = (winner && 'detailPage' in winner ? { ...preistraegerDetailContent, ...(winner.detailPage || {}) } : cmsDetail) as PreistraegerDetailCms;

View File

@@ -2,9 +2,15 @@ import React from 'react';
import { Calendar, MapPin } from 'lucide-react';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import Image from '@/spa/components/ui/UnoptimizedImage'
import {
pressEventsFromCms,
pressNewsFromCms,
type PressEventItem,
type PressNewsItem,
} from '@/spa/pressCollectionItems';
import { pressIndexContent } from '@/spa/pressIndexContent';
import { Link } from '@/spa/router';
@@ -15,14 +21,11 @@ const FB = '"Inter", sans-serif';
type PressIndexCms = Partial<typeof pressIndexContent> & {
hero?: Partial<typeof pressIndexContent.hero> & { image?: unknown }
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown; fallbackItems?: PressEventItem[] }
news?: Partial<typeof pressIndexContent.news> & { collectionFallbackImage?: unknown; fallbackItems?: PressNewsItem[] }
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown }
news?: Partial<typeof pressIndexContent.news> & { collectionFallbackImage?: unknown }
downloads?: Partial<typeof pressIndexContent.downloads> & { kitFile?: unknown }
}
type PressEventItem = (typeof pressIndexContent.events.fallbackItems)[number] & { image?: unknown }
type PressNewsItem = (typeof pressIndexContent.news.fallbackItems)[number] & { image?: unknown }
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
@@ -39,43 +42,6 @@ const statusLabelMap = (items: unknown) =>
return labels
}, {})
const normalizeStatus = (status: unknown, labels: Record<string, string>, defaultStatusLabel: string) => {
const value = fallbackText(status, defaultStatusLabel)
return labels[value] || value
}
const detailPath = (spaPath: unknown, slug: unknown, prefix: '/presse/events' | '/presse/blog') => {
const path = fallbackText(spaPath, '')
if (path.startsWith(`${prefix}/`)) return path
const pathSlug = fallbackText(slug, '').replace(/^\/+/, '')
return pathSlug ? `${prefix}/${pathSlug}` : prefix
}
const eventFromCms = (
event: CmsRouteDoc,
fallbackImage: string,
labels: Record<string, string>,
eventsSection: NonNullable<PressIndexCms['events']>,
): PressEventItem => ({
title: String(event.title || fallbackText(eventsSection.missingTitleLabel, pressIndexContent.events.missingTitleLabel)),
date: String(event.date || fallbackText(eventsSection.missingDateLabel, pressIndexContent.events.missingDateLabel)),
location: String(event.location || event.venue || fallbackText(eventsSection.missingLocationLabel, pressIndexContent.events.missingLocationLabel)),
cat: String(event.category || fallbackText(eventsSection.missingCategoryLabel, pressIndexContent.events.missingCategoryLabel)),
status: normalizeStatus(event.status, labels, fallbackText(eventsSection.defaultStatusLabel, pressIndexContent.events.defaultStatusLabel)),
img: docImageUrl(event, fallbackImage),
desc: String(event.description || event.meta?.description || ''),
slug: detailPath(event.spaPath, event.slug, '/presse/events'),
})
const postFromCms = (post: CmsRouteDoc, fallbackImage: string, newsSection: NonNullable<PressIndexCms['news']>): PressNewsItem => ({
title: String(post.title || fallbackText(newsSection.missingTitleLabel, pressIndexContent.news.missingTitleLabel)),
excerpt: String(post.excerpt || post.meta?.description || ''),
cat: String(post.cat || fallbackText(newsSection.missingCategoryLabel, pressIndexContent.news.missingCategoryLabel)),
img: docImageUrl(post, fallbackImage, 'heroImage'),
slug: detailPath(post.spaPath, post.slug, '/presse/blog'),
})
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>
}
@@ -185,23 +151,14 @@ const Press: React.FC = () => {
const eventStatusLabels = React.useMemo(() => statusLabelMap(eventsSection.statusLabels), [eventsSection.statusLabels]);
const eventCollectionFallbackImage = mediaUrl(eventsSection.collectionFallbackImage, `/images/${pressIndexContent.events.collectionFallbackImageFilename}`);
const newsCollectionFallbackImage = mediaUrl(newsSection.collectionFallbackImage, `/images/${pressIndexContent.news.collectionFallbackImageFilename}`);
const combinedEvents = React.useMemo(() => {
if (cmsEvents.length) {
return cmsEvents.map((event) =>
eventFromCms(
event,
eventCollectionFallbackImage,
eventStatusLabels,
eventsSection,
),
);
}
return fallbackArray<PressEventItem>(eventsSection.fallbackItems, pressIndexContent.events.fallbackItems);
}, [cmsEvents, eventCollectionFallbackImage, eventStatusLabels, eventsSection]);
const combinedNews = React.useMemo(() => {
if (cmsPosts.length) return cmsPosts.map((post) => postFromCms(post, newsCollectionFallbackImage, newsSection));
return fallbackArray<PressNewsItem>(newsSection.fallbackItems, pressIndexContent.news.fallbackItems);
}, [cmsPosts, newsCollectionFallbackImage, newsSection]);
const combinedEvents = React.useMemo(
() => pressEventsFromCms(cmsEvents, eventCollectionFallbackImage, eventStatusLabels, eventsSection),
[cmsEvents, eventCollectionFallbackImage, eventStatusLabels, eventsSection],
);
const combinedNews = React.useMemo(
() => pressNewsFromCms(cmsPosts, newsCollectionFallbackImage, newsSection),
[cmsPosts, newsCollectionFallbackImage, newsSection],
);
return (
<div className="animate-fade-in">

View File

@@ -0,0 +1,102 @@
import { docImageUrl } from '@/spa/cmsMediaField'
import type { CmsRouteDoc } from '@/spa/cmsRoute'
import { pressIndexContent } from '@/spa/pressIndexContent'
export type PressEventItem = {
title: string
date: string
location: string
cat: string
status: string
img: string
desc: string
slug: string
image?: unknown
}
export type PressNewsItem = {
title: string
excerpt: string
cat: string
img: string
slug: string
image?: unknown
}
type PressEventsSection = Partial<typeof pressIndexContent.events>
type PressNewsSection = Partial<typeof pressIndexContent.news>
const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback
const normalizeStatus = (
status: unknown,
labels: Record<string, string>,
defaultStatusLabel: string,
) => {
const value = fallbackText(status, defaultStatusLabel)
return labels[value] || value
}
const detailPath = (spaPath: unknown, slug: unknown, prefix: '/presse/events' | '/presse/blog') => {
const path = fallbackText(spaPath, '')
if (path.startsWith(`${prefix}/`)) return path
const pathSlug = fallbackText(slug, '').replace(/^\/+/, '')
return pathSlug ? `${prefix}/${pathSlug}` : prefix
}
export function pressEventsFromCms(
events: CmsRouteDoc[],
fallbackImage: string,
labels: Record<string, string>,
section: PressEventsSection,
): PressEventItem[] {
return events.map((event) => ({
title: String(
event.title ||
fallbackText(section.missingTitleLabel, pressIndexContent.events.missingTitleLabel),
),
date: String(
event.date ||
fallbackText(section.missingDateLabel, pressIndexContent.events.missingDateLabel),
),
location: String(
event.location ||
event.venue ||
fallbackText(section.missingLocationLabel, pressIndexContent.events.missingLocationLabel),
),
cat: String(
event.category ||
fallbackText(section.missingCategoryLabel, pressIndexContent.events.missingCategoryLabel),
),
status: normalizeStatus(
event.status,
labels,
fallbackText(section.defaultStatusLabel, pressIndexContent.events.defaultStatusLabel),
),
img: docImageUrl(event, fallbackImage),
desc: String(event.description || event.meta?.description || ''),
slug: detailPath(event.spaPath, event.slug, '/presse/events'),
}))
}
export function pressNewsFromCms(
posts: CmsRouteDoc[],
fallbackImage: string,
section: PressNewsSection,
): PressNewsItem[] {
return posts.map((post) => ({
title: String(
post.title ||
fallbackText(section.missingTitleLabel, pressIndexContent.news.missingTitleLabel),
),
excerpt: String(post.excerpt || post.meta?.description || ''),
cat: String(
post.cat ||
fallbackText(section.missingCategoryLabel, pressIndexContent.news.missingCategoryLabel),
),
img: docImageUrl(post, fallbackImage, 'heroImage'),
slug: detailPath(post.spaPath, post.slug, '/presse/blog'),
}))
}

View File

@@ -24,38 +24,6 @@ export const pressIndexContent = {
{ value: 'intern', label: 'Intern' },
{ value: 'abgeschlossen', label: 'Abgeschlossen' },
],
fallbackItems: [
{
title: 'Preisverleihung 2026',
date: '22. Okt 2026',
location: 'München, Residenz',
cat: 'Gala / Event',
status: 'In Planung',
img: '/images/buehne-moderatoren.jpg',
desc: 'Der glanzvolle Höhepunkt des Jahres. Die Verleihung der Preise in der prachtvollen Kulisse der Residenz München.',
slug: '/presse/events/preisverleihung-2026',
},
{
title: 'Mittelstands-Gipfel',
date: '15. Juni 2026',
location: 'Nürnberg',
cat: 'Workshop',
status: 'Anmeldung offen',
img: '/images/networking-innenhof.jpg',
desc: 'Regionaler Austausch und Best-Practices für bayerische Unternehmen auf dem Weg zum Preis.',
slug: '/presse/events/mittelstands-gipfel-2026',
},
{
title: 'Nominierten-Auswahl',
date: '04. Aug 2026',
location: 'Regensburg',
cat: 'Jury-Sitzung',
status: 'Intern',
img: '/images/saal-gedeckt.jpg',
desc: 'Das Gremium sichtet die Ergebnisse der Audits und legt die Nominierten für die Hauptpreise fest.',
slug: '/presse/events/nominierten-auswahl-2026',
},
],
},
news: {
eyebrow: 'Newsroom',
@@ -65,29 +33,6 @@ export const pressIndexContent = {
missingTitleLabel: 'Presse',
missingCategoryLabel: 'Presse',
collectionFallbackImageFilename: 'gala-saal-overview.jpg',
fallbackItems: [
{
title: 'Wie Bayerns KMU die KI nutzen',
excerpt: 'Ein Deep-Dive in die Bewerbungsunterlagen 2024 zeigt: Der Mittelstand treibt die Digitalisierung aktiv voran.',
cat: 'Innovation',
img: '/images/gala-saal-overview.jpg',
slug: '/presse/blog/ki-nutzung-bayerischer-kmu',
},
{
title: 'Start der Ehrenamts-Initiative',
excerpt: 'Gemeinsam mit unseren Partnern fördern wir soziale Projekte mittelständischer Unternehmen.',
cat: 'Engagement',
img: '/images/networking-innenhof.jpg',
slug: '/presse/blog/ehrenamts-initiative-2026',
},
{
title: 'Bayerische Wirtschaft wächst',
excerpt: 'Neue Prognosen zeigen ein stabiles Wachstum für den Mittelstand – gute Aussichten für die Awards.',
cat: 'Wirtschaft',
img: '/images/gala-dinner.jpg',
slug: '/presse/blog/bayerische-wirtschaft-waechst',
},
],
},
downloads: {
eyebrow: 'Pressekontakt & Material',

View File

@@ -18,18 +18,14 @@ const published2025Winners: CmsRouteDoc[] = Array.from({ length: 9 }, (_, index)
describe('home winner selection', () => {
it('uses a non-empty CMS selection without appending winners from the fallback year', () => {
const featured = selected2026Winners.map(({ id }) => id)
const winners = resolveHomeWinners(
featured,
[...selected2026Winners, ...published2025Winners],
[],
)
const winners = resolveHomeWinners(featured, [...selected2026Winners, ...published2025Winners])
expect(winners).toEqual(selected2026Winners)
expect(winners.some((winner) => winner.year === 2025)).toBe(false)
})
it('falls back to at most eight 2025 winners when the CMS selection is empty', () => {
const winners = resolveHomeWinners([], published2025Winners, [])
const winners = resolveHomeWinners([], published2025Winners)
expect(winners).toEqual(published2025Winners.slice(0, 8))
})
@@ -37,12 +33,8 @@ describe('home winner selection', () => {
it('omits an unpublished selected winner without changing the remaining page order', () => {
const [first, unpublished, third] = selected2026Winners
const selection = [third, { ...unpublished, image: { id: 99 } }, first]
const publicWinners = resolveHomeWinners(selection, [first, third], published2025Winners)
const previewWinners = resolveHomeWinners(
selection,
[first, unpublished, third],
published2025Winners,
)
const publicWinners = resolveHomeWinners(selection, [first, third])
const previewWinners = resolveHomeWinners(selection, [first, unpublished, third])
expect(publicWinners).toEqual([third, first])
expect(previewWinners).toEqual([third, unpublished, first])
@@ -51,8 +43,10 @@ describe('home winner selection', () => {
it('does not replace an entirely unpublished CMS selection with fallback winners', () => {
const [unpublished] = selected2026Winners
expect(
resolveHomeWinners([{ ...unpublished, image: { id: 99 } }], [], published2025Winners),
).toEqual([])
expect(resolveHomeWinners([{ ...unpublished, image: { id: 99 } }], [])).toEqual([])
})
it('does not resurrect static winners when the published collection is empty', () => {
expect(resolveHomeWinners([], [])).toEqual([])
})
})

View File

@@ -13,6 +13,7 @@ describe('Netzwerk jury members', () => {
expect(selectJuryDocs([3, { id: 1 }], docs).map((member) => member.id)).toEqual([3, 1])
expect(selectJuryDocs([], docs)).toEqual([])
expect(selectJuryDocs(undefined, docs)).toEqual(docs)
expect(selectJuryDocs(undefined, [])).toEqual([])
})
it('omits an unpublished selected member publicly and restores it in preview order', () => {

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { pressEventsFromCms, pressNewsFromCms } from '@/spa/pressCollectionItems'
import { pressIndexContent } from '@/spa/pressIndexContent'
describe('press collection items', () => {
it('does not replace an empty published events collection with demo event rows', () => {
expect(
pressEventsFromCms([], '/images/event-placeholder.jpg', {}, pressIndexContent.events),
).toEqual([])
})
it('does not replace an empty published posts collection with demo news cards', () => {
expect(pressNewsFromCms([], '/images/news-placeholder.jpg', pressIndexContent.news)).toEqual([])
})
})

View File

@@ -202,11 +202,11 @@ describe('public route hydration', () => {
const publicDocs = await findFixtureDocs('preistraeger', ids, false)
const previewDocs = await findFixtureDocs('preistraeger', ids, true)
expect(resolveHomeWinners(selection, publicDocs, []).map((doc) => doc.id)).toEqual([
expect(resolveHomeWinners(selection, publicDocs).map((doc) => doc.id)).toEqual([
third.id,
first.id,
])
expect(resolveHomeWinners(selection, previewDocs, []).map((doc) => doc.id)).toEqual([
expect(resolveHomeWinners(selection, previewDocs).map((doc) => doc.id)).toEqual([
third.id,
unpublished.id,
first.id,
@@ -221,7 +221,7 @@ describe('public route hydration', () => {
})
const republishedDocs = await findFixtureDocs('preistraeger', ids, false)
expect(resolveHomeWinners(selection, republishedDocs, []).map((doc) => doc.id)).toEqual([
expect(resolveHomeWinners(selection, republishedDocs).map((doc) => doc.id)).toEqual([
third.id,
unpublished.id,
first.id,
@@ -297,6 +297,43 @@ describe('public route hydration', () => {
expect(previewDocs.map((doc) => doc.id)).toEqual([first.id, unpublished.id, third.id])
})
it('leaves no event row after the final /presse event is unpublished', async () => {
const event = await payload.create({
collection: 'events',
context: { disableRevalidate: true },
data: {
_status: 'published',
slug: `${fixtureKey}-event-final`,
title: `${fixtureKey} event final`,
},
draft: false,
})
cleanup.push(() =>
payload.delete({
collection: 'events',
context: { disableRevalidate: true },
id: event.id,
}),
)
expect((await findFixtureDocs('events', [event.id], false)).map((doc) => doc.id)).toEqual([
event.id,
])
await payload.update({
collection: 'events',
context: { disableRevalidate: true },
id: event.id,
data: { _status: 'draft' },
draft: false,
})
expect(await findFixtureDocs('events', [event.id], false)).toEqual([])
expect((await findFixtureDocs('events', [event.id], true)).map((doc) => doc.id)).toEqual([
event.id,
])
})
it('omits an unpublished event previously rendered on /presse', async () => {
const published = await payload.create({
collection: 'events',
@@ -341,6 +378,44 @@ describe('public route hydration', () => {
expect(previewDocs.map((doc) => doc.id).sort()).toEqual(ids.sort())
})
it('leaves no news card after the final /presse article is unpublished', async () => {
const post = await payload.create({
collection: 'posts',
context: { disableRevalidate: true },
data: {
_status: 'published',
content: lexicalDocument,
slug: `${fixtureKey}-post-final`,
title: `${fixtureKey} post final`,
},
draft: false,
})
cleanup.push(() =>
payload.delete({
collection: 'posts',
context: { disableRevalidate: true },
id: post.id,
}),
)
expect((await findFixtureDocs('posts', [post.id], false)).map((doc) => doc.id)).toEqual([
post.id,
])
await payload.update({
collection: 'posts',
context: { disableRevalidate: true },
id: post.id,
data: { _status: 'draft' },
draft: false,
})
expect(await findFixtureDocs('posts', [post.id], false)).toEqual([])
expect((await findFixtureDocs('posts', [post.id], true)).map((doc) => doc.id)).toEqual([
post.id,
])
})
it('omits an unpublished press article previously rendered on /presse', async () => {
const published = await payload.create({
collection: 'posts',