diff --git a/package.json b/package.json index 83f4868..506b625 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "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", + "preload:participation-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-participation-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/index.ts b/src/collections/Pages/index.ts index 916af09..4c06a36 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -10,6 +10,7 @@ import { MediaBlock } from '../../blocks/MediaBlock/config' import { hero } from '@/heros/config' import { contactFields } from './contactFields' import { homeFields } from './homeFields' +import { participationFields } from './participationFields' import { slugField } from 'payload' import { populatePublishedAt } from '../../hooks/populatePublishedAt' import { generatePreviewPath } from '../../utilities/generatePreviewPath' @@ -39,8 +40,16 @@ const isContactPage = (_: unknown, siblingData?: { slug?: string; spaPath?: stri return spaPath === '/kontakt' || slug === 'kontakt' || slug === 'contact' || title === 'kontakt' || title === 'contact' } +const isParticipationPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => { + const slug = siblingData?.slug + const spaPath = siblingData?.spaPath + const title = siblingData?.title?.toLowerCase() + + return spaPath === '/teilnahme' || slug === 'teilnahme' || slug === 'participation' || title === 'teilnahme' || title === 'participation' +} + const isManagedSpaPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => - isHomePage(undefined, siblingData) || isContactPage(undefined, siblingData) + isHomePage(undefined, siblingData) || isContactPage(undefined, siblingData) || isParticipationPage(undefined, siblingData) export const Pages: CollectionConfig<'pages'> = { slug: 'pages', @@ -122,6 +131,13 @@ export const Pages: CollectionConfig<'pages'> = { fields: contactFields, label: 'Contact Page', }, + { + admin: { + condition: (data) => isParticipationPage(undefined, data), + }, + fields: participationFields, + label: 'Participation Page', + }, { name: 'meta', label: 'SEO', diff --git a/src/collections/Pages/participationFields.ts b/src/collections/Pages/participationFields.ts new file mode 100644 index 0000000..ec91d55 --- /dev/null +++ b/src/collections/Pages/participationFields.ts @@ -0,0 +1,400 @@ +import type { Field } from 'payload' + +import { participationContent } from '@/spa/participationContent' + +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 checkbox = (name: string, label: string, defaultValue = false): Field => ({ + name, + type: 'checkbox', + label, + defaultValue, +}) + +const sectionAdmin = (description: string) => ({ + description, + initCollapsed: true, +}) + +const linkGroup = (labelDefault: string, urlDefault: string): Field[] => [ + text('label', 'Label', labelDefault), + text('url', 'URL', urlDefault), +] + +const iconOptions = [ + { label: 'Alert circle', value: 'alertCircle' }, + { label: 'Arrow right', value: 'arrowRight' }, + { label: 'Building', value: 'building2' }, + { label: 'Check', value: 'check' }, + { label: 'Check circle', value: 'checkCircle2' }, + { label: 'File text', value: 'fileText' }, + { label: 'Map pin', value: 'mapPin' }, + { label: 'Search', value: 'search' }, + { label: 'Send', value: 'send' }, + { label: 'Star', value: 'star' }, + { label: 'Trending up', value: 'trendingUp' }, + { label: 'Trophy', value: 'trophy' }, + { label: 'User', value: 'user' }, + { label: 'User check', value: 'userCheck' }, + { label: 'User plus', value: 'userPlus' }, + { label: 'Users', value: 'users' }, +] + +const iconField = (defaultValue = 'star', dbName?: string): Field => ({ + name: 'icon', + label: 'Icon', + type: 'select', + dbName, + defaultValue, + options: iconOptions, +}) + +const factFields: Field[] = [text('label', 'Label'), textarea('desc', 'Description')] +const ctaGroup = (name: string, label: string, labelDefault: string, urlDefault: string): Field => ({ + name, + label, + type: 'group', + fields: linkGroup(labelDefault, urlDefault), +}) + +export const participationFields: Field[] = [ + { + name: 'participation', + label: 'Participation page layout sections', + type: 'group', + admin: { + description: + 'Edit the participation 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/${participationContent.hero.backgroundImageFilename}`), + text('imageAlt', 'Image alt text', participationContent.hero.imageAlt), + text('eyebrow', 'Eyebrow', participationContent.hero.eyebrow), + textarea('heading', 'Headline', participationContent.hero.heading), + textarea('description', 'Intro paragraph', participationContent.hero.description), + ], + }, + { + name: 'process', + label: '02 · Weg zur Auszeichnung timeline', + type: 'group', + admin: sectionAdmin('Timeline section rendered directly after the hero.'), + fields: [ + text('eyebrow', 'Eyebrow', participationContent.process.eyebrow), + textarea('heading', 'Heading', participationContent.process.heading), + textarea('description', 'Intro paragraph', participationContent.process.description), + text('scrollHint', 'Desktop scroll hint', participationContent.process.scrollHint), + text('progressLabel', 'Desktop progress label', participationContent.process.progressLabel), + { + name: 'steps', + label: 'Process steps', + type: 'array', + dbName: 'proc_steps', + defaultValue: participationContent.process.steps, + fields: [text('step', 'Step number'), text('title', 'Title'), textarea('desc', 'Description'), text('date', 'Date'), iconField('fileText')], + }, + ], + }, + { + name: 'eligibility', + label: '03 · Eligibility section', + type: 'group', + admin: sectionAdmin('Cream eligibility section with criteria rows, notes, and CTAs.'), + fields: [ + text('eyebrow', 'Eyebrow', participationContent.eligibility.eyebrow), + textarea('heading', 'Heading', participationContent.eligibility.heading), + textarea('description', 'Intro paragraph', participationContent.eligibility.description), + ctaGroup('primaryCta', 'Primary CTA', participationContent.eligibility.primaryCta.label, participationContent.eligibility.primaryCta.url), + ctaGroup('secondaryCta', 'Secondary CTA', participationContent.eligibility.secondaryCta.label, participationContent.eligibility.secondaryCta.url), + { + name: 'criteria', + label: 'Eligibility rows', + type: 'array', + dbName: 'elig_crit', + defaultValue: participationContent.eligibility.criteria, + fields: [text('num', 'Number'), text('title', 'Title'), textarea('body', 'Body'), iconField('mapPin')], + }, + { + name: 'notes', + label: 'Additional notes', + type: 'array', + dbName: 'elig_notes', + defaultValue: participationContent.eligibility.notes, + fields: [text('title', 'Title'), textarea('body', 'Body'), iconField('building2')], + }, + text('nudgeText', 'Bottom nudge text', participationContent.eligibility.nudgeText), + ctaGroup('nudgeCta', 'Bottom CTA', participationContent.eligibility.nudgeCta.label, participationContent.eligibility.nudgeCta.url), + ], + }, + { + name: 'evaluation', + label: '04 · Criteria / evaluation section', + type: 'group', + admin: sectionAdmin('Navy evaluation section with image, link, and criteria grid.'), + fields: [ + uploadField('image', 'Image', `Current frontend image: /images/${participationContent.evaluation.imageFilename}`), + text('imageAlt', 'Image alt text', participationContent.evaluation.imageAlt), + text('eyebrow', 'Eyebrow', participationContent.evaluation.eyebrow), + textarea('heading', 'Heading', participationContent.evaluation.heading), + textarea('body', 'Body copy', participationContent.evaluation.body), + ctaGroup('link', 'External criteria link', participationContent.evaluation.link.label, participationContent.evaluation.link.url), + { + name: 'criteria', + label: 'Evaluation criteria', + type: 'array', + dbName: 'eval_crit', + defaultValue: participationContent.evaluation.criteria, + fields: factFields, + }, + ], + }, + { + name: 'applicationWays', + label: '05 · Application paths', + type: 'group', + admin: sectionAdmin('Cream section explaining proposal, self-application, and special award paths.'), + fields: [ + text('eyebrow', 'Eyebrow', participationContent.applicationWays.eyebrow), + textarea('heading', 'Heading', participationContent.applicationWays.heading), + textarea('description', 'Intro paragraph', participationContent.applicationWays.description), + text('recommendedLabel', 'Featured card label', participationContent.applicationWays.recommendedLabel), + { + name: 'institutions', + label: 'Proposal institutions', + type: 'array', + dbName: 'app_inst', + defaultValue: participationContent.applicationWays.institutions, + fields: [textarea('text', 'Institution')], + }, + { + name: 'ways', + label: 'Application path cards', + type: 'array', + dbName: 'app_ways', + defaultValue: participationContent.applicationWays.ways, + fields: [ + text('label', 'Path label'), + textarea('title', 'Title'), + textarea('desc', 'Description'), + iconField('send', 'ico'), + checkbox('featured', 'Featured card'), + { + name: 'listType', + label: 'Detail list type', + type: 'select', + dbName: 'lt', + defaultValue: 'facts', + options: [ + { label: 'Use proposal institutions list', value: 'institutions' }, + { label: 'Use facts below', value: 'facts' }, + ], + }, + { + name: 'facts', + label: 'Fact rows', + type: 'array', + dbName: 'way_facts', + fields: factFields, + }, + ctaGroup('cta', 'CTA', 'Zum Formular', '#bewerben'), + ], + }, + ], + }, + { + name: 'datesBanner', + label: '06 · Dates banner', + type: 'group', + admin: sectionAdmin('Gold important-dates banner. Mobile and desktop labels differ slightly in the current design.'), + fields: [ + text('heading', 'Heading', participationContent.datesBanner.heading), + textarea('description', 'Desktop description', participationContent.datesBanner.description), + { + name: 'mobileItems', + label: 'Mobile date items', + type: 'array', + dbName: 'date_mob', + defaultValue: participationContent.datesBanner.mobileItems, + fields: [text('date', 'Date'), text('label', 'Label')], + }, + { + name: 'desktopItems', + label: 'Desktop date items', + type: 'array', + dbName: 'date_desk', + defaultValue: participationContent.datesBanner.desktopItems, + fields: [text('date', 'Date'), text('label', 'Label')], + }, + ], + }, + { + name: 'applicationForm', + label: '07 · Application form wrapper', + type: 'group', + admin: sectionAdmin('Navy/gold section containing the application form and PDF upload links.'), + fields: [ + uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`), + text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt), + text('imageCaption', 'Image caption', participationContent.applicationForm.imageCaption), + text('eyebrow', 'Eyebrow', participationContent.applicationForm.eyebrow), + textarea('heading', 'Heading', participationContent.applicationForm.heading), + textarea('description', 'Body copy', participationContent.applicationForm.description), + { + name: 'facts', + label: 'Fact rows', + type: 'array', + dbName: 'form_facts', + defaultValue: participationContent.applicationForm.facts, + fields: [text('num', 'Number'), text('label', 'Label'), textarea('desc', 'Description')], + }, + text('formEyebrow', 'Form eyebrow', participationContent.applicationForm.formEyebrow), + ctaGroup('uploadCta', 'Top upload CTA', participationContent.applicationForm.uploadCta.label, participationContent.applicationForm.uploadCta.url), + textarea('uploadPrompt', 'Bottom upload prompt', participationContent.applicationForm.uploadPrompt), + ctaGroup('uploadFooterCta', 'Bottom upload CTA', participationContent.applicationForm.uploadFooterCta.label, participationContent.applicationForm.uploadFooterCta.url), + ], + }, + { + name: 'form', + label: '08 · Application form copy and logic labels', + type: 'group', + admin: sectionAdmin('Text, options, and eligibility thresholds used inside the multi-step application form.'), + fields: [ + text('stepCompleteLabel', 'Completed step label', participationContent.form.stepCompleteLabel), + text('backLabel', 'Back button label', participationContent.form.backLabel), + text('nextLabel', 'Next button label', participationContent.form.nextLabel), + text('submitLabel', 'Submit button label', participationContent.form.submitLabel), + text('yesLabel', 'Yes option label', participationContent.form.yesLabel), + text('noLabel', 'No option label', participationContent.form.noLabel), + text('requiredSuffix', 'Required field suffix', participationContent.form.requiredSuffix), + { + name: 'validation', + label: 'Validation messages', + type: 'group', + fields: [ + text('choose', 'Choose message', participationContent.form.validation.choose), + text('required', 'Required message', participationContent.form.validation.required), + text('invalidEmail', 'Invalid email message', participationContent.form.validation.invalidEmail), + ], + }, + { + name: 'typeStep', + label: 'Step 1 · Submission type', + type: 'group', + fields: [ + text('eyebrow', 'Eyebrow', participationContent.form.typeStep.eyebrow), + text('heading', 'Heading', participationContent.form.typeStep.heading), + text('selfTitle', 'Self-application title', participationContent.form.typeStep.selfTitle), + textarea('selfDesc', 'Self-application description', participationContent.form.typeStep.selfDesc), + text('nominationTitle', 'Nomination title', participationContent.form.typeStep.nominationTitle), + textarea('nominationDesc', 'Nomination description', participationContent.form.typeStep.nominationDesc), + ], + }, + { + name: 'selfSteps', + label: 'Self-application step labels', + type: 'array', + dbName: 'self_steps', + defaultValue: participationContent.form.selfSteps, + fields: [text('label', 'Label'), iconField('trophy')], + }, + { + name: 'nominationSteps', + label: 'Nomination step labels', + type: 'array', + dbName: 'nom_steps', + defaultValue: participationContent.form.nominationSteps, + fields: [text('label', 'Label'), iconField('trophy')], + }, + { + name: 'employeeOptions', + label: 'Employee options', + type: 'array', + dbName: 'emp_opts', + defaultValue: participationContent.form.employeeOptions, + fields: [text('value', 'Value'), text('label', 'Label')], + }, + { + name: 'eligibility', + label: 'Self-application eligibility step', + type: 'group', + fields: [ + text('allowedEmployeeValues', 'Allowed employee option values, comma-separated', participationContent.form.eligibility.allowedEmployeeValues), + text('requiredBayernValue', 'Required Bavaria answer value', participationContent.form.eligibility.requiredBayernValue), + text('eyebrow', 'Eyebrow', participationContent.form.eligibility.eyebrow), + text('heading', 'Heading', participationContent.form.eligibility.heading), + text('employeesLabel', 'Employees field label', participationContent.form.eligibility.employeesLabel), + text('bayernLabel', 'Bavaria field label', participationContent.form.eligibility.bayernLabel), + text('ownerLabel', 'Owner field label', participationContent.form.eligibility.ownerLabel), + text('ineligibleHeading', 'Ineligible heading', participationContent.form.eligibility.ineligibleHeading), + textarea('ineligibleBody', 'Ineligible body', participationContent.form.eligibility.ineligibleBody), + text('ineligibleContactPrefix', 'Ineligible contact prefix', participationContent.form.eligibility.ineligibleContactPrefix), + text('ineligibleContactEmail', 'Ineligible contact email', participationContent.form.eligibility.ineligibleContactEmail), + ], + }, + { + name: 'selfContact', + label: 'Self-application contact step', + type: 'group', + fields: Object.entries(participationContent.form.selfContact).map(([name, value]) => text(name, name, value)), + }, + { + name: 'selfSummary', + label: 'Self-application summary step', + type: 'group', + fields: Object.entries(participationContent.form.selfSummary).map(([name, value]) => text(name, name, value)), + }, + { + name: 'nominationCompany', + label: 'Nomination company step', + type: 'group', + fields: Object.entries(participationContent.form.nominationCompany).map(([name, value]) => text(name, name, value)), + }, + { + name: 'nominationContact', + label: 'Nomination contact step', + type: 'group', + fields: Object.entries(participationContent.form.nominationContact).map(([name, value]) => text(name, name, value)), + }, + { + name: 'nominationSummary', + label: 'Nomination summary step', + type: 'group', + fields: Object.entries(participationContent.form.nominationSummary).map(([name, value]) => text(name, name, value)), + }, + { + name: 'success', + label: 'Success state copy', + type: 'group', + fields: Object.entries(participationContent.form.success).map(([name, value]) => text(name, name, value)), + }, + ], + }, + ], + }, +] diff --git a/src/payload-types.ts b/src/payload-types.ts index f6d4f22..a257361 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -488,6 +488,431 @@ export interface Page { | null; }; }; + /** + * Edit the participation page in the same order it appears on the frontend. The generic Hero and Content tabs are hidden for this SPA-managed route. + */ + participation?: { + /** + * Top image, eyebrow, headline, and intro copy. + */ + hero?: { + /** + * Current frontend image: /images/preisuebergabe.jpg + */ + backgroundImage?: (number | null) | Media; + imageAlt?: string | null; + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + }; + /** + * Timeline section rendered directly after the hero. + */ + process?: { + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + scrollHint?: string | null; + progressLabel?: string | null; + steps?: + | { + step?: string | null; + title?: string | null; + desc?: string | null; + date?: string | null; + icon?: + | ( + | 'alertCircle' + | 'arrowRight' + | 'building2' + | 'check' + | 'checkCircle2' + | 'fileText' + | 'mapPin' + | 'search' + | 'send' + | 'star' + | 'trendingUp' + | 'trophy' + | 'user' + | 'userCheck' + | 'userPlus' + | 'users' + ) + | null; + id?: string | null; + }[] + | null; + }; + /** + * Cream eligibility section with criteria rows, notes, and CTAs. + */ + eligibility?: { + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + primaryCta?: { + label?: string | null; + url?: string | null; + }; + secondaryCta?: { + label?: string | null; + url?: string | null; + }; + criteria?: + | { + num?: string | null; + title?: string | null; + body?: string | null; + icon?: + | ( + | 'alertCircle' + | 'arrowRight' + | 'building2' + | 'check' + | 'checkCircle2' + | 'fileText' + | 'mapPin' + | 'search' + | 'send' + | 'star' + | 'trendingUp' + | 'trophy' + | 'user' + | 'userCheck' + | 'userPlus' + | 'users' + ) + | null; + id?: string | null; + }[] + | null; + notes?: + | { + title?: string | null; + body?: string | null; + icon?: + | ( + | 'alertCircle' + | 'arrowRight' + | 'building2' + | 'check' + | 'checkCircle2' + | 'fileText' + | 'mapPin' + | 'search' + | 'send' + | 'star' + | 'trendingUp' + | 'trophy' + | 'user' + | 'userCheck' + | 'userPlus' + | 'users' + ) + | null; + id?: string | null; + }[] + | null; + nudgeText?: string | null; + nudgeCta?: { + label?: string | null; + url?: string | null; + }; + }; + /** + * Navy evaluation section with image, link, and criteria grid. + */ + evaluation?: { + /** + * Current frontend image: /images/buehne-gewinner.jpg + */ + image?: (number | null) | Media; + imageAlt?: string | null; + eyebrow?: string | null; + heading?: string | null; + body?: string | null; + link?: { + label?: string | null; + url?: string | null; + }; + criteria?: + | { + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Cream section explaining proposal, self-application, and special award paths. + */ + applicationWays?: { + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + recommendedLabel?: string | null; + institutions?: + | { + text?: string | null; + id?: string | null; + }[] + | null; + ways?: + | { + label?: string | null; + title?: string | null; + desc?: string | null; + icon?: + | ( + | 'alertCircle' + | 'arrowRight' + | 'building2' + | 'check' + | 'checkCircle2' + | 'fileText' + | 'mapPin' + | 'search' + | 'send' + | 'star' + | 'trendingUp' + | 'trophy' + | 'user' + | 'userCheck' + | 'userPlus' + | 'users' + ) + | null; + featured?: boolean | null; + listType?: ('institutions' | 'facts') | null; + facts?: + | { + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + cta?: { + label?: string | null; + url?: string | null; + }; + id?: string | null; + }[] + | null; + }; + /** + * Gold important-dates banner. Mobile and desktop labels differ slightly in the current design. + */ + datesBanner?: { + heading?: string | null; + description?: string | null; + mobileItems?: + | { + date?: string | null; + label?: string | null; + id?: string | null; + }[] + | null; + desktopItems?: + | { + date?: string | null; + label?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Navy/gold section containing the application form and PDF upload links. + */ + applicationForm?: { + /** + * Current frontend image: /images/preistraeger-jubel.jpg + */ + image?: (number | null) | Media; + imageAlt?: string | null; + imageCaption?: string | null; + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + facts?: + | { + num?: string | null; + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + formEyebrow?: string | null; + uploadCta?: { + label?: string | null; + url?: string | null; + }; + uploadPrompt?: string | null; + uploadFooterCta?: { + label?: string | null; + url?: string | null; + }; + }; + /** + * Text, options, and eligibility thresholds used inside the multi-step application form. + */ + form?: { + stepCompleteLabel?: string | null; + backLabel?: string | null; + nextLabel?: string | null; + submitLabel?: string | null; + yesLabel?: string | null; + noLabel?: string | null; + requiredSuffix?: string | null; + validation?: { + choose?: string | null; + required?: string | null; + invalidEmail?: string | null; + }; + typeStep?: { + eyebrow?: string | null; + heading?: string | null; + selfTitle?: string | null; + selfDesc?: string | null; + nominationTitle?: string | null; + nominationDesc?: string | null; + }; + selfSteps?: + | { + label?: string | null; + icon?: + | ( + | 'alertCircle' + | 'arrowRight' + | 'building2' + | 'check' + | 'checkCircle2' + | 'fileText' + | 'mapPin' + | 'search' + | 'send' + | 'star' + | 'trendingUp' + | 'trophy' + | 'user' + | 'userCheck' + | 'userPlus' + | 'users' + ) + | null; + id?: string | null; + }[] + | null; + nominationSteps?: + | { + label?: string | null; + icon?: + | ( + | 'alertCircle' + | 'arrowRight' + | 'building2' + | 'check' + | 'checkCircle2' + | 'fileText' + | 'mapPin' + | 'search' + | 'send' + | 'star' + | 'trendingUp' + | 'trophy' + | 'user' + | 'userCheck' + | 'userPlus' + | 'users' + ) + | null; + id?: string | null; + }[] + | null; + employeeOptions?: + | { + value?: string | null; + label?: string | null; + id?: string | null; + }[] + | null; + eligibility?: { + allowedEmployeeValues?: string | null; + requiredBayernValue?: string | null; + eyebrow?: string | null; + heading?: string | null; + employeesLabel?: string | null; + bayernLabel?: string | null; + ownerLabel?: string | null; + ineligibleHeading?: string | null; + ineligibleBody?: string | null; + ineligibleContactPrefix?: string | null; + ineligibleContactEmail?: string | null; + }; + selfContact?: { + eyebrow?: string | null; + heading?: string | null; + companyLabel?: string | null; + companyPlaceholder?: string | null; + nameLabel?: string | null; + namePlaceholder?: string | null; + emailLabel?: string | null; + emailPlaceholder?: string | null; + phoneLabel?: string | null; + phonePlaceholder?: string | null; + footnote?: string | null; + }; + selfSummary?: { + eyebrow?: string | null; + heading?: string | null; + companyLabel?: string | null; + employeesLabel?: string | null; + locationLabel?: string | null; + locationPrefix?: string | null; + contactLabel?: string | null; + }; + nominationCompany?: { + eyebrow?: string | null; + heading?: string | null; + companyLabel?: string | null; + companyPlaceholder?: string | null; + industryLabel?: string | null; + industryPlaceholder?: string | null; + locationLabel?: string | null; + locationPlaceholder?: string | null; + }; + nominationContact?: { + eyebrow?: string | null; + heading?: string | null; + nameLabel?: string | null; + namePlaceholder?: string | null; + emailLabel?: string | null; + emailPlaceholder?: string | null; + relationshipLabel?: string | null; + relationshipPlaceholder?: string | null; + footnote?: string | null; + }; + nominationSummary?: { + eyebrow?: string | null; + heading?: string | null; + companyLabel?: string | null; + industryLabel?: string | null; + locationLabel?: string | null; + nominatedByLabel?: string | null; + emptyValue?: string | null; + }; + success?: { + selfHeading?: string | null; + nominationHeading?: string | null; + selfPrefix?: string | null; + selfMiddle?: string | null; + selfSuffix?: string | null; + nominationPrefix?: string | null; + nominationMiddle?: string | null; + nominationSuffix?: string | null; + }; + }; + }; meta?: { title?: string | null; /** @@ -1764,6 +2189,331 @@ export interface PagesSelect { }; }; }; + participation?: + | T + | { + hero?: + | T + | { + backgroundImage?: T; + imageAlt?: T; + eyebrow?: T; + heading?: T; + description?: T; + }; + process?: + | T + | { + eyebrow?: T; + heading?: T; + description?: T; + scrollHint?: T; + progressLabel?: T; + steps?: + | T + | { + step?: T; + title?: T; + desc?: T; + date?: T; + icon?: T; + id?: T; + }; + }; + eligibility?: + | T + | { + eyebrow?: T; + heading?: T; + description?: T; + primaryCta?: + | T + | { + label?: T; + url?: T; + }; + secondaryCta?: + | T + | { + label?: T; + url?: T; + }; + criteria?: + | T + | { + num?: T; + title?: T; + body?: T; + icon?: T; + id?: T; + }; + notes?: + | T + | { + title?: T; + body?: T; + icon?: T; + id?: T; + }; + nudgeText?: T; + nudgeCta?: + | T + | { + label?: T; + url?: T; + }; + }; + evaluation?: + | T + | { + image?: T; + imageAlt?: T; + eyebrow?: T; + heading?: T; + body?: T; + link?: + | T + | { + label?: T; + url?: T; + }; + criteria?: + | T + | { + label?: T; + desc?: T; + id?: T; + }; + }; + applicationWays?: + | T + | { + eyebrow?: T; + heading?: T; + description?: T; + recommendedLabel?: T; + institutions?: + | T + | { + text?: T; + id?: T; + }; + ways?: + | T + | { + label?: T; + title?: T; + desc?: T; + icon?: T; + featured?: T; + listType?: T; + facts?: + | T + | { + label?: T; + desc?: T; + id?: T; + }; + cta?: + | T + | { + label?: T; + url?: T; + }; + id?: T; + }; + }; + datesBanner?: + | T + | { + heading?: T; + description?: T; + mobileItems?: + | T + | { + date?: T; + label?: T; + id?: T; + }; + desktopItems?: + | T + | { + date?: T; + label?: T; + id?: T; + }; + }; + applicationForm?: + | T + | { + image?: T; + imageAlt?: T; + imageCaption?: T; + eyebrow?: T; + heading?: T; + description?: T; + facts?: + | T + | { + num?: T; + label?: T; + desc?: T; + id?: T; + }; + formEyebrow?: T; + uploadCta?: + | T + | { + label?: T; + url?: T; + }; + uploadPrompt?: T; + uploadFooterCta?: + | T + | { + label?: T; + url?: T; + }; + }; + form?: + | T + | { + stepCompleteLabel?: T; + backLabel?: T; + nextLabel?: T; + submitLabel?: T; + yesLabel?: T; + noLabel?: T; + requiredSuffix?: T; + validation?: + | T + | { + choose?: T; + required?: T; + invalidEmail?: T; + }; + typeStep?: + | T + | { + eyebrow?: T; + heading?: T; + selfTitle?: T; + selfDesc?: T; + nominationTitle?: T; + nominationDesc?: T; + }; + selfSteps?: + | T + | { + label?: T; + icon?: T; + id?: T; + }; + nominationSteps?: + | T + | { + label?: T; + icon?: T; + id?: T; + }; + employeeOptions?: + | T + | { + value?: T; + label?: T; + id?: T; + }; + eligibility?: + | T + | { + allowedEmployeeValues?: T; + requiredBayernValue?: T; + eyebrow?: T; + heading?: T; + employeesLabel?: T; + bayernLabel?: T; + ownerLabel?: T; + ineligibleHeading?: T; + ineligibleBody?: T; + ineligibleContactPrefix?: T; + ineligibleContactEmail?: T; + }; + selfContact?: + | T + | { + eyebrow?: T; + heading?: T; + companyLabel?: T; + companyPlaceholder?: T; + nameLabel?: T; + namePlaceholder?: T; + emailLabel?: T; + emailPlaceholder?: T; + phoneLabel?: T; + phonePlaceholder?: T; + footnote?: T; + }; + selfSummary?: + | T + | { + eyebrow?: T; + heading?: T; + companyLabel?: T; + employeesLabel?: T; + locationLabel?: T; + locationPrefix?: T; + contactLabel?: T; + }; + nominationCompany?: + | T + | { + eyebrow?: T; + heading?: T; + companyLabel?: T; + companyPlaceholder?: T; + industryLabel?: T; + industryPlaceholder?: T; + locationLabel?: T; + locationPlaceholder?: T; + }; + nominationContact?: + | T + | { + eyebrow?: T; + heading?: T; + nameLabel?: T; + namePlaceholder?: T; + emailLabel?: T; + emailPlaceholder?: T; + relationshipLabel?: T; + relationshipPlaceholder?: T; + footnote?: T; + }; + nominationSummary?: + | T + | { + eyebrow?: T; + heading?: T; + companyLabel?: T; + industryLabel?: T; + locationLabel?: T; + nominatedByLabel?: T; + emptyValue?: T; + }; + success?: + | T + | { + selfHeading?: T; + nominationHeading?: T; + selfPrefix?: T; + selfMiddle?: T; + selfSuffix?: T; + nominationPrefix?: T; + nominationMiddle?: T; + nominationSuffix?: T; + }; + }; + }; meta?: | T | { diff --git a/src/scripts/preload-participation-page-cms.ts b/src/scripts/preload-participation-page-cms.ts new file mode 100644 index 0000000..405c8a5 --- /dev/null +++ b/src/scripts/preload-participation-page-cms.ts @@ -0,0 +1,78 @@ +import 'dotenv/config' + +import config from '@payload-config' +import { getPayload, type Payload } from 'payload' + +import { participationContent } from '@/spa/participationContent' + +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: '/teilnahme' } }, { slug: { equals: 'teilnahme' } }, { slug: { equals: 'participation' } }], + } as never, + }) + + const page = pageResult.docs[0] + if (!page) throw new Error('Participation page not found. Expected spaPath=/teilnahme or slug=teilnahme/participation.') + + const [heroImage, evaluationImage, formImage] = await Promise.all([ + mediaByFilename(payload, participationContent.hero.backgroundImageFilename), + mediaByFilename(payload, participationContent.evaluation.imageFilename), + mediaByFilename(payload, participationContent.applicationForm.imageFilename), + ]) + const { backgroundImageFilename: _heroFilename, ...heroContent } = participationContent.hero + const { imageFilename: _evaluationFilename, ...evaluationContent } = participationContent.evaluation + const { imageFilename: _formFilename, ...applicationFormContent } = participationContent.applicationForm + + await payload.update({ + collection: 'pages', + id: page.id, + overrideAccess: true, + context: { disableRevalidate: true }, + data: { + participation: { + hero: { + ...heroContent, + backgroundImage: heroImage, + }, + process: participationContent.process, + eligibility: participationContent.eligibility, + evaluation: { + ...evaluationContent, + image: evaluationImage, + }, + applicationWays: participationContent.applicationWays, + datesBanner: participationContent.datesBanner, + applicationForm: { + ...applicationFormContent, + image: formImage, + }, + form: participationContent.form, + }, + } as never, + }) + + payload.logger.info(`Preloaded participation CMS fields for page ${page.id}`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/src/spa/components/WegZurAuszeichnungSection.tsx b/src/spa/components/WegZurAuszeichnungSection.tsx index 6d0b2b6..0582b2c 100644 --- a/src/spa/components/WegZurAuszeichnungSection.tsx +++ b/src/spa/components/WegZurAuszeichnungSection.tsx @@ -8,21 +8,36 @@ import { ProcessCardTitle, } from '@/spa/components/ui/process-timeline'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import { participationContent } from '@/spa/participationContent'; const FF = '"IBM Plex Sans", sans-serif'; const FB = '"Inter", sans-serif'; const NAVY = '#111D55'; const GOLD = '#EFBF04'; -const STEPS = [ - { step: '01', title: 'Online-Einreichung', desc: 'Bewerbungsformular vollständig ausfüllen. Die Teilnahme ist kostenfrei und ohne bürokratischen Aufwand möglich.', Icon: FileText, date: 'Bis 30. Juni 2026' }, - { step: '02', title: 'Formale Vorprüfung', desc: 'Das Gremium sichtet alle Unterlagen auf Vollständigkeit, KMU-Konformität und regionale Zugehörigkeit im Freistaat.', Icon: Search, date: 'Juli 2026' }, - { step: '03', title: 'Audit & Jury-Sitzung',desc: 'Die besten Unternehmen werden durch unabhängige Experten-Audits vor Ort evaluiert und in der Jury-Sitzung bewertet.', Icon: UserPlus, date: 'Aug – Sep 2026' }, - { step: '04', title: 'Gala-Preisverleihung',desc: 'Die Gewinner werden im Rahmen der feierlichen Gala in München vor geladenen Gästen aus Wirtschaft und Politik geehrt.', Icon: Trophy, date: 'Oktober 2026' }, -]; +type ProcessContent = Partial; -export default function WegZurAuszeichnungSection() { +const ICONS = { + fileText: FileText, + search: Search, + userPlus: UserPlus, + trophy: Trophy, +}; + +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}
)}; +} + +export default function WegZurAuszeichnungSection({ content }: { content?: ProcessContent }) { const isMobile = useIsMobile(); + const section = { ...participationContent.process, ...(content || {}) }; + const steps = fallbackArray(section.steps, participationContent.process.steps); // ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ── if (isMobile) { @@ -30,42 +45,42 @@ export default function WegZurAuszeichnungSection() {
{/* Section header */}
- Schritt für Schritt + {fallbackText(section.eyebrow, participationContent.process.eyebrow)}

- IHR WEG ZUM
PREIS. +

- Von der Einreichung bis zur Gala – vier klar definierte Schritte auf dem Weg zur höchsten Auszeichnung des bayerischen Mittelstands. + {fallbackText(section.description, participationContent.process.description)}

{/* Stacked step cards */}
- {STEPS.map((item, index) => { - const Icon = item.Icon; + {steps.map((item, index) => { + const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText; return ( -
+
- Schritt {item.step} + Schritt {fallbackText(item.step, '')} - {item.date} + {fallbackText(item.date, '')}

- {item.title} + {fallbackText(item.title, '')}

- {item.desc} + {fallbackText(item.desc, '')}

{/* Step progress indicator */}
- {[0, 1, 2, 3].map(i => ( + {steps.map((_, i) => (
))}
@@ -84,13 +99,13 @@ export default function WegZurAuszeichnungSection() { {/* Section header */}
- Schritt für Schritt + {fallbackText(section.eyebrow, participationContent.process.eyebrow)}

- IHR WEG ZUM
PREIS. +

- Von der Einreichung bis zur Gala – vier klar definierte Schritte auf dem Weg zur höchsten Auszeichnung des bayerischen Mittelstands. + {fallbackText(section.description, participationContent.process.description)}

@@ -109,23 +124,23 @@ export default function WegZurAuszeichnungSection() { ))}
- Scrollen zum Erkunden + {fallbackText(section.scrollHint, participationContent.process.scrollHint)} {/* Progress bar */}
- 01 / 04 + {fallbackText(section.progressLabel, participationContent.process.progressLabel)}
{/* Cards strip */}
- {STEPS.map((item, index) => { - const Icon = item.Icon; + {steps.map((item, index) => { + const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText; return (
- {item.step} + {fallbackText(item.step, '')} @@ -145,22 +160,22 @@ export default function WegZurAuszeichnungSection() {
- Schritt {item.step} + Schritt {fallbackText(item.step, '')} - {item.date} + {fallbackText(item.date, '')}

- {item.title} + {fallbackText(item.title, '')}

- {item.desc} + {fallbackText(item.desc, '')}

{/* Step progress indicator */}
- {[0,1,2,3].map(i => ( + {steps.map((_, i) => (
))}
diff --git a/src/spa/components/forms/BewerbungsForm.tsx b/src/spa/components/forms/BewerbungsForm.tsx index 1512669..e591669 100644 --- a/src/spa/components/forms/BewerbungsForm.tsx +++ b/src/spa/components/forms/BewerbungsForm.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { ArrowRight, ArrowLeft, Check, Trophy, User, Send, Building2, AlertCircle } from 'lucide-react'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import { participationContent } from '@/spa/participationContent'; const FF = '"IBM Plex Sans", sans-serif'; @@ -138,43 +139,55 @@ type FormData = { nomBeziehung: string; }; -const STEPS_SELBST = [ - { label: 'Einreichung', icon: Trophy }, - { label: 'Schnell-Check',icon: Check }, - { label: 'Kontakt', icon: User }, - { label: 'Absenden', icon: Send }, -]; - -const STEPS_VORSCHLAG = [ - { label: 'Einreichung', icon: Trophy }, - { label: 'Unternehmen', icon: Building2}, - { label: 'Nominierender',icon: User }, - { label: 'Absenden', icon: Send }, -]; - -const MITARBEITER_OPTIONS = [ - { value: 'unter10', label: 'Unter 10' }, - { value: '10-50', label: '10 – 50' }, - { value: '51-200', label: '51 – 200' }, - { value: '201-500', label: '201 – 500'}, - { value: 'über500', label: 'Über 500' }, -]; - const variants = { enter: (dir: number) => ({ x: dir > 0 ? 40 : -40, opacity: 0 }), center: { x: 0, opacity: 1 }, exit: (dir: number) => ({ x: dir > 0 ? -40 : 40, opacity: 0 }), }; +type FormContent = Partial; +type StepItem = { label?: string | null; icon?: string | null }; +type EmployeeOption = { value?: string | null; label?: string | null }; + +const ICONS = { + alertCircle: AlertCircle, + arrowRight: ArrowRight, + building2: Building2, + check: Check, + send: Send, + trophy: Trophy, + user: User, +}; + +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; + +const mergeFormContent = (content?: FormContent) => ({ + ...participationContent.form, + ...(content || {}), + validation: { ...participationContent.form.validation, ...(content?.validation || {}) }, + typeStep: { ...participationContent.form.typeStep, ...(content?.typeStep || {}) }, + eligibility: { ...participationContent.form.eligibility, ...(content?.eligibility || {}) }, + selfContact: { ...participationContent.form.selfContact, ...(content?.selfContact || {}) }, + selfSummary: { ...participationContent.form.selfSummary, ...(content?.selfSummary || {}) }, + nominationCompany: { ...participationContent.form.nominationCompany, ...(content?.nominationCompany || {}) }, + nominationContact: { ...participationContent.form.nominationContact, ...(content?.nominationContact || {}) }, + nominationSummary: { ...participationContent.form.nominationSummary, ...(content?.nominationSummary || {}) }, + success: { ...participationContent.form.success, ...(content?.success || {}) }, +}); + /* ── Sub-components ───────────────────────────────────────────────────── */ -function Field({ label, error, children, optional, c }: { - label: string; error?: string; children: React.ReactNode; optional?: boolean; c: C; +function Field({ label, error, children, optional, c, requiredSuffix }: { + label: string; error?: string; children: React.ReactNode; optional?: boolean; c: C; requiredSuffix: string; }) { return (
{children} {error && {error}} @@ -242,28 +255,36 @@ function OptionButton({ selected, onClick, label, c, style }: { ); } -function YesNo({ value, onChange, c }: { - value: 'ja' | 'nein' | ''; onChange: (v: 'ja' | 'nein') => void; c: C; +function YesNo({ value, onChange, c, yesLabel, noLabel }: { + value: 'ja' | 'nein' | ''; onChange: (v: 'ja' | 'nein') => void; c: C; yesLabel: string; noLabel: string; }) { const isMobile = useIsMobile(); const flexStyle = isMobile ? { flex: 1 } : undefined; return (
- onChange('ja')} label="Ja" c={c} style={flexStyle} /> - onChange('nein')} label="Nein" c={c} style={flexStyle} /> + onChange('ja')} label={yesLabel} c={c} style={flexStyle} /> + onChange('nein')} label={noLabel} c={c} style={flexStyle} />
); } -function isEligible(data: FormData) { - return ['10-50', '51-200', '201-500'].includes(data.mitarbeiter) && data.standortBayern === 'ja'; +function isEligible(data: FormData, formContent: ReturnType) { + const allowed = fallbackText(formContent.eligibility.allowedEmployeeValues, participationContent.form.eligibility.allowedEmployeeValues) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + return allowed.includes(data.mitarbeiter) && data.standortBayern === fallbackText(formContent.eligibility.requiredBayernValue, 'ja'); } /* ── Main component ───────────────────────────────────────────────────── */ -export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'gold' }) { +export default function BewerbungsForm({ theme = 'dark', content }: { theme?: 'dark' | 'gold'; content?: FormContent }) { const c = theme === 'gold' ? GOLD : DARK; const isMobile = useIsMobile(); + const formContent = mergeFormContent(content); + const selfSteps = fallbackArray(formContent.selfSteps, participationContent.form.selfSteps); + const nominationSteps = fallbackArray(formContent.nominationSteps, participationContent.form.nominationSteps); + const employeeOptions = fallbackArray(formContent.employeeOptions, participationContent.form.employeeOptions); const [step, setStep] = useState(0); const [dir, setDir] = useState(1); @@ -276,7 +297,7 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g }); const [errors, setErrors] = useState>>({}); - const STEPS = data.type === 'vorschlag' ? STEPS_VORSCHLAG : STEPS_SELBST; + const STEPS = data.type === 'vorschlag' ? nominationSteps : selfSteps; const progress = step === 0 ? 0 : (step / (STEPS.length - 1)) * 100; const set = (k: keyof FormData, v: string) => { @@ -288,25 +309,25 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g const e: typeof errors = {}; if (data.type === 'selbst') { if (step === 1) { - if (!data.mitarbeiter) e.mitarbeiter = 'Bitte wählen'; - if (!data.standortBayern) e.standortBayern = 'Bitte wählen'; - if (!data.familiengeführt)e.familiengeführt= 'Bitte wählen'; + if (!data.mitarbeiter) e.mitarbeiter = fallbackText(formContent.validation.choose, participationContent.form.validation.choose); + if (!data.standortBayern) e.standortBayern = fallbackText(formContent.validation.choose, participationContent.form.validation.choose); + if (!data.familiengeführt)e.familiengeführt= fallbackText(formContent.validation.choose, participationContent.form.validation.choose); } if (step === 2) { - if (!data.firmenname) e.firmenname = 'Pflichtfeld'; - if (!data.kontaktName) e.kontaktName = 'Pflichtfeld'; - if (!data.email) e.email = 'Pflichtfeld'; - else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = 'Ungültige E-Mail'; + if (!data.firmenname) e.firmenname = fallbackText(formContent.validation.required, participationContent.form.validation.required); + if (!data.kontaktName) e.kontaktName = fallbackText(formContent.validation.required, participationContent.form.validation.required); + if (!data.email) e.email = fallbackText(formContent.validation.required, participationContent.form.validation.required); + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = fallbackText(formContent.validation.invalidEmail, participationContent.form.validation.invalidEmail); } } else { if (step === 1) { - if (!data.nomFirma) e.nomFirma = 'Pflichtfeld'; - if (!data.nomBranche) e.nomBranche = 'Pflichtfeld'; + if (!data.nomFirma) e.nomFirma = fallbackText(formContent.validation.required, participationContent.form.validation.required); + if (!data.nomBranche) e.nomBranche = fallbackText(formContent.validation.required, participationContent.form.validation.required); } if (step === 2) { - if (!data.nomName) e.nomName = 'Pflichtfeld'; - if (!data.nomEmail) e.nomEmail = 'Pflichtfeld'; - else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.nomEmail)) e.nomEmail = 'Ungültige E-Mail'; + if (!data.nomName) e.nomName = fallbackText(formContent.validation.required, participationContent.form.validation.required); + if (!data.nomEmail) e.nomEmail = fallbackText(formContent.validation.required, participationContent.form.validation.required); + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.nomEmail)) e.nomEmail = fallbackText(formContent.validation.invalidEmail, participationContent.form.validation.invalidEmail); } } setErrors(e); @@ -325,12 +346,12 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g

- {data.type === 'selbst' ? 'Anfrage eingegangen' : 'Nominierung eingegangen'} + {data.type === 'selbst' ? fallbackText(formContent.success.selfHeading, participationContent.form.success.selfHeading) : fallbackText(formContent.success.nominationHeading, participationContent.form.success.nominationHeading)}

{data.type === 'selbst' - ? <>Vielen Dank, {data.firmenname}. Wir senden Ihnen den vollständigen Fragebogen an {data.email}. - : <>Vielen Dank, {data.nomName}. Wir nehmen Kontakt mit {data.nomFirma} auf und informieren sie über Ihre Nominierung. + ? <>{fallbackText(formContent.success.selfPrefix, participationContent.form.success.selfPrefix)}{data.firmenname}{fallbackText(formContent.success.selfMiddle, participationContent.form.success.selfMiddle)}{data.email}{fallbackText(formContent.success.selfSuffix, participationContent.form.success.selfSuffix)} + : <>{fallbackText(formContent.success.nominationPrefix, participationContent.form.success.nominationPrefix)}{data.nomName}{fallbackText(formContent.success.nominationMiddle, participationContent.form.success.nominationMiddle)}{data.nomFirma}{fallbackText(formContent.success.nominationSuffix, participationContent.form.success.nominationSuffix)} }

@@ -349,7 +370,7 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g {/* Step indicators */}
{STEPS.map((s, i) => { - const Icon = s.icon; + const Icon = ICONS[s.icon as keyof typeof ICONS] || Trophy; const done = i < step; const active = i === step; return ( @@ -363,7 +384,7 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g marginBottom: -1, transition: 'color 0.2s', }}> - {done ? '✓' : s.label} + {done ? fallbackText(formContent.stepCompleteLabel, participationContent.form.stepCompleteLabel) : fallbackText(s.label, '')}
); })} @@ -379,14 +400,14 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g {/* ── Step 0: Typ-Wahl ── */} {step === 0 && (
-

Schritt 1

-

Art der Einreichung

+

{fallbackText(formContent.typeStep.eyebrow, participationContent.form.typeStep.eyebrow)}

+

{fallbackText(formContent.typeStep.heading, participationContent.form.typeStep.heading)}

- { set('type', 'selbst'); setDir(1); setStep(1); }} /> - { set('type', 'vorschlag'); setDir(1); setStep(1); }} />
@@ -396,27 +417,27 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g {step === 1 && data.type === 'selbst' && (
-

Schnell-Check

-

Grundlegende Eignung

+

{fallbackText(formContent.eligibility.eyebrow, participationContent.form.eligibility.eyebrow)}

+

{fallbackText(formContent.eligibility.heading, participationContent.form.eligibility.heading)}

- +
- {MITARBEITER_OPTIONS.map(o => ( - set('mitarbeiter', o.value)} label={o.label} /> + {employeeOptions.map(o => ( + set('mitarbeiter', fallbackText(o.value, ''))} label={fallbackText(o.label, '')} /> ))}
{errors.mitarbeiter && {errors.mitarbeiter}}
- - set('standortBayern', v)} /> + + set('standortBayern', v)} yesLabel={fallbackText(formContent.yesLabel, participationContent.form.yesLabel)} noLabel={fallbackText(formContent.noLabel, participationContent.form.noLabel)} /> {errors.standortBayern && {errors.standortBayern}}
- - set('familiengeführt', v)} /> + + set('familiengeführt', v)} yesLabel={fallbackText(formContent.yesLabel, participationContent.form.yesLabel)} noLabel={fallbackText(formContent.noLabel, participationContent.form.noLabel)} /> {errors.familiengeführt && {errors.familiengeführt}}
@@ -425,42 +446,42 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g {/* ── Eigenbewerbung Step 2: Kontakt / Ineligible ── */} {step === 2 && data.type === 'selbst' && (() => { - if (!isEligible(data)) return ( + if (!isEligible(data, formContent)) return (
-

Leider nicht förderfähig

+

{fallbackText(formContent.eligibility.ineligibleHeading, participationContent.form.eligibility.ineligibleHeading)}

- Der Bayerische Mittelstandspreis richtet sich an inhabergeführte Unternehmen mit 10–500 Mitarbeitern und Sitz in Bayern. Ihr Unternehmen erfüllt diese Voraussetzungen aktuell nicht. + {fallbackText(formContent.eligibility.ineligibleBody, participationContent.form.eligibility.ineligibleBody)}

- Fragen? Schreiben Sie uns: info@bmp-bayern.de + {fallbackText(formContent.eligibility.ineligibleContactPrefix, participationContent.form.eligibility.ineligibleContactPrefix)} {fallbackText(formContent.eligibility.ineligibleContactEmail, participationContent.form.eligibility.ineligibleContactEmail)}

); return (
-

Kontakt

-

Ihre Kontaktdaten

+

{fallbackText(formContent.selfContact.eyebrow, participationContent.form.selfContact.eyebrow)}

+

{fallbackText(formContent.selfContact.heading, participationContent.form.selfContact.heading)}

- - set('firmenname', e.target.value)} placeholder="Muster GmbH" /> + + set('firmenname', e.target.value)} placeholder={fallbackText(formContent.selfContact.companyPlaceholder, participationContent.form.selfContact.companyPlaceholder)} /> - - set('kontaktName', e.target.value)} placeholder="Vor- und Nachname" /> + + set('kontaktName', e.target.value)} placeholder={fallbackText(formContent.selfContact.namePlaceholder, participationContent.form.selfContact.namePlaceholder)} /> - - set('email', e.target.value)} placeholder="name@unternehmen.de" /> + + set('email', e.target.value)} placeholder={fallbackText(formContent.selfContact.emailPlaceholder, participationContent.form.selfContact.emailPlaceholder)} /> - - set('telefon', e.target.value)} placeholder="+49 89 ..." /> + + set('telefon', e.target.value)} placeholder={fallbackText(formContent.selfContact.phonePlaceholder, participationContent.form.selfContact.phonePlaceholder)} />

- Wir senden Ihnen den vollständigen Fragebogen automatisch per E-Mail zu. + {fallbackText(formContent.selfContact.footnote, participationContent.form.selfContact.footnote)}

); @@ -470,15 +491,15 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g {step === 3 && data.type === 'selbst' && (
-

Zusammenfassung

-

Ihre Bewerbung

+

{fallbackText(formContent.selfSummary.eyebrow, participationContent.form.selfSummary.eyebrow)}

+

{fallbackText(formContent.selfSummary.heading, participationContent.form.selfSummary.heading)}

{[ - { label: 'Unternehmen', value: data.firmenname }, - { label: 'Mitarbeiter', value: MITARBEITER_OPTIONS.find(o => o.value === data.mitarbeiter)?.label || '' }, - { label: 'Standort', value: `Bayern: ${data.standortBayern === 'ja' ? 'Ja' : 'Nein'}` }, - { label: 'Kontakt', value: `${data.kontaktName} · ${data.email}` }, + { label: fallbackText(formContent.selfSummary.companyLabel, participationContent.form.selfSummary.companyLabel), value: data.firmenname }, + { label: fallbackText(formContent.selfSummary.employeesLabel, participationContent.form.selfSummary.employeesLabel), value: employeeOptions.find(o => o.value === data.mitarbeiter)?.label || '' }, + { label: fallbackText(formContent.selfSummary.locationLabel, participationContent.form.selfSummary.locationLabel), value: `${fallbackText(formContent.selfSummary.locationPrefix, participationContent.form.selfSummary.locationPrefix)} ${data.standortBayern === 'ja' ? fallbackText(formContent.yesLabel, participationContent.form.yesLabel) : fallbackText(formContent.noLabel, participationContent.form.noLabel)}` }, + { label: fallbackText(formContent.selfSummary.contactLabel, participationContent.form.selfSummary.contactLabel), value: `${data.kontaktName} · ${data.email}` }, ].map(row => (
{row.label} @@ -493,18 +514,18 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g {step === 1 && data.type === 'vorschlag' && (
-

Nominierung

-

Nominiertes Unternehmen

+

{fallbackText(formContent.nominationCompany.eyebrow, participationContent.form.nominationCompany.eyebrow)}

+

{fallbackText(formContent.nominationCompany.heading, participationContent.form.nominationCompany.heading)}

- - set('nomFirma', e.target.value)} placeholder="Muster GmbH" /> + + set('nomFirma', e.target.value)} placeholder={fallbackText(formContent.nominationCompany.companyPlaceholder, participationContent.form.nominationCompany.companyPlaceholder)} /> - - set('nomBranche', e.target.value)} placeholder="z. B. Maschinenbau" /> + + set('nomBranche', e.target.value)} placeholder={fallbackText(formContent.nominationCompany.industryPlaceholder, participationContent.form.nominationCompany.industryPlaceholder)} /> - - set('nomStandort', e.target.value)} placeholder="z. B. München" /> + + set('nomStandort', e.target.value)} placeholder={fallbackText(formContent.nominationCompany.locationPlaceholder, participationContent.form.nominationCompany.locationPlaceholder)} />
@@ -514,22 +535,22 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g {step === 2 && data.type === 'vorschlag' && (
-

Nominierender

-

Ihre Angaben

+

{fallbackText(formContent.nominationContact.eyebrow, participationContent.form.nominationContact.eyebrow)}

+

{fallbackText(formContent.nominationContact.heading, participationContent.form.nominationContact.heading)}

- - set('nomName', e.target.value)} placeholder="Vor- und Nachname" /> + + set('nomName', e.target.value)} placeholder={fallbackText(formContent.nominationContact.namePlaceholder, participationContent.form.nominationContact.namePlaceholder)} /> - - set('nomEmail', e.target.value)} placeholder="ihre@email.de" /> + + set('nomEmail', e.target.value)} placeholder={fallbackText(formContent.nominationContact.emailPlaceholder, participationContent.form.nominationContact.emailPlaceholder)} /> - - set('nomBeziehung', e.target.value)} placeholder="z. B. Kunde, Partner, Bekannter" /> + + set('nomBeziehung', e.target.value)} placeholder={fallbackText(formContent.nominationContact.relationshipPlaceholder, participationContent.form.nominationContact.relationshipPlaceholder)} />

- Wir nehmen Kontakt mit dem nominierten Unternehmen auf und informieren es über Ihre Nominierung. + {fallbackText(formContent.nominationContact.footnote, participationContent.form.nominationContact.footnote)}

)} @@ -538,15 +559,15 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g {step === 3 && data.type === 'vorschlag' && (
-

Zusammenfassung

-

Ihre Nominierung

+

{fallbackText(formContent.nominationSummary.eyebrow, participationContent.form.nominationSummary.eyebrow)}

+

{fallbackText(formContent.nominationSummary.heading, participationContent.form.nominationSummary.heading)}

{[ - { label: 'Unternehmen', value: data.nomFirma }, - { label: 'Branche', value: data.nomBranche }, - { label: 'Standort', value: data.nomStandort || '–' }, - { label: 'Nominiert von',value: `${data.nomName} · ${data.nomEmail}` }, + { label: fallbackText(formContent.nominationSummary.companyLabel, participationContent.form.nominationSummary.companyLabel), value: data.nomFirma }, + { label: fallbackText(formContent.nominationSummary.industryLabel, participationContent.form.nominationSummary.industryLabel), value: data.nomBranche }, + { label: fallbackText(formContent.nominationSummary.locationLabel, participationContent.form.nominationSummary.locationLabel), value: data.nomStandort || fallbackText(formContent.nominationSummary.emptyValue, participationContent.form.nominationSummary.emptyValue) }, + { label: fallbackText(formContent.nominationSummary.nominatedByLabel, participationContent.form.nominationSummary.nominatedByLabel),value: `${data.nomName} · ${data.nomEmail}` }, ].map(row => (
{row.label} @@ -574,11 +595,11 @@ export default function BewerbungsForm({ theme = 'dark' }: { theme?: 'dark' | 'g onMouseEnter={e => (e.currentTarget.style.color = c.backTextHover)} onMouseLeave={e => (e.currentTarget.style.color = c.backText)} > - Zurück + {fallbackText(formContent.backLabel, participationContent.form.backLabel)} {step < STEPS.length - 1 ? ( - step === 2 && data.type === 'selbst' && !isEligible(data) ?
: + step === 2 && data.type === 'selbst' && !isEligible(data, formContent) ?
: ) : ( )}
diff --git a/src/spa/pages/Participation.tsx b/src/spa/pages/Participation.tsx index 2febadb..0eba77a 100644 --- a/src/spa/pages/Participation.tsx +++ b/src/spa/pages/Participation.tsx @@ -1,10 +1,13 @@ import React, { useRef } from 'react'; -import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star, type LucideIcon } from 'lucide-react'; +import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react'; import { Link } from '@/spa/router'; import BewerbungsForm from '@/spa/components/forms/BewerbungsForm'; import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; +import { useCmsRoute } from '@/spa/cmsRoute'; +import { participationContent } from '@/spa/participationContent'; import Image from '@/spa/components/ui/UnoptimizedImage' const FF = '"IBM Plex Sans", sans-serif'; @@ -13,9 +16,50 @@ const NAVY = '#111D55'; const GOLD = '#EFBF04'; const CREAM = '#E4E2E3'; +type ParticipationCms = Partial & { + hero?: Partial & { backgroundImage?: unknown }; + evaluation?: Partial & { image?: unknown }; + applicationForm?: Partial & { image?: unknown }; +}; + +const ICONS = { + building2: Building2, + mapPin: MapPin, + send: Send, + star: Star, + trendingUp: TrendingUp, + userCheck: UserCheck, + users: Users, +}; + +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 Participation: React.FC = () => { const isMobile = useIsMobile(); const qualSectionRef = useRef(null); + const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms; + const hero = { ...participationContent.hero, ...(cms.hero || {}) }; + const process = { ...participationContent.process, ...(cms.process || {}) }; + const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) }; + const evaluation = { ...participationContent.evaluation, ...(cms.evaluation || {}) }; + const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) }; + const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) }; + const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) }; + const form = { ...participationContent.form, ...(cms.form || {}) }; + const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria); + const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes); + const evaluationCriteria = fallbackArray(evaluation.criteria, participationContent.evaluation.criteria); + const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems); + const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems); + const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts); return (
@@ -23,8 +67,8 @@ const Participation: React.FC = () => { {/* ── HERO ─────────────────────────────────────────────────────────────── */}
Teilnahme BMP
@@ -33,20 +77,20 @@ const Participation: React.FC = () => {
- Bewerbungsphase 2026 + {fallbackText(hero.eyebrow, participationContent.hero.eyebrow)}

- GESTALTEN SIE
BAYERNS ZUKUNFT. +

- Alles, was Sie für Ihre erfolgreiche Bewerbung zum Bayerischen Mittelstandspreis wissen müssen. + {fallbackText(hero.description, participationContent.hero.description)}

{/* ── TIMELINE – direkt nach Hero ──────────────────────────────────────── */} - + {/* ── QUALIFICATIONS ───────────────────────────────────────────────────── */}
@@ -55,45 +99,40 @@ const Participation: React.FC = () => { {/* Header */}
- Berechtigung + {fallbackText(eligibility.eyebrow, participationContent.eligibility.eyebrow)}

- WER KANN
TEILNEHMEN? +

((e.currentTarget as HTMLElement).style.opacity = '0.85')} onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')} > - Jetzt bewerben + {fallbackText(eligibility.primaryCta?.label, participationContent.eligibility.primaryCta.label)} { (e.currentTarget as HTMLElement).style.borderColor = NAVY; }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(3,9,58,0.3)'; }} > - Mitglied werden + {fallbackText(eligibility.secondaryCta?.label, participationContent.eligibility.secondaryCta.label)}

- Vier klar definierte Kriterien: Erfüllen Sie alle, bewerben Sie sich kostenlos und ohne bürokratischen Aufwand. + {fallbackText(eligibility.description, participationContent.eligibility.description)}

{/* 4 criteria as editorial rows */}
- {[ - { num: '01', title: 'Standort Bayern', body: 'Sitz oder wesentliche Betriebsstätte im Freistaat Bayern.', Icon: MapPin }, - { num: '02', title: 'KMU-Größe', body: '50 bis 1.500 Mitarbeiter: klassischer Mittelstand im privatwirtschaftlichen Bereich.', Icon: Users }, - { num: '03', title: 'Marktreife', body: 'Mindestens drei vollständige Geschäftsjahre am Markt tätig.', Icon: TrendingUp }, - { num: '04', title: 'Privatwirtschaft', body: 'Keine überwiegende Zugehörigkeit zu staatlichen oder kommunalen Trägern.', Icon: Building2 }, - ].map((item, i, arr) => { - const Icon = item.Icon; + {eligibilityCriteria.map((item, i, arr) => { + const Icon = ICONS[item.icon as keyof typeof ICONS] || MapPin; return ( ); @@ -102,39 +141,35 @@ const Participation: React.FC = () => { {/* Zusätzliche Hinweise: Verbund + Wiederbewerber */}
-
- + {eligibilityNotes.map((note, i) => { + const Icon = ICONS[note.icon as keyof typeof ICONS] || Building2; + return ( +
+
-
Verbund & Gemeinschaft
+
{fallbackText(note.title, '')}

- Oder Sie sind ein Verbund bzw. eine Gemeinschaft von mittelständischen Unternehmen oder von mittelständischen Unternehmen und Organisationen/Institutionen, die einen Schwerpunkt in Bayern hat. -

-
-
-
- -
-
Erneute Bewerbung
-

- Nominierte ohne Gewinn können sich sofort erneut bewerben. Preisträger können sich nach 7 Jahren erneut bewerben. + {fallbackText(note.body, '')}

+ ); + })}
{/* CTA nudge */}
- Alle 4 Punkte erfüllt? Dann jetzt kostenlos bewerben. + {fallbackText(eligibility.nudgeText, participationContent.eligibility.nudgeText)}
((e.currentTarget as HTMLElement).style.opacity = '0.85')} onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')} > - Zum Formular + {fallbackText(eligibility.nudgeCta?.label, participationContent.eligibility.nudgeCta.label)}
@@ -148,8 +183,8 @@ const Participation: React.FC = () => { {/* Left – full-bleed image with gradient blend into navy on the right (+ heading on mobile) */}
Jury-Bewertung BMP {/* Dark overlay for depth */} @@ -163,9 +198,9 @@ const Participation: React.FC = () => { {/* Heading overlay – mobile only */} {isMobile && (
- Bewertung + {fallbackText(evaluation.eyebrow, participationContent.evaluation.eyebrow)}

- UNSERE
KRITERIEN. +

)} @@ -175,40 +210,32 @@ const Participation: React.FC = () => {
{!isMobile && ( <> - Bewertung + {fallbackText(evaluation.eyebrow, participationContent.evaluation.eyebrow)}

- UNSERE
KRITERIEN. +

)}

- Vier zentrale Säulen, bewertet durch eine vollständig unabhängige Expertenjury aus Wirtschaft, Wissenschaft und Gesellschaft. -
-
- Die Kriterien werden nicht gleich gewichtet. +

((e.currentTarget as HTMLElement).style.opacity = '0.8')} onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')} > - Kriterien & Bewertung + {fallbackText(evaluation.link?.label, participationContent.evaluation.link.label)}
{/* BOTTOM: full-width 4-column criteria grid */}
- {[ - { title: 'Innovations\u00ADkraft', desc: 'Zukunftsweisende Produkte, Dienstleistungen oder interne Prozessinnovationen.' }, - { title: 'Nachhaltigkeit', desc: 'Ökologische Verantwortung und Ressourcen-Effizienz im operativen Kern.' }, - { title: 'Unternehmens\u00ADkultur', desc: 'Mitarbeiterbindung, Aus- und Weiterbildung sowie wertebasierte Führung.' }, - { title: 'Regionale Wurzeln', desc: 'Engagement am Standort Bayern und Beitrag zur regionalen Wertschöpfung.' }, - ].map((crit, i) => ( + {evaluationCriteria.map((crit, i) => ( ))}
@@ -217,23 +244,19 @@ const Participation: React.FC = () => {
{/* ── BEWERBUNGSWEGE ───────────────────────────────────────────────────── */} - + {/* ── DATES BANNER ─────────────────────────────────────────────────────── */}
{isMobile ? ( /* Mobile: slim horizontal bar */
-
Wichtige Termine 2026
+
{fallbackText(datesBanner.heading, participationContent.datesBanner.heading)}
- {[ - { date: '30. Juni 2026', label: 'Bewerbungsschluss' }, - { date: 'August 2026', label: 'Nominierte' }, - { date: 'Oktober 2026', label: 'Gala-Verleihung' }, - ].map((d, i) => ( + {mobileDates.map((d, i) => (
0 ? 14 : 0, borderLeft: i > 0 ? '1px solid rgba(3,9,58,0.2)' : 'none' }}> -
{d.date}
-
{d.label}
+
{fallbackText(d.date, '')}
+
{fallbackText(d.label, '')}
))}
@@ -242,21 +265,17 @@ const Participation: React.FC = () => { /* Desktop: full banner */
-

Wichtige Termine 2026

-

Planen Sie Ihre Teilnahme rechtzeitig.

+

{fallbackText(datesBanner.heading, participationContent.datesBanner.heading)}

+

{fallbackText(datesBanner.description, participationContent.datesBanner.description)}

- {[ - { date: '30. Juni 2026', label: 'Bewerbungsschluss' }, - { date: 'August 2026', label: 'Bekanntgabe Nominierte' }, - { date: 'Oktober 2026', label: 'Gala-Verleihung' }, - ].map((d, i) => ( + {desktopDates.map((d, i) => (
-
{d.date}
-
{d.label}
+
{fallbackText(d.date, '')}
+
{fallbackText(d.label, '')}
- {i < 2 &&
} + {i < desktopDates.length - 1 &&
} ))}
@@ -268,25 +287,21 @@ const Participation: React.FC = () => {
{/* Left – copy */}
- Direktbewerbung + {fallbackText(applicationForm.eyebrow, participationContent.applicationForm.eyebrow)}

- BEWERBEN ODER
VORSCHLAGEN. +

- Sie können sich selbst bewerben oder ein herausragendes Unternehmen vorschlagen. Firmenverbunde können sich ebenfalls bewerben. Die Teilnahme ist vollständig kostenfrei. + {fallbackText(applicationForm.description, participationContent.applicationForm.description)}

- {[ - { num: '01', label: '0 EUR', desc: 'Teilnahmegebühr' }, - { num: '02', label: 'Schnell', desc: 'Ca. 30–45 Minuten' }, - { num: '03', label: 'Mehrstufig', desc: 'Transparenter Prozess' }, - ].map((item, i) => ( + {applicationFacts.map((item, i) => (
- {item.num} + {fallbackText(item.num, '')}
- {item.label} - {item.desc} + {fallbackText(item.label, '')} + {fallbackText(item.desc, '')}
))} @@ -314,39 +329,39 @@ const Participation: React.FC = () => { {!isMobile && (
- Preisverleihung 2025 + {mediaAlt(applicationForm.image,
- Preisverleihung 2025 + {fallbackText(applicationForm.imageCaption, participationContent.applicationForm.imageCaption)}
)}
- Bewerbung 2026 + {fallbackText(applicationForm.formEyebrow, participationContent.applicationForm.formEyebrow)} { (e.currentTarget as HTMLElement).style.color = '#111D55'; (e.currentTarget as HTMLElement).style.borderColor = '#111D55'; }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = 'rgba(17,29,85,0.55)'; (e.currentTarget as HTMLElement).style.borderColor = 'rgba(17,29,85,0.2)'; }} > - PDF hochladen + {fallbackText(applicationForm.uploadCta?.label, participationContent.applicationForm.uploadCta.label)}
- +
- Sie haben Ihre Unterlagen bereits als PDF vorbereitet? Laden Sie sie direkt hoch, statt das Formular auszufüllen. + {fallbackText(applicationForm.uploadPrompt, participationContent.applicationForm.uploadPrompt)} - - Formular hochladen → + + {fallbackText(applicationForm.uploadFooterCta?.label, participationContent.applicationForm.uploadFooterCta.label)}
@@ -408,31 +423,26 @@ function CriteriaCell({ crit, idx }: { crit: { title: string; desc: string }; id // ── BewerbungswegeSection ───────────────────────────────────────────────────── -const INSTITUTIONEN = [ - 'Senatoren und Mitglieder der Verbandsgruppe „Wir Eigentümerunternehmer"', - 'Bundesverband Deutscher Mittelstand (BM)', - 'Europäisches Wirtschaftsforum (EWIF)', - 'Union Mittelständischer Unternehmen (UMU)', - 'HAM – Hochschule für angewandtes Management und deren Regionalpartner', - 'Mitglieder des Wirtschaftsbeirates Bayern', - 'Ehemalige Preisträger des Bayerischen Mittelstandspreises', -]; +type ApplicationWaysContent = Partial; -function BewerbungswegeSection() { +function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }) { const isMobile = useIsMobile(); + const section = { ...participationContent.applicationWays, ...(content || {}) }; + const institutions = fallbackArray(section.institutions, participationContent.applicationWays.institutions); + const ways = fallbackArray(section.ways, participationContent.applicationWays.ways); return (
{/* Header */}
- Bewerbung 2026 + {fallbackText(section.eyebrow, participationContent.applicationWays.eyebrow)}

- HIER KÖNNEN SIE SICH
BEWERBEN ODER EIN
UNTERNEHMEN VORSCHLAGEN. +

- Als Unternehmen haben Sie zwei Möglichkeiten: Sie werden von einem unterstützenden Partner oder einer Institution vorgeschlagen, oder Sie ergreifen selbst die Initiative und füllen unsere Anmeldung aus. Die Teilnahme ist in allen Fällen kostenfrei. + {fallbackText(section.description, participationContent.applicationWays.description)}

@@ -441,47 +451,44 @@ function BewerbungswegeSection() {
{/* ── MOBILE: unified premium cards ── */} - {isMobile && ([ - { weg: 'Weg 01', title: 'Vorschlag unterbreiten', desc: 'Durch ehemalige Preisträger, die HAM, Senatoren und den Preis unterstützende Institutionen.', Icon: Send, checkItems: INSTITUTIONEN, cta: 'Vorschlag unterbreiten', featured: false }, - { weg: 'Weg 02', title: 'Initiativbewerbung', desc: 'Für Unternehmer, die selbst die Initiative ergreifen.', Icon: UserCheck, kvItems: [ { k: '0 EUR', v: 'Teilnahmegebühr' }, { k: 'Eigeninitiative', v: 'Direkt ohne Intermediär' }, { k: 'Für Unternehmer', v: 'Inhabergeführte KMU in Bayern' }, { k: 'Deadline', v: '30. Juni 2026' } ], cta: 'Jetzt bewerben', featured: true }, - { weg: 'Weg 03 · Sonderpreis', title: 'Bavarian Future Award', desc: 'Sonderpreis, initiiert durch die Studierenden der HAM, Hochschule für angewandtes Management.', Icon: Star, kvItems: [ { k: 'Vorschlag möglich', v: 'Durch HAM & Partner' }, { k: 'Selbstbewerbung', v: 'Direkte Einreichung' }, { k: 'Initiiert von', v: 'Studierenden der HAM' }, { k: 'Kategorie', v: 'Zukunft & Innovation' } ], cta: 'Zum Formular', featured: false }, - ] as { weg: string; title: string; desc: string; Icon: LucideIcon; checkItems?: string[]; kvItems?: { k: string; v: string }[]; cta: string; featured: boolean }[]).map((w) => { - const Icon = w.Icon; + {isMobile && ways.map((w) => { + const Icon = ICONS[w.icon as keyof typeof ICONS] || Send; + const facts = fallbackArray<{ label?: string | null; desc?: string | null }>(w.facts, []); return ( -
+
{/* Header */}
{w.featured && ( - Empfohlen + {fallbackText(section.recommendedLabel, participationContent.applicationWays.recommendedLabel)} )}
- {w.weg} -

{w.title}

-

{w.desc}

+ {fallbackText(w.label, '')} +

+

{fallbackText(w.desc, '')}

{/* Detail list */}
- {w.checkItems && w.checkItems.map((inst, i) => ( + {w.listType === 'institutions' && institutions.map((inst, i) => (
- {inst} + {fallbackText(inst.text, '')}
))} - {w.kvItems && w.kvItems.map((item, i) => ( + {w.listType !== 'institutions' && facts.map((item, i) => (
- {item.k} - {item.v} + {fallbackText(item.label, '')} + {fallbackText(item.desc, '')}
))}
{/* CTA */}
); @@ -490,114 +497,51 @@ function BewerbungswegeSection() { {/* ── DESKTOP: original 3 columns ── */} {!isMobile && (<> - {/* ── Weg A: Vorschlag ── */} -
-
- + {ways.map((w, index) => { + const Icon = ICONS[w.icon as keyof typeof ICONS] || Send; + const isFeatured = Boolean(w.featured); + const facts = fallbackArray<{ label?: string | null; desc?: string | null }>(w.facts, []); + const isLast = index === ways.length - 1; + return ( +
+
+
- Weg 01 -

- Vorschlag
unterbreiten + {fallbackText(w.label, '')} +

+

-

- Durch ehemalige Preisträger, die HAM, Senatoren und den Preis unterstützende Institutionen. +

+ {fallbackText(w.desc, '')}

- {/* Institution list */} -
- {INSTITUTIONEN.map((inst, i) => ( +
+ {w.listType === 'institutions' ? institutions.map((inst, i) => (
- {inst} + {fallbackText(inst.text, '')} +
+ )) : facts.map((item, i) => ( +
+ {fallbackText(item.label, '')} + {fallbackText(item.desc, '')}
))}
((e.currentTarget as HTMLElement).style.opacity = '0.85')} onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')} > - Vorschlag unterbreiten - -
- - {/* ── Weg B: Initiativbewerbung ── */} -
-
- -
- Weg 02 -

- Initiativ­bewerbung -

-

- Für Unternehmer, die selbst die Initiative ergreifen. -

- -
- {[ - { label: '0 EUR', desc: 'Teilnahmegebühr' }, - { label: 'Eigeninitiative', desc: 'Direkt ohne Intermediär' }, - { label: 'Für Unternehmer', desc: 'Inhabergeführte KMU in Bayern' }, - { label: 'Deadline', desc: '30. Juni 2026' }, - ].map((item, i) => ( -
- {item.label} - {item.desc} -
- ))} -
- - ((e.currentTarget as HTMLElement).style.opacity = '0.85')} - onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')} - > - Jetzt bewerben - -
- - {/* ── Weg C: Sonderpreis ── */} -
-
- -
- Weg 03 · Sonderpreis -

- Bavarian
Future Award -

-

- Sonderpreis, initiiert durch die Studierenden der HAM, Hochschule für angewandtes Management. -

- -
- {[ - { label: 'Vorschlag möglich', desc: 'Durch HAM & Partner' }, - { label: 'Selbstbewerbung', desc: 'Direkte Einreichung' }, - { label: 'Initiiert von', desc: 'Studierenden der HAM' }, - { label: 'Kategorie', desc: 'Zukunft & Innovation' }, - ].map((item, i) => ( -
- {item.label} - {item.desc} -
- ))} -
- - ((e.currentTarget as HTMLElement).style.opacity = '0.85')} - onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')} - > - Zum Formular + {fallbackText(w.cta?.label, 'Zum Formular')}
+ ); + })} )} diff --git a/src/spa/participationContent.ts b/src/spa/participationContent.ts new file mode 100644 index 0000000..563d3a7 --- /dev/null +++ b/src/spa/participationContent.ts @@ -0,0 +1,301 @@ +export const participationContent = { + hero: { + backgroundImageFilename: 'preisuebergabe.jpg', + imageAlt: 'Teilnahme BMP', + eyebrow: 'Bewerbungsphase 2026', + heading: 'GESTALTEN SIE\nBAYERNS ZUKUNFT.', + description: 'Alles, was Sie für Ihre erfolgreiche Bewerbung zum Bayerischen Mittelstandspreis wissen müssen.', + }, + process: { + eyebrow: 'Schritt für Schritt', + heading: 'IHR WEG ZUM\nPREIS.', + description: + 'Von der Einreichung bis zur Gala – vier klar definierte Schritte auf dem Weg zur höchsten Auszeichnung des bayerischen Mittelstands.', + scrollHint: 'Scrollen zum Erkunden', + progressLabel: '01 / 04', + steps: [ + { + step: '01', + title: 'Online-Einreichung', + desc: 'Bewerbungsformular vollständig ausfüllen. Die Teilnahme ist kostenfrei und ohne bürokratischen Aufwand möglich.', + icon: 'fileText', + date: 'Bis 30. Juni 2026', + }, + { + step: '02', + title: 'Formale Vorprüfung', + desc: 'Das Gremium sichtet alle Unterlagen auf Vollständigkeit, KMU-Konformität und regionale Zugehörigkeit im Freistaat.', + icon: 'search', + date: 'Juli 2026', + }, + { + step: '03', + title: 'Audit & Jury-Sitzung', + desc: 'Die besten Unternehmen werden durch unabhängige Experten-Audits vor Ort evaluiert und in der Jury-Sitzung bewertet.', + icon: 'userPlus', + date: 'Aug – Sep 2026', + }, + { + step: '04', + title: 'Gala-Preisverleihung', + desc: 'Die Gewinner werden im Rahmen der feierlichen Gala in München vor geladenen Gästen aus Wirtschaft und Politik geehrt.', + icon: 'trophy', + date: 'Oktober 2026', + }, + ], + }, + eligibility: { + eyebrow: 'Berechtigung', + heading: 'WER KANN\nTEILNEHMEN?', + primaryCta: { label: 'Jetzt bewerben', url: '#bewerben' }, + secondaryCta: { label: 'Mitglied werden', url: '/mitglied-werden' }, + description: + 'Vier klar definierte Kriterien: Erfüllen Sie alle, bewerben Sie sich kostenlos und ohne bürokratischen Aufwand.', + criteria: [ + { num: '01', title: 'Standort Bayern', body: 'Sitz oder wesentliche Betriebsstätte im Freistaat Bayern.', icon: 'mapPin' }, + { num: '02', title: 'KMU-Größe', body: '50 bis 1.500 Mitarbeiter: klassischer Mittelstand im privatwirtschaftlichen Bereich.', icon: 'users' }, + { num: '03', title: 'Marktreife', body: 'Mindestens drei vollständige Geschäftsjahre am Markt tätig.', icon: 'trendingUp' }, + { num: '04', title: 'Privatwirtschaft', body: 'Keine überwiegende Zugehörigkeit zu staatlichen oder kommunalen Trägern.', icon: 'building2' }, + ], + notes: [ + { + title: 'Verbund & Gemeinschaft', + body: + 'Oder Sie sind ein Verbund bzw. eine Gemeinschaft von mittelständischen Unternehmen oder von mittelständischen Unternehmen und Organisationen/Institutionen, die einen Schwerpunkt in Bayern hat.', + icon: 'building2', + }, + { + title: 'Erneute Bewerbung', + body: 'Nominierte ohne Gewinn können sich sofort erneut bewerben. Preisträger können sich nach 7 Jahren erneut bewerben.', + icon: 'userCheck', + }, + ], + nudgeText: 'Alle 4 Punkte erfüllt? Dann jetzt kostenlos bewerben.', + nudgeCta: { label: 'Zum Formular', url: '#bewerben' }, + }, + evaluation: { + imageFilename: 'buehne-gewinner.jpg', + imageAlt: 'Jury-Bewertung BMP', + eyebrow: 'Bewertung', + heading: 'UNSERE\nKRITERIEN.', + body: + 'Vier zentrale Säulen, bewertet durch eine vollständig unabhängige Expertenjury aus Wirtschaft, Wissenschaft und Gesellschaft.\n\nDie Kriterien werden nicht gleich gewichtet.', + link: { + label: 'Kriterien & Bewertung', + url: 'https://www.der-bayerische-mittelstandspreis.de/der-preis/kriterien/', + }, + criteria: [ + { title: 'Innovations­kraft', desc: 'Zukunftsweisende Produkte, Dienstleistungen oder interne Prozessinnovationen.' }, + { title: 'Nachhaltigkeit', desc: 'Ökologische Verantwortung und Ressourcen-Effizienz im operativen Kern.' }, + { title: 'Unternehmens­kultur', desc: 'Mitarbeiterbindung, Aus- und Weiterbildung sowie wertebasierte Führung.' }, + { title: 'Regionale Wurzeln', desc: 'Engagement am Standort Bayern und Beitrag zur regionalen Wertschöpfung.' }, + ], + }, + applicationWays: { + eyebrow: 'Bewerbung 2026', + heading: 'HIER KÖNNEN SIE SICH\nBEWERBEN ODER EIN\nUNTERNEHMEN VORSCHLAGEN.', + description: + 'Als Unternehmen haben Sie zwei Möglichkeiten: Sie werden von einem unterstützenden Partner oder einer Institution vorgeschlagen, oder Sie ergreifen selbst die Initiative und füllen unsere Anmeldung aus. Die Teilnahme ist in allen Fällen kostenfrei.', + institutions: [ + 'Senatoren und Mitglieder der Verbandsgruppe „Wir Eigentümerunternehmer"', + 'Bundesverband Deutscher Mittelstand (BM)', + 'Europäisches Wirtschaftsforum (EWIF)', + 'Union Mittelständischer Unternehmen (UMU)', + 'HAM – Hochschule für angewandtes Management und deren Regionalpartner', + 'Mitglieder des Wirtschaftsbeirates Bayern', + 'Ehemalige Preisträger des Bayerischen Mittelstandspreises', + ].map((text) => ({ text })), + ways: [ + { + label: 'Weg 01', + title: 'Vorschlag\nunterbreiten', + desc: 'Durch ehemalige Preisträger, die HAM, Senatoren und den Preis unterstützende Institutionen.', + icon: 'send', + featured: false, + cta: { label: 'Vorschlag unterbreiten', url: '#bewerben' }, + listType: 'institutions', + facts: [], + }, + { + label: 'Weg 02', + title: 'Initiativ­bewerbung', + desc: 'Für Unternehmer, die selbst die Initiative ergreifen.', + icon: 'userCheck', + featured: true, + cta: { label: 'Jetzt bewerben', url: '#bewerben' }, + listType: 'facts', + facts: [ + { label: '0 EUR', desc: 'Teilnahmegebühr' }, + { label: 'Eigeninitiative', desc: 'Direkt ohne Intermediär' }, + { label: 'Für Unternehmer', desc: 'Inhabergeführte KMU in Bayern' }, + { label: 'Deadline', desc: '30. Juni 2026' }, + ], + }, + { + label: 'Weg 03 · Sonderpreis', + title: 'Bavarian\nFuture Award', + desc: 'Sonderpreis, initiiert durch die Studierenden der HAM, Hochschule für angewandtes Management.', + icon: 'star', + featured: false, + cta: { label: 'Zum Formular', url: '#bewerben' }, + listType: 'facts', + facts: [ + { label: 'Vorschlag möglich', desc: 'Durch HAM & Partner' }, + { label: 'Selbstbewerbung', desc: 'Direkte Einreichung' }, + { label: 'Initiiert von', desc: 'Studierenden der HAM' }, + { label: 'Kategorie', desc: 'Zukunft & Innovation' }, + ], + }, + ], + recommendedLabel: 'Empfohlen', + }, + datesBanner: { + heading: 'Wichtige Termine 2026', + description: 'Planen Sie Ihre Teilnahme rechtzeitig.', + mobileItems: [ + { date: '30. Juni 2026', label: 'Bewerbungsschluss' }, + { date: 'August 2026', label: 'Nominierte' }, + { date: 'Oktober 2026', label: 'Gala-Verleihung' }, + ], + desktopItems: [ + { date: '30. Juni 2026', label: 'Bewerbungsschluss' }, + { date: 'August 2026', label: 'Bekanntgabe Nominierte' }, + { date: 'Oktober 2026', label: 'Gala-Verleihung' }, + ], + }, + applicationForm: { + imageFilename: 'preistraeger-jubel.jpg', + imageAlt: 'Preisverleihung 2025', + imageCaption: 'Preisverleihung 2025', + eyebrow: 'Direktbewerbung', + heading: 'BEWERBEN ODER\nVORSCHLAGEN.', + description: + 'Sie können sich selbst bewerben oder ein herausragendes Unternehmen vorschlagen. Firmenverbunde können sich ebenfalls bewerben. Die Teilnahme ist vollständig kostenfrei.', + facts: [ + { num: '01', label: '0 EUR', desc: 'Teilnahmegebühr' }, + { num: '02', label: 'Schnell', desc: 'Ca. 30–45 Minuten' }, + { num: '03', label: 'Mehrstufig', desc: 'Transparenter Prozess' }, + ], + formEyebrow: 'Bewerbung 2026', + uploadCta: { label: 'PDF hochladen', url: '/formular-hochladen' }, + uploadPrompt: 'Sie haben Ihre Unterlagen bereits als PDF vorbereitet? Laden Sie sie direkt hoch, statt das Formular auszufüllen.', + uploadFooterCta: { label: 'Formular hochladen →', url: '/formular-hochladen' }, + }, + form: { + stepCompleteLabel: '✓', + backLabel: 'Zurück', + nextLabel: 'Weiter', + submitLabel: 'Absenden', + yesLabel: 'Ja', + noLabel: 'Nein', + requiredSuffix: '*', + validation: { + choose: 'Bitte wählen', + required: 'Pflichtfeld', + invalidEmail: 'Ungültige E-Mail', + }, + typeStep: { + eyebrow: 'Schritt 1', + heading: 'Art der Einreichung', + selfTitle: 'Eigenbewerbung', + selfDesc: 'Ich bewerbe mein eigenes Unternehmen für den Bayerischen Mittelstandspreis.', + nominationTitle: 'Vorschlag', + nominationDesc: 'Ich schlage ein anderes Unternehmen vor, das den Preis verdient.', + }, + selfSteps: [ + { label: 'Einreichung', icon: 'trophy' }, + { label: 'Schnell-Check', icon: 'check' }, + { label: 'Kontakt', icon: 'user' }, + { label: 'Absenden', icon: 'send' }, + ], + nominationSteps: [ + { label: 'Einreichung', icon: 'trophy' }, + { label: 'Unternehmen', icon: 'building2' }, + { label: 'Nominierender', icon: 'user' }, + { label: 'Absenden', icon: 'send' }, + ], + employeeOptions: [ + { value: 'unter10', label: 'Unter 10' }, + { value: '10-50', label: '10 – 50' }, + { value: '51-200', label: '51 – 200' }, + { value: '201-500', label: '201 – 500' }, + { value: 'über500', label: 'Über 500' }, + ], + eligibility: { + allowedEmployeeValues: '10-50,51-200,201-500', + requiredBayernValue: 'ja', + eyebrow: 'Schnell-Check', + heading: 'Grundlegende Eignung', + employeesLabel: 'Wie viele Mitarbeiter hat Ihr Unternehmen?', + bayernLabel: 'Hat Ihr Unternehmen seinen Sitz in Bayern?', + ownerLabel: 'Ist Ihr Unternehmen inhabergeführt oder familiengeführt?', + ineligibleHeading: 'Leider nicht förderfähig', + ineligibleBody: + 'Der Bayerische Mittelstandspreis richtet sich an inhabergeführte Unternehmen mit 10–500 Mitarbeitern und Sitz in Bayern. Ihr Unternehmen erfüllt diese Voraussetzungen aktuell nicht.', + ineligibleContactPrefix: 'Fragen? Schreiben Sie uns:', + ineligibleContactEmail: 'info@bmp-bayern.de', + }, + selfContact: { + eyebrow: 'Kontakt', + heading: 'Ihre Kontaktdaten', + companyLabel: 'Firmenname', + companyPlaceholder: 'Muster GmbH', + nameLabel: 'Ihr Name', + namePlaceholder: 'Vor- und Nachname', + emailLabel: 'E-Mail', + emailPlaceholder: 'name@unternehmen.de', + phoneLabel: 'Telefon', + phonePlaceholder: '+49 89 ...', + footnote: 'Wir senden Ihnen den vollständigen Fragebogen automatisch per E-Mail zu.', + }, + selfSummary: { + eyebrow: 'Zusammenfassung', + heading: 'Ihre Bewerbung', + companyLabel: 'Unternehmen', + employeesLabel: 'Mitarbeiter', + locationLabel: 'Standort', + locationPrefix: 'Bayern:', + contactLabel: 'Kontakt', + }, + nominationCompany: { + eyebrow: 'Nominierung', + heading: 'Nominiertes Unternehmen', + companyLabel: 'Firmenname', + companyPlaceholder: 'Muster GmbH', + industryLabel: 'Branche', + industryPlaceholder: 'z. B. Maschinenbau', + locationLabel: 'Standort Bayern', + locationPlaceholder: 'z. B. München', + }, + nominationContact: { + eyebrow: 'Nominierender', + heading: 'Ihre Angaben', + nameLabel: 'Ihr Name', + namePlaceholder: 'Vor- und Nachname', + emailLabel: 'Ihre E-Mail', + emailPlaceholder: 'ihre@email.de', + relationshipLabel: 'Ihre Beziehung zum Unternehmen', + relationshipPlaceholder: 'z. B. Kunde, Partner, Bekannter', + footnote: 'Wir nehmen Kontakt mit dem nominierten Unternehmen auf und informieren es über Ihre Nominierung.', + }, + nominationSummary: { + eyebrow: 'Zusammenfassung', + heading: 'Ihre Nominierung', + companyLabel: 'Unternehmen', + industryLabel: 'Branche', + locationLabel: 'Standort', + nominatedByLabel: 'Nominiert von', + emptyValue: '–', + }, + success: { + selfHeading: 'Anfrage eingegangen', + nominationHeading: 'Nominierung eingegangen', + selfPrefix: 'Vielen Dank, ', + selfMiddle: '. Wir senden Ihnen den vollständigen Fragebogen an ', + selfSuffix: '.', + nominationPrefix: 'Vielen Dank, ', + nominationMiddle: '. Wir nehmen Kontakt mit ', + nominationSuffix: ' auf und informieren sie über Ihre Nominierung.', + }, + }, +}