feat: manage contact page via payload

This commit is contained in:
syntaxbullet
2026-06-22 10:21:05 +02:00
parent d39e696d48
commit ae98269b03
6 changed files with 512 additions and 41 deletions

View File

@@ -17,6 +17,8 @@
"migrate:spa-media": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-media.ts",
"migrate:spa-pages": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-pages.ts",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"preload:contact-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-contact-page-cms.ts",
"preload:home-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-home-page-cms.ts",
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"test": "pnpm run test:int && pnpm run test:e2e",

View File

@@ -0,0 +1,158 @@
import type { Field } from 'payload'
const uploadField = (name: string, label: string, description?: string): Field => ({
name,
type: 'upload',
relationTo: 'media',
label,
admin: description ? { description } : undefined,
})
const text = (name: string, label: string, defaultValue?: string): Field => ({
name,
type: 'text',
label,
defaultValue,
})
const textarea = (name: string, label: string, defaultValue?: string): Field => ({
name,
type: 'textarea',
label,
defaultValue,
})
const sectionAdmin = (description: string) => ({
description,
initCollapsed: true,
})
export const contactFields: Field[] = [
{
name: 'contact',
label: 'Contact page layout sections',
type: 'group',
admin: {
description:
'Edit the contact page in the same order it appears on the frontend. The generic Hero and Content tabs are hidden for this SPA-managed route.',
},
fields: [
{
name: 'hero',
label: '01 · Hero',
type: 'group',
admin: sectionAdmin('Top image, eyebrow, headline, and intro copy.'),
fields: [
uploadField('backgroundImage', 'Background image', 'Current frontend image: /images/gala-dinner.jpg'),
text('imageAlt', 'Image alt text', 'BMP Gala'),
text('eyebrow', 'Eyebrow', 'Dialog & Service'),
textarea('heading', 'Headline', 'Wir sind\nfür Sie da.'),
textarea(
'description',
'Intro paragraph',
'Fragen zur Bewerbung, zur Gala oder zu Partnermöglichkeiten unser Team begleitet Sie persönlich.',
),
],
},
{
name: 'info',
label: '02 · Direct contact and form panel',
type: 'group',
admin: sectionAdmin('Left direct-contact list plus the gold contact form panel.'),
fields: [
text('eyebrow', 'Direct contact eyebrow', 'Direktkontakt'),
textarea('heading', 'Direct contact heading', 'Kontakt­möglichkeiten'),
{
name: 'items',
label: 'Contact methods',
type: 'array',
defaultValue: [
{ icon: 'mapPin', title: 'Geschäftsstelle', lines: 'Maximilianstraße 42\n80538 München' },
{ icon: 'mail', title: 'E-Mail', lines: 'info@bmp-bayern.de' },
{ icon: 'phone', title: 'Telefon', lines: '+49 89 123 456 78' },
{ icon: 'clock', title: 'Bürozeiten', lines: 'Mo Fr: 09:00 17:00 Uhr' },
],
fields: [
{
name: 'icon',
label: 'Icon',
type: 'select',
defaultValue: 'mapPin',
options: [
{ label: 'Map pin', value: 'mapPin' },
{ label: 'Mail', value: 'mail' },
{ label: 'Phone', value: 'phone' },
{ label: 'Clock', value: 'clock' },
],
},
text('title', 'Title'),
textarea('lines', 'Lines'),
],
},
text('nextAwardLabel', 'Next award label', 'Nächste Preisverleihung:'),
text('nextAwardDate', 'Next award date', '22. Oktober 2026'),
uploadField('formImage', 'Desktop form image', 'Current frontend image: /images/preistraeger-gruppe.jpg'),
text('formImageAlt', 'Form image alt text', 'BMP Gala 2025'),
text('formImageLabel', 'Form image label', 'Gala 2025 München'),
text('formEyebrow', 'Form eyebrow', 'Kontaktformular'),
text('formTitle', 'Form title', 'Schreiben Sie uns'),
],
},
{
name: 'deadlines',
label: '03 · Deadline timeline',
type: 'group',
admin: sectionAdmin('Dark 2026 timeline with three deadline columns.'),
fields: [
text('watermark', 'Large background year', '2026'),
text('eyebrow', 'Eyebrow', 'Fahrplan 2026'),
{
name: 'items',
label: 'Deadline items',
type: 'array',
defaultValue: [
{ step: '01', label: 'Bewerbungsschluss', date: '30. Juni 2026', desc: 'Letzte Möglichkeit zur Einreichung Ihrer Unterlagen.' },
{ step: '02', label: 'Nominierung', date: '15. August 2026', desc: 'Bekanntgabe der Nominierten nach Jury-Sichtung.' },
{ step: '03', label: 'Preisverleihung', date: '22. Oktober 2026', desc: 'Festliche Gala in München.' },
],
fields: [text('step', 'Step'), text('label', 'Label'), text('date', 'Date'), textarea('desc', 'Description')],
},
],
},
{
name: 'faq',
label: '04 · FAQ',
type: 'group',
admin: sectionAdmin('FAQ heading and accordion entries.'),
fields: [
text('eyebrow', 'Eyebrow', 'Antworten'),
textarea('heading', 'Heading', 'Häufige\nFragen'),
{
name: 'items',
label: 'FAQ items',
type: 'array',
defaultValue: [
{
q: 'Was kostet die Teilnahme am BMP?',
a: 'Die Teilnahme am Bayerischen Mittelstandspreis ist für alle Unternehmen vollständig kostenfrei. Wir finanzieren uns durch Partner und Sponsoren, um exzellenten Mittelstand ohne finanzielle Hürden zu fördern.',
},
{
q: 'Nach welchen Kriterien wird bewertet?',
a: 'Die Bewertung basiert auf vier Säulen: Zukunftsfähigkeit & Innovation, Nachhaltigkeit & Verantwortung, Unternehmenskultur sowie Regionale Verwurzelung. Jede Säule fließt mit 25 % in das Gesamtergebnis ein.',
},
{
q: 'Wie läuft der Bewerbungsprozess ab?',
a: 'Der Prozess ist mehrstufig: 1. Online-Bewerbung oder Vorschlag, 2. Formale Prüfung & erste Jury-Sichtung, 3. Fach-Audits bei den Finalisten vor Ort, 4. Finale Jury-Sitzung und abschließende Preisverleihung.',
},
{
q: 'Wie lange dauert die Bewerbung?',
a: 'Dank unseres optimierten Bewerbungsformulars benötigen Sie für die erste Einreichung ca. 3045 Minuten. Tiefergehende Unterlagen werden erst in der Audit-Phase angefordert.',
},
],
fields: [textarea('q', 'Question'), textarea('a', 'Answer')],
},
],
},
],
},
]

View File

@@ -8,6 +8,7 @@ import { Content } from '../../blocks/Content/config'
import { FormBlock } from '../../blocks/Form/config'
import { MediaBlock } from '../../blocks/MediaBlock/config'
import { hero } from '@/heros/config'
import { contactFields } from './contactFields'
import { homeFields } from './homeFields'
import { slugField } from 'payload'
import { populatePublishedAt } from '../../hooks/populatePublishedAt'
@@ -30,6 +31,17 @@ const isHomePage = (_: unknown, siblingData?: { slug?: string; spaPath?: string;
return spaPath === '/' || slug === 'startseite' || slug === 'home' || title === 'home' || title === 'startseite'
}
const isContactPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => {
const slug = siblingData?.slug
const spaPath = siblingData?.spaPath
const title = siblingData?.title?.toLowerCase()
return spaPath === '/kontakt' || slug === 'kontakt' || slug === 'contact' || title === 'kontakt' || title === 'contact'
}
const isManagedSpaPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) =>
isHomePage(undefined, siblingData) || isContactPage(undefined, siblingData)
export const Pages: CollectionConfig<'pages'> = {
slug: 'pages',
access: {
@@ -74,14 +86,14 @@ export const Pages: CollectionConfig<'pages'> = {
tabs: [
{
admin: {
condition: (data) => !isHomePage(undefined, data),
condition: (data) => !isManagedSpaPage(undefined, data),
},
fields: [hero],
label: 'Hero',
},
{
admin: {
condition: (data) => !isHomePage(undefined, data),
condition: (data) => !isManagedSpaPage(undefined, data),
},
fields: [
{
@@ -103,6 +115,13 @@ export const Pages: CollectionConfig<'pages'> = {
fields: homeFields,
label: 'Home Page',
},
{
admin: {
condition: (data) => isContactPage(undefined, data),
},
fields: contactFields,
label: 'Contact Page',
},
{
name: 'meta',
label: 'SEO',

View File

@@ -415,6 +415,79 @@ export interface Page {
closeLabel?: string | null;
};
};
/**
* Edit the contact page in the same order it appears on the frontend. The generic Hero and Content tabs are hidden for this SPA-managed route.
*/
contact?: {
/**
* Top image, eyebrow, headline, and intro copy.
*/
hero?: {
/**
* Current frontend image: /images/gala-dinner.jpg
*/
backgroundImage?: (number | null) | Media;
imageAlt?: string | null;
eyebrow?: string | null;
heading?: string | null;
description?: string | null;
};
/**
* Left direct-contact list plus the gold contact form panel.
*/
info?: {
eyebrow?: string | null;
heading?: string | null;
items?:
| {
icon?: ('mapPin' | 'mail' | 'phone' | 'clock') | null;
title?: string | null;
lines?: string | null;
id?: string | null;
}[]
| null;
nextAwardLabel?: string | null;
nextAwardDate?: string | null;
/**
* Current frontend image: /images/preistraeger-gruppe.jpg
*/
formImage?: (number | null) | Media;
formImageAlt?: string | null;
formImageLabel?: string | null;
formEyebrow?: string | null;
formTitle?: string | null;
};
/**
* Dark 2026 timeline with three deadline columns.
*/
deadlines?: {
watermark?: string | null;
eyebrow?: string | null;
items?:
| {
step?: string | null;
label?: string | null;
date?: string | null;
desc?: string | null;
id?: string | null;
}[]
| null;
};
/**
* FAQ heading and accordion entries.
*/
faq?: {
eyebrow?: string | null;
heading?: string | null;
items?:
| {
q?: string | null;
a?: string | null;
id?: string | null;
}[]
| null;
};
};
meta?: {
title?: string | null;
/**
@@ -1629,6 +1702,68 @@ export interface PagesSelect<T extends boolean = true> {
closeLabel?: T;
};
};
contact?:
| T
| {
hero?:
| T
| {
backgroundImage?: T;
imageAlt?: T;
eyebrow?: T;
heading?: T;
description?: T;
};
info?:
| T
| {
eyebrow?: T;
heading?: T;
items?:
| T
| {
icon?: T;
title?: T;
lines?: T;
id?: T;
};
nextAwardLabel?: T;
nextAwardDate?: T;
formImage?: T;
formImageAlt?: T;
formImageLabel?: T;
formEyebrow?: T;
formTitle?: T;
};
deadlines?:
| T
| {
watermark?: T;
eyebrow?: T;
items?:
| T
| {
step?: T;
label?: T;
date?: T;
desc?: T;
id?: T;
};
};
faq?:
| T
| {
eyebrow?: T;
heading?: T;
items?:
| T
| {
q?: T;
a?: T;
id?: T;
};
};
};
meta?:
| T
| {

View File

@@ -0,0 +1,110 @@
import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
const mediaByFilename = async (payload: Payload, filename: string) => {
const result = await payload.find({
collection: 'media',
limit: 1,
pagination: false,
where: { filename: { equals: filename } },
})
return result.docs[0]?.id
}
async function main() {
const payload = await getPayload({ config })
const pageResult = await payload.find({
collection: 'pages',
limit: 1,
pagination: false,
draft: true,
overrideAccess: true,
where: {
or: [{ spaPath: { equals: '/kontakt' } }, { slug: { equals: 'kontakt' } }, { slug: { equals: 'contact' } }],
},
})
const page = pageResult.docs[0]
if (!page) throw new Error('Contact page not found. Expected spaPath=/kontakt or slug=kontakt/contact.')
const [heroImage, formImage] = await Promise.all([
mediaByFilename(payload, 'gala-dinner.jpg'),
mediaByFilename(payload, 'preistraeger-gruppe.jpg'),
])
await payload.update({
collection: 'pages',
id: page.id,
overrideAccess: true,
context: { disableRevalidate: true },
data: {
contact: {
hero: {
backgroundImage: heroImage,
imageAlt: 'BMP Gala',
eyebrow: 'Dialog & Service',
heading: 'Wir sind\nfür Sie da.',
description: 'Fragen zur Bewerbung, zur Gala oder zu Partnermöglichkeiten unser Team begleitet Sie persönlich.',
},
info: {
eyebrow: 'Direktkontakt',
heading: 'Kontakt­möglichkeiten',
items: [
{ icon: 'mapPin', title: 'Geschäftsstelle', lines: 'Maximilianstraße 42\n80538 München' },
{ icon: 'mail', title: 'E-Mail', lines: 'info@bmp-bayern.de' },
{ icon: 'phone', title: 'Telefon', lines: '+49 89 123 456 78' },
{ icon: 'clock', title: 'Bürozeiten', lines: 'Mo Fr: 09:00 17:00 Uhr' },
],
nextAwardLabel: 'Nächste Preisverleihung:',
nextAwardDate: '22. Oktober 2026',
formImage,
formImageAlt: 'BMP Gala 2025',
formImageLabel: 'Gala 2025 München',
formEyebrow: 'Kontaktformular',
formTitle: 'Schreiben Sie uns',
},
deadlines: {
watermark: '2026',
eyebrow: 'Fahrplan 2026',
items: [
{ step: '01', label: 'Bewerbungsschluss', date: '30. Juni 2026', desc: 'Letzte Möglichkeit zur Einreichung Ihrer Unterlagen.' },
{ step: '02', label: 'Nominierung', date: '15. August 2026', desc: 'Bekanntgabe der Nominierten nach Jury-Sichtung.' },
{ step: '03', label: 'Preisverleihung', date: '22. Oktober 2026', desc: 'Festliche Gala in München.' },
],
},
faq: {
eyebrow: 'Antworten',
heading: 'Häufige\nFragen',
items: [
{
q: 'Was kostet die Teilnahme am BMP?',
a: 'Die Teilnahme am Bayerischen Mittelstandspreis ist für alle Unternehmen vollständig kostenfrei. Wir finanzieren uns durch Partner und Sponsoren, um exzellenten Mittelstand ohne finanzielle Hürden zu fördern.',
},
{
q: 'Nach welchen Kriterien wird bewertet?',
a: 'Die Bewertung basiert auf vier Säulen: Zukunftsfähigkeit & Innovation, Nachhaltigkeit & Verantwortung, Unternehmenskultur sowie Regionale Verwurzelung. Jede Säule fließt mit 25 % in das Gesamtergebnis ein.',
},
{
q: 'Wie läuft der Bewerbungsprozess ab?',
a: 'Der Prozess ist mehrstufig: 1. Online-Bewerbung oder Vorschlag, 2. Formale Prüfung & erste Jury-Sichtung, 3. Fach-Audits bei den Finalisten vor Ort, 4. Finale Jury-Sitzung und abschließende Preisverleihung.',
},
{
q: 'Wie lange dauert die Bewerbung?',
a: 'Dank unseres optimierten Bewerbungsformulars benötigen Sie für die erste Einreichung ca. 3045 Minuten. Tiefergehende Unterlagen werden erst in der Audit-Phase angefordert.',
},
],
},
},
},
})
payload.logger.info(`Preloaded contact CMS fields for page ${page.id}`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -3,14 +3,43 @@ import { MapPin, Mail, Phone, Clock, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import KontaktForm from '@/spa/components/forms/KontaktForm';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useCmsRoute } from '@/spa/cmsRoute';
import Image from '@/spa/components/ui/UnoptimizedImage'
import type { Page } from '@/payload-types';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#EFE5E3';
const INK = '#3A3A3A';
const faqs = [
type ContactCms = NonNullable<Page['contact']>;
type ContactItem = NonNullable<NonNullable<ContactCms['info']>['items']>[number] | {
icon: string;
title: string;
lines: string[];
};
type DeadlineItem = NonNullable<NonNullable<ContactCms['deadlines']>['items']>[number];
type FaqItem = NonNullable<NonNullable<ContactCms['faq']>['items']>[number];
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 > 0 ? (value as T[]) : fallback;
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
const ICONS = {
mapPin: MapPin,
mail: Mail,
phone: Phone,
clock: Clock,
};
const DEFAULT_FAQS = [
{
q: 'Was kostet die Teilnahme am BMP?',
a: 'Die Teilnahme am Bayerischen Mittelstandspreis ist für alle Unternehmen vollständig kostenfrei. Wir finanzieren uns durch Partner und Sponsoren, um exzellenten Mittelstand ohne finanzielle Hürden zu fördern.',
@@ -29,15 +58,30 @@ const faqs = [
},
];
const deadlines = [
const DEFAULT_DEADLINES = [
{ step: '01', label: 'Bewerbungsschluss', date: '30. Juni 2026', desc: 'Letzte Möglichkeit zur Einreichung Ihrer Unterlagen.' },
{ step: '02', label: 'Nominierung', date: '15. August 2026', desc: 'Bekanntgabe der Nominierten nach Jury-Sichtung.' },
{ step: '03', label: 'Preisverleihung', date: '22. Oktober 2026', desc: 'Festliche Gala in München.' },
];
const DEFAULT_CONTACT_ITEMS = [
{ icon: 'mapPin', title: 'Geschäftsstelle', lines: ['Maximilianstraße 42', '80538 München'] },
{ icon: 'mail', title: 'E-Mail', lines: ['info@bmp-bayern.de'] },
{ icon: 'phone', title: 'Telefon', lines: ['+49 89 123 456 78'] },
{ icon: 'clock', title: 'Bürozeiten', lines: ['Mo Fr: 09:00 17:00 Uhr'] },
];
const Contact: React.FC = () => {
const [openFaq, setOpenFaq] = useState<number | null>(0);
const isMobile = useIsMobile();
const contact = (useCmsRoute()?.doc?.contact || {}) as ContactCms;
const hero = contact.hero || {};
const info = contact.info || {};
const deadlines = contact.deadlines || {};
const faq = contact.faq || {};
const contactItems = fallbackArray<ContactItem>(info.items, DEFAULT_CONTACT_ITEMS);
const deadlineItems = fallbackArray<DeadlineItem>(deadlines.items, DEFAULT_DEADLINES);
const faqItems = fallbackArray<FaqItem>(faq.items, DEFAULT_FAQS);
return (
<div style={{ background: CREAM }}>
@@ -45,8 +89,8 @@ const Contact: React.FC = () => {
{/* ── Hero ───────────────────────────────────────────────────────── */}
<section style={{ position: 'relative', height: '68vh', minHeight: 520, overflow: 'hidden', display: 'flex', alignItems: 'flex-end' }}>
<Image unoptimized
src="/images/gala-dinner.jpg"
alt="BMP Gala"
src={mediaUrl(hero.backgroundImage, '/images/gala-dinner.jpg')}
alt={mediaAlt(hero.backgroundImage, fallbackText(hero.imageAlt, 'BMP Gala'))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 40%' }}
/>
{/* Diagonal overlay */}
@@ -56,13 +100,13 @@ const Contact: React.FC = () => {
<div style={{ position: 'relative', zIndex: 2, width: '100%', paddingBottom: isMobile ? '3rem' : '5rem', paddingLeft: isMobile ? 24 : 80, paddingRight: isMobile ? 24 : 80 }}>
<p style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.28em', textTransform: 'uppercase', color: GOLD, marginBottom: 16 }}>
Dialog & Service
{fallbackText(hero.eyebrow, 'Dialog & Service')}
</p>
<h1 style={{ fontSize: isMobile ? 'clamp(2rem, 10vw, 3rem)' : 'clamp(3rem, 7vw, 6rem)', fontWeight: 900, lineHeight: 1.0, color: '#fff', letterSpacing: '-0.03em', marginBottom: 24, maxWidth: 640 }}>
Wir sind<br />für Sie da.
<Lines text={fallbackText(hero.heading, 'Wir sind\nfür Sie da.')} />
</h1>
<p style={{ fontSize: 18, color: 'rgba(255,255,255,0.72)', maxWidth: 480, lineHeight: 1.65, fontWeight: 400 }}>
Fragen zur Bewerbung, zur Gala oder zu Partnermöglichkeiten unser Team begleitet Sie persönlich.
{fallbackText(hero.description, 'Fragen zur Bewerbung, zur Gala oder zu Partnermöglichkeiten unser Team begleitet Sie persönlich.')}
</p>
</div>
</section>
@@ -77,35 +121,38 @@ const Contact: React.FC = () => {
{/* Left info panel */}
<div style={{ padding: isMobile ? '48px 24px' : '88px 56px 88px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative' }}>
<p style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.28em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', marginBottom: 20 }}>
Direktkontakt
{fallbackText(info.eyebrow, 'Direktkontakt')}
</p>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 20 }} />
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2.4rem)', fontWeight: 900, color: '#fff', lineHeight: 1.15, letterSpacing: '-0.03em', marginBottom: 52 }}>
Kontakt&shy;möglichkeiten
<Lines text={fallbackText(info.heading, 'Kontakt­möglichkeiten')} />
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
{[
{ icon: MapPin, title: 'Geschäftsstelle', lines: ['Maximilianstraße 42', '80538 München'] },
{ icon: Mail, title: 'E-Mail', lines: ['info@bmp-bayern.de'] },
{ icon: Phone, title: 'Telefon', lines: ['+49 89 123 456 78'] },
{ icon: Clock, title: 'Bürozeiten', lines: ['Mo Fr: 09:00 17:00 Uhr'] },
].map(({ icon: Icon, title, lines }, i) => (
{contactItems.map(({ icon, title, lines }, i) => {
const Icon = ICONS[icon as keyof typeof ICONS] || MapPin;
const normalizedLines = typeof lines === 'string'
? lines.split('\n').filter(Boolean)
: fallbackArray<{ line?: string | null } | string>(lines, [])
.map((line) => typeof line === 'string' ? line : line.line)
.filter((line): line is string => Boolean(line));
return (
<div key={i} style={{ display: 'flex', gap: 18, alignItems: 'flex-start' }}>
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon size={17} color="rgba(255,255,255,0.80)" />
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'rgba(255,255,255,0.40)', textTransform: 'uppercase', letterSpacing: '0.14em', marginBottom: 4 }}>{title}</div>
{lines.map((l, j) => <div key={j} style={{ fontSize: 17, color: '#fff', fontWeight: 500, lineHeight: 1.5 }}>{l}</div>)}
<div style={{ fontSize: 11, fontWeight: 700, color: 'rgba(255,255,255,0.40)', textTransform: 'uppercase', letterSpacing: '0.14em', marginBottom: 4 }}>{fallbackText(title, '')}</div>
{normalizedLines.map((l: string, j: number) => <div key={j} style={{ fontSize: 17, color: '#fff', fontWeight: 500, lineHeight: 1.5 }}>{l}</div>)}
</div>
</div>
))}
);
})}
</div>
<div style={{ marginTop: 52, paddingTop: 28, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
<p style={{ fontSize: 15, color: 'rgba(255,255,255,0.40)', lineHeight: 1.65 }}>
Nächste Preisverleihung: <strong style={{ color: '#fff' }}>22. Oktober 2026</strong>
{fallbackText(info.nextAwardLabel, 'Nächste Preisverleihung:')} <strong style={{ color: '#fff' }}>{fallbackText(info.nextAwardDate, '22. Oktober 2026')}</strong>
</p>
</div>
</div>
@@ -131,19 +178,19 @@ const Contact: React.FC = () => {
{!isMobile && (
<div style={{ height: 200, position: 'relative', overflow: 'hidden', flexShrink: 0, zIndex: 1 }}>
<Image unoptimized src="/images/preistraeger-gruppe.jpg" alt="BMP Gala 2025" style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 30%', filter: 'sepia(0.18) brightness(0.92)' }} />
<Image unoptimized src={mediaUrl(info.formImage, '/images/preistraeger-gruppe.jpg')} alt={mediaAlt(info.formImage, fallbackText(info.formImageAlt, 'BMP Gala 2025'))} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 30%', filter: 'sepia(0.18) brightness(0.92)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(168,120,0,0.1) 0%, rgba(168,120,0,0.25) 50%, rgba(168,120,0,0.92) 88%, #A87800 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'rgba(17,29,85,0.35)' }} />
<div style={{ position: 'absolute', bottom: 14, left: 40, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(17,29,85,0.7)' }} />
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.75)', textTransform: 'uppercase', letterSpacing: '0.2em' }}>Gala 2025 München</span>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.75)', textTransform: 'uppercase', letterSpacing: '0.2em' }}>{fallbackText(info.formImageLabel, 'Gala 2025 München')}</span>
</div>
</div>
)}
<div style={{ padding: isMobile ? '36px 20px' : '32px 48px 32px 40px', flex: isMobile ? 'none' : 1, display: 'flex', flexDirection: 'column', minHeight: isMobile ? 480 : 0, position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>Kontaktformular</span>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{fallbackText(info.formEyebrow, 'Kontaktformular')}</span>
<h3 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', letterSpacing: '-0.025em', textTransform: 'uppercase', lineHeight: 1.1, marginBottom: 24 }}>
Schreiben Sie uns
{fallbackText(info.formTitle, 'Schreiben Sie uns')}
</h3>
<KontaktForm theme="gold" />
</div>
@@ -154,38 +201,38 @@ const Contact: React.FC = () => {
{/* ── Deadlines ──────────────────────────────────────────────────── */}
<section style={{ background: '#0D1640', padding: isMobile ? '56px 0' : '88px 0', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', right: -20, top: '50%', transform: 'translateY(-50%)', fontSize: 'clamp(160px, 22vw, 280px)', fontWeight: 900, color: '#fff', opacity: 0.025, lineHeight: 1, pointerEvents: 'none', userSelect: 'none', letterSpacing: '-0.05em' }}>
2026
{fallbackText(deadlines.watermark, '2026')}
</div>
<div className="container mx-auto px-6" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: isMobile ? 48 : 72 }}>
<span style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.28em', textTransform: 'uppercase', color: '#EFBF04', whiteSpace: 'nowrap' }}>
Fahrplan 2026
{fallbackText(deadlines.eyebrow, 'Fahrplan 2026')}
</span>
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.10)' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)', gap: 0 }}>
{deadlines.map((item, i) => (
{deadlineItems.map((item, i) => (
<div key={i} style={isMobile ? {
paddingTop: i > 0 ? 36 : 0,
paddingBottom: i < deadlines.length - 1 ? 36 : 0,
borderBottom: i < deadlines.length - 1 ? '1px solid rgba(255,255,255,0.10)' : 'none',
paddingBottom: i < deadlineItems.length - 1 ? 36 : 0,
borderBottom: i < deadlineItems.length - 1 ? '1px solid rgba(255,255,255,0.10)' : 'none',
} : {
paddingRight: i < deadlines.length - 1 ? 48 : 0,
paddingRight: i < deadlineItems.length - 1 ? 48 : 0,
paddingLeft: i > 0 ? 48 : 0,
borderRight: i < deadlines.length - 1 ? '1px solid rgba(255,255,255,0.10)' : 'none',
borderRight: i < deadlineItems.length - 1 ? '1px solid rgba(255,255,255,0.10)' : 'none',
}}>
<div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.2em', color: `rgba(239,191,4,0.5)`, marginBottom: 18, fontVariantNumeric: 'tabular-nums' }}>
{item.step}
{fallbackText(item.step, '')}
</div>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.14em', color: 'rgba(255,255,255,0.45)', marginBottom: 12 }}>
{item.label}
{fallbackText(item.label, '')}
</div>
<div style={{ fontSize: 'clamp(1.4rem, 2.5vw, 2rem)', fontWeight: 900, color: '#fff', letterSpacing: '-0.03em', lineHeight: 1.1, marginBottom: 16 }}>
{item.date}
{fallbackText(item.date, '')}
</div>
<div style={{ fontSize: 16, color: 'rgba(255,255,255,0.45)', lineHeight: 1.7 }}>
{item.desc}
{fallbackText(item.desc, '')}
</div>
</div>
))}
@@ -201,17 +248,17 @@ const Contact: React.FC = () => {
{/* Left sticky label */}
<div style={isMobile ? undefined : { position: 'sticky', top: 96, alignSelf: 'start' }}>
<p style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.28em', textTransform: 'uppercase', color: '#4A8FC9', marginBottom: 20 }}>
Antworten
{fallbackText(faq.eyebrow, 'Antworten')}
</p>
<div style={{ width: 28, height: 2, background: GOLD, marginBottom: 20 }} />
<h2 style={{ fontSize: isMobile ? 'clamp(1.8rem, 8vw, 2.4rem)' : 'clamp(2rem, 3.5vw, 2.8rem)', fontWeight: 900, color: NAVY, letterSpacing: '-0.03em', lineHeight: 1.1 }}>
Häufige<br />Fragen
<Lines text={fallbackText(faq.heading, 'Häufige\nFragen')} />
</h2>
</div>
{/* Right accordion */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{faqs.map((faq, idx) => {
{faqItems.map((faq, idx) => {
const isOpen = openFaq === idx;
return (
<div
@@ -233,7 +280,7 @@ const Contact: React.FC = () => {
}}
>
<span style={{ fontSize: 17, fontWeight: 700, color: isOpen ? NAVY : INK, flex: 1, paddingRight: 20, lineHeight: 1.4 }}>
{faq.q}
{fallbackText(faq.q, '')}
</span>
<div style={{
width: 30, height: 30, borderRadius: '50%', flexShrink: 0,
@@ -260,7 +307,7 @@ const Contact: React.FC = () => {
style={{ overflow: 'hidden' }}
>
<div style={{ padding: '0 24px 24px', fontSize: 17, color: 'rgba(58,58,58,0.72)', lineHeight: 1.75 }}>
{faq.a}
{fallbackText(faq.a, '')}
</div>
</motion.div>
)}