From 2842caa9367b77c1ce7a998a7db27abbb4a422e5 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 1 Jul 2026 18:02:12 +0200 Subject: [PATCH] feat: update Preistraeger page to include a simple winner grid for 2023 and remove unused components fix: change featured status for participation content and remove deprecated award card feat: add migration for new participation phase form fields and backfill existing data feat: create AwardsGridSection component for displaying awards with improved layout feat: implement NewsletterInterestForm component for newsletter sign-up with validation --- src/collections/Pages/homeFields.ts | 44 +++ src/collections/Pages/participationFields.ts | 48 ++- ..._163206_participation_phase_form_fields.ts | 218 +++++++++++++ src/migrations/index.ts | 6 + src/payload-types.ts | 234 ++++++++++++- src/spa/components/AwardsGridSection.tsx | 158 +++++++++ .../forms/NewsletterInterestForm.tsx | 159 +++++++++ src/spa/components/layout/Layout.tsx | 6 +- src/spa/components/ui/process-timeline.tsx | 21 +- src/spa/homeContent.ts | 50 +++ src/spa/pages/About.tsx | 12 + src/spa/pages/Home.tsx | 72 +++- src/spa/pages/Participation.tsx | 307 +++++++----------- src/spa/pages/Preistraeger.tsx | 126 +++++-- src/spa/participationContent.ts | 68 +++- 15 files changed, 1274 insertions(+), 255 deletions(-) create mode 100644 src/migrations/20260701_163206_participation_phase_form_fields.ts create mode 100644 src/spa/components/AwardsGridSection.tsx create mode 100644 src/spa/components/forms/NewsletterInterestForm.tsx diff --git a/src/collections/Pages/homeFields.ts b/src/collections/Pages/homeFields.ts index c0f38ff..5a83410 100644 --- a/src/collections/Pages/homeFields.ts +++ b/src/collections/Pages/homeFields.ts @@ -60,6 +60,36 @@ const iconField = (defaultValue = 'star'): Field => ({ options: iconOptions, }) +const newsletterVariantFields = ( + defaults: typeof homeContent.application.newsletterPhaseEvaluation, + benefitsDbName: string, +): Field[] => [ + text('eyebrow', 'Left eyebrow', defaults.eyebrow), + textarea('heading', 'Left heading', defaults.heading), + textarea('body', 'Left body copy', defaults.body), + { + name: 'benefits', + label: 'Benefit rows', + type: 'array', + dbName: benefitsDbName, + defaultValue: defaults.benefits, + fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')], + }, + text('formEyebrow', 'Form eyebrow', defaults.formEyebrow), + text('formTitle', 'Form title', defaults.formTitle), + textarea('formBody', 'Form body copy', defaults.formBody), + text('emailLabel', 'Email label', defaults.emailLabel), + text('emailPlaceholder', 'Email placeholder', defaults.emailPlaceholder), + text('submitLabel', 'Submit button label', defaults.submitLabel), + textarea('privacy', 'Privacy note', defaults.privacy), + text('validationRequired', 'Required email validation message', defaults.validationRequired), + text('validationInvalid', 'Invalid email validation message', defaults.validationInvalid), + text('successHeading', 'Success heading', defaults.successHeading), + textarea('successBody', 'Success body copy', defaults.successBody), + text('footnote', 'Footnote normal text', defaults.footnote), + text('footnoteStrong', 'Footnote highlighted text', defaults.footnoteStrong), +] + export const homeFields: Field[] = [ { name: 'home', @@ -236,6 +266,20 @@ export const homeFields: Field[] = [ text('formTitle', 'Form title', homeContent.application.formTitle), text('footnote', 'Footnote normal text', homeContent.application.footnote), text('footnoteStrong', 'Footnote highlighted text', homeContent.application.footnoteStrong), + { + name: 'newsletterPhaseEvaluation', + label: 'Phase 02 · Newsletter form', + type: 'group', + admin: sectionAdmin('Homepage newsletter copy used when the active application phase is Auswertung / Jury bewertet.'), + fields: newsletterVariantFields(homeContent.application.newsletterPhaseEvaluation, 'home_news_eval_bens'), + }, + { + name: 'newsletterPhaseCompleted', + label: 'Phase 03 · Newsletter form', + type: 'group', + admin: sectionAdmin('Homepage newsletter copy used when the active application phase is Preisverleihung abgeschlossen.'), + fields: newsletterVariantFields(homeContent.application.newsletterPhaseCompleted, 'home_news_done_bens'), + }, ], }, { diff --git a/src/collections/Pages/participationFields.ts b/src/collections/Pages/participationFields.ts index 5bc5871..77bf17b 100644 --- a/src/collections/Pages/participationFields.ts +++ b/src/collections/Pages/participationFields.ts @@ -76,6 +76,35 @@ const ctaGroup = (name: string, label: string, labelDefault: string, urlDefault: type: 'group', fields: linkGroup(labelDefault, urlDefault), }) +const newsletterVariantFields = ( + defaults: typeof participationContent.applicationForm.newsletterPhaseEvaluation, + benefitsDbName: string, +): Field[] => [ + text('eyebrow', 'Left eyebrow', defaults.eyebrow), + textarea('heading', 'Left heading', defaults.heading), + textarea('body', 'Left body copy', defaults.body), + { + name: 'benefits', + label: 'Benefit rows', + type: 'array', + dbName: benefitsDbName, + defaultValue: defaults.benefits, + fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')], + }, + text('formEyebrow', 'Form eyebrow', defaults.formEyebrow), + text('formTitle', 'Form title', defaults.formTitle), + textarea('formBody', 'Form body copy', defaults.formBody), + text('emailLabel', 'Email label', defaults.emailLabel), + text('emailPlaceholder', 'Email placeholder', defaults.emailPlaceholder), + text('submitLabel', 'Submit button label', defaults.submitLabel), + textarea('privacy', 'Privacy note', defaults.privacy), + text('validationRequired', 'Required email validation message', defaults.validationRequired), + text('validationInvalid', 'Invalid email validation message', defaults.validationInvalid), + text('successHeading', 'Success heading', defaults.successHeading), + textarea('successBody', 'Success body copy', defaults.successBody), + text('footnote', 'Footnote normal text', defaults.footnote), + text('footnoteStrong', 'Footnote highlighted text', defaults.footnoteStrong), +] const awardCardDefaults = participationContent.awardsGrid.cards.map(({ imageFilename: _imageFilename, ...card }) => card) export const participationFields: Field[] = [ @@ -158,7 +187,7 @@ export const participationFields: Field[] = [ name: 'applicationWays', label: '04 · Application paths', type: 'group', - admin: sectionAdmin('Cream section explaining proposal, self-application, and special award paths.'), + admin: sectionAdmin('Cream section explaining proposal and self-application paths.'), fields: [ text('eyebrow', 'Eyebrow', participationContent.applicationWays.eyebrow), textarea('heading', 'Heading', participationContent.applicationWays.heading), @@ -270,7 +299,7 @@ export const participationFields: Field[] = [ name: 'applicationForm', label: '07 · Application form wrapper', type: 'group', - admin: sectionAdmin('Navy/gold section containing the application form and PDF upload links.'), + admin: sectionAdmin('Navy/gold section containing the phase-aware application form or newsletter prompt.'), fields: [ uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`), text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt), @@ -287,9 +316,24 @@ export const participationFields: Field[] = [ fields: [text('num', 'Number'), text('label', 'Label'), textarea('desc', 'Description')], }, text('formEyebrow', 'Form eyebrow', participationContent.applicationForm.formEyebrow), + text('formTitle', 'Form title', participationContent.applicationForm.formTitle), 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: 'newsletterPhaseEvaluation', + label: 'Phase 02 · Newsletter form', + type: 'group', + admin: sectionAdmin('Participation page newsletter copy used when the active application phase is Auswertung / Jury bewertet.'), + fields: newsletterVariantFields(participationContent.applicationForm.newsletterPhaseEvaluation, 'part_news_eval_bens'), + }, + { + name: 'newsletterPhaseCompleted', + label: 'Phase 03 · Newsletter form', + type: 'group', + admin: sectionAdmin('Participation page newsletter copy used when the active application phase is Preisverleihung abgeschlossen.'), + fields: newsletterVariantFields(participationContent.applicationForm.newsletterPhaseCompleted, 'part_news_done_bens'), + }, ], }, { diff --git a/src/migrations/20260701_163206_participation_phase_form_fields.ts b/src/migrations/20260701_163206_participation_phase_form_fields.ts new file mode 100644 index 0000000..cd4870c --- /dev/null +++ b/src/migrations/20260701_163206_participation_phase_form_fields.ts @@ -0,0 +1,218 @@ +import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite' + +type MigrationDB = MigrateUpArgs['db'] + +type TextColumn = { + name: string + defaultValue: string +} + +type BenefitRow = { + num: string + label: string + desc: string +} + +const participationPageWhere = "(`spa_path` = '/teilnahme' OR `slug` IN ('teilnahme', 'participation'))" + +const textColumns: TextColumn[] = [ + { name: 'participation_application_form_form_title', defaultValue: 'Kostenlos bewerben' }, + { name: 'participation_application_form_newsletter_phase_evaluation_eyebrow', defaultValue: 'Updates erhalten' }, + { name: 'participation_application_form_newsletter_phase_evaluation_heading', defaultValue: 'DIE BEWERBUNG\nIST GESCHLOSSEN.' }, + { + name: 'participation_application_form_newsletter_phase_evaluation_body', + defaultValue: + 'Die Bewerbungsphase 2026 ist geschlossen. Hinterlassen Sie Ihre E-Mail und erhalten Sie Updates zur Juryphase, zur Preisverleihung und zur nächsten Runde.', + }, + { name: 'participation_application_form_newsletter_phase_evaluation_form_eyebrow', defaultValue: 'Newsletter abonnieren' }, + { name: 'participation_application_form_newsletter_phase_evaluation_form_title', defaultValue: 'Updates erhalten' }, + { + name: 'participation_application_form_newsletter_phase_evaluation_form_body', + defaultValue: + 'Tragen Sie Ihre E-Mail ein und erhalten Sie relevante Informationen zum weiteren Verlauf des Bayerischen Mittelstandspreises.', + }, + { name: 'participation_application_form_newsletter_phase_evaluation_email_label', defaultValue: 'E-Mail-Adresse' }, + { name: 'participation_application_form_newsletter_phase_evaluation_email_placeholder', defaultValue: 'name@unternehmen.de' }, + { name: 'participation_application_form_newsletter_phase_evaluation_submit_label', defaultValue: 'Eintragen' }, + { + name: 'participation_application_form_newsletter_phase_evaluation_privacy', + defaultValue: 'Kein Spam. Nur relevante Informationen rund um den Bayerischen Mittelstandspreis.', + }, + { + name: 'participation_application_form_newsletter_phase_evaluation_validation_required', + defaultValue: 'Bitte geben Sie Ihre E-Mail-Adresse ein.', + }, + { + name: 'participation_application_form_newsletter_phase_evaluation_validation_invalid', + defaultValue: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', + }, + { name: 'participation_application_form_newsletter_phase_evaluation_success_heading', defaultValue: 'Danke' }, + { + name: 'participation_application_form_newsletter_phase_evaluation_success_body', + defaultValue: 'Ihre E-Mail wurde für BMP-Updates eingetragen.', + }, + { name: 'participation_application_form_newsletter_phase_evaluation_footnote', defaultValue: 'BMP-Updates -' }, + { name: 'participation_application_form_newsletter_phase_evaluation_footnote_strong', defaultValue: 'Juryphase und Termine' }, + { name: 'participation_application_form_newsletter_phase_completed_eyebrow', defaultValue: 'Nächste Runde' }, + { name: 'participation_application_form_newsletter_phase_completed_heading', defaultValue: 'DEN START\nNICHT\nVERPASSEN.' }, + { + name: 'participation_application_form_newsletter_phase_completed_body', + defaultValue: + 'Der aktuelle Preisjahrgang ist abgeschlossen. Hinterlassen Sie Ihre E-Mail und wir informieren Sie, sobald es Neuigkeiten zur nächsten Bewerbungsphase gibt.', + }, + { name: 'participation_application_form_newsletter_phase_completed_form_eyebrow', defaultValue: 'Newsletter abonnieren' }, + { name: 'participation_application_form_newsletter_phase_completed_form_title', defaultValue: 'Informiert bleiben' }, + { + name: 'participation_application_form_newsletter_phase_completed_form_body', + defaultValue: 'Tragen Sie sich ein und erhalten Sie Hinweise zum Bewerbungsstart, zu Veranstaltungen und zu ausgezeichneten Unternehmen.', + }, + { name: 'participation_application_form_newsletter_phase_completed_email_label', defaultValue: 'E-Mail-Adresse' }, + { name: 'participation_application_form_newsletter_phase_completed_email_placeholder', defaultValue: 'name@unternehmen.de' }, + { name: 'participation_application_form_newsletter_phase_completed_submit_label', defaultValue: 'Abonnieren' }, + { + name: 'participation_application_form_newsletter_phase_completed_privacy', + defaultValue: 'Sie erhalten nur BMP-relevante Informationen. Eine Abmeldung ist jederzeit möglich.', + }, + { + name: 'participation_application_form_newsletter_phase_completed_validation_required', + defaultValue: 'Bitte geben Sie Ihre E-Mail-Adresse ein.', + }, + { + name: 'participation_application_form_newsletter_phase_completed_validation_invalid', + defaultValue: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', + }, + { name: 'participation_application_form_newsletter_phase_completed_success_heading', defaultValue: 'Angemeldet' }, + { + name: 'participation_application_form_newsletter_phase_completed_success_body', + defaultValue: 'Danke. Sie erhalten BMP-Updates an die angegebene E-Mail-Adresse.', + }, + { name: 'participation_application_form_newsletter_phase_completed_footnote', defaultValue: 'Bewerbungsstart im Blick -' }, + { name: 'participation_application_form_newsletter_phase_completed_footnote_strong', defaultValue: 'BMP-Updates erhalten' }, +] + +const evaluationBenefits: BenefitRow[] = [ + { num: '01', label: 'Juryphase', desc: 'Updates zum Verlauf' }, + { num: '02', label: 'Preisverleihung', desc: 'Termine und Einblicke' }, + { num: '03', label: 'Nächste Runde', desc: 'Start nicht verpassen' }, +] + +const completedBenefits: BenefitRow[] = [ + { num: '01', label: 'Startsignal', desc: 'Neue Bewerbungsphase' }, + { num: '02', label: 'BMP-News', desc: 'Preisträger und Termine' }, + { num: '03', label: 'Erinnerung', desc: 'Rechtzeitig vorbereitet' }, +] + +const ident = (value: string) => `\`${value.replace(/`/g, '``')}\`` +const literal = (value: string) => `'${value.replace(/'/g, "''")}'` + +async function tableExists(db: MigrationDB, tableName: string) { + const rows = (await db.all( + sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`), + )) as Array<{ name: string }> + + return rows.length > 0 +} + +async function columnExists(db: MigrationDB, tableName: string, columnName: string) { + if (!(await tableExists(db, tableName))) return false + + const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }> + + return columns.some((column) => column.name === columnName) +} + +async function addTextColumn(db: MigrationDB, tableName: string, column: TextColumn) { + if (await columnExists(db, tableName, column.name)) return + + await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(column.name)} text DEFAULT ${literal(column.defaultValue)};`)) +} + +async function dropColumn(db: MigrationDB, tableName: string, columnName: string) { + if (!(await columnExists(db, tableName, columnName))) return + + await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident(columnName)};`)) +} + +async function backfillParticipationTextColumn(db: MigrationDB, column: TextColumn) { + if (!(await columnExists(db, 'pages', column.name))) return + + await db.run( + sql.raw( + `UPDATE ${ident('pages')} SET ${ident(column.name)} = ${literal(column.defaultValue)} WHERE ${ident(column.name)} IS NULL AND ${participationPageWhere};`, + ), + ) +} + +async function createBenefitTables(db: MigrationDB, tableName: string, versionTableName: string) { + await db.run(sql.raw(`CREATE TABLE IF NOT EXISTS ${ident(tableName)} ( + ${ident('_order')} integer NOT NULL, + ${ident('_parent_id')} integer NOT NULL, + ${ident('id')} text PRIMARY KEY NOT NULL, + ${ident('num')} text, + ${ident('label')} text, + ${ident('desc')} text, + FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('pages')}(${ident('id')}) ON UPDATE no action ON DELETE cascade + );`)) + await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_order_idx`)} ON ${ident(tableName)} (${ident('_order')});`)) + await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_parent_id_idx`)} ON ${ident(tableName)} (${ident('_parent_id')});`)) + + await db.run(sql.raw(`CREATE TABLE IF NOT EXISTS ${ident(versionTableName)} ( + ${ident('_order')} integer NOT NULL, + ${ident('_parent_id')} integer NOT NULL, + ${ident('id')} integer PRIMARY KEY NOT NULL, + ${ident('num')} text, + ${ident('label')} text, + ${ident('desc')} text, + ${ident('_uuid')} text, + FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('_pages_v')}(${ident('id')}) ON UPDATE no action ON DELETE cascade + );`)) + await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${versionTableName}_order_idx`)} ON ${ident(versionTableName)} (${ident('_order')});`)) + await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${versionTableName}_parent_id_idx`)} ON ${ident(versionTableName)} (${ident('_parent_id')});`)) +} + +async function seedBenefitRows(db: MigrationDB, tableName: string, rows: BenefitRow[]) { + if (!(await tableExists(db, tableName))) return + + for (const [index, row] of rows.entries()) { + await db.run( + sql.raw(`INSERT INTO ${ident(tableName)} (${ident('_order')}, ${ident('_parent_id')}, ${ident('id')}, ${ident('num')}, ${ident('label')}, ${ident('desc')}) + SELECT ${index + 1}, ${ident('pages')}.${ident('id')}, lower(hex(randomblob(12))), ${literal(row.num)}, ${literal(row.label)}, ${literal(row.desc)} + FROM ${ident('pages')} + WHERE ${participationPageWhere} + AND NOT EXISTS ( + SELECT 1 + FROM ${ident(tableName)} + WHERE ${ident('_parent_id')} = ${ident('pages')}.${ident('id')} + AND ${ident('_order')} = ${index + 1} + );`), + ) + } +} + +export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise { + for (const column of textColumns) { + await addTextColumn(db, 'pages', column) + await addTextColumn(db, '_pages_v', { + name: `version_${column.name}`, + defaultValue: column.defaultValue, + }) + await backfillParticipationTextColumn(db, column) + } + + await createBenefitTables(db, 'part_news_eval_bens', '_part_news_eval_bens_v') + await createBenefitTables(db, 'part_news_done_bens', '_part_news_done_bens_v') + await seedBenefitRows(db, 'part_news_eval_bens', evaluationBenefits) + await seedBenefitRows(db, 'part_news_done_bens', completedBenefits) +} + +export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise { + await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('part_news_eval_bens')};`)) + await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('_part_news_eval_bens_v')};`)) + await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('part_news_done_bens')};`)) + await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('_part_news_done_bens_v')};`)) + + for (const column of [...textColumns].reverse()) { + await dropColumn(db, 'pages', column.name) + await dropColumn(db, '_pages_v', `version_${column.name}`) + } +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 1b3ddb0..e480b2a 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -3,6 +3,7 @@ import * as migration_20260630_165002_participation_awards_grid from './20260630 import * as migration_20260630_172754_network_partner_logo_uploads from './20260630_172754_network_partner_logo_uploads'; import * as migration_20260630_175614_partners_collection from './20260630_175614_partners_collection'; import * as migration_20260630_182219_prune_unused_admin_collections from './20260630_182219_prune_unused_admin_collections'; +import * as migration_20260701_163206_participation_phase_form_fields from './20260701_163206_participation_phase_form_fields'; export const migrations = [ { @@ -30,4 +31,9 @@ export const migrations = [ down: migration_20260630_182219_prune_unused_admin_collections.down, name: '20260630_182219_prune_unused_admin_collections', }, + { + up: migration_20260701_163206_participation_phase_form_fields.up, + down: migration_20260701_163206_participation_phase_form_fields.down, + name: '20260701_163206_participation_phase_form_fields', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 3daa038..ca5965d 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -380,6 +380,64 @@ export interface Page { formTitle?: string | null; footnote?: string | null; footnoteStrong?: string | null; + /** + * Homepage newsletter copy used when the active application phase is Auswertung / Jury bewertet. + */ + newsletterPhaseEvaluation?: { + eyebrow?: string | null; + heading?: string | null; + body?: string | null; + benefits?: + | { + num?: string | null; + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + formEyebrow?: string | null; + formTitle?: string | null; + formBody?: string | null; + emailLabel?: string | null; + emailPlaceholder?: string | null; + submitLabel?: string | null; + privacy?: string | null; + validationRequired?: string | null; + validationInvalid?: string | null; + successHeading?: string | null; + successBody?: string | null; + footnote?: string | null; + footnoteStrong?: string | null; + }; + /** + * Homepage newsletter copy used when the active application phase is Preisverleihung abgeschlossen. + */ + newsletterPhaseCompleted?: { + eyebrow?: string | null; + heading?: string | null; + body?: string | null; + benefits?: + | { + num?: string | null; + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + formEyebrow?: string | null; + formTitle?: string | null; + formBody?: string | null; + emailLabel?: string | null; + emailPlaceholder?: string | null; + submitLabel?: string | null; + privacy?: string | null; + validationRequired?: string | null; + validationInvalid?: string | null; + successHeading?: string | null; + successBody?: string | null; + footnote?: string | null; + footnoteStrong?: string | null; + }; }; /** * Text, options, and eligibility thresholds used inside the home page multi-step application form. @@ -808,7 +866,7 @@ export interface Page { }; }; /** - * Cream section explaining proposal, self-application, and special award paths. + * Cream section explaining proposal and self-application paths. */ applicationWays?: { eyebrow?: string | null; @@ -915,7 +973,7 @@ export interface Page { | null; }; /** - * Navy/gold section containing the application form and PDF upload links. + * Navy/gold section containing the phase-aware application form or newsletter prompt. */ applicationForm?: { /** @@ -936,6 +994,7 @@ export interface Page { }[] | null; formEyebrow?: string | null; + formTitle?: string | null; uploadCta?: { label?: string | null; url?: string | null; @@ -945,6 +1004,64 @@ export interface Page { label?: string | null; url?: string | null; }; + /** + * Participation page newsletter copy used when the active application phase is Auswertung / Jury bewertet. + */ + newsletterPhaseEvaluation?: { + eyebrow?: string | null; + heading?: string | null; + body?: string | null; + benefits?: + | { + num?: string | null; + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + formEyebrow?: string | null; + formTitle?: string | null; + formBody?: string | null; + emailLabel?: string | null; + emailPlaceholder?: string | null; + submitLabel?: string | null; + privacy?: string | null; + validationRequired?: string | null; + validationInvalid?: string | null; + successHeading?: string | null; + successBody?: string | null; + footnote?: string | null; + footnoteStrong?: string | null; + }; + /** + * Participation page newsletter copy used when the active application phase is Preisverleihung abgeschlossen. + */ + newsletterPhaseCompleted?: { + eyebrow?: string | null; + heading?: string | null; + body?: string | null; + benefits?: + | { + num?: string | null; + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + formEyebrow?: string | null; + formTitle?: string | null; + formBody?: string | null; + emailLabel?: string | null; + emailPlaceholder?: string | null; + submitLabel?: string | null; + privacy?: string | null; + validationRequired?: string | null; + validationInvalid?: string | null; + successHeading?: string | null; + successBody?: string | null; + footnote?: string | null; + footnoteStrong?: string | null; + }; }; /** * Text, options, and eligibility thresholds used inside the multi-step application form. @@ -3097,6 +3214,62 @@ export interface PagesSelect { formTitle?: T; footnote?: T; footnoteStrong?: T; + newsletterPhaseEvaluation?: + | T + | { + eyebrow?: T; + heading?: T; + body?: T; + benefits?: + | T + | { + num?: T; + label?: T; + desc?: T; + id?: T; + }; + formEyebrow?: T; + formTitle?: T; + formBody?: T; + emailLabel?: T; + emailPlaceholder?: T; + submitLabel?: T; + privacy?: T; + validationRequired?: T; + validationInvalid?: T; + successHeading?: T; + successBody?: T; + footnote?: T; + footnoteStrong?: T; + }; + newsletterPhaseCompleted?: + | T + | { + eyebrow?: T; + heading?: T; + body?: T; + benefits?: + | T + | { + num?: T; + label?: T; + desc?: T; + id?: T; + }; + formEyebrow?: T; + formTitle?: T; + formBody?: T; + emailLabel?: T; + emailPlaceholder?: T; + submitLabel?: T; + privacy?: T; + validationRequired?: T; + validationInvalid?: T; + successHeading?: T; + successBody?: T; + footnote?: T; + footnoteStrong?: T; + }; }; form?: | T @@ -3550,6 +3723,7 @@ export interface PagesSelect { id?: T; }; formEyebrow?: T; + formTitle?: T; uploadCta?: | T | { @@ -3563,6 +3737,62 @@ export interface PagesSelect { label?: T; url?: T; }; + newsletterPhaseEvaluation?: + | T + | { + eyebrow?: T; + heading?: T; + body?: T; + benefits?: + | T + | { + num?: T; + label?: T; + desc?: T; + id?: T; + }; + formEyebrow?: T; + formTitle?: T; + formBody?: T; + emailLabel?: T; + emailPlaceholder?: T; + submitLabel?: T; + privacy?: T; + validationRequired?: T; + validationInvalid?: T; + successHeading?: T; + successBody?: T; + footnote?: T; + footnoteStrong?: T; + }; + newsletterPhaseCompleted?: + | T + | { + eyebrow?: T; + heading?: T; + body?: T; + benefits?: + | T + | { + num?: T; + label?: T; + desc?: T; + id?: T; + }; + formEyebrow?: T; + formTitle?: T; + formBody?: T; + emailLabel?: T; + emailPlaceholder?: T; + submitLabel?: T; + privacy?: T; + validationRequired?: T; + validationInvalid?: T; + successHeading?: T; + successBody?: T; + footnote?: T; + footnoteStrong?: T; + }; }; form?: | T diff --git a/src/spa/components/AwardsGridSection.tsx b/src/spa/components/AwardsGridSection.tsx new file mode 100644 index 0000000..ea921bd --- /dev/null +++ b/src/spa/components/AwardsGridSection.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { ArrowRight, Mail } from 'lucide-react'; + +import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; +import Image from '@/spa/components/ui/UnoptimizedImage'; +import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; +import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import { participationContent } from '@/spa/participationContent'; +import { Link } from '@/spa/router'; + +const FF = '"IBM Plex Sans", sans-serif'; +const FB = '"Inter", sans-serif'; +const NAVY = '#111D55'; +const GOLD = '#EFBF04'; + +type AwardCard = (typeof participationContent.awardsGrid.cards)[number]; +type AwardCardCms = Partial & { image?: unknown }; + +export type AwardsGridContent = Partial & { + cards?: AwardCardCms[]; +}; + +const fallbackText = (value: unknown, fallback: string) => + typeof value === 'string' && value.length > 0 ? value : fallback; + +const mergeAwardCards = (value: unknown, fallback: AwardCard[]): (AwardCard & { image?: unknown })[] => { + const cards = Array.isArray(value) && value.length > 0 ? (value as AwardCardCms[]) : fallback; + return cards.map((card, index) => ({ ...(fallback[index] || fallback[0]), ...card })); +}; + +function Lines({ text }: { text: string }) { + return <>{text.split('\n').map((line, i) => {i > 0 &&
}{line}
)}; +} + +export default function AwardsGridSection({ content }: { content?: AwardsGridContent }) { + const isMobile = useIsMobile(); + const section = { ...participationContent.awardsGrid, ...(content || {}) }; + const cards = mergeAwardCards(section.cards, participationContent.awardsGrid.cards); + + return ( +
+ +
+ + {fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)} + +
+

+ +

+

+ {fallbackText(section.description, participationContent.awardsGrid.description)} +

+
+
+ +
+ {cards.map((card, index) => { + const imageFilename = fallbackText(card.imageFilename, participationContent.awardsGrid.cards[index]?.imageFilename || participationContent.awardsGrid.cards[0].imageFilename); + const isContainedImage = imageFilename.endsWith('.png') || imageFilename.includes('roland-berger'); + const isLast = index === cards.length - 1; + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/spa/components/forms/NewsletterInterestForm.tsx b/src/spa/components/forms/NewsletterInterestForm.tsx new file mode 100644 index 0000000..9e59285 --- /dev/null +++ b/src/spa/components/forms/NewsletterInterestForm.tsx @@ -0,0 +1,159 @@ +import React, { useState } from 'react'; +import { Check, Mail } from 'lucide-react'; + +import { useIsMobile } from '@/spa/hooks/useIsMobile'; + +const FF = '"IBM Plex Sans", sans-serif'; + +export type NewsletterBenefit = { num: string; label: string; desc: string }; + +export type NewsletterInterestCopy = { + eyebrow: string; + heading: string; + body: string; + benefits: readonly NewsletterBenefit[]; + formEyebrow: string; + formTitle: string; + formBody: string; + emailLabel: string; + emailPlaceholder: string; + submitLabel: string; + privacy: string; + validationRequired: string; + validationInvalid: string; + successHeading: string; + successBody: string; + footnote: string; + footnoteStrong: string; +}; + +export type NewsletterInterestCms = Partial> & { + benefits?: unknown; +}; + +export function NewsletterInterestForm({ copy }: { copy: NewsletterInterestCopy }) { + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + const [submitted, setSubmitted] = useState(false); + const isMobile = useIsMobile(); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmedEmail = email.trim(); + + if (!trimmedEmail) { + setError(copy.validationRequired); + return; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) { + setError(copy.validationInvalid); + return; + } + + setError(''); + setSubmitted(true); + }; + + if (submitted) { + return ( +
+
+ +
+

+ {copy.successHeading} +

+

+ {copy.successBody} +

+
+ ); + } + + return ( +
+

+ {copy.formBody} +

+ +
+
+ { + setEmail(event.target.value); + if (error) setError(''); + }} + placeholder={copy.emailPlaceholder} + aria-invalid={Boolean(error)} + style={{ + width: '100%', + height: 50, + padding: '0 16px', + border: `1.5px solid ${error ? '#b91c1c' : 'rgba(17,29,85,0.22)'}`, + background: 'rgba(255,255,255,0.55)', + color: '#111D55', + fontFamily: FF, + fontSize: 16, + outline: 'none', + boxSizing: 'border-box', + }} + /> + {error &&
{error}
} +
+ +
+

+ {copy.privacy} +

+
+ ); +} diff --git a/src/spa/components/layout/Layout.tsx b/src/spa/components/layout/Layout.tsx index eae996b..62ef84f 100644 --- a/src/spa/components/layout/Layout.tsx +++ b/src/spa/components/layout/Layout.tsx @@ -7,6 +7,7 @@ import { Link, useLocation } from '@/spa/router'; import { ArrowRight } from 'lucide-react'; import LoadingScreen from '@/spa/components/ui/LoadingScreen'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import { useApplicationPhase } from '@/spa/cmsRoute'; import { defaultFooterData, defaultHeaderData, @@ -37,6 +38,7 @@ const LINE = 'rgba(239,191,4,0.35)'; const FF_DISPLAY = '"IBM Plex Sans", sans-serif'; const FF_BODY = '"Inter", sans-serif'; const HIDE_MEMBERSHIP_FOOTER_ENTRY = true; +const APPLICATION_PHASE_OPEN = '0'; const isMembershipFooterLink = (link?: { label?: string; path?: string }) => link?.path === '/mitglied-werden' || link?.label?.trim().toLowerCase() === 'mitglied werden'; @@ -72,6 +74,8 @@ const Layout: React.FC = ({ children, header = defaultHeaderData, f const footerCtas = footer.ctas; const fallbackHeaderCtas = defaultHeaderData.ctas; const fallbackFooterCtas = defaultFooterData.ctas; + const applicationPhase = useApplicationPhase(); + const showFloatingApplicationCta = applicationPhase?.activePhase === APPLICATION_PHASE_OPEN; const footerMembershipCta = footerCtas[1] || fallbackFooterCtas[1]; const showFooterMembershipCta = !HIDE_MEMBERSHIP_FOOTER_ENTRY || !isMembershipFooterLink(footerMembershipCta); @@ -565,7 +569,7 @@ const Layout: React.FC = ({ children, header = defaultHeaderData, f {/* ── FLOATING CTA ─────────────────────────────── */} - {location.pathname === '/' && !formVisible && !isMobile && ( + {location.pathname === '/' && showFloatingApplicationCta && !formVisible && !isMobile && ( void) => { + window.addEventListener("resize", onStoreChange) + return () => window.removeEventListener("resize", onStoreChange) +} + +const getViewportWidth = () => window.innerWidth + +const getServerViewportWidth = () => 0 + +function useViewportWidth() { + return React.useSyncExternalStore( + subscribeToViewportWidth, + getViewportWidth, + getServerViewportWidth + ) +} + interface ContainerScrollContextValue { scrollYProgress: MotionValue } @@ -128,13 +145,13 @@ export const ProcessCard: React.FC = ({ const { scrollYProgress } = useContainerScrollContext() const start = index / itemsLength const end = start + 1 / itemsLength - const innerWidth = typeof window === "undefined" ? 0 : window.innerWidth + const viewportWidth = useViewportWidth() const [ref, { width }] = useMeasure() const x = useTransform( scrollYProgress, [start, end], - [innerWidth, -((width ?? 0) * index) + 64 * index] + [viewportWidth, -((width ?? 0) * index) + 64 * index] ) return ( ? (value as Array).map((item) => (typeof item === 'string' ? item : fallbackText(item.text, ''))).filter(Boolean) : fallback +const isParticipationPage = (page: CmsRouteDoc) => { + const title = String(page.title || '').toLowerCase() + + return page.spaPath === '/teilnahme' || page.slug === 'teilnahme' || page.slug === 'participation' || title === 'teilnahme' || title === 'participation' +} + function HighlightedText({ text, highlight }: { text: string; highlight: string }) { const index = text.indexOf(highlight) if (!highlight || index < 0) return <>{text} @@ -115,6 +122,7 @@ const About: React.FC = () => { const isMobile = useIsMobile() const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms const cmsWinners = useCmsCollection('preistraeger') + const cmsPages = useCmsCollection('pages') const hero = { ...aboutContent.hero, ...(cms.hero || {}) } const prize = { ...aboutContent.prize, ...(cms.prize || {}) } const mittelstand = { ...aboutContent.mittelstand, ...(cms.mittelstand || {}) } @@ -124,6 +132,8 @@ const About: React.FC = () => { const goals = { ...aboutContent.goals, ...(cms.goals || {}) } const values = { ...aboutContent.values, ...(cms.values || {}) } const cta = { ...aboutContent.cta, ...(cms.cta || {}) } + const participationPage = cmsPages.find(isParticipationPage) + const participationAwardsGrid = (participationPage?.participation as { awardsGrid?: AwardsGridContent } | undefined)?.awardsGrid const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta) const highlightFallbackImage = mediaUrl( highlights.fallbackImage, @@ -407,6 +417,8 @@ const About: React.FC = () => { ))} + +
diff --git a/src/spa/pages/Home.tsx b/src/spa/pages/Home.tsx index 1d87e7f..d2ce37a 100644 --- a/src/spa/pages/Home.tsx +++ b/src/spa/pages/Home.tsx @@ -5,6 +5,7 @@ import { Link } from '@/spa/router'; import { PartnerTicker } from '@/spa/components/ui/partner-ticker'; import { WINNERS } from '@/spa/data/winners'; import BewerbungsForm from '@/spa/components/forms/BewerbungsForm'; +import { NewsletterInterestForm, type NewsletterBenefit, type NewsletterInterestCms, type NewsletterInterestCopy } from '@/spa/components/forms/NewsletterInterestForm'; import TestimonialsSection from '@/spa/components/TestimonialsSection'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; @@ -55,6 +56,8 @@ type HomeSectionCms = CmsRecord & { imageAlt?: string | null; imageLabel?: string | null; items?: unknown; + newsletterPhaseCompleted?: NewsletterInterestCms | null; + newsletterPhaseEvaluation?: NewsletterInterestCms | null; note?: string | null; quote?: string | null; stats?: unknown; @@ -116,6 +119,41 @@ const fallbackArray = (value: unknown, fallback: readonly T[]) => Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; const HIDE_MEMBERSHIP_HOME_HERO_CTA = true; +const APPLICATION_PHASE_OPEN = '0'; + +const newsletterInterestCopy: Record = { + '1': homeContent.application.newsletterPhaseEvaluation, + '2': homeContent.application.newsletterPhaseCompleted, +}; + +const mergeNewsletterInterestCopy = ( + value: NewsletterInterestCms | null | undefined, + fallback: NewsletterInterestCopy, +): NewsletterInterestCopy => ({ + eyebrow: fallbackText(value?.eyebrow, fallback.eyebrow), + heading: fallbackText(value?.heading, fallback.heading), + body: fallbackText(value?.body, fallback.body), + benefits: fallbackArray(value?.benefits, fallback.benefits), + formEyebrow: fallbackText(value?.formEyebrow, fallback.formEyebrow), + formTitle: fallbackText(value?.formTitle, fallback.formTitle), + formBody: fallbackText(value?.formBody, fallback.formBody), + emailLabel: fallbackText(value?.emailLabel, fallback.emailLabel), + emailPlaceholder: fallbackText(value?.emailPlaceholder, fallback.emailPlaceholder), + submitLabel: fallbackText(value?.submitLabel, fallback.submitLabel), + privacy: fallbackText(value?.privacy, fallback.privacy), + validationRequired: fallbackText(value?.validationRequired, fallback.validationRequired), + validationInvalid: fallbackText(value?.validationInvalid, fallback.validationInvalid), + successHeading: fallbackText(value?.successHeading, fallback.successHeading), + successBody: fallbackText(value?.successBody, fallback.successBody), + footnote: fallbackText(value?.footnote, fallback.footnote), + footnoteStrong: fallbackText(value?.footnoteStrong, fallback.footnoteStrong), +}); + +const getNewsletterInterestCopy = (phase: string | undefined, application: HomeSectionCms) => { + const fallback = newsletterInterestCopy[phase || ''] || newsletterInterestCopy['2']; + const cmsCopy = phase === '1' ? application.newsletterPhaseEvaluation : application.newsletterPhaseCompleted; + return mergeNewsletterInterestCopy(cmsCopy, fallback); +}; const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | null) => cta?.url === '/mitglied-werden' || @@ -238,6 +276,9 @@ const Home: React.FC = () => { const form = home.form || {}; const videoModal = home.videoModal || {}; const applicationPhase = useApplicationPhase(); + const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN; + const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN; + const newsletterCopy = getNewsletterInterestCopy(activeApplicationPhase, application); const cmsPreistraeger = useCmsCollection('preistraeger'); const selectedWinners = fallbackArray(winnersSection.featured, []); const heroSecondaryCta = { @@ -260,6 +301,7 @@ const Home: React.FC = () => { const introStats = fallbackArray(intro.stats, homeContent.intro.stats); const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items); const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits); + const finalSectionBenefits = showApplicationForm ? applicationBenefits : newsletterCopy.benefits; const winnerFallbackImage = mediaUrl(winnersSection.fallbackImage, `/images/${homeContent.winners.fallbackImageFilename}`); const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title); const videoSrc = mediaUrl(videoModal.video, ''); @@ -354,6 +396,9 @@ const Home: React.FC = () => {
+ {/* Status Phase Slider */} + + {/* Schnell-Check Section */}
@@ -503,9 +548,6 @@ const Home: React.FC = () => {
- {/* Status Phase Slider */} - - {/* Winners Grid – neue Preisträger-Übersicht */}
@@ -617,8 +659,8 @@ const Home: React.FC = () => {
- {/* CTA Section – with embedded form */} -
+ {/* CTA Section – with phase-aware embedded form */} +
{/* Subtle radial glow behind left copy */}
@@ -626,18 +668,18 @@ const Home: React.FC = () => { {/* Left – pitch copy */}
- {fallbackText(application.eyebrow, homeContent.application.eyebrow)} + {showApplicationForm ? fallbackText(application.eyebrow, homeContent.application.eyebrow) : newsletterCopy.eyebrow}

- +

- {fallbackText(application.body, homeContent.application.body)} + {showApplicationForm ? fallbackText(application.body, homeContent.application.body) : newsletterCopy.body}

{/* Benefit rows */}
- {applicationBenefits.map(item => ( + {finalSectionBenefits.map(item => (
{item.num}
@@ -687,10 +729,10 @@ const Home: React.FC = () => { )} {/* Form area */} -
- {fallbackText(application.formEyebrow, homeContent.application.formEyebrow)} -

{fallbackText(application.formTitle, homeContent.application.formTitle)}

- +
+ {showApplicationForm ? fallbackText(application.formEyebrow, homeContent.application.formEyebrow) : newsletterCopy.formEyebrow} +

{showApplicationForm ? fallbackText(application.formTitle, homeContent.application.formTitle) : newsletterCopy.formTitle}

+ {showApplicationForm ? : }
@@ -698,8 +740,8 @@ const Home: React.FC = () => { {/* Footnote row */}
- {fallbackText(application.footnote, homeContent.application.footnote)} - {fallbackText(application.footnoteStrong, homeContent.application.footnoteStrong)} + {showApplicationForm ? fallbackText(application.footnote, homeContent.application.footnote) : newsletterCopy.footnote} + {showApplicationForm ? fallbackText(application.footnoteStrong, homeContent.application.footnoteStrong) : newsletterCopy.footnoteStrong}
diff --git a/src/spa/pages/Participation.tsx b/src/spa/pages/Participation.tsx index 70be5f1..117ca1e 100644 --- a/src/spa/pages/Participation.tsx +++ b/src/spa/pages/Participation.tsx @@ -1,12 +1,14 @@ import React, { useRef } from 'react'; -import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star, Mail } 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 { NewsletterInterestForm, type NewsletterBenefit, type NewsletterInterestCms, type NewsletterInterestCopy } from '@/spa/components/forms/NewsletterInterestForm'; +import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection'; 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 { useApplicationPhase, useCmsRoute } from '@/spa/cmsRoute'; import { participationContent } from '@/spa/participationContent'; import Image from '@/spa/components/ui/UnoptimizedImage' @@ -18,12 +20,15 @@ const CREAM = '#E4E2E3'; type ParticipationCms = Partial & { hero?: Partial & { backgroundImage?: unknown }; - awardsGrid?: Partial & { cards?: AwardCardCms[] }; - applicationForm?: Partial & { image?: unknown }; + awardsGrid?: AwardsGridContent; + applicationForm?: ParticipationApplicationFormCms; }; -type AwardCard = (typeof participationContent.awardsGrid.cards)[number]; -type AwardCardCms = Partial & { image?: unknown }; +type ParticipationApplicationFormCms = Partial & { + image?: unknown; + newsletterPhaseCompleted?: NewsletterInterestCms | null; + newsletterPhaseEvaluation?: NewsletterInterestCms | null; +}; const ICONS = { building2: Building2, @@ -38,12 +43,50 @@ const ICONS = { const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback; -const fallbackArray = (value: unknown, fallback: T[]) => +const fallbackArray = (value: unknown, fallback: readonly T[]) => Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; -const mergeAwardCards = (value: unknown, fallback: AwardCard[]): (AwardCard & { image?: unknown })[] => { - const cards = Array.isArray(value) && value.length > 0 ? (value as AwardCardCms[]) : fallback; - return cards.map((card, index) => ({ ...(fallback[index] || fallback[0]), ...card })); +const APPLICATION_PHASE_OPEN = '0'; + +const newsletterInterestCopy: Record = { + '1': participationContent.applicationForm.newsletterPhaseEvaluation, + '2': participationContent.applicationForm.newsletterPhaseCompleted, +}; + +const mergeNewsletterInterestCopy = ( + value: NewsletterInterestCms | null | undefined, + fallback: NewsletterInterestCopy, +): NewsletterInterestCopy => ({ + eyebrow: fallbackText(value?.eyebrow, fallback.eyebrow), + heading: fallbackText(value?.heading, fallback.heading), + body: fallbackText(value?.body, fallback.body), + benefits: fallbackArray(value?.benefits, fallback.benefits), + formEyebrow: fallbackText(value?.formEyebrow, fallback.formEyebrow), + formTitle: fallbackText(value?.formTitle, fallback.formTitle), + formBody: fallbackText(value?.formBody, fallback.formBody), + emailLabel: fallbackText(value?.emailLabel, fallback.emailLabel), + emailPlaceholder: fallbackText(value?.emailPlaceholder, fallback.emailPlaceholder), + submitLabel: fallbackText(value?.submitLabel, fallback.submitLabel), + privacy: fallbackText(value?.privacy, fallback.privacy), + validationRequired: fallbackText(value?.validationRequired, fallback.validationRequired), + validationInvalid: fallbackText(value?.validationInvalid, fallback.validationInvalid), + successHeading: fallbackText(value?.successHeading, fallback.successHeading), + successBody: fallbackText(value?.successBody, fallback.successBody), + footnote: fallbackText(value?.footnote, fallback.footnote), + footnoteStrong: fallbackText(value?.footnoteStrong, fallback.footnoteStrong), +}); + +const getNewsletterInterestCopy = (phase: string | undefined, applicationForm: ParticipationApplicationFormCms) => { + const fallback = newsletterInterestCopy[phase || ''] || newsletterInterestCopy['2']; + const cmsCopy = phase === '1' ? applicationForm.newsletterPhaseEvaluation : applicationForm.newsletterPhaseCompleted; + return mergeNewsletterInterestCopy(cmsCopy, fallback); +}; + +const isFutureAwardApplicationWay = (value: { label?: unknown; title?: unknown }) => { + const label = typeof value.label === 'string' ? value.label.toLowerCase() : ''; + const title = typeof value.title === 'string' ? value.title.toLowerCase() : ''; + + return label.includes('sonderpreis') || title.includes('bavarian') || title.includes('future award'); }; function Lines({ text }: { text: string }) { @@ -54,6 +97,7 @@ const Participation: React.FC = () => { const isMobile = useIsMobile(); const qualSectionRef = useRef(null); const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms; + const applicationPhase = useApplicationPhase(); const hero = { ...participationContent.hero, ...(cms.hero || {}) }; const process = { ...participationContent.process, ...(cms.process || {}) }; const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) }; @@ -62,11 +106,15 @@ const Participation: React.FC = () => { const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) }; const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) }; const form = { ...participationContent.form, ...(cms.form || {}) }; + const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN; + const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN; + const newsletterCopy = getNewsletterInterestCopy(activeApplicationPhase, applicationForm); const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria); const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes); const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems); const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems); const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts); + const finalSectionFacts = showApplicationForm ? applicationFacts : newsletterCopy.benefits; return (
@@ -224,21 +272,21 @@ const Participation: React.FC = () => { {/* ── FORM SECTION ─────────────────────────────────────────────────────── */} -
+
{/* Left – copy */}
- {fallbackText(applicationForm.eyebrow, participationContent.applicationForm.eyebrow)} + {showApplicationForm ? fallbackText(applicationForm.eyebrow, participationContent.applicationForm.eyebrow) : newsletterCopy.eyebrow}

- +

- {fallbackText(applicationForm.description, participationContent.applicationForm.description)} + {showApplicationForm ? fallbackText(applicationForm.description, participationContent.applicationForm.description) : newsletterCopy.body}

- {applicationFacts.map((item, i) => ( -
+ {finalSectionFacts.map((item, i) => ( +
{fallbackText(item.num, '')}
{fallbackText(item.label, '')} @@ -279,31 +327,51 @@ const Participation: React.FC = () => {
)} -
-
- {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)'; }} - > - - {fallbackText(applicationForm.uploadCta?.label, participationContent.applicationForm.uploadCta.label)} - -
- +
+ {showApplicationForm ? ( + <> +
+ {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)'; }} + > + + {fallbackText(applicationForm.uploadCta?.label, participationContent.applicationForm.uploadCta.label)} + +
+

{fallbackText(applicationForm.formTitle, participationContent.applicationForm.formTitle)}

+ + + ) : ( + <> + {newsletterCopy.formEyebrow} +

{newsletterCopy.formTitle}

+ + + )}
- - {fallbackText(applicationForm.uploadPrompt, participationContent.applicationForm.uploadPrompt)} - - - {fallbackText(applicationForm.uploadFooterCta?.label, participationContent.applicationForm.uploadFooterCta.label)} - + {showApplicationForm ? ( + <> + + {fallbackText(applicationForm.uploadPrompt, participationContent.applicationForm.uploadPrompt)} + + + {fallbackText(applicationForm.uploadFooterCta?.label, participationContent.applicationForm.uploadFooterCta.label)} + + + ) : ( +
+ {newsletterCopy.footnote} + {newsletterCopy.footnoteStrong} +
+ )}
@@ -349,7 +417,9 @@ 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); + const ways = fallbackArray(section.ways, participationContent.applicationWays.ways).filter( + (way) => !isFutureAwardApplicationWay(way), + ); const fallbackCta = { ...participationContent.applicationWays.fallbackCta, ...(section.fallbackCta || {}) }; return (
@@ -368,24 +438,21 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
- {/* 3 Wege – premium swipe cards on mobile, 3-col grid on desktop */} -
+ {/* Wege – premium swipe cards on mobile, equal grid columns on desktop */} +
{/* ── MOBILE: unified premium cards ── */} {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 && ( - {fallbackText(section.recommendedLabel, participationContent.applicationWays.recommendedLabel)} - )} -
- +
+
- {fallbackText(w.label, '')} + {fallbackText(w.label, '')}

{fallbackText(w.desc, '')}

@@ -420,23 +487,22 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent } {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 ( -
-
- +
+
+
- {fallbackText(w.label, '')} -

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

-

+

{fallbackText(w.desc, '')}

-
+
{w.listType === 'institutions' ? institutions.map((inst, i) => (
@@ -445,9 +511,9 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent } {fallbackText(inst.text, '')}
)) : facts.map((item, i) => ( -
- {fallbackText(item.label, '')} - {fallbackText(item.desc, '')} +
+ {fallbackText(item.label, '')} + {fallbackText(item.desc, '')}
))}
@@ -471,131 +537,4 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent } ); } -type AwardsGridContent = Partial; - -function AwardsGridSection({ content }: { content?: AwardsGridContent }) { - const isMobile = useIsMobile(); - const section = { ...participationContent.awardsGrid, ...(content || {}) }; - const cards = mergeAwardCards(section.cards, participationContent.awardsGrid.cards); - - return ( -
- -
- - {fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)} - -
-

- -

-

- {fallbackText(section.description, participationContent.awardsGrid.description)} -

-
-
- -
- {cards.map((card, index) => { - const imageFilename = fallbackText(card.imageFilename, participationContent.awardsGrid.cards[index]?.imageFilename || participationContent.awardsGrid.cards[0].imageFilename); - const isContainedImage = imageFilename.endsWith('.png') || imageFilename.includes('roland-berger'); - const isLast = index === cards.length - 1; - - return ( - - ); - })} -
-
- ); -} - export default Participation; diff --git a/src/spa/pages/Preistraeger.tsx b/src/spa/pages/Preistraeger.tsx index ebb9563..f975f82 100644 --- a/src/spa/pages/Preistraeger.tsx +++ b/src/spa/pages/Preistraeger.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo } from 'react'; import { Link } from '@/spa/router'; -import { Trophy, ChevronRight, ArrowRight } from 'lucide-react'; +import { Trophy, ArrowRight } from 'lucide-react'; import { WINNERS, type Winner } from '@/spa/data/winners'; import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'; import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; @@ -14,6 +14,7 @@ const GOLD = '#EFBF04'; const BORDER = '#D0D5DD'; const GRAY = '#666666'; const BG_ALT = '#E4E2E3'; +const SIMPLE_LIST_YEAR = 2023; type PreistraegerIndexCms = Partial & { hero?: Partial & { image?: unknown } @@ -69,6 +70,7 @@ export default function Preistraeger() { const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear(); const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]); + const usesSimpleWinnerGrid = selectedYear === SIMPLE_LIST_YEAR; return (
@@ -129,6 +131,8 @@ export default function Preistraeger() {
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
+ ) : usesSimpleWinnerGrid ? ( + ) : (
{fallbackText(cta.primaryCta?.label, preistraegerIndexContent.cta.primaryCta.label)} -
- - {fallbackText(cta.secondaryPrefix, preistraegerIndexContent.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)'; - }} - > - {fallbackText(cta.secondaryCta?.label, preistraegerIndexContent.cta.secondaryCta.label)} - -
); @@ -257,3 +229,91 @@ function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string ); } + +function SimpleWinnerGrid({ winners }: { winners: Winner[] }) { + const isMobile = useIsMobile(); + + return ( +
+ {winners.map(winner => ( + + ))} +
+ ); +} + +function SimpleWinnerCard({ winner }: { winner: Winner }) { + const isMobile = useIsMobile(); + const hasDescription = winner.shortDesc.trim().length > 0; + + return ( +
+
+ + {winner.year} · {winner.type} + + {winner.category ? ( + + {winner.category} + + ) : null} +
+ +

+ {winner.name} +

+ + {hasDescription ? ( +

+ {winner.shortDesc} +

+ ) : null} +
+ ); +} diff --git a/src/spa/participationContent.ts b/src/spa/participationContent.ts index 3cdc665..6776611 100644 --- a/src/spa/participationContent.ts +++ b/src/spa/participationContent.ts @@ -107,7 +107,7 @@ export const participationContent = { title: 'Initiativ­bewerbung', desc: 'Für Unternehmer, die selbst die Initiative ergreifen.', icon: 'userCheck', - featured: true, + featured: false, cta: { label: 'Jetzt bewerben', url: '#bewerben' }, listType: 'facts', facts: [ @@ -117,21 +117,6 @@ export const participationContent = { { 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', }, @@ -164,9 +149,60 @@ export const participationContent = { { num: '03', label: 'Mehrstufig', desc: 'Transparenter Prozess' }, ], formEyebrow: 'Bewerbung 2026', + formTitle: 'Kostenlos bewerben', 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' }, + newsletterPhaseEvaluation: { + eyebrow: 'Updates erhalten', + heading: 'DIE BEWERBUNG\nIST GESCHLOSSEN.', + body: + 'Die Bewerbungsphase 2026 ist geschlossen. Hinterlassen Sie Ihre E-Mail und erhalten Sie Updates zur Juryphase, zur Preisverleihung und zur nächsten Runde.', + benefits: [ + { num: '01', label: 'Juryphase', desc: 'Updates zum Verlauf' }, + { num: '02', label: 'Preisverleihung', desc: 'Termine und Einblicke' }, + { num: '03', label: 'Nächste Runde', desc: 'Start nicht verpassen' }, + ], + formEyebrow: 'Newsletter abonnieren', + formTitle: 'Updates erhalten', + formBody: + 'Tragen Sie Ihre E-Mail ein und erhalten Sie relevante Informationen zum weiteren Verlauf des Bayerischen Mittelstandspreises.', + emailLabel: 'E-Mail-Adresse', + emailPlaceholder: 'name@unternehmen.de', + submitLabel: 'Eintragen', + privacy: 'Kein Spam. Nur relevante Informationen rund um den Bayerischen Mittelstandspreis.', + validationRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein.', + validationInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', + successHeading: 'Danke', + successBody: 'Ihre E-Mail wurde für BMP-Updates eingetragen.', + footnote: 'BMP-Updates -', + footnoteStrong: 'Juryphase und Termine', + }, + newsletterPhaseCompleted: { + eyebrow: 'Nächste Runde', + heading: 'DEN START\nNICHT\nVERPASSEN.', + body: + 'Der aktuelle Preisjahrgang ist abgeschlossen. Hinterlassen Sie Ihre E-Mail und wir informieren Sie, sobald es Neuigkeiten zur nächsten Bewerbungsphase gibt.', + benefits: [ + { num: '01', label: 'Startsignal', desc: 'Neue Bewerbungsphase' }, + { num: '02', label: 'BMP-News', desc: 'Preisträger und Termine' }, + { num: '03', label: 'Erinnerung', desc: 'Rechtzeitig vorbereitet' }, + ], + formEyebrow: 'Newsletter abonnieren', + formTitle: 'Informiert bleiben', + formBody: + 'Tragen Sie sich ein und erhalten Sie Hinweise zum Bewerbungsstart, zu Veranstaltungen und zu ausgezeichneten Unternehmen.', + emailLabel: 'E-Mail-Adresse', + emailPlaceholder: 'name@unternehmen.de', + submitLabel: 'Abonnieren', + privacy: 'Sie erhalten nur BMP-relevante Informationen. Eine Abmeldung ist jederzeit möglich.', + validationRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein.', + validationInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', + successHeading: 'Angemeldet', + successBody: 'Danke. Sie erhalten BMP-Updates an die angegebene E-Mail-Adresse.', + footnote: 'Bewerbungsstart im Blick -', + footnoteStrong: 'BMP-Updates erhalten', + }, }, form: { stepCompleteLabel: '✓',