feat: manage press index via payload

This commit is contained in:
syntaxbullet
2026-06-22 11:10:59 +02:00
parent aecdb65a14
commit 27b29a8a7a
7 changed files with 600 additions and 130 deletions

View File

@@ -3,9 +3,10 @@ import { Link } from '@/spa/router';
import { Calendar, MapPin, Download, ArrowRight, FileText } from 'lucide-react';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsCollection } from '@/spa/cmsRoute';
import { docImageUrl } from '@/spa/cmsMediaField';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { pressIndexContent } from '@/spa/pressIndexContent';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
@@ -13,67 +14,52 @@ const CREAM = '#E4E2E3';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
const events = [
{
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',
},
];
type PressIndexCms = Partial<typeof pressIndexContent> & {
hero?: Partial<typeof pressIndexContent.hero> & { image?: unknown }
}
const news = [
{
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',
},
];
type PressEventItem = (typeof pressIndexContent.events.fallbackItems)[number]
type PressNewsItem = (typeof pressIndexContent.news.fallbackItems)[number]
function NewsCard({ item, idx, isMobile }: { item: typeof news[0]; idx: number; isMobile: boolean }) {
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;
const statusLabels: Record<string, string> = {
geplant: 'In Planung',
'anmeldung-offen': 'Anmeldung offen',
intern: 'Intern',
abgeschlossen: 'Abgeschlossen',
}
const normalizeStatus = (status: unknown) => {
const value = fallbackText(status, 'Geplant')
return statusLabels[value] || value
}
const eventFromCms = (event: CmsRouteDoc): PressEventItem => ({
title: String(event.title || 'Event'),
date: String(event.date || 'Termin folgt'),
location: String(event.location || event.venue || 'Bayern'),
cat: String(event.category || 'Event'),
status: normalizeStatus(event.status),
img: docImageUrl(event, '/images/buehne-moderatoren.jpg'),
desc: String(event.description || event.meta?.description || ''),
slug: String(event.spaPath || `/presse/events/${event.slug}`),
})
const postFromCms = (post: CmsRouteDoc): PressNewsItem => ({
title: String(post.title || 'Presse'),
excerpt: String(post.excerpt || post.meta?.description || ''),
cat: String(post.cat || 'Presse'),
img: docImageUrl(post, '/images/gala-saal-overview.jpg', 'heroImage'),
slug: String(post.spaPath || `/presse/blog/${post.slug}`),
})
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>
}
function NewsCard({ item, idx, isMobile, readMoreLabel, total }: { item: PressNewsItem; idx: number; isMobile: boolean; readMoreLabel: string; total: number }) {
const [hovered, setHovered] = React.useState(false);
return (
<Link
@@ -85,7 +71,7 @@ function NewsCard({ item, idx, isMobile }: { item: typeof news[0]; idx: number;
flexDirection: 'column',
cursor: 'pointer',
borderLeft: isMobile ? 'none' : (idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none'),
borderBottom: isMobile && idx < news.length - 1 ? '1px solid rgba(255,255,255,0.07)' : 'none',
borderBottom: isMobile && idx < total - 1 ? '1px solid rgba(255,255,255,0.07)' : 'none',
textDecoration: 'none',
}}
>
@@ -177,7 +163,7 @@ function NewsCard({ item, idx, isMobile }: { item: typeof news[0]; idx: number;
gap: 5,
}}
>
Weiterlesen <ArrowRight size={12} />
{readMoreLabel} <ArrowRight size={12} />
</span>
</div>
</Link>
@@ -217,41 +203,23 @@ function DarkInput(props: React.InputHTMLAttributes<HTMLInputElement>) {
const Press: React.FC = () => {
const isMobile = useIsMobile();
const cms = (useCmsRoute()?.doc?.pressIndex || {}) as PressIndexCms;
const hero = { ...pressIndexContent.hero, ...(cms.hero || {}) };
const eventsSection = { ...pressIndexContent.events, ...(cms.events || {}) };
const newsSection = { ...pressIndexContent.news, ...(cms.news || {}) };
const downloads = { ...pressIndexContent.downloads, ...(cms.downloads || {}) };
const accreditation = { ...pressIndexContent.accreditation, ...(cms.accreditation || {}) };
const contactLines = fallbackText(downloads.contactLines, pressIndexContent.downloads.contactLines).split('\n');
const cmsEvents = useCmsCollection('events');
const cmsPosts = useCmsCollection('posts');
const combinedEvents = React.useMemo(() => {
const existing = new Set(events.map((event) => event.slug.split('/').pop()));
return [
...events,
...cmsEvents
.filter((event) => event.slug && !existing.has(String(event.slug)))
.map((event) => ({
title: String(event.title || 'Event'),
date: String(event.date || 'Termin folgt'),
location: String(event.location || event.venue || 'Bayern'),
cat: String(event.category || 'Event'),
status: String(event.status || 'Geplant'),
img: docImageUrl(event, '/images/buehne-moderatoren.jpg'),
desc: String(event.description || event.meta?.description || ''),
slug: String(event.spaPath || `/presse/events/${event.slug}`),
})),
];
}, [cmsEvents]);
if (cmsEvents.length) return cmsEvents.map(eventFromCms);
return fallbackArray<PressEventItem>(eventsSection.fallbackItems, pressIndexContent.events.fallbackItems);
}, [cmsEvents, eventsSection.fallbackItems]);
const combinedNews = React.useMemo(() => {
const existing = new Set(news.map((item) => item.slug.split('/').pop()));
return [
...news,
...cmsPosts
.filter((post) => post.slug && !existing.has(String(post.slug)))
.map((post) => ({
title: String(post.title || 'Presse'),
excerpt: String(post.excerpt || post.meta?.description || ''),
cat: String(post.cat || 'Presse'),
img: docImageUrl(post, '/images/gala-saal-overview.jpg', 'heroImage'),
slug: String(post.spaPath || `/presse/blog/${post.slug}`),
})),
];
}, [cmsPosts]);
if (cmsPosts.length) return cmsPosts.map(postFromCms);
return fallbackArray<PressNewsItem>(newsSection.fallbackItems, pressIndexContent.news.fallbackItems);
}, [cmsPosts, newsSection.fallbackItems]);
const [hoveredEvent, setHoveredEvent] = useState<number | null>(null);
const [dlHovered, setDlHovered] = useState(false);
const [submitHovered, setSubmitHovered] = useState(false);
@@ -283,8 +251,8 @@ const Press: React.FC = () => {
}}
>
<Image unoptimized
src="/images/gala-dinner.jpg"
alt="Presse BMP"
src={mediaUrl(hero.image, `/images/${pressIndexContent.hero.imageFilename}`)}
alt={mediaAlt(hero.image, fallbackText(hero.imageAlt, pressIndexContent.hero.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
<div
@@ -319,7 +287,7 @@ const Press: React.FC = () => {
color: GOLD,
}}
>
Presse &amp; Newsroom
{fallbackText(hero.eyebrow, pressIndexContent.hero.eyebrow)}
</span>
</div>
<h1
@@ -335,7 +303,7 @@ const Press: React.FC = () => {
overflowWrap: 'anywhere',
}}
>
EVENTS &amp;<br />BERICHTERSTATTUNG.
<Lines text={fallbackText(hero.heading, pressIndexContent.hero.heading)} />
</h1>
<div style={{ width: 48, height: 2, background: GOLD, marginBottom: 24 }} />
<p
@@ -349,7 +317,7 @@ const Press: React.FC = () => {
margin: 0,
}}
>
Alle Termine, Pressemitteilungen und Medienmaterial rund um den Bayerischen Mittelstandspreis.
{fallbackText(hero.description, pressIndexContent.hero.description)}
</p>
</div>
</section>
@@ -380,7 +348,7 @@ const Press: React.FC = () => {
marginBottom: 16,
}}
>
Termine 2026
{fallbackText(eventsSection.eyebrow, pressIndexContent.events.eyebrow)}
</div>
<h2
style={{
@@ -393,7 +361,7 @@ const Press: React.FC = () => {
margin: 0,
}}
>
EVENTS RUND UM DEN PREIS.
<Lines text={fallbackText(eventsSection.heading, pressIndexContent.events.heading)} />
</h2>
</div>
<div>
@@ -406,7 +374,7 @@ const Press: React.FC = () => {
margin: 0,
}}
>
Von der Jurysitzung bis zur festlichen Gala – alle Veranstaltungen auf einen Blick.
{fallbackText(eventsSection.description, pressIndexContent.events.description)}
</p>
</div>
</div>
@@ -429,7 +397,7 @@ const Press: React.FC = () => {
{/* Left – image cell */}
<div style={{ position: 'relative', overflow: 'hidden', height: isMobile ? 200 : 240 }}>
<Image unoptimized src={event.img} alt={event.title} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
<div style={{ position: 'absolute', top: 20, left: 20, padding: '5px 12px', fontFamily: FF, fontSize: 9, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.18em', background: event.status === 'Anmeldung offen' ? GOLD : 'rgba(3,9,58,0.85)', color: event.status === 'Anmeldung offen' ? NAVY : 'rgba(255,255,255,0.6)' }}>
<div style={{ position: 'absolute', top: 20, left: 20, padding: '5px 12px', fontFamily: FF, fontSize: 9, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.18em', background: event.status === fallbackText(eventsSection.openStatusLabel, pressIndexContent.events.openStatusLabel) ? GOLD : 'rgba(3,9,58,0.85)', color: event.status === fallbackText(eventsSection.openStatusLabel, pressIndexContent.events.openStatusLabel) ? NAVY : 'rgba(255,255,255,0.6)' }}>
{event.status}
</div>
</div>
@@ -449,7 +417,7 @@ const Press: React.FC = () => {
<div style={{ width: 24, height: 1, background: 'rgba(239,191,4,0.4)', marginBottom: 14 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.7, flex: 1 }}>{event.desc}</p>
<Link to={event.slug} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16, fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: '#101828', textDecoration: 'none' }}>
Details <ArrowRight size={12} />
{fallbackText(eventsSection.detailLabel, pressIndexContent.events.detailLabel)} <ArrowRight size={12} />
</Link>
</div>
</div>
@@ -481,7 +449,7 @@ const Press: React.FC = () => {
marginBottom: 16,
}}
>
Newsroom
{fallbackText(newsSection.eyebrow, pressIndexContent.news.eyebrow)}
</div>
<h2
style={{
@@ -494,7 +462,7 @@ const Press: React.FC = () => {
margin: 0,
}}
>
NACHRICHTEN &amp; EINBLICKE.
<Lines text={fallbackText(newsSection.heading, pressIndexContent.news.heading)} />
</h2>
</div>
<div>
@@ -507,7 +475,7 @@ const Press: React.FC = () => {
margin: 0,
}}
>
Berichte, Hintergründe und Einblicke rund um den bayerischen Mittelstand und den BMP.
{fallbackText(newsSection.description, pressIndexContent.news.description)}
</p>
</div>
</div>
@@ -515,7 +483,7 @@ const Press: React.FC = () => {
{/* 3-column news grid */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)' }}>
{combinedNews.map((item, idx) => (
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} />
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} readMoreLabel={fallbackText(newsSection.readMoreLabel, pressIndexContent.news.readMoreLabel)} total={combinedNews.length} />
))}
</div>
</section>
@@ -547,7 +515,7 @@ const Press: React.FC = () => {
marginBottom: 16,
}}
>
Pressekontakt &amp; Material
{fallbackText(downloads.eyebrow, pressIndexContent.downloads.eyebrow)}
</div>
<h2
@@ -561,7 +529,7 @@ const Press: React.FC = () => {
margin: '0 0 20px',
}}
>
PRESSE-MATERIAL &amp; KONTAKT.
<Lines text={fallbackText(downloads.heading, pressIndexContent.downloads.heading)} />
</h2>
{/* Gold divider */}
@@ -577,13 +545,13 @@ const Press: React.FC = () => {
marginBottom: 48,
}}
>
Laden Sie unser offizielles Material herunter oder kontaktieren Sie direkt unser Presseteam für individuelle Anfragen.
{fallbackText(downloads.description, pressIndexContent.downloads.description)}
</p>
{/* Download button */}
<a
href="/Print_BMP.zip"
download="Print_BMP.zip"
href={fallbackText(downloads.kitUrl, pressIndexContent.downloads.kitUrl)}
download={fallbackText(downloads.kitDownloadName, pressIndexContent.downloads.kitDownloadName)}
onMouseEnter={() => setDlHovered(true)}
onMouseLeave={() => setDlHovered(false)}
style={{
@@ -622,10 +590,10 @@ const Press: React.FC = () => {
marginBottom: 3,
}}
>
Download Presse-Kit
{fallbackText(downloads.kitLabel, pressIndexContent.downloads.kitLabel)}
</div>
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.4)' }}>
Print_BMP.zip – 1,5 MB
{fallbackText(downloads.kitMeta, pressIndexContent.downloads.kitMeta)}
</div>
</div>
</a>
@@ -643,7 +611,7 @@ const Press: React.FC = () => {
marginBottom: 12,
}}
>
Pressekontakt
{fallbackText(downloads.contactEyebrow, pressIndexContent.downloads.contactEyebrow)}
</div>
<div
style={{
@@ -654,7 +622,7 @@ const Press: React.FC = () => {
marginBottom: 4,
}}
>
Tanja Meier
{fallbackText(downloads.contactName, pressIndexContent.downloads.contactName)}
</div>
<div
style={{
@@ -665,9 +633,12 @@ const Press: React.FC = () => {
overflowWrap: 'anywhere',
}}
>
presse@bmp-bayern.de
<br />
+49 89 123 456 99
{contactLines.map((line, index) => (
<React.Fragment key={`${line}-${index}`}>
{line}
{index < contactLines.length - 1 && <br />}
</React.Fragment>
))}
</div>
</div>
</div>
@@ -717,7 +688,7 @@ const Press: React.FC = () => {
margin: '0 0 16px',
}}
>
Anfrage eingegangen
{fallbackText(accreditation.successHeading, pressIndexContent.accreditation.successHeading)}
</h3>
<p
style={{
@@ -728,7 +699,7 @@ const Press: React.FC = () => {
margin: 0,
}}
>
Vielen Dank für Ihre Akkreditierungsanfrage. Unser Presseteam meldet sich zeitnah bei Ihnen.
{fallbackText(accreditation.successMessage, pressIndexContent.accreditation.successMessage)}
</p>
</div>
) : (
@@ -744,7 +715,7 @@ const Press: React.FC = () => {
marginBottom: 20,
}}
>
Akkreditierung
{fallbackText(accreditation.eyebrow, pressIndexContent.accreditation.eyebrow)}
</div>
<h3
@@ -758,7 +729,7 @@ const Press: React.FC = () => {
margin: '0 0 16px',
}}
>
AKKREDITIERUNG ANFRAGEN.
<Lines text={fallbackText(accreditation.heading, pressIndexContent.accreditation.heading)} />
</h3>
{/* Gold divider */}
@@ -773,7 +744,7 @@ const Press: React.FC = () => {
marginBottom: 36,
}}
>
Melden Sie sich für unsere Presse-Verteiler an oder fordern Sie eine Akkreditierung für die Gala-Verleihung an.
{fallbackText(accreditation.description, pressIndexContent.accreditation.description)}
</p>
{/* Form fields */}
@@ -789,7 +760,7 @@ const Press: React.FC = () => {
color: 'rgba(255,255,255,0.35)',
}}
>
Medium / Redaktion *
{fallbackText(accreditation.mediumLabel, pressIndexContent.accreditation.mediumLabel)}
</label>
<DarkInput
type="text"
@@ -809,7 +780,7 @@ const Press: React.FC = () => {
color: 'rgba(255,255,255,0.35)',
}}
>
Ihr Name *
{fallbackText(accreditation.nameLabel, pressIndexContent.accreditation.nameLabel)}
</label>
<DarkInput
type="text"
@@ -829,7 +800,7 @@ const Press: React.FC = () => {
color: 'rgba(255,255,255,0.35)',
}}
>
E-Mail-Adresse *
{fallbackText(accreditation.emailLabel, pressIndexContent.accreditation.emailLabel)}
</label>
<DarkInput
type="email"
@@ -863,7 +834,7 @@ const Press: React.FC = () => {
transition: 'background 0.15s, box-shadow 0.2s',
}}
>
Akkreditierung anfragen <ArrowRight size={13} />
{fallbackText(accreditation.submitLabel, pressIndexContent.accreditation.submitLabel)} <ArrowRight size={13} />
</button>
</div>
</>