From 5944ebc62a4e9b841953f86070d4e8fe921a54e5 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 22 Jun 2026 10:54:18 +0200 Subject: [PATCH] feat: manage about page via payload --- package.json | 1 + src/collections/Pages/aboutFields.ts | 254 +++++++++++++ src/collections/Pages/index.ts | 17 + src/payload-types.ts | 344 ++++++++++++++++++ src/scripts/preload-about-page-cms.ts | 89 +++++ src/spa/aboutContent.ts | 142 ++++++++ src/spa/pages/About.tsx | 489 +++++++++++++------------- 7 files changed, 1085 insertions(+), 251 deletions(-) create mode 100644 src/collections/Pages/aboutFields.ts create mode 100644 src/scripts/preload-about-page-cms.ts create mode 100644 src/spa/aboutContent.ts diff --git a/package.json b/package.json index 5b10b25..65b695d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "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:about-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-about-page-cms.ts", "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", "preload:impressum-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-impressum-page-cms.ts", diff --git a/src/collections/Pages/aboutFields.ts b/src/collections/Pages/aboutFields.ts new file mode 100644 index 0000000..7b23463 --- /dev/null +++ b/src/collections/Pages/aboutFields.ts @@ -0,0 +1,254 @@ +import type { Field } from 'payload' + +import { aboutContent } from '@/spa/aboutContent' + +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 ctaGroup = (name: string, label: string, labelDefault: string, urlDefault: string): Field => ({ + name, + label, + type: 'group', + fields: [text('label', 'Label', labelDefault), text('url', 'URL', urlDefault)], +}) + +const sectionAdmin = (description: string) => ({ + description, + initCollapsed: true, +}) + +const statFields: Field[] = [text('value', 'Value'), text('label', 'Label')] +const bodyFields: Field[] = [textarea('text', 'Paragraph')] +const numberedFields: Field[] = [text('num', 'Number'), text('title', 'Title'), textarea('desc', 'Description')] + +export const aboutFields: Field[] = [ + { + name: 'about', + label: 'About / Der BMP page layout sections', + type: 'group', + admin: { + description: + 'Edit the Der BMP 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 hero image, headline, CTAs, and stats strip.'), + fields: [ + uploadField('backgroundImage', 'Background image', `Current frontend image: /images/${aboutContent.hero.backgroundImageFilename}`), + text('imageAlt', 'Image alt text', aboutContent.hero.imageAlt), + text('eyebrow', 'Eyebrow', aboutContent.hero.eyebrow), + textarea('heading', 'Heading', aboutContent.hero.heading), + text('highlight', 'Highlighted heading line', aboutContent.hero.highlight), + textarea('description', 'Intro paragraph', aboutContent.hero.description), + ctaGroup('primaryCta', 'Primary CTA', aboutContent.hero.primaryCta.label, aboutContent.hero.primaryCta.url), + ctaGroup('secondaryCta', 'Secondary CTA', aboutContent.hero.secondaryCta.label, aboutContent.hero.secondaryCta.url), + { + name: 'stats', + label: 'Stats', + type: 'array', + dbName: 'about_hero_stats', + defaultValue: aboutContent.hero.stats, + fields: statFields, + }, + ], + }, + { + name: 'prize', + label: '02 · Was ist der Preis?', + type: 'group', + admin: sectionAdmin('Cream text/image section explaining the prize.'), + fields: [ + uploadField('image', 'Image', `Current frontend image: /images/${aboutContent.prize.imageFilename}`), + text('imageAlt', 'Image alt text', aboutContent.prize.imageAlt), + text('eyebrow', 'Eyebrow', aboutContent.prize.eyebrow), + textarea('heading', 'Heading', aboutContent.prize.heading), + { + name: 'body', + label: 'Body paragraphs', + type: 'array', + dbName: 'about_prize_body', + defaultValue: aboutContent.prize.body.map((text) => ({ text })), + fields: bodyFields, + }, + { + name: 'facts', + label: 'Key facts', + type: 'array', + dbName: 'about_prize_facts', + defaultValue: aboutContent.prize.facts, + fields: statFields, + }, + text('imageLabel', 'Desktop image label', aboutContent.prize.imageLabel), + ], + }, + { + name: 'mittelstand', + label: '03 · Bedeutung für den Mittelstand', + type: 'group', + admin: sectionAdmin('Navy text/image section about relevance and goals.'), + fields: [ + uploadField('image', 'Image', `Current frontend image: /images/${aboutContent.mittelstand.imageFilename}`), + text('imageAlt', 'Image alt text', aboutContent.mittelstand.imageAlt), + text('eyebrow', 'Eyebrow', aboutContent.mittelstand.eyebrow), + textarea('heading', 'Heading', aboutContent.mittelstand.heading), + { + name: 'body', + label: 'Body paragraphs', + type: 'array', + dbName: 'about_mid_body', + defaultValue: aboutContent.mittelstand.body.map((text) => ({ text })), + fields: bodyFields, + }, + ], + }, + { + name: 'highlights', + label: '04 · Preisträger highlights', + type: 'group', + admin: sectionAdmin('Header copy and editor-selected winners for the photo grid.'), + fields: [ + text('eyebrow', 'Eyebrow', aboutContent.highlights.eyebrow), + textarea('heading', 'Heading', aboutContent.highlights.heading), + ctaGroup('cta', 'CTA', aboutContent.highlights.cta.label, aboutContent.highlights.cta.url), + text('hoverLabel', 'Card hover label', aboutContent.highlights.hoverLabel), + { + name: 'year', + label: 'Fallback winner year', + type: 'number', + defaultValue: aboutContent.highlights.year, + }, + { + name: 'selectedWinners', + label: 'Selected winners', + type: 'relationship', + relationTo: 'preistraeger', + hasMany: true, + maxRows: 8, + admin: { + description: 'Choose up to 8 Preisträger to show. If empty, the frontend falls back to winners from the selected year.', + }, + }, + ], + }, + { + name: 'testimonials', + label: '05 · Testimonials', + type: 'group', + admin: sectionAdmin('Testimonial heading and slides. Upload images when available, or keep the migrated URL fallback.'), + fields: [ + text('eyebrow', 'Eyebrow', aboutContent.testimonials.eyebrow), + textarea('heading', 'Heading', aboutContent.testimonials.heading), + { + name: 'items', + label: 'Testimonials', + type: 'array', + dbName: 'about_tst_items', + defaultValue: aboutContent.testimonials.items, + fields: [ + uploadField('image', 'Image'), + text('imageUrl', 'Fallback image URL'), + text('name', 'Name'), + text('role', 'Role'), + text('company', 'Company'), + text('year', 'Award year'), + textarea('quote', 'Quote'), + ], + }, + ], + }, + { + name: 'history', + label: '06 · Geschichte & Meilensteine', + type: 'group', + admin: sectionAdmin('Navy timeline section.'), + fields: [ + text('eyebrow', 'Eyebrow', aboutContent.history.eyebrow), + textarea('heading', 'Heading', aboutContent.history.heading), + text('highlight', 'Highlighted heading word', aboutContent.history.highlight), + text('watermark', 'Desktop watermark', aboutContent.history.watermark), + { + name: 'items', + label: 'Timeline items', + type: 'array', + dbName: 'about_hist_items', + defaultValue: aboutContent.history.items, + fields: [text('year', 'Year'), text('title', 'Title'), textarea('desc', 'Description')], + }, + ], + }, + { + name: 'goals', + label: '07 · Zielsetzung', + type: 'group', + admin: sectionAdmin('Cream values list next to the history timeline.'), + fields: [ + text('eyebrow', 'Eyebrow', aboutContent.goals.eyebrow), + textarea('heading', 'Heading', aboutContent.goals.heading), + { + name: 'items', + label: 'Goal rows', + type: 'array', + dbName: 'about_goal_items', + defaultValue: aboutContent.goals.items, + fields: numberedFields, + }, + ], + }, + { + name: 'values', + label: '08 · Vorteile & Werte', + type: 'group', + admin: sectionAdmin('Cream editorial rows before the final CTA.'), + fields: [ + text('eyebrow', 'Eyebrow', aboutContent.values.eyebrow), + textarea('heading', 'Heading', aboutContent.values.heading), + textarea('description', 'Description', aboutContent.values.description), + { + name: 'items', + label: 'Value rows', + type: 'array', + dbName: 'about_val_items', + defaultValue: aboutContent.values.items, + fields: [text('num', 'Number'), text('title', 'Title')], + }, + ], + }, + { + name: 'cta', + label: '09 · Final CTA', + type: 'group', + admin: sectionAdmin('Bottom navy CTA section.'), + fields: [ + text('eyebrow', 'Eyebrow', aboutContent.cta.eyebrow), + textarea('heading', 'Heading', aboutContent.cta.heading), + textarea('description', 'Description', aboutContent.cta.description), + ctaGroup('primaryCta', 'Primary CTA', aboutContent.cta.primaryCta.label, aboutContent.cta.primaryCta.url), + text('secondaryPrefix', 'Secondary prefix', aboutContent.cta.secondaryPrefix), + ctaGroup('secondaryCta', 'Secondary CTA', aboutContent.cta.secondaryCta.label, aboutContent.cta.secondaryCta.url), + ], + }, + ], + }, +] diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index 490e73b..59644d6 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 { aboutFields } from './aboutFields' import { contactFields } from './contactFields' import { homeFields } from './homeFields' import { impressumFields } from './impressumFields' @@ -49,6 +50,14 @@ const isParticipationPage = (_: unknown, siblingData?: { slug?: string; spaPath? return spaPath === '/teilnahme' || slug === 'teilnahme' || slug === 'participation' || title === 'teilnahme' || title === 'participation' } +const isAboutPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => { + const slug = siblingData?.slug + const spaPath = siblingData?.spaPath + const title = siblingData?.title?.toLowerCase() + + return spaPath === '/der-bmp' || slug === 'der-bmp' || slug === 'about' || title === 'der bmp' || title === 'about' +} + const isImpressumPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => { const slug = siblingData?.slug const spaPath = siblingData?.spaPath @@ -61,6 +70,7 @@ const isManagedSpaPage = (_: unknown, siblingData?: { slug?: string; spaPath?: s isHomePage(undefined, siblingData) || isContactPage(undefined, siblingData) || isParticipationPage(undefined, siblingData) || + isAboutPage(undefined, siblingData) || isImpressumPage(undefined, siblingData) export const Pages: CollectionConfig<'pages'> = { @@ -150,6 +160,13 @@ export const Pages: CollectionConfig<'pages'> = { fields: participationFields, label: 'Participation Page', }, + { + admin: { + condition: (data) => isAboutPage(undefined, data), + }, + fields: aboutFields, + label: 'About Page', + }, { admin: { condition: (data) => isImpressumPage(undefined, data), diff --git a/src/payload-types.ts b/src/payload-types.ts index 5d0f91f..6926c99 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -913,6 +913,184 @@ export interface Page { }; }; }; + /** + * Edit the Der BMP page in the same order it appears on the frontend. The generic Hero and Content tabs are hidden for this SPA-managed route. + */ + about?: { + /** + * Top hero image, headline, CTAs, and stats strip. + */ + hero?: { + /** + * Current frontend image: /images/saal-leer-atmosphaere.jpg + */ + backgroundImage?: (number | null) | Media; + imageAlt?: string | null; + eyebrow?: string | null; + heading?: string | null; + highlight?: string | null; + description?: string | null; + primaryCta?: { + label?: string | null; + url?: string | null; + }; + secondaryCta?: { + label?: string | null; + url?: string | null; + }; + stats?: + | { + value?: string | null; + label?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Cream text/image section explaining the prize. + */ + prize?: { + /** + * Current frontend image: /images/preistraeger-gruppe.jpg + */ + image?: (number | null) | Media; + imageAlt?: string | null; + eyebrow?: string | null; + heading?: string | null; + body?: + | { + text?: string | null; + id?: string | null; + }[] + | null; + facts?: + | { + value?: string | null; + label?: string | null; + id?: string | null; + }[] + | null; + imageLabel?: string | null; + }; + /** + * Navy text/image section about relevance and goals. + */ + mittelstand?: { + /** + * Current frontend image: /images/networking-innenhof.jpg + */ + image?: (number | null) | Media; + imageAlt?: string | null; + eyebrow?: string | null; + heading?: string | null; + body?: + | { + text?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Header copy and editor-selected winners for the photo grid. + */ + highlights?: { + eyebrow?: string | null; + heading?: string | null; + cta?: { + label?: string | null; + url?: string | null; + }; + hoverLabel?: string | null; + year?: number | null; + /** + * Choose up to 8 Preisträger to show. If empty, the frontend falls back to winners from the selected year. + */ + selectedWinners?: (number | Preistraeger)[] | null; + }; + /** + * Testimonial heading and slides. Upload images when available, or keep the migrated URL fallback. + */ + testimonials?: { + eyebrow?: string | null; + heading?: string | null; + items?: + | { + image?: (number | null) | Media; + imageUrl?: string | null; + name?: string | null; + role?: string | null; + company?: string | null; + year?: string | null; + quote?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Navy timeline section. + */ + history?: { + eyebrow?: string | null; + heading?: string | null; + highlight?: string | null; + watermark?: string | null; + items?: + | { + year?: string | null; + title?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Cream values list next to the history timeline. + */ + goals?: { + eyebrow?: string | null; + heading?: string | null; + items?: + | { + num?: string | null; + title?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Cream editorial rows before the final CTA. + */ + values?: { + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + items?: + | { + num?: string | null; + title?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Bottom navy CTA section. + */ + cta?: { + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + primaryCta?: { + label?: string | null; + url?: string | null; + }; + secondaryPrefix?: string | null; + secondaryCta?: { + label?: string | null; + url?: string | null; + }; + }; + }; /** * Edit the Impressum page content. Legal section body blocks are stored as JSON so nested paragraphs, lists, notices, addresses, and subsections can keep their frontend order. */ @@ -2558,6 +2736,172 @@ export interface PagesSelect { }; }; }; + about?: + | T + | { + hero?: + | T + | { + backgroundImage?: T; + imageAlt?: T; + eyebrow?: T; + heading?: T; + highlight?: T; + description?: T; + primaryCta?: + | T + | { + label?: T; + url?: T; + }; + secondaryCta?: + | T + | { + label?: T; + url?: T; + }; + stats?: + | T + | { + value?: T; + label?: T; + id?: T; + }; + }; + prize?: + | T + | { + image?: T; + imageAlt?: T; + eyebrow?: T; + heading?: T; + body?: + | T + | { + text?: T; + id?: T; + }; + facts?: + | T + | { + value?: T; + label?: T; + id?: T; + }; + imageLabel?: T; + }; + mittelstand?: + | T + | { + image?: T; + imageAlt?: T; + eyebrow?: T; + heading?: T; + body?: + | T + | { + text?: T; + id?: T; + }; + }; + highlights?: + | T + | { + eyebrow?: T; + heading?: T; + cta?: + | T + | { + label?: T; + url?: T; + }; + hoverLabel?: T; + year?: T; + selectedWinners?: T; + }; + testimonials?: + | T + | { + eyebrow?: T; + heading?: T; + items?: + | T + | { + image?: T; + imageUrl?: T; + name?: T; + role?: T; + company?: T; + year?: T; + quote?: T; + id?: T; + }; + }; + history?: + | T + | { + eyebrow?: T; + heading?: T; + highlight?: T; + watermark?: T; + items?: + | T + | { + year?: T; + title?: T; + desc?: T; + id?: T; + }; + }; + goals?: + | T + | { + eyebrow?: T; + heading?: T; + items?: + | T + | { + num?: T; + title?: T; + desc?: T; + id?: T; + }; + }; + values?: + | T + | { + eyebrow?: T; + heading?: T; + description?: T; + items?: + | T + | { + num?: T; + title?: T; + id?: T; + }; + }; + cta?: + | T + | { + eyebrow?: T; + heading?: T; + description?: T; + primaryCta?: + | T + | { + label?: T; + url?: T; + }; + secondaryPrefix?: T; + secondaryCta?: + | T + | { + label?: T; + url?: T; + }; + }; + }; impressum?: | T | { diff --git a/src/scripts/preload-about-page-cms.ts b/src/scripts/preload-about-page-cms.ts new file mode 100644 index 0000000..7f2a3f9 --- /dev/null +++ b/src/scripts/preload-about-page-cms.ts @@ -0,0 +1,89 @@ +import 'dotenv/config' + +import config from '@payload-config' +import { getPayload, type Payload } from 'payload' + +import { aboutContent } from '@/spa/aboutContent' + +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: '/der-bmp' } }, { slug: { equals: 'der-bmp' } }, { slug: { equals: 'about' } }], + }, + }) + + const page = pageResult.docs[0] + if (!page) throw new Error('About page not found. Expected spaPath=/der-bmp or slug=der-bmp/about.') + + const [heroImage, prizeImage, mittelstandImage, winnerResult] = await Promise.all([ + mediaByFilename(payload, aboutContent.hero.backgroundImageFilename), + mediaByFilename(payload, aboutContent.prize.imageFilename), + mediaByFilename(payload, aboutContent.mittelstand.imageFilename), + payload.find({ + collection: 'preistraeger', + limit: 8, + pagination: false, + draft: true, + overrideAccess: true, + where: { year: { equals: aboutContent.highlights.year } }, + }), + ]) + + const { backgroundImageFilename: _heroFilename, ...heroContent } = aboutContent.hero + const { imageFilename: _prizeFilename, ...prizeContent } = aboutContent.prize + const { imageFilename: _mittelstandFilename, ...mittelstandContent } = aboutContent.mittelstand + + await payload.update({ + collection: 'pages', + id: page.id, + overrideAccess: true, + context: { disableRevalidate: true }, + data: { + about: { + ...aboutContent, + hero: { + ...heroContent, + backgroundImage: heroImage, + }, + prize: { + ...prizeContent, + image: prizeImage, + body: aboutContent.prize.body.map((text) => ({ text })), + }, + mittelstand: { + ...mittelstandContent, + image: mittelstandImage, + body: aboutContent.mittelstand.body.map((text) => ({ text })), + }, + highlights: { + ...aboutContent.highlights, + selectedWinners: winnerResult.docs.map((winner) => winner.id), + }, + }, + } as never, + }) + + payload.logger.info(`Preloaded About CMS fields for page ${page.id}`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/src/spa/aboutContent.ts b/src/spa/aboutContent.ts new file mode 100644 index 0000000..2cec1d4 --- /dev/null +++ b/src/spa/aboutContent.ts @@ -0,0 +1,142 @@ +export const aboutContent = { + hero: { + backgroundImageFilename: 'saal-leer-atmosphaere.jpg', + imageAlt: 'BMP Gala', + eyebrow: 'Bayerischer Mittelstandspreis', + heading: 'WERTE, DIE\nBAYERN BEWEGEN', + highlight: 'BAYERN BEWEGEN', + description: + 'Seit 2005 ehrt der BMP herausragende Leistungen im bayerischen Mittelstand. Entdecken Sie die Geschichte, die Vision und die Menschen dahinter.', + primaryCta: { label: 'Jetzt bewerben', url: '/teilnahme' }, + secondaryCta: { label: 'Alle Preisträger', url: '/preistraeger' }, + stats: [ + { value: '20+', label: 'Jahre' }, + { value: '0 €', label: 'Teilnahme' }, + { value: '100%', label: 'Unabhängig' }, + ], + }, + prize: { + imageFilename: 'preistraeger-gruppe.jpg', + imageAlt: 'BMP Gala Preisverleihung', + eyebrow: 'Was ist der Preis?', + heading: 'DER BAYERISCHE\nMITTELSTANDSPREIS', + body: [ + 'Seit 2005 ist der BMP eine der bedeutendsten Auszeichnungen für den bayerischen Mittelstand. Vergeben von einer vollständig unabhängigen Expertenjury, ehrt er Unternehmen, die durch Innovation, Nachhaltigkeit und regionale Verwurzelung herausragen.', + 'Entstanden aus einer Initiative bayerischer Wirtschaftsvertreter, ehrt der Preis heute jährlich die Leuchttürme unserer Wirtschaft.', + ], + facts: [ + { value: '2005', label: 'Gegründet' }, + { value: '0 €', label: 'Teilnahmegebühr' }, + { value: '5', label: 'Jury-Schritte' }, + ], + imageLabel: 'BMP Preisverleihung · München', + }, + mittelstand: { + imageFilename: 'networking-innenhof.jpg', + imageAlt: 'Bayerischer Mittelstand', + eyebrow: 'Relevanz & Zielsetzung', + heading: 'BEDEUTUNG FÜR\nDEN MITTELSTAND', + body: [ + 'Der Bayerische Mittelstandspreis ist weit mehr als eine Trophäe. Seit seiner Gründung im Jahr 2005 verfolgt er das Ziel, die Vielfalt und Leistungsstärke bayerischer Familienunternehmen und KMU sichtbar zu machen.', + 'In einer globalisierten Welt sind es diese Unternehmen, die für Stabilität, Innovation und soziale Verantwortung stehen. Der BMP macht sie sichtbar.', + ], + }, + highlights: { + eyebrow: 'Preisträger-Highlights', + heading: 'AUSGEZEICHNETE 2025', + cta: { label: 'Alle Preisträger', url: '/preistraeger' }, + hoverLabel: 'Detail →', + year: 2025, + selectedWinners: [], + }, + testimonials: { + eyebrow: 'Stimmen der Preisträger', + heading: 'DAS SAGEN UNSERE\nPREISTRÄGER.', + items: [ + { + imageUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=600', + name: 'Dr. Maria Huber', + role: 'Geschäftsführerin', + company: 'TechVision GmbH', + year: '2025', + quote: 'Der BMP hat uns neue Türen geöffnet. Die Auszeichnung ist ein wichtiges Qualitätssiegel für unser gesamtes Team.', + }, + { + imageUrl: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=600', + name: 'Hannes Weber', + role: 'Inhaber & CEO', + company: 'Weber Präzision GmbH', + year: '2023', + quote: 'Der Gewinn war ein Moment des Stolzes für die gesamte Belegschaft. Die höchste Anerkennung für unsere tägliche Arbeit.', + }, + { + imageUrl: 'https://images.unsplash.com/photo-1580489944761-15a19d654956?auto=format&fit=crop&q=80&w=600', + name: 'Lisa Berger', + role: 'Gründerin & CEO', + company: 'EcoTech Bavaria', + year: '2024', + quote: 'Das Netzwerk, das wir durch den BMP gewonnen haben, ist unbezahlbar. Wir fanden Partner, die unsere Werte teilen.', + }, + { + imageUrl: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&q=80&w=600', + name: 'Dr. Michael Schuh', + role: 'Vorstand', + company: 'Bayerischer Maschinenbau', + year: '2024', + quote: 'Der Preis hat unsere Sichtbarkeit weit über die bayerischen Grenzen hinaus gestärkt. Ein echtes Premium-Siegel.', + }, + { + imageUrl: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&q=80&w=600', + name: 'Anna Kellner', + role: 'Mitgründerin', + company: 'AlpinTech Solutions', + year: '2024', + quote: 'Der BMP hat unsere Employer Brand transformiert. Wir gewinnen heute die besten Talente, weil wir Preisträger sind.', + }, + ], + }, + history: { + eyebrow: 'Geschichte & Meilensteine', + heading: '20 JAHRE\nMITTELSTANDSEXZELLENZ', + highlight: 'MITTELSTANDS', + watermark: '20', + items: [ + { year: '2005', title: 'Gründung', desc: 'Initiative bayerischer Wirtschaftsvertreter zur Stärkung des Mittelstands.' }, + { year: '2010', title: 'Erste mediale Partnerschaft', desc: 'Kooperation mit bayernweiten TV- und Printmedien.' }, + { year: '2015', title: '100 Preisträger', desc: 'Jubiläum: 100 ausgezeichnete Unternehmen seit der ersten Verleihung.' }, + { year: '2020', title: 'Digital-Transformation', desc: 'Erweiterung um digitale Bewerbungsprozesse und Online-Formate.' }, + { year: '2025', title: 'Jubiläumsjahr', desc: '20 Jahre BMP: stärker, sichtbarer und vernetzter denn je.' }, + { year: '2026', title: 'Bavarian Future Award', desc: 'Ergänzung des Portfolios um den Bavarian Future Award mit Fokus auf zukunftsweisende Unternehmen.' }, + ], + }, + goals: { + eyebrow: 'Zielsetzung', + heading: 'WOFÜR WIR\nSTEHEN', + items: [ + { num: '01', title: 'Sichtbarkeit', desc: 'Die Vielfalt und Leistungsstärke des bayerischen Mittelstands in der Öffentlichkeit sichtbar machen.' }, + { num: '02', title: 'Netzwerk', desc: 'Branchenübergreifende Verbindungen zwischen herausragenden Unternehmen und Entscheidern schaffen.' }, + { num: '03', title: 'Werte', desc: 'Wertebasierte Führung, nachhaltige Unternehmenskultur und gesellschaftliche Verantwortung fördern.' }, + { num: '04', title: 'Arbeitgebermarke', desc: 'Den Mittelstand als attraktiven Arbeitgeber stärken und Talente für Bayern gewinnen.' }, + { num: '05', title: 'Resilienz', desc: 'Die Widerstandsfähigkeit bayerischer Unternehmen würdigen, die auch in unsicheren Zeiten Stabilität und Verantwortung beweisen.' }, + ], + }, + values: { + eyebrow: 'Vorteile & Werte', + heading: 'WARUM DER\nBMP ZÄHLT', + description: 'Drei Dimensionen, die den Unterschied machen: für Ihr Unternehmen, Ihre Mitarbeitenden und Ihren Marktauftritt.', + items: [ + { num: '01', title: 'Exzellenz-Siegel' }, + { num: '02', title: 'Partner-Netzwerk' }, + { num: '03', title: 'Reichweite' }, + ], + }, + cta: { + eyebrow: 'Starten Sie Ihre Reise', + heading: 'SCHREIBEN AUCH SIE GESCHICHTE', + description: + 'Werden Sie Teil der Wall of Fame des bayerischen Mittelstands. Die Teilnahmegebühr beträgt 0 EUR und die Teilnahme lohnt sich in jeder Phase.', + primaryCta: { label: 'Jetzt kostenlos bewerben', url: '/teilnahme' }, + secondaryPrefix: 'Oder:', + secondaryCta: { label: 'Vereinsmitglied werden', url: '/mitglied-werden' }, + }, +} diff --git a/src/spa/pages/About.tsx b/src/spa/pages/About.tsx index b768ebb..1672207 100644 --- a/src/spa/pages/About.tsx +++ b/src/spa/pages/About.tsx @@ -1,146 +1,238 @@ -import React, { useState } from 'react'; -import { Link } from '@/spa/router'; -import { Star, ChevronRight } from 'lucide-react'; -import TestimonialsSection from '@/spa/components/TestimonialsSection'; -import { WINNERS } from '@/spa/data/winners'; -import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; -import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import React, { useMemo, useState } from 'react' +import { ChevronRight, Star } from 'lucide-react' + +import TestimonialsSection from '@/spa/components/TestimonialsSection' +import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg' import Image from '@/spa/components/ui/UnoptimizedImage' +import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField' +import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute' +import { WINNERS, type Winner } from '@/spa/data/winners' +import { useIsMobile } from '@/spa/hooks/useIsMobile' +import { Link } from '@/spa/router' +import { aboutContent } from '@/spa/aboutContent' -const FF = '"IBM Plex Sans", sans-serif'; -const FB = '"Inter", sans-serif'; -const NAVY = '#111D55'; -const GOLD = '#EFBF04'; -const CREAM = '#E4E2E3'; +const FF = '"IBM Plex Sans", sans-serif' +const FB = '"Inter", sans-serif' +const NAVY = '#111D55' +const GOLD = '#EFBF04' +const CREAM = '#E4E2E3' -// 8 highlight winners from 2025 -const HIGHLIGHTS = WINNERS.filter(w => w.year === 2025).slice(0, 8); +type AboutCms = Partial & { + hero?: Partial & { backgroundImage?: unknown } + prize?: Partial & { image?: unknown } + mittelstand?: Partial & { image?: unknown } + highlights?: Partial & { selectedWinners?: unknown[] | null } +} + +type TextRow = { text?: string | null } + +const FALLBACK_HIGHLIGHTS = WINNERS.filter((w) => w.year === aboutContent.highlights.year).slice(0, 8) + +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 ? (value as T[]) : fallback) + +function Lines({ text }: { text: string }) { + return ( + <> + {text.split('\n').map((line, i) => ( + + {i > 0 &&
} + {line} +
+ ))} + + ) +} + +const paragraphs = (value: unknown, fallback: string[]) => + Array.isArray(value) && value.length + ? (value as Array).map((item) => (typeof item === 'string' ? item : fallbackText(item.text, ''))).filter(Boolean) + : fallback + +function HighlightedText({ text, highlight }: { text: string; highlight: string }) { + const index = text.indexOf(highlight) + if (!highlight || index < 0) return <>{text} + + return ( + <> + {text.slice(0, index)} + {highlight} + {text.slice(index + highlight.length)} + + ) +} + +const normalizeWinner = (doc: CmsRouteDoc | undefined): Winner | undefined => { + if (!doc) return undefined + return { + id: String(doc.id), + slug: String(doc.slug || ''), + name: String(doc.title || 'Preisträger'), + category: String(doc.category || 'Preisträger'), + year: Number(doc.year || aboutContent.highlights.year), + type: String(doc.awardType || 'Auszeichnung'), + img: mediaUrl(doc.image, '/images/gala-saal-overview.jpg'), + shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''), + longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''), + quote: String(doc.quote || ''), + quotePerson: String(doc.quotePerson || doc.title || ''), + quoteRole: String(doc.quoteRole || ''), + location: String(doc.location || ''), + industry: String(doc.industry || ''), + website: String(doc.website || ''), + hasMedia: Boolean(doc.hasMedia), + } +} + +const winnerFromRelationship = (entry: unknown, allDocs: CmsRouteDoc[]) => { + if (entry && typeof entry === 'object') return normalizeWinner(entry as CmsRouteDoc) + const doc = allDocs.find((item) => String(item.id) === String(entry)) + return normalizeWinner(doc) +} const About: React.FC = () => { - const isMobile = useIsMobile(); + const isMobile = useIsMobile() + const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms + const cmsWinners = useCmsCollection('preistraeger') + const hero = { ...aboutContent.hero, ...(cms.hero || {}) } + const prize = { ...aboutContent.prize, ...(cms.prize || {}) } + const mittelstand = { ...aboutContent.mittelstand, ...(cms.mittelstand || {}) } + const highlights = { ...aboutContent.highlights, ...(cms.highlights || {}) } + const testimonials = { ...aboutContent.testimonials, ...(cms.testimonials || {}) } + const history = { ...aboutContent.history, ...(cms.history || {}) } + const goals = { ...aboutContent.goals, ...(cms.goals || {}) } + const values = { ...aboutContent.values, ...(cms.values || {}) } + const cta = { ...aboutContent.cta, ...(cms.cta || {}) } + + const highlightWinners = useMemo(() => { + const selected = fallbackArray(highlights.selectedWinners, []) + .map((entry) => winnerFromRelationship(entry, cmsWinners)) + .filter((winner): winner is Winner => Boolean(winner)) + + if (selected.length) return selected.slice(0, 8) + + const isWinnerFromYear = (winner: Winner | undefined): winner is Winner => + Boolean(winner) && winner?.year === Number(highlights.year || aboutContent.highlights.year) + + const cmsByYear = cmsWinners + .map(normalizeWinner) + .filter(isWinnerFromYear) + .slice(0, 8) + + return cmsByYear.length ? cmsByYear : FALLBACK_HIGHLIGHTS + }, [cmsWinners, highlights.selectedWinners, highlights.year]) + return (
- - {/* ── 1. HERO ─────────────────────────────────────────────────────────── */}
- BMP Gala - {/* Left-to-right dark overlay */}
- {/* Gold accent line */}
- {/* Gold bottom line */}
- Bayerischer Mittelstandspreis + {fallbackText(hero.eyebrow, aboutContent.hero.eyebrow)}

- WERTE, DIE
- BAYERN BEWEGEN + {fallbackText(hero.heading, aboutContent.hero.heading).split('\n').map((line, index) => ( + + {index > 0 &&
} + {line} +
+ ))}

- Seit 2005 ehrt der BMP herausragende Leistungen im bayerischen Mittelstand. Entdecken Sie die Geschichte, die Vision und die Menschen dahinter. + {fallbackText(hero.description, aboutContent.hero.description)}

{ (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.color = '#101828'; }} - onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = '#111D55'; (e.currentTarget as HTMLElement).style.color = '#fff'; }} + onMouseEnter={(e) => { e.currentTarget.style.background = GOLD; e.currentTarget.style.color = '#101828' }} + onMouseLeave={(e) => { e.currentTarget.style.background = '#111D55'; e.currentTarget.style.color = '#fff' }} > - Jetzt bewerben + {fallbackText(hero.primaryCta?.label, aboutContent.hero.primaryCta.label)} { (e.currentTarget as HTMLElement).style.background = '#FFD130'; (e.currentTarget as HTMLElement).style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }} - onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }} + 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 = GOLD; e.currentTarget.style.boxShadow = 'none' }} > - Alle Preisträger + {fallbackText(hero.secondaryCta?.label, aboutContent.hero.secondaryCta.label)}
- {/* Stats strip */}
- {[['20+', 'Jahre'], ['0 €', 'Teilnahme'], ['100%', 'Unabhängig']].map(([val, lbl], i) => ( -
-
{val}
-
{lbl}
+ {fallbackArray(hero.stats, aboutContent.hero.stats).map((item, i) => ( +
+
{fallbackText(item.value, '')}
+
{fallbackText(item.label, '')}
))}
- - {/* ── 2a. WAS IST DER PREIS? (cream, text left / image right) ─────────── */}
- - {/* Left – text on cream */}
{!isMobile && ( <> - Was ist der Preis? + {fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)}

- DER BAYERISCHE
MITTELSTANDSPREIS +

)}
-

- Seit 2005 ist der BMP eine der bedeutendsten Auszeichnungen für den bayerischen Mittelstand. Vergeben von einer vollständig unabhängigen Expertenjury, ehrt er Unternehmen, die durch Innovation, Nachhaltigkeit und regionale Verwurzelung herausragen. -

-

- Entstanden aus einer Initiative bayerischer Wirtschaftsvertreter, ehrt der Preis heute jährlich die Leuchttürme unserer Wirtschaft. -

- {/* Key facts – moved above the copy on mobile */} + {paragraphs(prize.body, aboutContent.prize.body).map((text, index, arr) => ( +

+ {text} +

+ ))}
- {[ - { val: '2005', label: 'Gegründet' }, - { val: '0 €', label: 'Teilnahmegebühr' }, - { val: '5', label: 'Jury-Schritte' }, - ].map((f, i) => ( -
-
{f.val}
-
{f.label}
+ {fallbackArray(prize.facts, aboutContent.prize.facts).map((f, i, arr) => ( +
+
{fallbackText(f.value, '')}
+
{fallbackText(f.label, '')}
))}
- {/* Right – full-bleed image (+ heading on mobile) */}
- BMP Gala Preisverleihung
- {/* Heading overlay – mobile only */} {isMobile && (
- Was ist der Preis? + {fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)}

- DER BAYERISCHE
MITTELSTANDSPREIS +

)} - {/* Decorative label – desktop only */} {!isMobile && (
-
BMP Preisverleihung · München
+
{fallbackText(prize.imageLabel, aboutContent.prize.imageLabel)}
)} @@ -148,15 +240,13 @@ const About: React.FC = () => {
- {/* ── 2b. BEDEUTUNG FÜR DEN MITTELSTAND (navy, image left / text right) ── */}
- - {/* Left – full-bleed image (+ heading on mobile) */}
- Bayerischer Mittelstand
{
)} - {/* Heading overlay – mobile only */} {isMobile && (
- Relevanz & Zielsetzung + {fallbackText(mittelstand.eyebrow, aboutContent.mittelstand.eyebrow)}

- BEDEUTUNG FÜR
DEN MITTELSTAND +

)}
- {/* Right – text on navy */}
{!isMobile && ( <> - Relevanz & Zielsetzung + {fallbackText(mittelstand.eyebrow, aboutContent.mittelstand.eyebrow)}

- BEDEUTUNG FÜR
DEN MITTELSTAND +

)}
-

- Der Bayerische Mittelstandspreis ist weit mehr als eine Trophäe. Seit seiner Gründung im Jahr 2005 verfolgt er das Ziel, die Vielfalt und Leistungsstärke bayerischer Familienunternehmen und KMU sichtbar zu machen. -

-

- In einer globalisierten Welt sind es diese Unternehmen, die für Stabilität, Innovation und soziale Verantwortung stehen. Der BMP macht sie sichtbar. -

+ {paragraphs(mittelstand.body, aboutContent.mittelstand.body).map((text, index, arr) => ( +

+ {text} +

+ ))}
- - {/* ── 3. PREISTRÄGER-HIGHLIGHTS ─────────────────────────────────────────── */}
- {/* Section header */}
- Preisträger-Highlights + {fallbackText(highlights.eyebrow, aboutContent.highlights.eyebrow)}

- AUSGEZEICHNETE 2025 +

- Alle Preisträger + {fallbackText(highlights.cta?.label, aboutContent.highlights.cta.label)}
- {/* 4-col photo grid */} -
- {HIGHLIGHTS.map(w => )} +
+ {highlightWinners.map((w) => )}
+ - {/* ── 4. TESTIMONIALS ──────────────────────────────────────────────────── */} - - - - {/* ── 5. GESCHICHTE & ZIELSETZUNG ──────────────────────────────────────── */}
- - {/* LEFT – navy, timeline */}
- {/* Ghost "20" watermark – desktop only */} - {!isMobile &&
20
} + {!isMobile &&
{fallbackText(history.watermark, aboutContent.history.watermark)}
} - Geschichte & Meilensteine + {fallbackText(history.eyebrow, aboutContent.history.eyebrow)}

- 20 JAHRE
MITTELSTANDSEXZELLENZ + {fallbackText(history.heading, aboutContent.history.heading).split('\n').map((line, index) => ( + + {index > 0 &&
} + +
+ ))}

- {/* Timeline */}
- {/* Vertical gold line */}
- {[ - { year: '2005', title: 'Gründung', desc: 'Initiative bayerischer Wirtschaftsvertreter zur Stärkung des Mittelstands.' }, - { year: '2010', title: 'Erste mediale Partnerschaft', desc: 'Kooperation mit bayernweiten TV- und Printmedien.' }, - { year: '2015', title: '100 Preisträger', desc: 'Jubiläum: 100 ausgezeichnete Unternehmen seit der ersten Verleihung.' }, - { year: '2020', title: 'Digital-Transformation', desc: 'Erweiterung um digitale Bewerbungsprozesse und Online-Formate.' }, - { year: '2025', title: 'Jubiläumsjahr', desc: '20 Jahre BMP: stärker, sichtbarer und vernetzter denn je.' }, - { year: '2026', title: 'Bavarian Future Award', desc: 'Ergänzung des Portfolios um den Bavarian Future Award mit Fokus auf zukunftsweisende Unternehmen.' }, - ].map((item, i, arr) => ( -
- {/* Gold dot */} + {fallbackArray(history.items, aboutContent.history.items).map((item, i, arr) => ( +
-
{item.year}
-
{item.title}
-
{item.desc}
+
{fallbackText(item.year, '')}
+
{fallbackText(item.title, '')}
+
{fallbackText(item.desc, '')}
))}
- {/* RIGHT – cream, Zielsetzung */}
- Zielsetzung + {fallbackText(goals.eyebrow, aboutContent.goals.eyebrow)}

- WOFÜR WIR
STEHEN +

- {[ - { n: '01', title: 'Sichtbarkeit', desc: 'Die Vielfalt und Leistungsstärke des bayerischen Mittelstands in der Öffentlichkeit sichtbar machen.' }, - { n: '02', title: 'Netzwerk', desc: 'Branchenübergreifende Verbindungen zwischen herausragenden Unternehmen und Entscheidern schaffen.' }, - { n: '03', title: 'Werte', desc: 'Wertebasierte Führung, nachhaltige Unternehmenskultur und gesellschaftliche Verantwortung fördern.' }, - { n: '04', title: 'Arbeitgebermarke', desc: 'Den Mittelstand als attraktiven Arbeitgeber stärken und Talente für Bayern gewinnen.' }, - { n: '05', title: 'Resilienz', desc: 'Die Widerstandsfähigkeit bayerischer Unternehmen würdigen, die auch in unsicheren Zeiten Stabilität und Verantwortung beweisen.' }, - ].map((item, i, arr) => ( -
- {item.n} + {fallbackArray(goals.items, aboutContent.goals.items).map((item, i, arr) => ( +
+ {fallbackText(item.num, '')}
-
{item.title}
-
{item.desc}
+
{fallbackText(item.title, '')}
+
{fallbackText(item.desc, '')}
))} @@ -310,111 +359,69 @@ const About: React.FC = () => {
- {/* ── 5. VORTEILE & WERTE ──────────────────────────────────────────────── */}
- {/* Header */}
- Vorteile & Werte + {fallbackText(values.eyebrow, aboutContent.values.eyebrow)}

- WARUM DER
BMP ZÄHLT +

- Drei Dimensionen, die den Unterschied machen: für Ihr Unternehmen, Ihre Mitarbeitenden und Ihren Marktauftritt. + {fallbackText(values.description, aboutContent.values.description)}

- {/* Editorial rows – no cards, pure typography */} - {[ - { - num: '01', - title: 'Exzellenz-Siegel', - }, - { - num: '02', - title: 'Partner-Netzwerk', - }, - { - num: '03', - title: 'Reichweite', - }, - ].map((item, i, arr) => ( - + {fallbackArray(values.items, aboutContent.values.items).map((item, i, arr) => ( + ))}
- - {/* ── 5. CTA ───────────────────────────────────────────────────────────── */}
- {/* Gold decorative lines */}
- Starten Sie Ihre Reise + {fallbackText(cta.eyebrow, aboutContent.cta.eyebrow)}

- SCHREIBEN AUCH SIE GESCHICHTE +

- Werden Sie Teil der Wall of Fame des bayerischen Mittelstands. Die Teilnahmegebühr beträgt 0 EUR und die Teilnahme lohnt sich in jeder Phase. + {fallbackText(cta.description, aboutContent.cta.description)}

{ (e.currentTarget as HTMLElement).style.background = '#FFD130'; (e.currentTarget as HTMLElement).style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }} - onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }} + 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 = GOLD; e.currentTarget.style.boxShadow = 'none' }} > - Jetzt kostenlos bewerben + {fallbackText(cta.primaryCta?.label, aboutContent.cta.primaryCta.label)}
- Oder: + {fallbackText(cta.secondaryPrefix, aboutContent.cta.secondaryPrefix)} { - (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)'; - }} + 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)' }} > - Vereinsmitglied werden + {fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)}
-
- ); -}; + ) +} -// ── HighlightCard ───────────────────────────────────────────────────────────── - -function HighlightCard({ winner }: { winner: import('@/spa/data/winners').Winner }) { - const [hovered, setHovered] = useState(false); - const isMobile = useIsMobile(); +function HighlightCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string }) { + const [hovered, setHovered] = useState(false) + const isMobile = useIsMobile() return ( setHovered(true)} onMouseLeave={() => setHovered(false)} > - {winner.name} + {winner.name}
@@ -437,49 +440,33 @@ function HighlightCard({ winner }: { winner: import('@/spa/data/winners').Winner {winner.year} · {winner.type}
-

- {winner.name} -

-

- {winner.category} -

+

{winner.name}

+

{winner.category}

- {/* hover pill */}
- Detail → + {hoverLabel}
- ); + ) } -// ── ValueRow ────────────────────────────────────────────────────────────────── - -function ValueRow({ item, last }: { item: { num: string; title: string }; last: boolean }) { - const [hovered, setHovered] = useState(false); - const isMobileRow = useIsMobile(); +function ValueRow({ item, last }: { item: { num?: string | null; title?: string | null }; last: boolean }) { + const [hovered, setHovered] = useState(false) + const isMobileRow = useIsMobile() return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > - {/* Left: number */}
-
{item.num}
+
{fallbackText(item.num, '')}
- {/* Right: title (short label only) */}
-

{item.title}

+

{fallbackText(item.title, '')}

- ); + ) } -export default About; +export default About