Add migration scripts and preload participation awards script

- Created migration index file to manage database migrations.
- Added script to preload participation awards and related blog posts into the CMS.
- Introduced new types and default data for application phases.
- Added award articles data structure for the upcoming awards.
This commit is contained in:
syntaxbullet
2026-06-30 19:10:53 +02:00
parent 9545f2513d
commit 7ff405d1a2
47 changed files with 68728 additions and 986 deletions

View File

@@ -1,10 +1,11 @@
'use client'
import React, { useEffect, useState } from 'react'
import React from 'react'
import Layout from '@/spa/components/layout/Layout'
import ScrollToTop from '@/spa/components/layout/ScrollToTop'
import { defaultHeaderData, type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation'
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import type { SpaSiteSettingsData } from '@/spa/siteSettings'
import About from '@/spa/pages/About'
@@ -60,42 +61,20 @@ export default function NextApp({
footer,
cmsRoute,
siteSettings,
applicationPhase,
}: {
pathname: string
header?: SpaHeaderData
footer?: SpaFooterData
cmsRoute?: CmsRouteData
siteSettings?: SpaSiteSettingsData
applicationPhase?: SpaApplicationPhaseData
}) {
const [loading, setLoading] = useState(true)
const headerData = header || defaultHeaderData
useEffect(() => {
const timer = window.setTimeout(() => setLoading(false), 1500)
return () => window.clearTimeout(timer)
}, [])
if (loading) {
return (
<div className="fixed inset-0 z-[1000] bg-primary flex flex-col items-center justify-center">
<div className="relative">
<div className="w-24 h-24 border-2 border-accent/20 border-t-accent rounded-full animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-accent font-display font-bold text-2xl">{headerData.loadingBrand}</span>
</div>
</div>
<div className="mt-8 text-white/40 uppercase tracking-[0.4em] text-[10px] font-bold animate-pulse">
{headerData.loadingLabel}
</div>
</div>
)
}
const page = React.createElement(resolvePage(pathname))
return (
<>
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings } : undefined}>
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationPhase } : undefined}>
<ScrollToTop />
<Layout header={header} footer={footer}>
{page}

View File

@@ -0,0 +1,97 @@
export type SpaApplicationPhaseLink = {
label?: string | null
url?: string | null
to?: string | null
}
export type SpaApplicationPhaseItem = {
num?: string | null
tag?: string | null
pulse?: boolean | null
phase?: string | null
headline?: string | null
body?: string | null
cta?: SpaApplicationPhaseLink | null
accent?: string | null
bg?: string | null
glow?: string | null
meta?: string | null
successApplications?: string | null
successWinners?: string | null
membershipCta?: SpaApplicationPhaseLink | null
}
export type SpaApplicationPhaseData = {
activePhase: string
noActionLabel: string
successApplicationsLabel: string
successWinnersLabel: string
phases: SpaApplicationPhaseItem[]
}
export const defaultApplicationPhaseData: SpaApplicationPhaseData = {
activePhase: '0',
noActionLabel: 'Kein Handlungsbedarf',
successApplicationsLabel: 'Bewerbungen',
successWinnersLabel: 'Preisträger',
phases: [
{
num: '01',
tag: 'JETZT OFFEN',
pulse: true,
phase: 'Bewerbungsphase 2026',
headline: 'BEWERBEN SIE SICH JETZT.',
body:
'Die Bewerbungsphase 2026 ist eröffnet – es ist noch nicht zu spät. Reichen Sie Ihre Unterlagen bis zum 30. Juni kostenlos ein und zeigen Sie, was den bayerischen Mittelstand ausmacht.',
cta: { label: 'Jetzt kostenlos bewerben', url: '/teilnahme' },
accent: '#EFBF04',
bg: '#020A1E',
glow: 'rgba(239,191,4,0.14)',
meta: 'Deadline: 30. Juni 2026',
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
},
{
num: '02',
tag: 'IN AUSWERTUNG',
pulse: true,
phase: 'Auswertungsphase 2026',
headline: 'DIE JURY BEWERTET GERADE.',
body:
'Die Anmeldephase 2026 ist geschlossen. Unsere unabhängige Expertenjury aus Wissenschaft und Praxis sichtet und bewertet derzeit alle Einreichungen.',
cta: {},
accent: '#4A8FC9',
bg: '#060D1C',
glow: 'rgba(74,143,201,0.12)',
meta: 'Ergebnis: August 2026',
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
},
{
num: '03',
tag: 'ABGESCHLOSSEN',
pulse: false,
phase: 'Preisverleihung · Oktober 2026',
headline: 'DER BMP 2026 WAR EIN ERFOLG.',
body:
'Wir danken allen 247 Bewerberinnen und Bewerbern für Mut und Exzellenz. Drei außergewöhnliche Unternehmen wurden in München ausgezeichnet – ein unvergesslicher Abend.',
cta: { label: 'Alle Preisträger ansehen', url: '/preistraeger' },
accent: '#EFBF04',
bg: '#06090F',
glow: 'rgba(239,191,4,0.08)',
meta: 'Gala · Oktober 2026 · München',
successApplications: '247',
successWinners: '3',
},
],
}
export function normalizeApplicationPhaseData(
value?: Partial<SpaApplicationPhaseData> | null,
): SpaApplicationPhaseData {
return {
activePhase: value?.activePhase || defaultApplicationPhaseData.activePhase,
noActionLabel: value?.noActionLabel || defaultApplicationPhaseData.noActionLabel,
successApplicationsLabel: value?.successApplicationsLabel || defaultApplicationPhaseData.successApplicationsLabel,
successWinnersLabel: value?.successWinnersLabel || defaultApplicationPhaseData.successWinnersLabel,
phases: value?.phases?.length ? value.phases : defaultApplicationPhaseData.phases,
}
}

186
src/spa/awardArticles.ts Normal file
View File

@@ -0,0 +1,186 @@
export const AWARD_CONTACT_EMAIL = 'info@bmp-bayern.de'
const mailto = (subject: string) => `mailto:${AWARD_CONTACT_EMAIL}?subject=${encodeURIComponent(subject)}`
export const awardArticles = [
{
slug: 'goldene-bavaria',
title: 'Die Goldene Bavaria',
cat: 'Auszeichnung',
date: '30. Juni 2026',
author: 'BMP Redaktion',
authorRole: 'Bayerischer Mittelstandspreis',
readTime: '4 min',
imageFilename: 'goldene-bavaria.png',
imageAlt: 'Goldene Bavaria Illustration',
excerpt:
'Die höchste Auszeichnung des Bayerischen Mittelstandspreises steht für unternehmerische Exzellenz, gesellschaftliche Verantwortung und den Mut, Zukunft aktiv zu gestalten.',
sections: [
{
heading: 'Was ist die Goldene Bavaria?',
body:
'Die Goldene Bavaria ist die bedeutendste Einzelauszeichnung des Bayerischen Mittelstandspreises. Sie steht für unternehmerische Exzellenz, gesellschaftliche Verantwortung und den Mut, Zukunft aktiv zu gestalten. Werte, die den bayerischen Unternehmergeist seit Generationen prägen.',
},
{
body:
'Als physische Trophäe verkörpert sie diesen Anspruch auf einzigartige Weise: ein dreidimensionales, maßstabgetreues Abbild der Original-Bavaria auf der Theresienwiese. 30 Zentimeter hoch, in Bronze gegossen und vollständig vergoldet. Ein Stück Bayern, das bleibt.',
},
{
heading: 'Ein Preis mit Geschichte und Tradition',
body:
'Die Goldene Bavaria ist ein Wanderpokal. Wer sie erhält, trägt sie nicht nur nach Hause, sondern übernimmt Verantwortung. Im nächsten Jahr übergibt der Vorjahressieger die Trophäe persönlich an den neuen Würdigen. Eine Geste, die Verbundenheit schafft und aus einem Preis eine lebendige Gemeinschaft macht.',
},
{
heading: 'Neu im Jahr 2026',
body:
'Erstmals werden in diesem Jahr drei Goldene Bavaria verliehen: der Roland-Berger-Zukunftspreis für visionäre Unternehmensführung, der Bavarian Future Award als Sonderpreis der HAM und ein Sonderpreis für Gesellschaft und Bildung.',
},
{
heading: 'Wie sieht sie aus?',
body:
'Die Goldene Bavaria ist kein generisches Pokal-Design. Sie ist ein Kunstobjekt: gegossen nach den exakten dreidimensionalen Daten der weltbekannten Münchner Bavaria-Statue, gefertigt in Bronze und veredelt durch echte Vergoldung.',
},
{
body:
'Ein Foto der Goldenen Bavaria zeigen wir nach der feierlichen Erstverleihung am 17. Juli 2026. Aus Wertschätzung gegenüber den ersten drei Preisträgern gebührt ihnen der erste Blick.',
},
],
tags: ['Goldene Bavaria', 'Auszeichnung', 'Mittelstand', 'Bayern'],
relatedSlugs: ['roland-berger-zukunftspreis', 'bavarian-future-award'],
card: {
label: 'Auszeichnung 01',
title: 'Goldene\nBavaria',
description: 'Die höchste Auszeichnung des Bayerischen Mittelstandspreises.',
},
articleCta: { label: 'Artikel lesen', url: '/presse/blog/goldene-bavaria' },
mailtoCta: { label: 'Kontakt aufnehmen', url: mailto('Interesse an der Goldenen Bavaria') },
detailSidebarCta: {
heading: 'Zur Goldenen Bavaria',
text: 'Sie haben Fragen zur Auszeichnung oder zur Teilnahme am Bayerischen Mittelstandspreis?',
label: 'Kontakt aufnehmen',
url: mailto('Frage zur Goldenen Bavaria'),
},
},
{
slug: 'roland-berger-zukunftspreis',
title: 'Roland-Berger-Zukunftspreis',
cat: 'Auszeichnung',
date: '30. Juni 2026',
author: 'BMP Redaktion',
authorRole: 'Bayerischer Mittelstandspreis',
readTime: '3 min',
imageFilename: 'prof-roland-berger.jpg',
imageAlt: 'Prof. Dr. h.c. Roland Berger',
excerpt:
'Der Roland-Berger-Zukunftspreis würdigt Menschen, Unternehmen und Initiativen, die Zukunft nicht nur vorhersagen, sondern aktiv gestalten.',
sections: [
{
heading: 'Zukunft aktiv gestalten',
body:
'Mit dem Roland-Berger-Zukunftspreis wird 2026 erstmals eine besondere Auszeichnung im Rahmen des Bayerischen Mittelstandspreises vergeben. Der Preis richtet den Blick auf Menschen, Unternehmen und Initiativen, die Zukunft nicht nur vorhersagen, sondern aktiv gestalten.',
},
{
heading: 'Der Namensgeber',
body:
'Namensgeber des Preises ist Prof. Dr. h.c. Roland Berger, einer der renommiertesten Unternehmer und Vordenker Europas. Als Gründer der internationalen Strategieberatung Roland Berger sowie stellvertretender Vorsitzender des Stiftungsrats von Wir Eigentümerunternehmer steht er seit Jahrzehnten für unternehmerische Verantwortung, Innovationskraft und zukunftsorientiertes Denken.',
},
{
heading: 'Wofür der Preis steht',
body:
'Der Roland-Berger-Zukunftspreis würdigt herausragende Beiträge, die mit Robustheit, Resilienz, Zukunftsfähigkeit, Innovation, Nachhaltigkeit und Verantwortung überzeugen. Ausgezeichnet werden diejenigen, die Wandel aktiv gestalten und als Gestaltungskräfte Impulse für die Zukunft setzen.',
},
{
heading: 'Besonderer Stellenwert',
body:
'Die Auszeichnung persönlich von Prof. Dr. h.c. Roland Berger überreicht zu bekommen, ist eine hohe Anerkennung für herausragendes Engagement und wegweisende Leistungen. Der Preis verbindet unternehmerische Exzellenz mit dem Anspruch, nachhaltige Impulse für Wirtschaft und Gesellschaft zu setzen.',
},
{
body:
'Da der Roland-Berger-Zukunftspreis 2026 erstmals verliehen wird, folgen weitere Informationen zu Hintergründen, Kriterien und Besonderheiten dieser neuen Auszeichnung.',
},
],
tags: ['Roland Berger', 'Zukunftspreis', 'Innovation', 'Verantwortung'],
relatedSlugs: ['goldene-bavaria', 'bavarian-future-award'],
card: {
label: 'Auszeichnung 02',
title: 'Roland-Berger-\nZukunftspreis',
description: 'Für visionäre Unternehmensführung und zukunftsorientiertes Denken.',
},
articleCta: { label: 'Artikel lesen', url: '/presse/blog/roland-berger-zukunftspreis' },
mailtoCta: { label: 'Kontakt aufnehmen', url: mailto('Interesse am Roland-Berger-Zukunftspreis') },
detailSidebarCta: {
heading: 'Zum Zukunftspreis',
text: 'Sie möchten mehr über den Roland-Berger-Zukunftspreis oder eine passende Einreichung erfahren?',
label: 'Kontakt aufnehmen',
url: mailto('Frage zum Roland-Berger-Zukunftspreis'),
},
},
{
slug: 'bavarian-future-award',
title: 'Bavarian Future Award',
cat: 'Auszeichnung',
date: '30. Juni 2026',
author: 'BMP Redaktion',
authorRole: 'Bayerischer Mittelstandspreis',
readTime: '3 min',
imageFilename: 'goldene-bavaria.png',
imageAlt: 'Bavarian Future Award Illustration',
excerpt:
'Der Sonderpreis der Hochschule für angewandtes Management macht vorbildliche Unternehmensnachfolge im bayerischen Mittelstand sichtbar.',
sections: [
{
heading: 'Sonderpreis der Studierenden',
body:
'Der Bavarian Future Award ist ein Sonderpreis im Rahmen des Bayerischen Mittelstandspreises und wird von Studierenden des Masterstudiengangs Management (M.A.) der Hochschule für angewandtes Management vergeben.',
},
{
heading: 'Nachfolge als Zukunftsaufgabe',
body:
'Der Award zeichnet Unternehmen aus, die ihre Unternehmensnachfolge beispielhaft gestalten und damit die Zukunftsfähigkeit des Mittelstands sichern. Eine erfolgreiche Nachfolge ist weit mehr als die Übergabe von Verantwortung. Sie ist ein strategischer Prozess, der die Grundlage für langfristige Stabilität, Wachstum und unternehmerische Weiterentwicklung schafft.',
},
{
heading: 'Bewertungsschwerpunkte',
body:
'Im Mittelpunkt stehen Nachfolgelösungen, die zeigen, wie der Generationswechsel erfolgreich gelingen kann. Bewertet werden frühzeitige strategische Planung, transparente Kommunikation, klares Rollenverständnis, aktive Einbindung von Mitarbeitenden und die Fähigkeit, bewährte Traditionen mit neuen Impulsen zu verbinden.',
},
{
body:
'Unternehmen, die diese Faktoren berücksichtigen, schaffen Vertrauen, sichern wertvolles Know-how und legen den Grundstein für nachhaltigen Erfolg über Generationen hinweg.',
},
{
heading: 'Sichtbarkeit für vorbildliche Lösungen',
body:
'Der Bavarian Future Award macht diese Nachfolgelösungen sichtbar und würdigt Unternehmen, die Verantwortung übernehmen, Wandel aktiv gestalten und damit ein wichtiges Zeichen für die Zukunft des bayerischen Mittelstands setzen.',
},
],
tags: ['Bavarian Future Award', 'HAM', 'Nachfolge', 'Studentenpreis'],
relatedSlugs: ['goldene-bavaria', 'roland-berger-zukunftspreis'],
card: {
label: 'Auszeichnung 03',
title: 'Studentenpreis /\nBavarian Future Award',
description: 'Der Sonderpreis der HAM für vorbildlich gestaltete Unternehmensnachfolge.',
},
articleCta: { label: 'Artikel lesen', url: '/presse/blog/bavarian-future-award' },
mailtoCta: { label: 'Kontakt aufnehmen', url: mailto('Interesse am Bavarian Future Award') },
detailSidebarCta: {
heading: 'Zum Future Award',
text: 'Sie haben Fragen zum Studentenpreis oder möchten ein Unternehmen ins Gespräch bringen?',
label: 'Kontakt aufnehmen',
url: mailto('Frage zum Bavarian Future Award'),
},
},
]
export const participationAwardsGrid = {
eyebrow: 'Auszeichnungen 2026',
heading: 'DREI AUSZEICHNUNGEN.\nEIN ANSPRUCH.',
description:
'Direkt im Bewerbungsjahr 2026 stehen drei Auszeichnungen im Fokus: die Goldene Bavaria, der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award.',
cards: awardArticles.map(({ imageFilename, imageAlt, card, articleCta, mailtoCta }) => ({
...card,
imageFilename,
imageAlt,
articleCta,
mailtoCta,
})),
}

View File

@@ -20,6 +20,7 @@ export const blogDetailContent = {
tag: 'Presse',
},
categoryColors: {
Auszeichnung: '#111D55',
Innovation: '#2563EB',
Engagement: '#16A34A',
Wirtschaft: '#9333EA',

View File

@@ -3,7 +3,7 @@ import type { Footer, Header, Media } from '@/payload-types'
export type SpaIconName = 'instagram' | 'linkedin' | 'facebook'
export type SpaLink = { label: string; path: string }
export type SpaSocialLink = { label: string; href: string; icon: SpaIconName }
export type SpaCtaLink = SpaLink & { variant?: 'primary' | 'secondary' | 'subtle' | 'upload' }
export type SpaCtaLink = SpaLink & { variant?: 'primary' | 'secondary' | 'subtle' | 'login' }
export type SpaMedia = { url?: string; alt?: string }
export type SpaHeaderNavItem = SpaLink & {
@@ -23,13 +23,10 @@ export type SpaHeaderData = {
closeLabel: string
dialogLabel: string
overviewLabel: string
loadingBrand: string
loadingLabel: string
navItems: SpaHeaderNavItem[]
secondaryLinks: SpaLink[]
socialLinks: SpaSocialLink[]
ctas: SpaCtaLink[]
languages: string[]
}
export type SpaFooterLinkColumn = { label: string; links: SpaLink[] }
@@ -69,9 +66,6 @@ export const defaultHeaderData: SpaHeaderData = {
closeLabel: 'Menü schließen',
dialogLabel: 'Navigation',
overviewLabel: 'Überblick',
loadingBrand: 'BMP',
loadingLabel: 'Exzellenz wird geladen...',
languages: ['DE', 'EN'],
socialLinks: defaultSocialLinks,
navItems: [
{
@@ -85,7 +79,6 @@ export const defaultHeaderData: SpaHeaderData = {
key: 'teilnahme', label: 'Ihr Weg zum Preis', path: '/teilnahme', subLabel: 'IHR WEG ZUM PREIS',
sub: [
{ label: 'Teilnahmevoraussetzungen', path: '/teilnahme#voraussetzungen' },
{ label: 'Kriterien & Bewertung', path: '/teilnahme#kriterien' },
{ label: 'Fristen & Termine', path: '/teilnahme#fristen' },
{ label: 'Bewerbungsformular', path: '/teilnahme#bewerben' },
],
@@ -124,7 +117,7 @@ export const defaultHeaderData: SpaHeaderData = {
ctas: [
{ label: 'Jetzt bewerben', path: '/teilnahme#bewerben', variant: 'primary' },
{ label: 'Mitglied werden', path: '/mitglied-werden', variant: 'secondary' },
{ label: 'Formular hochladen', path: '/formular-hochladen', variant: 'upload' },
{ label: 'Login', path: '/admin', variant: 'login' },
],
}
@@ -173,7 +166,25 @@ const mediaToSpa = (value: unknown, fallback?: SpaMedia): SpaMedia | undefined =
return fallback
}
type HeaderLanguage = string | { label?: string }
const isParticipationCriteriaLink = (link?: { label?: string; path?: string }) => {
const normalizedLabel = link?.label?.trim().toLowerCase()
return (
link?.path === '/teilnahme#kriterien' ||
normalizedLabel === 'kriterien & bewertung' ||
normalizedLabel === 'kriterien und bewertung'
)
}
const withoutParticipationCriteriaLink = (items: SpaHeaderNavItem[]) =>
items.map((item) =>
item.key === 'teilnahme'
? {
...item,
sub: item.sub?.filter((subItem) => !isParticipationCriteriaLink(subItem)),
}
: item,
)
type FooterPartnerLogo = {
alt?: string
@@ -181,13 +192,15 @@ type FooterPartnerLogo = {
}
export function normalizeHeaderData(header?: Partial<Header>): SpaHeaderData {
const navItems = header?.navItems?.length
? (header.navItems as unknown as SpaHeaderNavItem[])
: defaultHeaderData.navItems
return {
...defaultHeaderData,
...header,
logo: mediaToSpa(header?.logo, defaultHeaderData.logo),
navItems: header?.navItems?.length
? (header.navItems as unknown as SpaHeaderNavItem[])
: defaultHeaderData.navItems,
navItems: withoutParticipationCriteriaLink(navItems),
secondaryLinks: header?.secondaryLinks?.length
? (header.secondaryLinks as unknown as SpaLink[])
: defaultHeaderData.secondaryLinks,
@@ -195,11 +208,6 @@ export function normalizeHeaderData(header?: Partial<Header>): SpaHeaderData {
? (header.socialLinks as unknown as SpaSocialLink[])
: defaultHeaderData.socialLinks,
ctas: header?.ctas?.length ? (header.ctas as unknown as SpaCtaLink[]) : defaultHeaderData.ctas,
languages: header?.languages?.length
? (header.languages as HeaderLanguage[])
.map((item) => (typeof item === 'string' ? item : item.label))
.filter((label): label is string => Boolean(label))
: defaultHeaderData.languages,
}
}

View File

@@ -2,6 +2,7 @@
import React, { createContext, useContext } from 'react'
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import type { SpaSiteSettingsData } from '@/spa/siteSettings'
export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
@@ -24,6 +25,7 @@ export type CmsRouteData = {
doc: CmsRouteDoc
lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>>
siteSettings?: SpaSiteSettingsData
applicationPhase?: SpaApplicationPhaseData
}
const CmsRouteContext = createContext<CmsRouteData | undefined>(undefined)
@@ -39,3 +41,7 @@ export function useCmsRoute() {
export function useCmsCollection(collection: CmsRouteCollection) {
return useCmsRoute()?.lists?.[collection] || []
}
export function useApplicationPhase() {
return useCmsRoute()?.applicationPhase
}

View File

@@ -4,7 +4,7 @@ import Image from '@/spa/components/ui/UnoptimizedImage'
import React, { useState, useEffect, useRef } from 'react';
import { Link, useLocation } from '@/spa/router';
import { ArrowRight, Upload } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
import LoadingScreen from '@/spa/components/ui/LoadingScreen';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import {
@@ -36,6 +36,10 @@ const SEP = 'rgba(239,191,4,0.2)';
const LINE = 'rgba(239,191,4,0.35)';
const FF_DISPLAY = '"IBM Plex Sans", sans-serif';
const FF_BODY = '"Inter", sans-serif';
const HIDE_MEMBERSHIP_FOOTER_ENTRY = true;
const isMembershipFooterLink = (link?: { label?: string; path?: string }) =>
link?.path === '/mitglied-werden' || link?.label?.trim().toLowerCase() === 'mitglied werden';
/* ── SocialLink helper ─────────────────────────────────── */
const SocialLink: React.FC<{ href:string; icon:SpaIconName; label:string }> = ({ href, icon, label }) => {
@@ -54,24 +58,29 @@ const SocialLink: React.FC<{ href:string; icon:SpaIconName; label:string }> = ({
const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, footer = defaultFooterData }) => {
const NAV = header.navItems;
const SECONDARY = header.secondaryLinks;
const SOCIAL = header.socialLinks;
const linkedInHeaderSocial = header.socialLinks.filter((item) => item.icon === 'linkedin');
const linkedInFooterSocial = footer.socialLinks.filter((item) => item.icon === 'linkedin');
const HEADER_SOCIAL = linkedInHeaderSocial.length
? linkedInHeaderSocial
: defaultHeaderData.socialLinks.filter((item) => item.icon === 'linkedin');
const FOOTER_SOCIAL = linkedInFooterSocial.length
? linkedInFooterSocial
: defaultFooterData.socialLinks.filter((item) => item.icon === 'linkedin');
const logo = header.logo?.url || defaultHeaderData.logo?.url || '';
const footerLogo = footer.logo?.url || defaultFooterData.logo?.url || '';
const headerCtas = header.ctas;
const footerCtas = footer.ctas;
const fallbackHeaderCtas = defaultHeaderData.ctas;
const fallbackFooterCtas = defaultFooterData.ctas;
const footerMembershipCta = footerCtas[1] || fallbackFooterCtas[1];
const showFooterMembershipCta =
!HIDE_MEMBERSHIP_FOOTER_ENTRY || !isMembershipFooterLink(footerMembershipCta);
const [open, setOpen] = useState(false); // desktop overlay
const [activeKey, setActive] = useState<string | null>(null); // desktop hover subnav
const [mobileOpen, setMobileOpen] = useState(false); // mobile sidebar overlay
const [expandKey, setExpand] = useState<string | null>(null); // mobile accordion
const [lang, setLang] = useState(header.languages[0] || defaultHeaderData.languages[0]);
const [mounted, setMounted] = useState(false);
const [showLoader, setShowLoader] = useState(() => {
if (typeof window === 'undefined') return false;
sessionStorage.removeItem('bmp-intro-seen');
return true;
});
const [showLoader, setShowLoader] = useState(true);
const [formVisible, setFormVisible] = useState(false);
const location = useLocation();
const firstLinkRef = useRef<HTMLAnchorElement>(null);
@@ -220,24 +229,10 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
<span style={{ fontSize:8, color:'rgba(5,26,135,0.5)', letterSpacing:2, userSelect:'none' }}>◆</span>
</div>
{/* RIGHT – social + lang */}
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'flex-end', gap:20, padding:'0 28px', height:'100%' }}>
{/* RIGHT – social */}
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'flex-end', padding:'0 28px', height:'100%' }}>
<div style={{ display:'flex', gap:14, alignItems:'center' }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
<div style={{ width:1, height:20, background:'rgba(239,191,4,0.3)' }} />
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
{header.languages.map((l, i) => (
<React.Fragment key={l}>
{i===1 && <span style={{ color:'rgba(239,191,4,.35)', fontSize: 14 }}>|</span>}
<button
onClick={() => setLang(l)}
style={{ fontSize:10, fontWeight:600, letterSpacing:'.1em', textTransform:'uppercase', background:'none', border:'none', cursor:'pointer', padding:'1px 0', lineHeight:1, color:lang===l ? GOLD : MUTED, transition:'color .2s', fontFamily:FF_DISPLAY }}
onMouseEnter={e => { if (lang !== l) e.currentTarget.style.color = '#fff'; }}
onMouseLeave={e => { if (lang !== l) e.currentTarget.style.color = MUTED; }}
>{l}</button>
</React.Fragment>
))}
{HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
</div>
@@ -326,7 +321,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
transition:'opacity .2s ease, transform .2s ease',
pointerEvents: activeKey===item.key ? 'all' : 'none',
}}>
<div style={{ fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:'.22em', color:'#009EE0', marginBottom:16, fontFamily:FF_DISPLAY }}>
<div style={{ fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:'.22em', color:'rgba(255,255,255,.4)', marginBottom:16, fontFamily:FF_DISPLAY }}>
{header.overviewLabel}
</div>
<div style={{ fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'.1em', color:'#fff', marginBottom:20, fontFamily:FF_DISPLAY }}>
@@ -352,20 +347,8 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
<div style={{ padding:'40px 80px 40px 48px', display:'flex', flexDirection:'column', gap:24 }}>
<div style={{ display:'flex', alignItems:'center', gap:20, flexWrap:'wrap' }}>
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
{header.languages.map((l,i) => (
<React.Fragment key={l}>
{i===1 && <span style={{ color:'rgba(239,191,4,.35)', fontSize: 14 }}>|</span>}
<button onClick={() => setLang(l)}
style={{ fontSize:12, fontWeight:600, letterSpacing:'.1em', textTransform:'uppercase', background:'none', border:'none', cursor:'pointer', padding:0, color:lang===l ? GOLD : MUTED, transition:'color .2s' }}
onMouseEnter={e=>{ if(lang!==l) e.currentTarget.style.color='#fff'; }}
onMouseLeave={e=>{ if(lang!==l) e.currentTarget.style.color=MUTED; }}
>{l}</button>
</React.Fragment>
))}
</div>
<div style={{ display:'flex', gap:14 }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
{HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
</div>
@@ -385,39 +368,34 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
onMouseEnter={e=>{ e.currentTarget.style.background='#FFD130'; e.currentTarget.style.boxShadow='0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }}
onMouseLeave={e=>{ e.currentTarget.style.background='#EFBF04'; e.currentTarget.style.boxShadow='none'; }}
>{headerCtas[0]?.label || fallbackHeaderCtas[0].label}</Link>
<Link to={headerCtas[1]?.path || fallbackHeaderCtas[1].path} onClick={() => setOpen(false)}
style={{ display:'inline-block', background:'transparent', color:MUTED, fontSize: 15, fontWeight:700, textTransform:'uppercase', letterSpacing:'.12em', padding:'10px 24px', textDecoration:'none', transition:'border-color .2s, color .2s', borderRadius:0, fontFamily:FF_DISPLAY, border:'1.5px solid rgba(239,191,4,0.35)' }}
onMouseEnter={e=>{ e.currentTarget.style.borderColor='#EFBF04'; e.currentTarget.style.color='#EFBF04'; }}
onMouseLeave={e=>{ e.currentTarget.style.borderColor='rgba(239,191,4,0.35)'; e.currentTarget.style.color=MUTED; }}
>{headerCtas[1]?.label || fallbackHeaderCtas[1].label}</Link>
<div style={{ height:'1px', background:'rgba(239,191,4,0.12)', margin:'4px 0' }} />
<Link to={headerCtas[2]?.path || fallbackHeaderCtas[2].path} onClick={() => setOpen(false)}
style={{
display:'flex', alignItems:'center', gap:10,
background:'rgba(255,255,255,0.04)',
color:'rgba(255,255,255,0.45)',
background:'transparent',
color:MUTED,
fontSize:11, fontWeight:700,
textTransform:'uppercase', letterSpacing:'.14em',
padding:'10px 18px',
textDecoration:'none',
fontFamily:FF_DISPLAY,
border:'1px dashed rgba(255,255,255,0.15)',
border:'1px solid rgba(239,191,4,0.28)',
transition:'color .2s, border-color .2s, background .2s',
}}
onMouseEnter={e=>{
e.currentTarget.style.color='#fff';
e.currentTarget.style.borderColor='rgba(255,255,255,0.4)';
e.currentTarget.style.background='rgba(255,255,255,0.07)';
e.currentTarget.style.color=GOLD;
e.currentTarget.style.borderColor='rgba(239,191,4,0.7)';
e.currentTarget.style.background='rgba(239,191,4,0.05)';
}}
onMouseLeave={e=>{
e.currentTarget.style.color='rgba(255,255,255,0.45)';
e.currentTarget.style.borderColor='rgba(255,255,255,0.15)';
e.currentTarget.style.background='rgba(255,255,255,0.04)';
e.currentTarget.style.color=MUTED;
e.currentTarget.style.borderColor='rgba(239,191,4,0.28)';
e.currentTarget.style.background='transparent';
}}
>
<Upload size={12} strokeWidth={2} /> {headerCtas[2]?.label || fallbackHeaderCtas[2].label}
{headerCtas[2]?.label || fallbackHeaderCtas[2].label}
</Link>
</div>
@@ -560,31 +538,16 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
<Link to={headerCtas[0]?.path || fallbackHeaderCtas[0].path} onClick={() => setMobileOpen(false)}
style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:52, background:GOLD, color:'#101828', fontSize:14, fontWeight:700, textTransform:'uppercase', letterSpacing:'.12em', textDecoration:'none', fontFamily:FF_DISPLAY }}
>{headerCtas[0]?.label || fallbackHeaderCtas[0].label}</Link>
<Link to={headerCtas[1]?.path || fallbackHeaderCtas[1].path} onClick={() => setMobileOpen(false)}
style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:50, background:'transparent', color:'#fff', fontSize:14, fontWeight:700, textTransform:'uppercase', letterSpacing:'.12em', textDecoration:'none', fontFamily:FF_DISPLAY, border:`1.5px solid ${LINE}` }}
>{headerCtas[1]?.label || fallbackHeaderCtas[1].label}</Link>
<Link to={headerCtas[2]?.path || fallbackHeaderCtas[2].path} onClick={() => setMobileOpen(false)}
style={{ display:'flex', alignItems:'center', justifyContent:'center', gap:8, minHeight:46, background:'none', color:'rgba(255,255,255,0.5)', fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:'.14em', textDecoration:'none', fontFamily:FF_DISPLAY, border:'1px dashed rgba(255,255,255,0.2)' }}
><Upload size={13} strokeWidth={2} /> {headerCtas[2]?.label || fallbackHeaderCtas[2].label}</Link>
style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:46, background:'none', color:MUTED, fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:'.14em', textDecoration:'none', fontFamily:FF_DISPLAY, border:'1px solid rgba(239,191,4,0.28)' }}
>{headerCtas[2]?.label || fallbackHeaderCtas[2].label}</Link>
</div>
{/* Footer zone: lang + social + secondary */}
{/* Footer zone: social + secondary */}
<div style={{ marginTop:8, padding:'20px 24px 28px', borderTop:`1px solid ${SEP}`, display:'flex', flexDirection:'column', gap:18 }}>
{/* Lang + social row */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between' }}>
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
{header.languages.map((l, i) => (
<React.Fragment key={l}>
{i === 1 && <span style={{ color:LINE, fontSize:13 }}>|</span>}
<button onClick={() => setLang(l)}
style={{ fontSize:12, fontWeight:600, letterSpacing:'.1em', textTransform:'uppercase', background:'none', border:'none', cursor:'pointer', color:lang===l ? GOLD : MUTED, padding:'4px 0', lineHeight:1, fontFamily:FF_DISPLAY }}
>{l}</button>
</React.Fragment>
))}
</div>
<div style={{ display:'flex', gap:18 }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
{/* Social row */}
<div style={{ display:'flex', gap:18 }}>
{HEADER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
{/* Secondary links */}
<div style={{ display:'flex', flexWrap:'wrap', gap:'0 20px' }}>
@@ -770,31 +733,33 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
}}
>{footerCtas[0]?.label || fallbackFooterCtas[0].label}</Link>
<Link
to={footerCtas[1]?.path || fallbackFooterCtas[1].path}
style={{
fontFamily: FF_DISPLAY,
fontSize: 16,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.12em',
color: MUTED,
border: '1.5px solid rgba(239,191,4,0.35)',
padding: '8px 22px',
textDecoration: 'none',
display: 'inline-block',
borderRadius: 2,
transition: 'border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = GOLD;
e.currentTarget.style.color = GOLD;
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'rgba(239,191,4,0.35)';
e.currentTarget.style.color = MUTED;
}}
>{footerCtas[1]?.label || fallbackFooterCtas[1].label}</Link>
{showFooterMembershipCta && (
<Link
to={footerCtas[1]?.path || fallbackFooterCtas[1].path}
style={{
fontFamily: FF_DISPLAY,
fontSize: 16,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.12em',
color: MUTED,
border: '1.5px solid rgba(239,191,4,0.35)',
padding: '8px 22px',
textDecoration: 'none',
display: 'inline-block',
borderRadius: 2,
transition: 'border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = GOLD;
e.currentTarget.style.color = GOLD;
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'rgba(239,191,4,0.35)';
e.currentTarget.style.color = MUTED;
}}
>{footerCtas[1]?.label || fallbackFooterCtas[1].label}</Link>
)}
<Link
to={footerCtas[2]?.path || fallbackFooterCtas[2].path}
@@ -937,7 +902,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
{footer.copyright}
</p>
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
{SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
{FOOTER_SOCIAL.map(s => <SocialLink key={s.label} {...s} />)}
</div>
</div>
</footer>

View File

@@ -9,7 +9,7 @@ import { Link } from '@/spa/router'
const ANTHRA = '#1A2030'
const NAVY = '#111D55'
const GOLD = '#EFBF04'
const CREAM = '#EFE5E3'
const PAGE_BG = '#fff'
const FF = '"IBM Plex Sans", sans-serif'
const FB = '"Inter", sans-serif'
@@ -51,7 +51,7 @@ export default function LegalPage({ content, cmsKey }: { content: LegalPageConte
}))
return (
<div style={{ background: CREAM, minHeight: '100vh' }}>
<div style={{ background: PAGE_BG, minHeight: '100vh' }}>
<section style={{ background: ANTHRA, padding: isMobile ? '48px 24px 40px' : '64px 80px 52px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.2), transparent)` }} />
<Link

View File

@@ -63,6 +63,7 @@ function useContainerScrollContext() {
export const ContainerScroll = ({
children,
className,
style,
...props
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
const scrollRef = React.useRef<HTMLDivElement>(null)
@@ -74,6 +75,7 @@ export const ContainerScroll = ({
<div
ref={scrollRef}
className={cn("relative min-h-[120vh]", className)}
style={{ position: "relative", ...style }}
{...props}
>
{children}
@@ -126,7 +128,7 @@ export const ProcessCard: React.FC<ProcessCardProps> = ({
const { scrollYProgress } = useContainerScrollContext()
const start = index / itemsLength
const end = start + 1 / itemsLength
const { innerWidth } = window
const innerWidth = typeof window === "undefined" ? 0 : window.innerWidth
const [ref, { width }] = useMeasure()
const x = useTransform(

View File

@@ -61,60 +61,6 @@ export const homeContent = {
'Wir suchen Vorbilder, die sich trotz aller Risiken nicht scheuen, Visionen in Businesspläne und Ideen in Unternehmungen zu verwandeln, und das mit großem persönlichen Einsatz und Verantwortung für Ihre Mitarbeiter. Seit vielen Jahren würdigen wir herausragende unternehmerische Leistungen im Freistaat.',
cta: { label: 'Mehr über den Preis', url: '/der-bmp' },
},
status: {
activePhase: '0',
noActionLabel: 'Kein Handlungsbedarf',
successApplicationsLabel: 'Bewerbungen',
successWinnersLabel: 'Preisträger',
phases: [
{
num: '01',
tag: 'JETZT OFFEN',
pulse: true,
phase: 'Bewerbungsphase 2026',
headline: 'BEWERBEN SIE SICH JETZT.',
body:
'Die Bewerbungsphase 2026 ist eröffnet – es ist noch nicht zu spät. Reichen Sie Ihre Unterlagen bis zum 30. Juni kostenlos ein und zeigen Sie, was den bayerischen Mittelstand ausmacht.',
cta: { label: 'Jetzt kostenlos bewerben', url: '/teilnahme' },
accent: '#EFBF04',
bg: '#020A1E',
glow: 'rgba(239,191,4,0.14)',
meta: 'Deadline: 30. Juni 2026',
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
},
{
num: '02',
tag: 'IN AUSWERTUNG',
pulse: true,
phase: 'Auswertungsphase 2026',
headline: 'DIE JURY BEWERTET GERADE.',
body:
'Die Anmeldephase 2026 ist geschlossen. Unsere unabhängige Expertenjury aus Wissenschaft und Praxis sichtet und bewertet derzeit alle Einreichungen.',
cta: {},
accent: '#4A8FC9',
bg: '#060D1C',
glow: 'rgba(74,143,201,0.12)',
meta: 'Ergebnis: August 2026',
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
},
{
num: '03',
tag: 'ABGESCHLOSSEN',
pulse: false,
phase: 'Preisverleihung · Oktober 2026',
headline: 'DER BMP 2026 WAR EIN ERFOLG.',
body:
'Wir danken allen 247 Bewerberinnen und Bewerbern für Mut und Exzellenz. Drei außergewöhnliche Unternehmen wurden in München ausgezeichnet – ein unvergesslicher Abend.',
cta: { label: 'Alle Preisträger ansehen', url: '/preistraeger' },
accent: '#EFBF04',
bg: '#06090F',
glow: 'rgba(239,191,4,0.08)',
meta: 'Gala · Oktober 2026 · München',
successApplications: '247',
successWinners: '3',
},
],
},
winners: {
eyebrow: 'Success Stories',
heading: 'DIE BESTEN UNTERNEHMEN IN BAYERN',

View File

@@ -16,6 +16,7 @@ const FB = '"Inter", sans-serif'
const NAVY = '#111D55'
const GOLD = '#EFBF04'
const CREAM = '#E4E2E3'
const HIDE_MEMBERSHIP_ABOUT_CTA = true
type AboutCms = Partial<typeof aboutContent> & {
hero?: Partial<typeof aboutContent.hero> & { backgroundImage?: unknown }
@@ -36,6 +37,12 @@ const fallbackText = (value: unknown, fallback: string) => (typeof value === 'st
const fallbackArray = <T,>(value: unknown, fallback: T[]) => (Array.isArray(value) && value.length ? (value as T[]) : fallback)
const isMembershipCta = (cta?: { label?: string | null; url?: string | null } | null) => {
const normalizedLabel = cta?.label?.trim().toLowerCase()
return cta?.url === '/mitglied-werden' || normalizedLabel === 'mitglied werden' || normalizedLabel === 'vereinsmitglied werden'
}
function Lines({ text }: { text: string }) {
return (
<>
@@ -117,6 +124,7 @@ const About: React.FC = () => {
const goals = { ...aboutContent.goals, ...(cms.goals || {}) }
const values = { ...aboutContent.values, ...(cms.values || {}) }
const cta = { ...aboutContent.cta, ...(cms.cta || {}) }
const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta)
const highlightFallbackImage = mediaUrl(
highlights.fallbackImage,
`/images/${aboutContent.highlights.fallbackImageFilename}`,
@@ -359,7 +367,7 @@ const About: React.FC = () => {
</div>
</div>
<div style={{ background: '#EFE5E3', padding: isMobile ? '56px 24px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<div style={{ background: '#fff', padding: isMobile ? '56px 24px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(goals.eyebrow, aboutContent.goals.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3vw, 2.8rem)', fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, margin: '0 0 40px' }}>
<Lines text={fallbackText(goals.heading, aboutContent.goals.heading)} />
@@ -423,17 +431,19 @@ const About: React.FC = () => {
>
{fallbackText(cta.primaryCta?.label, aboutContent.cta.primaryCta.label)} <ChevronRight size={16} />
</Link>
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.3)', marginRight: 8 }}>{fallbackText(cta.secondaryPrefix, aboutContent.cta.secondaryPrefix)}</span>
<Link
to={fallbackText(cta.secondaryCta?.url, aboutContent.cta.secondaryCta.url)}
style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1, transition: 'color 0.15s, borderColor 0.15s' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#EFBF04'; e.currentTarget.style.borderBottomColor = '#EFBF04' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(239,191,4,0.7)'; e.currentTarget.style.borderBottomColor = 'rgba(239,191,4,0.3)' }}
>
{fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} />
</Link>
</div>
{showSecondaryCta && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.3)', marginRight: 8 }}>{fallbackText(cta.secondaryPrefix, aboutContent.cta.secondaryPrefix)}</span>
<Link
to={fallbackText(cta.secondaryCta?.url, aboutContent.cta.secondaryCta.url)}
style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1, transition: 'color 0.15s, borderColor 0.15s' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#EFBF04'; e.currentTarget.style.borderBottomColor = '#EFBF04' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(239,191,4,0.7)'; e.currentTarget.style.borderBottomColor = 'rgba(239,191,4,0.3)' }}
>
{fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} />
</Link>
</div>
)}
</div>
</section>
</div>

View File

@@ -11,7 +11,7 @@ import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#EFE5E3';
const PAGE_BG = '#fff';
const INK = '#3A3A3A';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
@@ -81,10 +81,21 @@ export default function BlogDetail() {
const metaCopy = detail.metaCopy || blogDetailContent.meta;
const sidebarCta = detail.sidebarCta || blogDetailContent.sidebarCta;
const relatedCopy = detail.related || blogDetailContent.related;
const sidebarCtaUrl = fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url);
const sidebarCtaIsExternal = /^(mailto:|tel:|https?:)/.test(sidebarCtaUrl);
const sidebarCtaStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '13px 20px',
background: NAVY, color: '#fff',
fontFamily: FF, fontSize: 14, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.1em',
textDecoration: 'none',
transition: 'background 0.15s',
};
if (!article) {
return (
<div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: CREAM }}>
<div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: PAGE_BG }}>
<h2 style={{ fontFamily: FF, fontSize: '2rem', fontWeight: 900, color: NAVY }}>{fallbackText(notFound.title, blogDetailContent.notFound.title)}</h2>
<Link to={fallbackText(notFound.backUrl, blogDetailContent.notFound.backUrl)} style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
<ArrowLeft size={14} /> {fallbackText(notFound.backLabel, blogDetailContent.notFound.backLabel)}
@@ -177,7 +188,7 @@ export default function BlogDetail() {
</section>
{/* ── ARTICLE BODY ─────────────────────────────────────────────────── */}
<section style={{ background: CREAM, padding: isMobile ? '48px 0 0' : '80px 0 0' }}>
<section style={{ background: PAGE_BG, padding: isMobile ? '48px 0 0' : '80px 0 0' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '0 24px' : '0 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 320px', gap: isMobile ? 40 : 80 }}>
{/* LEFT – article text */}
@@ -263,22 +274,25 @@ export default function BlogDetail() {
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(58,58,58,0.55)', lineHeight: 1.7, marginBottom: 20 }}>
{fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)}
</p>
<Link
to={fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '13px 20px',
background: NAVY, color: '#fff',
fontFamily: FF, fontSize: 14, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.1em',
textDecoration: 'none',
transition: 'background 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
>
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</Link>
{sidebarCtaIsExternal ? (
<a
href={sidebarCtaUrl}
style={sidebarCtaStyle}
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
>
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</a>
) : (
<Link
to={sidebarCtaUrl}
style={sidebarCtaStyle}
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
>
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</Link>
)}
</div>
</div>
</aside>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '@/spa/components/ui/button';
import { Play, ChevronRight, X, Star } from 'lucide-react';
import { Link } from '@/spa/router';
@@ -9,17 +9,86 @@ import TestimonialsSection from '@/spa/components/TestimonialsSection';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase'
import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
import { homeContent } from '@/spa/homeContent'
const FF = '"IBM Plex Sans", sans-serif';
type CmsRecord = Record<string, unknown>;
type HomeCms = Record<string, any>;
type StatusPhase = HomeCms & {
cta?: { label?: string; to?: string; url?: string } | null;
membershipCta?: { label?: string; to?: string; url?: string } | null;
type CmsLink = { label?: string | null; to?: string | null; url?: string | null };
type HomeHeroCms = {
backgroundImage?: unknown;
badge?: string | null;
description?: string | null;
headingAccent?: string | null;
headingLine1?: string | null;
imageAlt?: string | null;
partners?: unknown;
partnersLabel?: string | null;
primaryCta?: CmsLink | null;
secondaryCta?: CmsLink | null;
videoLabel?: string | null;
};
type HomeSectionCms = CmsRecord & {
awardCaption?: string | null;
awardImage?: unknown;
awardImageAlt?: string | null;
benefits?: unknown;
body?: string | null;
cardFallbackTitle?: string | null;
criteria?: unknown;
cta?: CmsLink | null;
eyebrow?: string | null;
fallbackImage?: unknown;
featured?: unknown;
footnote?: string | null;
footnoteStrong?: string | null;
formEyebrow?: string | null;
formTitle?: string | null;
heading?: string | null;
hoverLabel?: string | null;
image?: unknown;
imageAlt?: string | null;
imageLabel?: string | null;
items?: unknown;
note?: string | null;
quote?: string | null;
stats?: unknown;
};
type HomeVideoModalCms = {
closeLabel?: string | null;
description?: string | null;
title?: string | null;
video?: unknown;
};
type HomeTestimonialsCms = {
desktopHeading?: string | null;
eyebrow?: string | null;
heading?: string | null;
items?: Array<Record<string, unknown>> | null;
nextLabel?: string | null;
previousLabel?: string | null;
slideLabelPrefix?: string | null;
yearPrefix?: string | null;
};
type HomeCms = {
application?: HomeSectionCms;
benefits?: HomeSectionCms;
form?: Partial<typeof homeContent.form>;
hero?: HomeHeroCms;
intro?: HomeSectionCms;
quickCheck?: HomeSectionCms;
testimonials?: HomeTestimonialsCms;
videoModal?: HomeVideoModalCms;
winners?: HomeSectionCms;
};
type RenderedStatusPhase = Omit<SpaApplicationPhaseItem, 'accent' | 'bg' | 'cta' | 'membershipCta'> & {
accent: string;
bg: string;
cta?: { label: string; to: string } | null;
membershipCta?: { label: string; to: string } | null;
};
type WinnerCardData = CmsRecord & {
id?: string | number;
@@ -41,6 +110,13 @@ const fallbackText = (value: unknown, fallback: string) =>
const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const HIDE_MEMBERSHIP_HOME_HERO_CTA = true;
const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | null) =>
cta?.url === '/mitglied-werden' ||
cta?.to === '/mitglied-werden' ||
cta?.label?.trim().toLowerCase() === 'mitglied werden';
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
@@ -108,6 +184,7 @@ function HomeWinnerCard({
const Home: React.FC = () => {
const [showVideo, setShowVideo] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const isMobile = useIsMobile();
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
const hero = home.hero || {};
@@ -118,8 +195,14 @@ const Home: React.FC = () => {
const application = home.application || {};
const form = home.form || {};
const videoModal = home.videoModal || {};
const applicationPhase = useApplicationPhase();
const cmsPreistraeger = useCmsCollection('preistraeger');
const selectedWinners = fallbackArray(winnersSection.featured, []);
const heroSecondaryCta = {
label: fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label),
url: fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url),
};
const showHeroSecondaryCta = !HIDE_MEMBERSHIP_HOME_HERO_CTA || !isMembershipCta(heroSecondaryCta);
const selectedWinnerDocs = selectedWinners
.map((selected) => {
const selectedRecord = selected as { id?: string | number }
@@ -136,6 +219,16 @@ const Home: React.FC = () => {
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
const winnerFallbackImage = mediaUrl(winnersSection.fallbackImage, `/images/${homeContent.winners.fallbackImageFilename}`);
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
const videoSrc = mediaUrl(videoModal.video, '');
useEffect(() => {
if (!showVideo || !videoSrc) return;
videoRef.current?.play().catch(() => {
// Browser autoplay rules can still require the native control click.
});
}, [showVideo, videoSrc]);
return (
<div className="animate-fade-in">
@@ -177,24 +270,27 @@ const Home: React.FC = () => {
{fallbackText(hero.primaryCta?.label, homeContent.hero.primaryCta.label)} <ChevronRight size={16} />
</Link>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: isMobile ? 'stretch' : 'center' }}>
<Link
to={fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url)}
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'rgba(255,255,255,0.85)', border:'1.5px solid rgba(255,255,255,0.35)', padding:'14px 32px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent: isMobile ? 'center' : 'flex-start', gap:10, transition:'border-color 0.15s, color 0.15s', background:'transparent', width: isMobile ? '100%' : 'auto' }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.35)'; (e.currentTarget as HTMLElement).style.color = 'rgba(255,255,255,0.85)'; }}
>
{fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label)}
</Link>
<button
onClick={() => setShowVideo(true)}
className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
>
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
<Play size={20} fill="currentColor" />
</div>
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
</button>
{showHeroSecondaryCta && (
<Link
to={heroSecondaryCta.url}
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'rgba(255,255,255,0.85)', border:'1.5px solid rgba(255,255,255,0.35)', padding:'14px 32px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent: isMobile ? 'center' : 'flex-start', gap:10, transition:'border-color 0.15s, color 0.15s', background:'transparent', width: isMobile ? '100%' : 'auto' }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.35)'; (e.currentTarget as HTMLElement).style.color = 'rgba(255,255,255,0.85)'; }}
>
{heroSecondaryCta.label}
</Link>
)}
<button
type="button"
onClick={() => setShowVideo(true)}
className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
>
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
<Play size={20} fill="currentColor" />
</div>
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
</button>
</div>
</div>
</div>
@@ -221,7 +317,7 @@ const Home: React.FC = () => {
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', position: 'relative' }}>
{/* Left (desktop) / Bottom (mobile) – text block (light bg, dark text) */}
<div style={{ background: '#E4E2E3', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
{/* Eyebrow + heading – desktop only (on mobile these are overlaid on the image) */}
{!isMobile && (
<>
@@ -272,7 +368,7 @@ const Home: React.FC = () => {
{/* Overlay: mobile = navy shadow bottom-left (for the heading); desktop = leftward fade into text bg */}
<div style={{ position: 'absolute', inset: 0, background: isMobile
? 'linear-gradient(to top, rgba(3,9,58,0.92) 0%, rgba(3,9,58,0.55) 34%, rgba(3,9,58,0.12) 68%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.55) 0%, transparent 55%)'
: 'linear-gradient(to left, transparent 40%, #E4E2E3 100%)' }} />
: 'linear-gradient(to left, transparent 40%, #fff 100%)' }} />
{/* Heading overlay – mobile only */}
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
@@ -366,7 +462,7 @@ const Home: React.FC = () => {
</section>
{/* Status Phase Slider */}
<StatusSlider data={home.status} />
<StatusSlider data={applicationPhase} />
{/* Winners Grid – neue Preisträger-Übersicht */}
<section style={{ background: '#111D55' }}>
@@ -419,8 +515,8 @@ const Home: React.FC = () => {
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 560 }}>
{/* Left – text on cream */}
<div style={{ background: '#E4E2E3', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
{/* Left – text on white */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
{!isMobile && (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(benefits.eyebrow, homeContent.benefits.eyebrow)}</span>
@@ -436,7 +532,7 @@ const Home: React.FC = () => {
<div
key={i}
style={{ display: 'grid', gridTemplateColumns: '48px 1fr', gap: '0 20px', padding: '24px 0', borderBottom: '1px solid #D0D5DD', cursor: 'default', transition: 'background 0.15s' }}
onMouseEnter={e => (e.currentTarget.style.background = '#EAE7E6')}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(17,29,85,0.04)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: '#EFBF04', letterSpacing: '0.1em', paddingTop: 2 }}>{item.num}</span>
@@ -458,7 +554,7 @@ const Home: React.FC = () => {
/>
<div style={{ position: 'absolute', inset: 0, background: isMobile
? 'linear-gradient(to top, rgba(3,9,58,0.92) 0%, rgba(3,9,58,0.5) 36%, rgba(3,9,58,0.1) 70%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)'
: 'linear-gradient(to right, #E4E2E3 0%, transparent 30%), linear-gradient(to top, rgba(3,9,58,0.82) 0%, transparent 50%)' }} />
: 'linear-gradient(to right, #fff 0%, transparent 30%), linear-gradient(to top, rgba(3,9,58,0.82) 0%, transparent 50%)' }} />
{/* Heading overlay – mobile only (bottom-left) */}
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
@@ -480,9 +576,9 @@ const Home: React.FC = () => {
</section>
{/* CTA Section – with embedded form */}
<section style={{ background: '#111D55', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben">
<section style={{ background: '#24366A', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben">
{/* Subtle radial glow behind left copy */}
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(5,26,135,0.35) 0%, transparent 70%)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%)', pointerEvents: 'none' }} />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}>
@@ -570,18 +666,32 @@ const Home: React.FC = () => {
{showVideo && (
<div className="fixed inset-0 z-[200] bg-black flex items-center justify-center p-6 bg-opacity-95">
<button
type="button"
onClick={() => setShowVideo(false)}
className="absolute top-8 right-8 text-white/60 hover:text-white transition-colors"
aria-label={fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}
>
<X size={40} />
</button>
<div className="w-full max-w-6xl aspect-video bg-white/5 rounded-3xl overflow-hidden flex items-center justify-center relative">
<div className="text-center">
<Play size={80} className="text-accent mx-auto mb-6 opacity-40" />
<p className="text-white/40 uppercase tracking-widest text-sm">{fallbackText(videoModal.title, homeContent.videoModal.title)}</p>
<p className="text-white/60 mt-4 text-xs italic font-body">{fallbackText(videoModal.description, homeContent.videoModal.description)}</p>
<Button onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
</div>
{videoSrc ? (
<video
ref={videoRef}
src={videoSrc}
className="h-full w-full bg-black object-contain"
controls
autoPlay
playsInline
aria-label={videoTitle}
/>
) : (
<div className="text-center">
<Play size={80} className="text-accent mx-auto mb-6 opacity-40" />
<p className="text-white/40 uppercase tracking-widest text-sm">{videoTitle}</p>
<p className="text-white/60 mt-4 text-xs italic font-body">{fallbackText(videoModal.description, homeContent.videoModal.description)}</p>
<Button type="button" onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
</div>
)}
</div>
</div>
)}
@@ -591,26 +701,28 @@ const Home: React.FC = () => {
// ─── StatusSlider ─────────────────────────────────────────────────────────────
function StatusSlider({ data }: { data?: HomeCms }) {
function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
const isMobile = useIsMobile();
const phases = fallbackArray(data?.phases, homeContent.status.phases).map((phase, i) => {
const phaseRecord = phase as StatusPhase
const fallbackPhase = homeContent.status.phases[i]
const fallbackCta = fallbackPhase?.cta?.label
? { label: fallbackPhase.cta.label, to: fallbackPhase.cta.url }
if (!data?.phases?.length) return null;
const phases = data.phases.map((phase): RenderedStatusPhase => {
const cta = phase.cta?.label
? { label: phase.cta.label, to: phase.cta.to || phase.cta.url || '/' }
: null
const cta = phaseRecord?.cta?.label
? { label: phaseRecord.cta.label, to: phaseRecord.cta.to || phaseRecord.cta.url || fallbackPhase?.cta?.url || '/' }
: fallbackCta
const membershipCta = phaseRecord?.membershipCta?.label
? { label: phaseRecord.membershipCta.label, to: phaseRecord.membershipCta.to || phaseRecord.membershipCta.url || homeContent.hero.secondaryCta.url }
const membershipCta = phase.membershipCta?.label
? { label: phase.membershipCta.label, to: phase.membershipCta.to || phase.membershipCta.url || '/mitglied-werden' }
: undefined
return { ...fallbackPhase, ...phaseRecord, cta, membershipCta }
return {
...phase,
accent: phase.accent || '#EFBF04',
bg: phase.bg || '#020A1E',
cta,
membershipCta,
}
});
const fallbackActivePhase = Number(homeContent.status.activePhase);
const configuredActive = data?.activePhase !== undefined && data?.activePhase !== null ? Number(data.activePhase) : fallbackActivePhase;
const active = Math.max(0, Math.min(phases.length - 1, Number.isFinite(configuredActive) ? configuredActive : fallbackActivePhase));
const configuredActive = Number(data.activePhase);
const active = Math.max(0, Math.min(phases.length - 1, Number.isFinite(configuredActive) ? configuredActive : 0));
const phase = phases[active];
return (
@@ -625,9 +737,9 @@ function StatusSlider({ data }: { data?: HomeCms }) {
<StatusSlideCard
phase={phase}
isMobile={isMobile}
noActionLabel={fallbackText(data?.noActionLabel, homeContent.status.noActionLabel)}
successApplicationsLabel={fallbackText(data?.successApplicationsLabel, homeContent.status.successApplicationsLabel)}
successWinnersLabel={fallbackText(data?.successWinnersLabel, homeContent.status.successWinnersLabel)}
noActionLabel={data.noActionLabel}
successApplicationsLabel={data.successApplicationsLabel}
successWinnersLabel={data.successWinnersLabel}
/>
</section>
);
@@ -642,7 +754,7 @@ function StatusSlideCard({
successApplicationsLabel,
successWinnersLabel,
}: {
phase: StatusPhase & HomeCms;
phase: RenderedStatusPhase;
isMobile: boolean;
noActionLabel: string;
successApplicationsLabel: string;
@@ -722,10 +834,10 @@ function StatusSlideCard({
) : (
/* Success stats */
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: FF, fontSize: 68, fontWeight: 900, color: phase.accent, lineHeight: 1, opacity: 0.9 }}>{fallbackText(phase.successApplications, homeContent.status.phases[2]?.successApplications || '')}</div>
<div style={{ fontFamily: FF, fontSize: 68, fontWeight: 900, color: phase.accent, lineHeight: 1, opacity: 0.9 }}>{fallbackText(phase.successApplications, '')}</div>
<div style={{ fontFamily: FF, fontSize: 8, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.50)', marginTop: 4, marginBottom: 20 }}>{successApplicationsLabel}</div>
<div style={{ width: 1, height: 28, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} />
<div style={{ fontFamily: FF, fontSize: 44, fontWeight: 900, color: '#fff', lineHeight: 1 }}>{fallbackText(phase.successWinners, homeContent.status.phases[2]?.successWinners || '')}</div>
<div style={{ fontFamily: FF, fontSize: 44, fontWeight: 900, color: '#fff', lineHeight: 1 }}>{fallbackText(phase.successWinners, '')}</div>
<div style={{ fontFamily: FF, fontSize: 8, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.50)', marginTop: 4 }}>{successWinnersLabel}</div>
</div>
)}
@@ -735,7 +847,7 @@ function StatusSlideCard({
<div style={{ textAlign: 'center' }}>
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} />
<Link
to={fallbackText(phase.membershipCta?.to || phase.membershipCta?.url, homeContent.hero.secondaryCta.url)}
to={phase.membershipCta?.to || '/mitglied-werden'}
onMouseDown={e => e.stopPropagation()}
style={{
fontFamily: FF,
@@ -760,7 +872,7 @@ function StatusSlideCard({
(e.currentTarget as HTMLElement).style.borderColor = `${phase.accent}55`;
}}
>
{fallbackText(phase.membershipCta?.label, homeContent.hero.secondaryCta.label)} <ChevronRight size={13} />
{phase.membershipCta?.label} <ChevronRight size={13} />
</Link>
</div>
)}

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Link } from '@/spa/router';
import { ChevronRight, X, ArrowRight, Globe, Linkedin, Building2, MapPin, ExternalLink } from 'lucide-react';
import { ChevronRight, X, ArrowRight, Globe, Linkedin, Building2, MapPin, ExternalLink, Play } from 'lucide-react';
import SponsoringForm from '@/spa/components/forms/SponsoringForm';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsRoute } from '@/spa/cmsRoute';
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { homeContent } from '@/spa/homeContent';
import { netzwerkContent } from '@/spa/netzwerkContent';
import Image from '@/spa/components/ui/UnoptimizedImage'
@@ -14,6 +15,7 @@ const FB = '"Inter", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
const HIDE_MEMBERSHIP_NETWORK_CTA = true;
const ROLE_COLOR: Record<string, string> = {
'Hauptsponsor': '#EFBF04',
@@ -28,6 +30,18 @@ type NetzwerkCms = Partial<typeof netzwerkContent> & {
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown }
}
type HomeVideoCms = {
hero?: {
videoLabel?: string | null
}
videoModal?: {
video?: unknown
title?: string | null
description?: string | null
closeLabel?: string | null
}
}
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
type JuryMember = (typeof netzwerkContent.jury.members)[number] & { imageUpload?: unknown }
type Partner = (typeof netzwerkContent.partners.items)[number] & { stableId?: string; tier: 1 | 2 | 3; heroImg?: string; linkedin?: string; logoSubtext?: string }
@@ -40,6 +54,11 @@ type PartnerModalCopy = typeof netzwerkContent.partners.modal
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 managedImage = (upload: unknown, fallback: unknown) => mediaUrl(upload, fallbackText(fallback, ''))
const isMembershipCta = (cta?: { label?: string | null; url?: string | null } | null) => {
const normalizedLabel = cta?.label?.trim().toLowerCase();
return cta?.url === '/mitglied-werden' || normalizedLabel === 'mitglied werden' || normalizedLabel === 'vereinsmitglied werden';
}
function Lines({ text, highlight }: { text: string; highlight?: string }) {
return (
@@ -443,7 +462,12 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
const Netzwerk: React.FC = () => {
const isMobile = useIsMobile();
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
const greetingVideoRef = useRef<HTMLVideoElement>(null);
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
const home = (homePage?.home || {}) as HomeVideoCms;
const homeVideoModal = home.videoModal || {};
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
@@ -465,6 +489,21 @@ const Netzwerk: React.FC = () => {
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
const greetingVideoSrc = mediaUrl(homeVideoModal.video, '');
const greetingVideoTitle = fallbackText(homeVideoModal.title, homeContent.videoModal.title);
const greetingVideoLabel = fallbackText(home.hero?.videoLabel, homeContent.hero.videoLabel);
const greetingVideoCloseLabel = fallbackText(homeVideoModal.closeLabel, homeContent.videoModal.closeLabel);
const showMembershipFootnote =
!HIDE_MEMBERSHIP_NETWORK_CTA ||
!isMembershipCta({ label: sponsoring.footnoteLabel, url: sponsoring.footnoteUrl });
useEffect(() => {
if (!showGreetingVideo || !greetingVideoSrc) return;
greetingVideoRef.current?.play().catch(() => {
// Browser autoplay rules can still require the native control click.
});
}, [showGreetingVideo, greetingVideoSrc]);
return (
<div className="animate-fade-in">
@@ -532,6 +571,47 @@ const Netzwerk: React.FC = () => {
<div style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 20, paddingLeft: 24 }}>
{fallbackText(patronage.greetingAttribution, netzwerkContent.patronage.greetingAttribution)}
</div>
{greetingVideoSrc && (
<div style={{ paddingLeft: 24, marginTop: 28 }}>
<button
type="button"
onClick={() => setShowGreetingVideo(true)}
aria-label={greetingVideoLabel}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
border: `1px solid ${GOLD}73`,
background: 'rgba(239,191,4,0.1)',
color: '#fff',
cursor: 'pointer',
fontFamily: FF,
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.12em',
lineHeight: 1,
padding: '13px 18px',
textTransform: 'uppercase',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => {
const el = e.currentTarget as HTMLElement;
el.style.background = GOLD;
el.style.borderColor = GOLD;
el.style.color = NAVY;
}}
onMouseLeave={e => {
const el = e.currentTarget as HTMLElement;
el.style.background = 'rgba(239,191,4,0.1)';
el.style.borderColor = `${GOLD}73`;
el.style.color = '#fff';
}}
>
<Play size={15} fill="currentColor" />
{greetingVideoLabel}
</button>
</div>
)}
</div>
</div>
</div>
@@ -757,37 +837,39 @@ const Netzwerk: React.FC = () => {
</div>
</div>
{/* Footnote */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: 10 }}>{fallbackText(sponsoring.footnotePrefix, netzwerkContent.sponsoring.footnotePrefix)}</span>
<Link
to={fallbackText(sponsoring.footnoteUrl, netzwerkContent.sponsoring.footnoteUrl)}
style={{
fontFamily: '"IBM Plex Sans", sans-serif',
fontSize: 15,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'rgba(239,191,4,0.7)',
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderBottom: '1px solid rgba(239,191,4,0.3)',
paddingBottom: 1,
transition: 'color 0.15s, borderColor 0.15s',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.color = '#EFBF04';
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
}}
>
{fallbackText(sponsoring.footnoteLabel, netzwerkContent.sponsoring.footnoteLabel)} <ChevronRight size={11} />
</Link>
</div>
{showMembershipFootnote && (
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: 10 }}>{fallbackText(sponsoring.footnotePrefix, netzwerkContent.sponsoring.footnotePrefix)}</span>
<Link
to={fallbackText(sponsoring.footnoteUrl, netzwerkContent.sponsoring.footnoteUrl)}
style={{
fontFamily: '"IBM Plex Sans", sans-serif',
fontSize: 15,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'rgba(239,191,4,0.7)',
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderBottom: '1px solid rgba(239,191,4,0.3)',
paddingBottom: 1,
transition: 'color 0.15s, borderColor 0.15s',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.color = '#EFBF04';
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
}}
>
{fallbackText(sponsoring.footnoteLabel, netzwerkContent.sponsoring.footnoteLabel)} <ChevronRight size={11} />
</Link>
</div>
)}
<div style={{ position: 'relative', zIndex: 1, height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.3), transparent)`, flexShrink: 0 }} />
</section>
@@ -797,6 +879,68 @@ const Netzwerk: React.FC = () => {
<PartnerModal partner={selectedPartner} copy={partnerModal} onClose={() => setSelectedPartner(null)} />
)}
{/* ── 9. GRUSSWORT VIDEO MODAL ────────────────────────────────────────── */}
{showGreetingVideo && greetingVideoSrc && (
<div
onClick={(e) => { if (e.target === e.currentTarget) setShowGreetingVideo(false); }}
style={{
position: 'fixed',
inset: 0,
zIndex: 520,
background: 'rgba(0,0,0,0.95)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? 16 : 24,
}}
>
<button
type="button"
onClick={() => setShowGreetingVideo(false)}
aria-label={greetingVideoCloseLabel}
style={{
position: 'absolute',
top: isMobile ? 18 : 32,
right: isMobile ? 18 : 32,
width: isMobile ? 42 : 52,
height: isMobile ? 42 : 52,
border: 'none',
borderRadius: '50%',
background: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.75)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s, color 0.15s',
}}
onMouseEnter={e => {
const el = e.currentTarget as HTMLElement;
el.style.background = 'rgba(255,255,255,0.18)';
el.style.color = '#fff';
}}
onMouseLeave={e => {
const el = e.currentTarget as HTMLElement;
el.style.background = 'rgba(255,255,255,0.1)';
el.style.color = 'rgba(255,255,255,0.75)';
}}
>
<X size={isMobile ? 24 : 30} />
</button>
<div style={{ width: '100%', maxWidth: 1120, aspectRatio: '16/9', background: '#000' }}>
<video
ref={greetingVideoRef}
src={greetingVideoSrc}
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
controls
autoPlay
playsInline
aria-label={greetingVideoTitle}
/>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useRef } from 'react';
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react';
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star, Mail } from 'lucide-react';
import { Link } from '@/spa/router';
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
@@ -18,10 +18,13 @@ const CREAM = '#E4E2E3';
type ParticipationCms = Partial<typeof participationContent> & {
hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown };
evaluation?: Partial<typeof participationContent.evaluation> & { image?: unknown };
awardsGrid?: Partial<typeof participationContent.awardsGrid> & { cards?: AwardCardCms[] };
applicationForm?: Partial<typeof participationContent.applicationForm> & { image?: unknown };
};
type AwardCard = (typeof participationContent.awardsGrid.cards)[number];
type AwardCardCms = Partial<AwardCard> & { image?: unknown };
const ICONS = {
building2: Building2,
mapPin: MapPin,
@@ -38,6 +41,11 @@ const fallbackText = (value: unknown, fallback: string) =>
const fallbackArray = <T,>(value: unknown, fallback: T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const mergeAwardCards = (value: unknown, fallback: AwardCard[]): (AwardCard & { image?: unknown })[] => {
const cards = Array.isArray(value) && value.length > 0 ? (value as AwardCardCms[]) : fallback;
return cards.map((card, index) => ({ ...(fallback[index] || fallback[0]), ...card }));
};
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
@@ -49,14 +57,13 @@ const Participation: React.FC = () => {
const hero = { ...participationContent.hero, ...(cms.hero || {}) };
const process = { ...participationContent.process, ...(cms.process || {}) };
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
const evaluation = { ...participationContent.evaluation, ...(cms.evaluation || {}) };
const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) };
const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) };
const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) };
const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) };
const form = { ...participationContent.form, ...(cms.form || {}) };
const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes);
const evaluationCriteria = fallbackArray(evaluation.criteria, participationContent.evaluation.criteria);
const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems);
const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems);
const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts);
@@ -174,75 +181,6 @@ const Participation: React.FC = () => {
</div>
</section>
{/* ── CRITERIA / BEWERTUNG ─────────────────────────────────────────────── */}
<section id="kriterien" style={{ background: NAVY, overflow: 'hidden', position: 'relative' }}>
{/* TOP: editorial split – image left (45%) / header right (55%) */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '45% 55%', minHeight: isMobile ? 'auto' : 480 }}>
{/* Left – full-bleed image with gradient blend into navy on the right (+ heading on mobile) */}
<div style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : undefined }}>
<Image unoptimized
src={mediaUrl(evaluation.image, `/images/${participationContent.evaluation.imageFilename}`)}
alt={mediaAlt(evaluation.image, fallbackText(evaluation.imageAlt, participationContent.evaluation.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block' }}
/>
{/* Dark overlay for depth */}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(3,9,58,0.35) 0%, rgba(3,9,58,0.15) 50%, rgba(3,9,58,0.35) 100%)' }} />
{/* Right-side blend into navy (desktop) */}
{!isMobile && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to right, transparent 50%, rgba(3,9,58,0.7) 80%, #111D55 100%)' }} />}
{/* Mobile heading shadow (bottom-left) */}
{isMobile && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,9,58,0.94) 0%, rgba(3,9,58,0.55) 38%, rgba(3,9,58,0.12) 72%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)' }} />}
{/* Bottom blend for continuity with grid below */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 120, background: 'linear-gradient(to bottom, transparent, #111D55)' }} />
{/* Heading overlay – mobile only */}
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 24px', zIndex: 2 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(evaluation.eyebrow, participationContent.evaluation.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 9vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 0.97, margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.6)' }}>
<Lines text={fallbackText(evaluation.heading, participationContent.evaluation.heading)} />
</h2>
</div>
)}
</div>
{/* Right – overline + headline + intro */}
<div style={{ padding: isMobile ? '32px 24px 40px' : '80px 80px 72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
{!isMobile && (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(evaluation.eyebrow, participationContent.evaluation.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2.4rem, 4vw, 3.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 0.97, margin: '0 0 28px' }}>
<Lines text={fallbackText(evaluation.heading, participationContent.evaluation.heading)} />
</h2>
</>
)}
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 28 }} />
<p style={{ fontFamily: FB, fontSize: 19, color: 'rgba(255,255,255,0.68)', lineHeight: 1.8, margin: 0, maxWidth: 420 }}>
<Lines text={fallbackText(evaluation.body, participationContent.evaluation.body)} />
</p>
<a
href={fallbackText(evaluation.link?.url, participationContent.evaluation.link.url)}
target="_blank"
rel="noopener noreferrer"
style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: GOLD, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 28, borderBottom: `1px solid rgba(239,191,4,0.4)`, paddingBottom: 2, alignSelf: 'flex-start' }}
onMouseEnter={e => ((e.currentTarget as HTMLElement).style.opacity = '0.8')}
onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')}
>
{fallbackText(evaluation.link?.label, participationContent.evaluation.link.label)} <ArrowRight size={13} />
</a>
</div>
</div>
{/* BOTTOM: full-width 4-column criteria grid */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(4, 1fr)', borderTop: '1px solid rgba(255,255,255,0.07)', position: 'relative' }}>
{evaluationCriteria.map((crit, i) => (
<CriteriaCell key={i} crit={crit} idx={i} />
))}
</div>
<div style={{ height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.2), transparent)` }} />
</section>
{/* ── BEWERBUNGSWEGE ───────────────────────────────────────────────────── */}
<BewerbungswegeSection content={applicationWays} />
@@ -282,6 +220,9 @@ const Participation: React.FC = () => {
)}
</section>
{/* ── AWARDS GRID ─────────────────────────────────────────────────────── */}
<AwardsGridSection content={awardsGrid} />
{/* ── FORM SECTION ─────────────────────────────────────────────────────── */}
<section style={{ background: NAVY, position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben">
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', flex: 1, minHeight: 0, overflow: isMobile ? 'visible' : 'hidden' }}>
@@ -400,27 +341,6 @@ function QualRow({ item, Icon, last }: { item: { num: string; title: string; bod
);
}
// ── CriteriaCell ──────────────────────────────────────────────────────────────
function CriteriaCell({ crit, idx }: { crit: { title: string; desc: string }; idx: number }) {
const [hovered, setHovered] = React.useState(false);
const isMobileCell = useIsMobile();
return (
<div
style={{ padding: isMobileCell ? '32px 24px' : '56px 40px', borderLeft: !isMobileCell && idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none', borderTop: isMobileCell && idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none', display: 'flex', flexDirection: 'column', gap: 0, cursor: 'default', transition: 'background 0.2s', background: hovered ? 'rgba(255,255,255,0.03)' : 'transparent' }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.15em', textTransform: 'uppercase' }}>0{idx + 1}</span>
</div>
<div style={{ width: hovered ? '100%' : 28, height: 1, background: hovered ? 'rgba(239,191,4,0.6)' : 'rgba(239,191,4,0.3)', marginBottom: 20, transition: 'width 0.5s ease' }} />
<h4 style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em', lineHeight: 1.3, marginBottom: 14 }}>{crit.title}</h4>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.65)', lineHeight: 1.7, flex: 1 }}>{crit.desc}</p>
</div>
);
}
// ── BewerbungswegeSection ─────────────────────────────────────────────────────
type ApplicationWaysContent = Partial<typeof participationContent.applicationWays>;
@@ -551,4 +471,131 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
);
}
type AwardsGridContent = Partial<typeof participationContent.awardsGrid>;
function AwardsGridSection({ content }: { content?: AwardsGridContent }) {
const isMobile = useIsMobile();
const section = { ...participationContent.awardsGrid, ...(content || {}) };
const cards = mergeAwardCards(section.cards, participationContent.awardsGrid.cards);
return (
<section id="auszeichnungen" style={{ background: '#F7F7F5', position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>
{fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)}
</span>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.9fr) minmax(0, 1fr)', gap: isMobile ? 16 : 56, alignItems: 'end' }}>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0, overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(section.heading, participationContent.awardsGrid.heading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.55)', lineHeight: 1.8, margin: 0 }}>
{fallbackText(section.description, participationContent.awardsGrid.description)}
</p>
</div>
</div>
<div style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', borderBottom: '1px solid rgba(3,9,58,0.08)' }}>
{cards.map((card, index) => {
const imageFilename = fallbackText(card.imageFilename, participationContent.awardsGrid.cards[index]?.imageFilename || participationContent.awardsGrid.cards[0].imageFilename);
const isContainedImage = imageFilename.endsWith('.png') || imageFilename.includes('roland-berger');
const isLast = index === cards.length - 1;
return (
<article
key={`${fallbackText(card.title, 'award')}-${index}`}
style={{
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 'auto' : 620,
borderRight: !isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
borderBottom: isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
background: index === 1 ? NAVY : 'rgba(255,255,255,0.72)',
}}
>
<div style={{ height: isMobile ? 220 : 260, background: imageFilename.includes('roland-berger') ? '#DDE7E2' : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<Image unoptimized
src={mediaUrl(card.image, `/images/${imageFilename}`)}
alt={mediaAlt(card.image, fallbackText(card.imageAlt, fallbackText(card.title, 'Auszeichnung')))}
style={{
width: '100%',
height: '100%',
objectFit: isContainedImage ? 'contain' : 'cover',
objectPosition: index === 1 ? 'center top' : 'center',
padding: imageFilename.endsWith('.png') ? 22 : 0,
boxSizing: 'border-box',
display: 'block',
}}
/>
</div>
<div style={{ padding: isMobile ? '28px 24px 32px' : '36px 36px 40px', display: 'flex', flexDirection: 'column', flex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.26em', textTransform: 'uppercase', color: index === 1 ? 'rgba(239,191,4,0.8)' : '#4A8FC9', marginBottom: 12 }}>
{fallbackText(card.label, '')}
</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.25rem, 2vw, 1.7rem)', fontWeight: 900, color: index === 1 ? '#fff' : '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 16px', overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(card.title, '')} />
</h3>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 22 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: index === 1 ? 'rgba(255,255,255,0.62)' : 'rgba(16,24,40,0.55)', lineHeight: 1.7, margin: 0, flex: 1 }}>
{fallbackText(card.description, '')}
</p>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 10, marginTop: 32 }}>
<Link
to={fallbackText(card.articleCta?.url, '#bewerben')}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
background: GOLD,
color: '#101828',
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
{fallbackText(card.articleCta?.label, 'Artikel lesen')} <ArrowRight size={13} />
</Link>
<a
href={fallbackText(card.mailtoCta?.url, `mailto:${participationContent.form.eligibility.ineligibleContactEmail}`)}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: index === 1 ? '#fff' : NAVY,
border: `1px solid ${index === 1 ? 'rgba(255,255,255,0.22)' : 'rgba(3,9,58,0.18)'}`,
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
<Mail size={13} /> {fallbackText(card.mailtoCta?.label, 'Kontakt aufnehmen')}
</a>
</div>
</div>
</article>
);
})}
</div>
</section>
);
}
export default Participation;

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router';
import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react';
import { Trophy, ChevronRight, ArrowRight } from 'lucide-react';
import { WINNERS, type Winner } from '@/spa/data/winners';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
@@ -47,14 +47,10 @@ const normalizeWinner = (
export default function Preistraeger() {
const isMobile = useIsMobile();
const [filterCategory, setFilterCategory] = useState('');
const [search, setSearch] = useState('');
const [mediaOnly, setMediaOnly] = useState(false);
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
const cmsPreistraeger = useCmsCollection('preistraeger');
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) };
const filters = { ...preistraegerIndexContent.filters, ...(cms.filters || {}) };
const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) };
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
@@ -69,20 +65,10 @@ export default function Preistraeger() {
return WINNERS;
}, [card, cardFallbackImage, cmsPreistraeger]);
const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
const categories = useMemo(() => [...new Set(winners.map((winner) => winner.category))].sort((a, b) => a.localeCompare(b, 'de')), [winners]);
const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const filtered = useMemo(() => winners.filter(w => {
if (w.year !== selectedYear) return false;
if (filterCategory && w.category !== filterCategory) return false;
if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false;
if (mediaOnly && !w.hasMedia) return false;
return true;
}), [selectedYear, filterCategory, search, mediaOnly, winners]);
const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); };
const hasFilter = !!(filterCategory || search || mediaOnly);
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
return (
<div style={{ background: '#fff', minHeight: '100vh' }}>
@@ -102,16 +88,16 @@ export default function Preistraeger() {
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
</div>
{/* Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 24px' : '48px 80px 32px' }}>
{/* Year Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 20px' : '48px 80px 24px' }}>
{/* #93: Headline entfernt – Seitenname steht bereits oben im Breadcrumb. */}
{/* Year Tabs */}
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, marginBottom: 32, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
{years.map(year => (
<button
key={year}
onClick={() => { setActiveYear(year); reset(); }}
onClick={() => setActiveYear(year)}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700,
padding: isMobile ? '12px 20px' : '12px 32px',
@@ -128,54 +114,6 @@ export default function Preistraeger() {
</button>
))}
</div>
{/* Filter Row */}
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: 'stretch', marginBottom: 20, flexWrap: 'wrap' }}>
<SelectControl
value={filterCategory}
onChange={setFilterCategory}
placeholder={fallbackText(filters.categoryPlaceholder, preistraegerIndexContent.filters.categoryPlaceholder)}
options={categories.map(c => ({ label: c, value: c }))}
width={isMobile ? undefined : 280}
/>
<SearchControl value={search} onChange={setSearch} placeholder={fallbackText(filters.searchPlaceholder, preistraegerIndexContent.filters.searchPlaceholder)} />
</div>
{/* Toggle row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828' }}>
{fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 15 }}>{fallbackText(filters.mediaIcon, preistraegerIndexContent.filters.mediaIcon)}</span>
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>{fallbackText(filters.mediaLabel, preistraegerIndexContent.filters.mediaLabel)}</span>
<button
onClick={() => setMediaOnly(v => !v)}
style={{
width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
background: mediaOnly ? NAVY : BORDER,
position: 'relative', transition: 'background 0.2s',
}}
>
<span style={{
position: 'absolute', top: 3, left: mediaOnly ? 20 : 3, width: 16, height: 16,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
}} />
</button>
</div>
</div>
{hasFilter && (
<button
onClick={reset}
style={{ fontFamily: FF, fontSize: 16, color: GRAY, background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
onMouseEnter={e => (e.currentTarget.style.color = GOLD)}
onMouseLeave={e => (e.currentTarget.style.color = GRAY)}
>
<X size={14} /> {fallbackText(filters.resetLabel, preistraegerIndexContent.filters.resetLabel)}
</button>
)}
</div>
</div>
{/* Count bar */}
@@ -319,61 +257,3 @@ function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string
</Link>
);
}
// ── SelectControl ────────────────────────────────────────────────────────────
function SelectControl({ value, onChange, placeholder, options, width }: {
value: string; onChange: (v: string) => void;
placeholder: string; options: { label: string; value: string }[];
width?: number;
}) {
const active = !!value;
return (
<div style={{ position: 'relative', width: width ?? '100%', flexShrink: width ? 0 : 1 }}>
<select
value={value}
onChange={e => onChange(e.target.value)}
style={{
width: '100%', height: 44, padding: '0 36px 0 14px',
fontFamily: FF, fontSize: 16,
border: `1px solid ${active ? NAVY : BORDER}`,
color: active ? NAVY : GRAY,
background: '#fff', borderRadius: 0,
appearance: 'none', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
onBlur={e => (e.currentTarget.style.borderColor = active ? NAVY : BORDER)}
>
<option value="">{placeholder}</option>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown size={16} style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: '#101828' }} />
</div>
);
}
// ── SearchControl ────────────────────────────────────────────────────────────
function SearchControl({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<div style={{ position: 'relative', flex: 1, minWidth: 200 }}>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
style={{
width: '100%', height: 44, padding: '0 40px 0 14px',
fontFamily: FF, fontSize: 16,
border: `1px solid ${BORDER}`, borderRadius: 0,
background: '#fff', outline: 'none', boxSizing: 'border-box',
transition: 'border-color 0.15s', color: '#101828',
}}
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
onBlur={e => (e.currentTarget.style.borderColor = BORDER)}
/>
<Search size={16} style={{ position: 'absolute', right: 14, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: GRAY }} />
</div>
);
}

View File

@@ -524,6 +524,7 @@ const Press: React.FC = () => {
{/* Left col */}
<div
style={{
background: '#fff',
padding: isMobile ? '48px 24px' : '88px 80px',
display: 'flex',
flexDirection: 'column',

View File

@@ -1,3 +1,5 @@
import { participationAwardsGrid } from './awardArticles'
export const participationContent = {
hero: {
backgroundImageFilename: 'preisuebergabe.jpg',
@@ -74,24 +76,6 @@ export const participationContent = {
nudgeText: 'Alle 4 Punkte erfüllt? Dann jetzt kostenlos bewerben.',
nudgeCta: { label: 'Zum Formular', url: '#bewerben' },
},
evaluation: {
imageFilename: 'buehne-gewinner.jpg',
imageAlt: 'Jury-Bewertung BMP',
eyebrow: 'Bewertung',
heading: 'UNSERE\nKRITERIEN.',
body:
'Vier zentrale Säulen, bewertet durch eine vollständig unabhängige Expertenjury aus Wirtschaft, Wissenschaft und Gesellschaft.\n\nDie Kriterien werden nicht gleich gewichtet.',
link: {
label: 'Kriterien & Bewertung',
url: 'https://www.der-bayerische-mittelstandspreis.de/der-preis/kriterien/',
},
criteria: [
{ title: 'Innovations­kraft', desc: 'Zukunftsweisende Produkte, Dienstleistungen oder interne Prozessinnovationen.' },
{ title: 'Nachhaltigkeit', desc: 'Ökologische Verantwortung und Ressourcen-Effizienz im operativen Kern.' },
{ title: 'Unternehmens­kultur', desc: 'Mitarbeiterbindung, Aus- und Weiterbildung sowie wertebasierte Führung.' },
{ title: 'Regionale Wurzeln', desc: 'Engagement am Standort Bayern und Beitrag zur regionalen Wertschöpfung.' },
],
},
applicationWays: {
eyebrow: 'Bewerbung 2026',
heading: 'HIER KÖNNEN SIE SICH\nBEWERBEN ODER EIN\nUNTERNEHMEN VORSCHLAGEN.',
@@ -165,6 +149,7 @@ export const participationContent = {
{ date: 'Oktober 2026', label: 'Gala-Verleihung' },
],
},
awardsGrid: participationAwardsGrid,
applicationForm: {
imageFilename: 'preistraeger-jubel.jpg',
imageAlt: 'Preisverleihung 2025',

View File

@@ -6,14 +6,6 @@ export const preistraegerIndexContent = {
breadcrumb: {
label: 'Preisträger:innen',
},
filters: {
categoryPlaceholder: 'Preiskategorie …',
searchPlaceholder: 'Stichwortsuche …',
storyHeading: 'Diese Erfolgsgeschichten müssen erzählt werden.',
mediaIcon: '💬',
mediaLabel: 'Mit Medienbeiträgen (Videos & Storys)',
resetLabel: 'Filter zurücksetzen',
},
count: {
label: 'Preisträger:innen',
},