diff --git a/package.json b/package.json index 4ac476f..83f4868 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/collections/Pages/contactFields.ts b/src/collections/Pages/contactFields.ts new file mode 100644 index 0000000..52d9c9e --- /dev/null +++ b/src/collections/Pages/contactFields.ts @@ -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. 30–45 Minuten. Tiefergehende Unterlagen werden erst in der Audit-Phase angefordert.', + }, + ], + fields: [textarea('q', 'Question'), textarea('a', 'Answer')], + }, + ], + }, + ], + }, +] diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index 9277f08..916af09 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -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', diff --git a/src/payload-types.ts b/src/payload-types.ts index abc36c6..f6d4f22 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 { 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 | { diff --git a/src/scripts/preload-contact-page-cms.ts b/src/scripts/preload-contact-page-cms.ts new file mode 100644 index 0000000..dcc0c34 --- /dev/null +++ b/src/scripts/preload-contact-page-cms.ts @@ -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. 30–45 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) +}) diff --git a/src/spa/pages/Contact.tsx b/src/spa/pages/Contact.tsx index 46c553a..1553b2a 100644 --- a/src/spa/pages/Contact.tsx +++ b/src/spa/pages/Contact.tsx @@ -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; +type ContactItem = NonNullable['items']>[number] | { + icon: string; + title: string; + lines: string[]; +}; +type DeadlineItem = NonNullable['items']>[number]; +type FaqItem = NonNullable['items']>[number]; + +const fallbackText = (value: unknown, fallback: string) => + typeof value === 'string' && value.length > 0 ? value : fallback; + +const fallbackArray = (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) => {i > 0 &&
}{line}
)}; +} + +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(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(info.items, DEFAULT_CONTACT_ITEMS); + const deadlineItems = fallbackArray(deadlines.items, DEFAULT_DEADLINES); + const faqItems = fallbackArray(faq.items, DEFAULT_FAQS); return (
@@ -45,8 +89,8 @@ const Contact: React.FC = () => { {/* ── Hero ───────────────────────────────────────────────────────── */}
BMP Gala {/* Diagonal overlay */} @@ -56,13 +100,13 @@ const Contact: React.FC = () => {

- Dialog & Service + {fallbackText(hero.eyebrow, 'Dialog & Service')}

- Wir sind
für Sie da. +

- 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.')}

@@ -77,35 +121,38 @@ const Contact: React.FC = () => { {/* Left – info panel */}

- Direktkontakt + {fallbackText(info.eyebrow, 'Direktkontakt')}

- Kontakt­möglichkeiten +

- {[ - { 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 (
-
{title}
- {lines.map((l, j) =>
{l}
)} +
{fallbackText(title, '')}
+ {normalizedLines.map((l: string, j: number) =>
{l}
)}
- ))} + ); + })}

- Nächste Preisverleihung: 22. Oktober 2026 + {fallbackText(info.nextAwardLabel, 'Nächste Preisverleihung:')} {fallbackText(info.nextAwardDate, '22. Oktober 2026')}

@@ -131,19 +178,19 @@ const Contact: React.FC = () => { {!isMobile && (
- BMP Gala 2025 + {mediaAlt(info.formImage,
- Gala 2025 München + {fallbackText(info.formImageLabel, 'Gala 2025 München')}
)}
- Kontaktformular + {fallbackText(info.formEyebrow, 'Kontaktformular')}

- Schreiben Sie uns + {fallbackText(info.formTitle, 'Schreiben Sie uns')}

@@ -154,38 +201,38 @@ const Contact: React.FC = () => { {/* ── Deadlines ──────────────────────────────────────────────────── */}
- 2026 + {fallbackText(deadlines.watermark, '2026')}
- Fahrplan 2026 + {fallbackText(deadlines.eyebrow, 'Fahrplan 2026')}
- {deadlines.map((item, i) => ( + {deadlineItems.map((item, 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', }}>
- {item.step} + {fallbackText(item.step, '')}
- {item.label} + {fallbackText(item.label, '')}
- {item.date} + {fallbackText(item.date, '')}
- {item.desc} + {fallbackText(item.desc, '')}
))} @@ -201,17 +248,17 @@ const Contact: React.FC = () => { {/* Left – sticky label */}

- Antworten + {fallbackText(faq.eyebrow, 'Antworten')}

- Häufige
Fragen +

{/* Right – accordion */}
- {faqs.map((faq, idx) => { + {faqItems.map((faq, idx) => { const isOpen = openFaq === idx; return (
{ }} > - {faq.q} + {fallbackText(faq.q, '')}
{ style={{ overflow: 'hidden' }} >
- {faq.a} + {fallbackText(faq.a, '')}
)}