From 9ebe544987fa71b5e14b9c94b78c005b3372772c Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 13 Jul 2026 16:07:58 +0200 Subject: [PATCH] feat: add application form configuration and migration for shared application form data - Introduced a new configuration file for the application form with various fields and validation messages. - Created a migration script to set up the database schema for the application form, including necessary tables and default values. - Added a migration for supporting awards copy to enhance the pages with additional columns. - Implemented a new SPA component for the application form section, integrating form logic and UI elements. --- src/ApplicationForm/config.ts | 205 +++++++++++ src/app/(frontend)/[[...slug]]/page.tsx | 4 + src/collections/Pages/homeFields.ts | 4 +- src/collections/Pages/participationFields.ts | 6 +- ...20260713_155500_shared_application_form.ts | 191 ++++++++++ ...00_participation_supporting_awards_copy.ts | 59 +++ src/migrations/index.ts | 12 + src/payload-types.ts | 337 +++++++++++++++++- src/payload.config.ts | 3 +- src/scripts/preload-globals-cms.ts | 15 +- src/spa/NextApp.tsx | 5 +- src/spa/applicationForm.ts | 21 ++ src/spa/awardArticles.ts | 3 + src/spa/cmsRoute.tsx | 6 + src/spa/components/AwardsGridSection.tsx | 10 + .../components/WegZurAuszeichnungSection.tsx | 69 ++-- .../forms/ApplicationFormSection.tsx | 115 ++++++ src/spa/pages/Home.tsx | 104 +----- src/spa/pages/Participation.tsx | 116 +----- 19 files changed, 1026 insertions(+), 259 deletions(-) create mode 100644 src/ApplicationForm/config.ts create mode 100644 src/migrations/20260713_155500_shared_application_form.ts create mode 100644 src/migrations/20260713_163000_participation_supporting_awards_copy.ts create mode 100644 src/spa/applicationForm.ts create mode 100644 src/spa/components/forms/ApplicationFormSection.tsx diff --git a/src/ApplicationForm/config.ts b/src/ApplicationForm/config.ts new file mode 100644 index 0000000..a2f22bb --- /dev/null +++ b/src/ApplicationForm/config.ts @@ -0,0 +1,205 @@ +import type { Field, GlobalConfig } from 'payload' + +import { authenticated } from '@/access/authenticated' +import { homeContent } from '@/spa/homeContent' + +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 iconOptions = [ + { label: 'Alert circle', value: 'alertCircle' }, + { label: 'Arrow right', value: 'arrowRight' }, + { label: 'Building', value: 'building2' }, + { label: 'Check', value: 'check' }, + { label: 'Send', value: 'send' }, + { label: 'Trophy', value: 'trophy' }, + { label: 'User', value: 'user' }, +] + +const iconField = (): Field => ({ + name: 'icon', + label: 'Icon', + type: 'select', + defaultValue: 'trophy', + options: iconOptions, +}) + +const formFields: Field[] = [ + text('stepCompleteLabel', 'Completed step label', homeContent.form.stepCompleteLabel), + text('backLabel', 'Back button label', homeContent.form.backLabel), + text('nextLabel', 'Next button label', homeContent.form.nextLabel), + text('submitLabel', 'Submit button label', homeContent.form.submitLabel), + text('yesLabel', 'Yes option label', homeContent.form.yesLabel), + text('noLabel', 'No option label', homeContent.form.noLabel), + text('requiredSuffix', 'Required field suffix', homeContent.form.requiredSuffix), + { + name: 'validation', + label: 'Validation messages', + type: 'group', + fields: [ + text('choose', 'Choose message', homeContent.form.validation.choose), + text('required', 'Required message', homeContent.form.validation.required), + text('invalidEmail', 'Invalid email message', homeContent.form.validation.invalidEmail), + ], + }, + { + name: 'typeStep', + label: 'Step 1 · Submission type', + type: 'group', + fields: [ + text('eyebrow', 'Eyebrow', homeContent.form.typeStep.eyebrow), + text('heading', 'Heading', homeContent.form.typeStep.heading), + text('selfTitle', 'Self-application title', homeContent.form.typeStep.selfTitle), + textarea('selfDesc', 'Self-application description', homeContent.form.typeStep.selfDesc), + text('nominationTitle', 'Nomination title', homeContent.form.typeStep.nominationTitle), + textarea('nominationDesc', 'Nomination description', homeContent.form.typeStep.nominationDesc), + ], + }, + { + name: 'selfSteps', + label: 'Self-application step labels', + type: 'array', + dbName: 'application_form_self_steps', + defaultValue: homeContent.form.selfSteps, + fields: [text('label', 'Label'), iconField()], + }, + { + name: 'nominationSteps', + label: 'Nomination step labels', + type: 'array', + dbName: 'application_form_nom_steps', + defaultValue: homeContent.form.nominationSteps, + fields: [text('label', 'Label'), iconField()], + }, + { + name: 'employeeOptions', + label: 'Employee options', + type: 'array', + dbName: 'application_form_emp_opts', + defaultValue: homeContent.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', homeContent.form.eligibility.allowedEmployeeValues), + text('requiredBayernValue', 'Required Bavaria answer value', homeContent.form.eligibility.requiredBayernValue), + text('eyebrow', 'Eyebrow', homeContent.form.eligibility.eyebrow), + text('heading', 'Heading', homeContent.form.eligibility.heading), + text('employeesLabel', 'Employees field label', homeContent.form.eligibility.employeesLabel), + text('bayernLabel', 'Bavaria field label', homeContent.form.eligibility.bayernLabel), + text('ownerLabel', 'Owner field label', homeContent.form.eligibility.ownerLabel), + text('ineligibleHeading', 'Ineligible heading', homeContent.form.eligibility.ineligibleHeading), + textarea('ineligibleBody', 'Ineligible body', homeContent.form.eligibility.ineligibleBody), + text('ineligibleContactPrefix', 'Ineligible contact prefix', homeContent.form.eligibility.ineligibleContactPrefix), + text('ineligibleContactEmail', 'Ineligible contact email', homeContent.form.eligibility.ineligibleContactEmail), + ], + }, + { + name: 'selfContact', + label: 'Self-application contact step', + type: 'group', + fields: Object.entries(homeContent.form.selfContact).map(([name, value]) => text(name, name, value)), + }, + { + name: 'selfSummary', + label: 'Self-application summary step', + type: 'group', + fields: Object.entries(homeContent.form.selfSummary).map(([name, value]) => text(name, name, value)), + }, + { + name: 'nominationCompany', + label: 'Nomination company step', + type: 'group', + fields: Object.entries(homeContent.form.nominationCompany).map(([name, value]) => text(name, name, value)), + }, + { + name: 'nominationContact', + label: 'Nomination contact step', + type: 'group', + fields: Object.entries(homeContent.form.nominationContact).map(([name, value]) => text(name, name, value)), + }, + { + name: 'nominationSummary', + label: 'Nomination summary step', + type: 'group', + fields: Object.entries(homeContent.form.nominationSummary).map(([name, value]) => text(name, name, value)), + }, + { + name: 'success', + label: 'Success state copy', + type: 'group', + fields: Object.entries(homeContent.form.success).map(([name, value]) => text(name, name, value)), + }, +] + +export const ApplicationForm: GlobalConfig = { + slug: 'application-form', + label: 'Bewerbungsformular', + access: { + read: () => true, + update: authenticated, + }, + admin: { + group: 'Formulare', + }, + fields: [ + { + name: 'section', + label: 'Gemeinsamer Formularbereich', + type: 'group', + admin: { + description: 'Gemeinsamer Bereich für Startseite und Teilnahme-Seite.', + }, + fields: [ + text('eyebrow', 'Eyebrow', homeContent.application.eyebrow), + textarea('heading', 'Heading', homeContent.application.heading), + textarea('body', 'Body copy', homeContent.application.body), + { + name: 'benefits', + label: 'Benefit rows', + type: 'array', + dbName: 'application_form_benefits', + defaultValue: homeContent.application.benefits, + fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')], + }, + { + name: 'awardImage', + type: 'upload', + relationTo: 'media', + label: 'Award image', + admin: { + description: `Current frontend image: /images/${homeContent.application.awardImageFilename}`, + }, + }, + text('awardImageAlt', 'Award image alt text', homeContent.application.awardImageAlt), + text('formEyebrow', 'Form eyebrow', homeContent.application.formEyebrow), + text('formTitle', 'Form title', homeContent.application.formTitle), + text('footnote', 'Footnote normal text', homeContent.application.footnote), + text('footnoteStrong', 'Footnote highlighted text', homeContent.application.footnoteStrong), + ], + }, + { + name: 'form', + label: 'Formularfelder und Logik', + type: 'group', + admin: { + description: 'Gemeinsame Texte, Optionen und Eignungsregeln des mehrstufigen Bewerbungsformulars.', + }, + fields: formFields, + }, + ], +} diff --git a/src/app/(frontend)/[[...slug]]/page.tsx b/src/app/(frontend)/[[...slug]]/page.tsx index 30966e9..3fd3e0b 100644 --- a/src/app/(frontend)/[[...slug]]/page.tsx +++ b/src/app/(frontend)/[[...slug]]/page.tsx @@ -5,6 +5,7 @@ import { draftMode } from 'next/headers' import { notFound } from 'next/navigation' import NextApp from '@/spa/NextApp' +import { normalizeApplicationFormData, type SpaApplicationFormData } from '@/spa/applicationForm' import { normalizeApplicationPhaseData, type SpaApplicationPhaseData } from '@/spa/applicationPhase' import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation' import type { CmsRouteCollection, CmsRouteDoc } from '@/spa/cmsRoute' @@ -104,6 +105,7 @@ export default async function Page({ params }: Args) { header, footer, siteSettings, + applicationForm, applicationPhase, newsletterForm, route, @@ -117,6 +119,7 @@ export default async function Page({ params }: Args) { payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'site-settings', depth: 1 }).catch(() => undefined), + payload.findGlobal({ slug: 'application-form', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'application-phase', depth: 0 }).catch(() => undefined), payload.findGlobal({ slug: 'newsletter-form', depth: 0 }).catch(() => undefined), findRouteDoc(payload, pathname, draft), @@ -136,6 +139,7 @@ export default async function Page({ params }: Args) { header={normalizeHeaderData(header)} footer={normalizeFooterData(footer)} siteSettings={normalizeSiteSettings(siteSettings as Partial | undefined)} + applicationForm={normalizeApplicationFormData(applicationForm as SpaApplicationFormData | undefined)} applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial | undefined)} newsletterForm={normalizeNewsletterFormData(newsletterForm as Partial | undefined)} cmsRoute={{ diff --git a/src/collections/Pages/homeFields.ts b/src/collections/Pages/homeFields.ts index 01971bc..ca0de38 100644 --- a/src/collections/Pages/homeFields.ts +++ b/src/collections/Pages/homeFields.ts @@ -223,7 +223,7 @@ export const homeFields: Field[] = [ name: 'application', label: '07 · Application form section', type: 'group', - admin: sectionAdmin('Final application pitch, award image, embedded form heading, and footnote.'), + admin: { ...sectionAdmin('Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true }, fields: [ text('eyebrow', 'Eyebrow', homeContent.application.eyebrow), textarea('heading', 'Heading', homeContent.application.heading), @@ -242,7 +242,7 @@ export const homeFields: Field[] = [ name: 'form', label: '08 · Embedded application form copy and logic labels', type: 'group', - admin: sectionAdmin('Text, options, and eligibility thresholds used inside the home page multi-step application form.'), + admin: { ...sectionAdmin('Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true }, fields: [ text('stepCompleteLabel', 'Completed step label', homeContent.form.stepCompleteLabel), text('backLabel', 'Back button label', homeContent.form.backLabel), diff --git a/src/collections/Pages/participationFields.ts b/src/collections/Pages/participationFields.ts index 8870164..868dc51 100644 --- a/src/collections/Pages/participationFields.ts +++ b/src/collections/Pages/participationFields.ts @@ -248,6 +248,8 @@ export const participationFields: Field[] = [ text('eyebrow', 'Eyebrow', participationContent.awardsGrid.eyebrow), textarea('heading', 'Heading', participationContent.awardsGrid.heading), textarea('description', 'Intro paragraph', participationContent.awardsGrid.description), + textarea('supportingHeading', 'Other prizes heading', participationContent.awardsGrid.supportingHeading), + textarea('supportingDescription', 'Other prizes subtext', participationContent.awardsGrid.supportingDescription), { name: 'cards', label: 'Award cards', @@ -270,7 +272,7 @@ export const participationFields: Field[] = [ name: 'applicationForm', label: '07 · Application form wrapper', type: 'group', - admin: sectionAdmin('Navy/gold section containing the phase-aware application form or newsletter prompt.'), + admin: { ...sectionAdmin('Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true }, fields: [ uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`), text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt), @@ -297,7 +299,7 @@ export const participationFields: Field[] = [ 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.'), + admin: { ...sectionAdmin('Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true }, fields: [ text('stepCompleteLabel', 'Completed step label', participationContent.form.stepCompleteLabel), text('backLabel', 'Back button label', participationContent.form.backLabel), diff --git a/src/migrations/20260713_155500_shared_application_form.ts b/src/migrations/20260713_155500_shared_application_form.ts new file mode 100644 index 0000000..a406313 --- /dev/null +++ b/src/migrations/20260713_155500_shared_application_form.ts @@ -0,0 +1,191 @@ +import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite' + +import { homeContent } from '@/spa/homeContent' + +type MigrationDB = MigrateUpArgs['db'] + +const ident = (value: string) => `\`${value.replace(/`/g, '``')}\`` +const literal = (value: string) => `'${value.replace(/'/g, "''")}'` +const snake = (value: string) => value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) + +const sectionScalars = [ + ['eyebrow', homeContent.application.eyebrow], + ['heading', homeContent.application.heading], + ['body', homeContent.application.body], + ['awardImageAlt', homeContent.application.awardImageAlt], + ['formEyebrow', homeContent.application.formEyebrow], + ['formTitle', homeContent.application.formTitle], + ['footnote', homeContent.application.footnote], + ['footnoteStrong', homeContent.application.footnoteStrong], +] as const + +const topLevelFormScalars = [ + ['stepCompleteLabel', homeContent.form.stepCompleteLabel], + ['backLabel', homeContent.form.backLabel], + ['nextLabel', homeContent.form.nextLabel], + ['submitLabel', homeContent.form.submitLabel], + ['yesLabel', homeContent.form.yesLabel], + ['noLabel', homeContent.form.noLabel], + ['requiredSuffix', homeContent.form.requiredSuffix], +] as const + +const formGroups = { + validation: homeContent.form.validation, + typeStep: homeContent.form.typeStep, + eligibility: homeContent.form.eligibility, + selfContact: homeContent.form.selfContact, + selfSummary: homeContent.form.selfSummary, + nominationCompany: homeContent.form.nominationCompany, + nominationContact: homeContent.form.nominationContact, + nominationSummary: homeContent.form.nominationSummary, + success: homeContent.form.success, +} as const + +const mainScalarDefinitions = [ + ...sectionScalars.map(([name, value]) => [`section_${snake(name)}`, value] as const), + ...topLevelFormScalars.map(([name, value]) => [`form_${snake(name)}`, value] as const), + ...Object.entries(formGroups).flatMap(([group, values]) => + Object.entries(values).map(([name, value]) => [`form_${snake(group)}_${snake(name)}`, value] as const), + ), +] + +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 createArrayTable( + db: MigrationDB, + tableName: string, + fields: Array<{ name: string; defaultValue?: string }>, +) { + const fieldDefinitions = fields + .map((field) => `${ident(field.name)} text${field.defaultValue ? ` DEFAULT ${literal(field.defaultValue)}` : ''}`) + .join(',\n') + + await db.run(sql.raw(` + CREATE TABLE IF NOT EXISTS ${ident(tableName)} ( + \`_order\` integer NOT NULL, + \`_parent_id\` integer NOT NULL, + \`id\` text PRIMARY KEY NOT NULL, + ${fieldDefinitions}, + FOREIGN KEY (\`_parent_id\`) REFERENCES \`application_form\`(\`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)} (\`_order\`);`)) + await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_parent_id_idx`)} ON ${ident(tableName)} (\`_parent_id\`);`)) +} + +async function seedArrayFromPage( + db: MigrationDB, + target: string, + source: string, + columns: string[], + defaults: ReadonlyArray>, +) { + const sourceExists = await tableExists(db, source) + if (sourceExists) { + const columnList = columns.map(ident).join(', ') + await db.run(sql.raw(` + INSERT OR IGNORE INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, ${columnList}) + SELECT source.\`_order\`, 1, lower(hex(randomblob(16))), ${columns.map((column) => `source.${ident(column)}`).join(', ')} + FROM ${ident(source)} source + INNER JOIN \`pages\` page ON page.\`id\` = source.\`_parent_id\` + WHERE page.\`spa_path\` = '/' OR page.\`slug\` IN ('startseite', 'home') + ORDER BY source.\`_order\`; + `)) + } + + const count = (await db.all(sql.raw(`SELECT COUNT(*) AS count FROM ${ident(target)} WHERE \`_parent_id\` = 1;`))) as Array<{ count: number }> + if (Number(count[0]?.count || 0) > 0) return + + for (const [index, item] of defaults.entries()) { + const values = columns.map((column) => literal(String(item[column] ?? ''))).join(', ') + await db.run(sql.raw(` + INSERT INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, ${columns.map(ident).join(', ')}) + VALUES (${index + 1}, 1, lower(hex(randomblob(16))), ${values}); + `)) + } +} + +export async function up({ db }: MigrateUpArgs): Promise { + const scalarColumns = mainScalarDefinitions + .map(([name, value]) => `${ident(name)} text DEFAULT ${literal(value)}`) + .join(',\n') + + await db.run(sql.raw(` + CREATE TABLE IF NOT EXISTS \`application_form\` ( + \`id\` integer PRIMARY KEY NOT NULL, + ${scalarColumns}, + \`section_award_image_id\` integer, + \`updated_at\` text, + \`created_at\` text, + FOREIGN KEY (\`section_award_image_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null + ); + `)) + await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `application_form_section_section_award_image_idx` ON `application_form` (`section_award_image_id`);')) + + await createArrayTable(db, 'application_form_benefits', [ + { name: 'num' }, + { name: 'label' }, + { name: 'desc' }, + ]) + await createArrayTable(db, 'application_form_self_steps', [ + { name: 'label' }, + { name: 'icon', defaultValue: 'trophy' }, + ]) + await createArrayTable(db, 'application_form_nom_steps', [ + { name: 'label' }, + { name: 'icon', defaultValue: 'trophy' }, + ]) + await createArrayTable(db, 'application_form_emp_opts', [ + { name: 'value' }, + { name: 'label' }, + ]) + + await db.run(sql.raw(` + INSERT OR IGNORE INTO \`application_form\` (\`id\`, \`updated_at\`, \`created_at\`) + VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); + `)) + + if (await columnExists(db, 'pages', 'home_application_eyebrow')) { + const assignments = [ + ...sectionScalars.map(([name]) => [`section_${snake(name)}`, `home_application_${snake(name)}`] as const), + ...topLevelFormScalars.map(([name]) => [`form_${snake(name)}`, `home_form_${snake(name)}`] as const), + ...Object.keys(formGroups).flatMap((group) => + Object.keys(formGroups[group as keyof typeof formGroups]).map((name) => [ + `form_${snake(group)}_${snake(name)}`, + `home_form_${snake(group)}_${snake(name)}`, + ] as const), + ), + ] + .filter(([, source]) => source !== 'home_application_award_image') + .map(([target, source]) => `${ident(target)} = COALESCE((SELECT ${ident(source)} FROM \`pages\` WHERE \`spa_path\` = '/' OR \`slug\` IN ('startseite', 'home') ORDER BY CASE WHEN \`spa_path\` = '/' THEN 0 ELSE 1 END LIMIT 1), ${ident(target)})`) + + if (await columnExists(db, 'pages', 'home_application_award_image_id')) { + assignments.push("`section_award_image_id` = (SELECT `home_application_award_image_id` FROM `pages` WHERE `spa_path` = '/' OR `slug` IN ('startseite', 'home') ORDER BY CASE WHEN `spa_path` = '/' THEN 0 ELSE 1 END LIMIT 1)") + } + await db.run(sql.raw(`UPDATE \`application_form\` SET ${assignments.join(',\n')} WHERE \`id\` = 1;`)) + } + + await seedArrayFromPage(db, 'application_form_benefits', 'pages_home_application_benefits', ['num', 'label', 'desc'], homeContent.application.benefits) + await seedArrayFromPage(db, 'application_form_self_steps', 'home_self_steps', ['label', 'icon'], homeContent.form.selfSteps) + await seedArrayFromPage(db, 'application_form_nom_steps', 'home_nom_steps', ['label', 'icon'], homeContent.form.nominationSteps) + await seedArrayFromPage(db, 'application_form_emp_opts', 'home_emp_opts', ['value', 'label'], homeContent.form.employeeOptions) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.run(sql.raw('DROP TABLE IF EXISTS `application_form_emp_opts`;')) + await db.run(sql.raw('DROP TABLE IF EXISTS `application_form_nom_steps`;')) + await db.run(sql.raw('DROP TABLE IF EXISTS `application_form_self_steps`;')) + await db.run(sql.raw('DROP TABLE IF EXISTS `application_form_benefits`;')) + await db.run(sql.raw('DROP TABLE IF EXISTS `application_form`;')) +} diff --git a/src/migrations/20260713_163000_participation_supporting_awards_copy.ts b/src/migrations/20260713_163000_participation_supporting_awards_copy.ts new file mode 100644 index 0000000..1682ea9 --- /dev/null +++ b/src/migrations/20260713_163000_participation_supporting_awards_copy.ts @@ -0,0 +1,59 @@ +import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite' + +type MigrationDB = MigrateUpArgs['db'] + +const heading = 'WEITERE AUSZEICHNUNGEN.' +const description = + 'Der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award setzen jeweils einen eigenen Schwerpunkt.' + +const columns = [ + { + page: 'participation_awards_grid_supporting_heading', + version: 'version_participation_awards_grid_supporting_heading', + defaultValue: heading, + }, + { + page: 'participation_awards_grid_supporting_description', + version: 'version_participation_awards_grid_supporting_description', + defaultValue: description, + }, +] as const + +const ident = (value: string) => `\`${value.replace(/`/g, '``')}\`` +const literal = (value: string) => `'${value.replace(/'/g, "''")}'` + +async function columnExists(db: MigrationDB, tableName: string, columnName: string) { + const table = (await db.all( + sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`), + )) as Array<{ name: string }> + if (table.length === 0) return false + + const tableColumns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }> + return tableColumns.some((column) => column.name === columnName) +} + +async function addTextColumn(db: MigrationDB, tableName: string, columnName: string, defaultValue: string) { + if (await columnExists(db, tableName, columnName)) return + await db.run( + sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} text DEFAULT ${literal(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)};`)) +} + +export async function up({ db }: MigrateUpArgs): Promise { + for (const column of columns) { + await addTextColumn(db, 'pages', column.page, column.defaultValue) + await addTextColumn(db, '_pages_v', column.version, column.defaultValue) + } +} + +export async function down({ db }: MigrateDownArgs): Promise { + for (const column of [...columns].reverse()) { + await dropColumn(db, '_pages_v', column.version) + await dropColumn(db, 'pages', column.page) + } +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 7a2b361..20072ee 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -10,6 +10,8 @@ import * as migration_20260707_124500_site_settings_preistraeger_placeholder fro import * as migration_20260707_130500_preistraeger_awards_jury_members from './20260707_130500_preistraeger_awards_jury_members'; import * as migration_20260712_120000_partner_media_only from './20260712_120000_partner_media_only'; import * as migration_20260713_133000_newsletter_form_and_subscriptions from './20260713_133000_newsletter_form_and_subscriptions'; +import * as migration_20260713_155500_shared_application_form from './20260713_155500_shared_application_form'; +import * as migration_20260713_163000_participation_supporting_awards_copy from './20260713_163000_participation_supporting_awards_copy'; export const migrations = [ { @@ -72,4 +74,14 @@ export const migrations = [ down: migration_20260713_133000_newsletter_form_and_subscriptions.down, name: '20260713_133000_newsletter_form_and_subscriptions', }, + { + up: migration_20260713_155500_shared_application_form.up, + down: migration_20260713_155500_shared_application_form.down, + name: '20260713_155500_shared_application_form', + }, + { + up: migration_20260713_163000_participation_supporting_awards_copy.up, + down: migration_20260713_163000_participation_supporting_awards_copy.down, + name: '20260713_163000_participation_supporting_awards_copy', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index c61914f..19dd72f 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -114,6 +114,7 @@ export interface Config { footer: Footer; 'site-settings': SiteSetting; 'application-phase': ApplicationPhase; + 'application-form': ApplicationForm; 'newsletter-form': NewsletterForm; }; globalsSelect: { @@ -121,6 +122,7 @@ export interface Config { footer: FooterSelect | FooterSelect; 'site-settings': SiteSettingsSelect | SiteSettingsSelect; 'application-phase': ApplicationPhaseSelect | ApplicationPhaseSelect; + 'application-form': ApplicationFormSelect | ApplicationFormSelect; 'newsletter-form': NewsletterFormSelect | NewsletterFormSelect; }; locale: null; @@ -362,7 +364,7 @@ export interface Page { | null; }; /** - * Final application pitch, award image, embedded form heading, and footnote. + * Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular. */ application?: { eyebrow?: string | null; @@ -388,7 +390,7 @@ export interface Page { footnoteStrong?: string | null; }; /** - * Text, options, and eligibility thresholds used inside the home page multi-step application form. + * Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular. */ form?: { stepCompleteLabel?: string | null; @@ -901,6 +903,8 @@ export interface Page { eyebrow?: string | null; heading?: string | null; description?: string | null; + supportingHeading?: string | null; + supportingDescription?: string | null; cards?: | { label?: string | null; @@ -921,7 +925,7 @@ export interface Page { | null; }; /** - * Navy/gold section containing the phase-aware application form or newsletter prompt. + * Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular. */ applicationForm?: { /** @@ -954,7 +958,7 @@ export interface Page { }; }; /** - * Text, options, and eligibility thresholds used inside the multi-step application form. + * Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular. */ form?: { stepCompleteLabel?: string | null; @@ -3624,6 +3628,8 @@ export interface PagesSelect { eyebrow?: T; heading?: T; description?: T; + supportingHeading?: T; + supportingDescription?: T; cards?: | T | { @@ -5507,6 +5513,161 @@ export interface ApplicationPhase { updatedAt?: string | null; createdAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "application-form". + */ +export interface ApplicationForm { + id: number; + /** + * Gemeinsamer Bereich für Startseite und Teilnahme-Seite. + */ + section?: { + eyebrow?: string | null; + heading?: string | null; + body?: string | null; + benefits?: + | { + num?: string | null; + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + /** + * Current frontend image: /images/preisuebergabe.jpg + */ + awardImage?: (number | null) | Media; + awardImageAlt?: string | null; + formEyebrow?: string | null; + formTitle?: string | null; + footnote?: string | null; + footnoteStrong?: string | null; + }; + /** + * Gemeinsame Texte, Optionen und Eignungsregeln des mehrstufigen Bewerbungsformulars. + */ + 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' | 'send' | 'trophy' | 'user') | null; + id?: string | null; + }[] + | null; + nominationSteps?: + | { + label?: string | null; + icon?: ('alertCircle' | 'arrowRight' | 'building2' | 'check' | 'send' | 'trophy' | 'user') | 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; + }; + }; + updatedAt?: string | null; + createdAt?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "newsletter-form". @@ -5745,6 +5906,174 @@ export interface ApplicationPhaseSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "application-form_select". + */ +export interface ApplicationFormSelect { + section?: + | T + | { + eyebrow?: T; + heading?: T; + body?: T; + benefits?: + | T + | { + num?: T; + label?: T; + desc?: T; + id?: T; + }; + awardImage?: T; + awardImageAlt?: T; + formEyebrow?: T; + formTitle?: T; + footnote?: T; + footnoteStrong?: 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; + }; + }; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "newsletter-form_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index ef9db80..7257647 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -14,6 +14,7 @@ import { Posts } from './collections/Posts' import { Preistraeger } from './collections/Preistraeger' import { Users } from './collections/Users' import { ApplicationPhase } from './ApplicationPhase/config' +import { ApplicationForm } from './ApplicationForm/config' import { Footer } from './Footer/config' import { Header } from './Header/config' import { NewsletterForm } from './NewsletterForm/config' @@ -71,7 +72,7 @@ export default buildConfig({ }), collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, NewsletterSubscriptions, Media, Users], cors: [getServerSideURL()].filter(Boolean), - globals: [Header, Footer, SiteSettings, ApplicationPhase, NewsletterForm], + globals: [Header, Footer, SiteSettings, ApplicationPhase, ApplicationForm, NewsletterForm], plugins, secret: process.env.PAYLOAD_SECRET, sharp, diff --git a/src/scripts/preload-globals-cms.ts b/src/scripts/preload-globals-cms.ts index 82f2f13..3f86346 100644 --- a/src/scripts/preload-globals-cms.ts +++ b/src/scripts/preload-globals-cms.ts @@ -4,6 +4,7 @@ import config from '@payload-config' import { getPayload } from 'payload' import { mediaByFilename } from './mediaByFilename' +import { defaultApplicationFormData } from '@/spa/applicationForm' import { defaultApplicationPhaseData } from '@/spa/applicationPhase' import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation' import { defaultNewsletterFormData } from '@/spa/newsletterForm' @@ -12,11 +13,12 @@ import { defaultSiteSettings } from '@/spa/siteSettings' async function main() { const payload = await getPayload({ config }) - const [logo, ewifLogo, partnerLogos, skylineImage] = await Promise.all([ + const [logo, ewifLogo, partnerLogos, skylineImage, applicationFormImage] = await Promise.all([ mediaByFilename(payload, 'bmp-logo.png'), mediaByFilename(payload, 'ewif-logo.png'), mediaByFilename(payload, 'partner-logos.png'), mediaByFilename(payload, 'munich-skyline.jpg'), + mediaByFilename(payload, 'preisuebergabe.jpg'), ]) const { logo: _headerLogo, ...headerData } = defaultHeaderData @@ -60,6 +62,17 @@ async function main() { context: { disableRevalidate: true }, data: defaultApplicationPhaseData as never, }), + payload.updateGlobal({ + slug: 'application-form', + context: { disableRevalidate: true }, + data: { + ...defaultApplicationFormData, + section: { + ...defaultApplicationFormData.section, + awardImage: applicationFormImage, + }, + } as never, + }), payload.updateGlobal({ slug: 'newsletter-form', context: { disableRevalidate: true }, diff --git a/src/spa/NextApp.tsx b/src/spa/NextApp.tsx index 0e2470b..5f8239b 100644 --- a/src/spa/NextApp.tsx +++ b/src/spa/NextApp.tsx @@ -4,6 +4,7 @@ import React from 'react' import Layout from '@/spa/components/layout/Layout' import ScrollToTop from '@/spa/components/layout/ScrollToTop' +import type { SpaApplicationFormData } from '@/spa/applicationForm' import type { SpaApplicationPhaseData } from '@/spa/applicationPhase' import { type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation' import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute' @@ -62,6 +63,7 @@ export default function NextApp({ footer, cmsRoute, siteSettings, + applicationForm, applicationPhase, newsletterForm, }: { @@ -70,6 +72,7 @@ export default function NextApp({ footer?: SpaFooterData cmsRoute?: CmsRouteData siteSettings?: SpaSiteSettingsData + applicationForm?: SpaApplicationFormData applicationPhase?: SpaApplicationPhaseData newsletterForm?: SpaNewsletterFormData }) { @@ -77,7 +80,7 @@ export default function NextApp({ return ( <> - + {page} diff --git a/src/spa/applicationForm.ts b/src/spa/applicationForm.ts new file mode 100644 index 0000000..b644c37 --- /dev/null +++ b/src/spa/applicationForm.ts @@ -0,0 +1,21 @@ +import { homeContent } from '@/spa/homeContent' + +export type SpaApplicationFormData = { + section?: Partial & { awardImage?: unknown } + form?: Partial +} + +export const defaultApplicationFormData: SpaApplicationFormData = { + section: homeContent.application, + form: homeContent.form, +} + +export function normalizeApplicationFormData(value?: SpaApplicationFormData | null): SpaApplicationFormData { + return { + section: { + ...homeContent.application, + ...(value?.section || {}), + }, + form: value?.form || homeContent.form, + } +} diff --git a/src/spa/awardArticles.ts b/src/spa/awardArticles.ts index be6defa..b6b2a10 100644 --- a/src/spa/awardArticles.ts +++ b/src/spa/awardArticles.ts @@ -176,6 +176,9 @@ export const participationAwardsGrid = { heading: 'DREI AUSZEICHNUNGEN.\nEIN ANSPRUCH.', description: 'Direkt im Bewerbungsjahr 2026 stehen drei Auszeichnungen im Fokus: die Goldene Bavaria, der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award.', + supportingHeading: 'WEITERE AUSZEICHNUNGEN.', + supportingDescription: + 'Der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award setzen jeweils einen eigenen Schwerpunkt.', cards: awardArticles.map(({ imageFilename, imageAlt, card, articleCta, mailtoCta }) => ({ ...card, imageFilename, diff --git a/src/spa/cmsRoute.tsx b/src/spa/cmsRoute.tsx index 066f661..2eb102b 100644 --- a/src/spa/cmsRoute.tsx +++ b/src/spa/cmsRoute.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext } from 'react' +import type { SpaApplicationFormData } from '@/spa/applicationForm' import type { SpaApplicationPhaseData } from '@/spa/applicationPhase' import type { SpaNewsletterFormData } from '@/spa/newsletterForm' import type { SpaSiteSettingsData } from '@/spa/siteSettings' @@ -26,6 +27,7 @@ export type CmsRouteData = { doc: CmsRouteDoc lists?: Partial> siteSettings?: SpaSiteSettingsData + applicationForm?: SpaApplicationFormData applicationPhase?: SpaApplicationPhaseData newsletterForm?: SpaNewsletterFormData } @@ -56,6 +58,10 @@ export function useApplicationPhase() { return useCmsRoute()?.applicationPhase } +export function useApplicationForm() { + return useCmsRoute()?.applicationForm +} + export function useNewsletterForm() { return useCmsRoute()?.newsletterForm } diff --git a/src/spa/components/AwardsGridSection.tsx b/src/spa/components/AwardsGridSection.tsx index 91923a1..8fb1db0 100644 --- a/src/spa/components/AwardsGridSection.tsx +++ b/src/spa/components/AwardsGridSection.tsx @@ -318,6 +318,16 @@ export default function AwardsGridSection({ content, featuredGoldeneBavaria = fa {featuredCard && supportingCards.length > 0 ? (
+
+
+

+ +

+

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

+
+
) : null} diff --git a/src/spa/components/WegZurAuszeichnungSection.tsx b/src/spa/components/WegZurAuszeichnungSection.tsx index a1b8fff..c348967 100644 --- a/src/spa/components/WegZurAuszeichnungSection.tsx +++ b/src/spa/components/WegZurAuszeichnungSection.tsx @@ -14,6 +14,7 @@ const FF = '"IBM Plex Sans", sans-serif'; const FB = '"Inter", sans-serif'; const NAVY = '#111D55'; const GOLD = '#EFBF04'; +const CARD_BACKGROUND = '#FFF8E1'; type ProcessContent = Partial; @@ -43,14 +44,14 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce // ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ── if (isMobile) { return ( -
+
{/* Section header */}
- {fallbackText(section.eyebrow, participationContent.process.eyebrow)} -

+ {fallbackText(section.eyebrow, participationContent.process.eyebrow)} +

-

+

{fallbackText(section.description, participationContent.process.description)}

@@ -60,29 +61,29 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce {steps.map((item, index) => { const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText; return ( -
+
-
- +
+
- {stepPrefix} {fallbackText(item.step, '')} - - {fallbackText(item.date, '')} + {stepPrefix} {fallbackText(item.step, '')} + + {fallbackText(item.date, '')}
-

+

{fallbackText(item.title, '')}

-

+

{fallbackText(item.desc, '')}

{/* Step progress indicator */}
{steps.map((_, i) => ( -
+
))}
@@ -94,18 +95,18 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce } return ( - + {/* Section header */} -
+
- {fallbackText(section.eyebrow, participationContent.process.eyebrow)} -

+ {fallbackText(section.eyebrow, participationContent.process.eyebrow)} +

-

+

{fallbackText(section.description, participationContent.process.description)}

@@ -120,18 +121,18 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce width="12" height="7" viewBox="0 0 12 7" fill="none" style={{ opacity: 1 - i * 0.3, animation: `scrollBounce 1.6s ease-in-out ${i * 0.18}s infinite` }} > - + ))}
- + {fallbackText(section.scrollHint, participationContent.process.scrollHint)} {/* Progress bar */} -
-
+
+
- {fallbackText(section.progressLabel, participationContent.process.progressLabel)} + {fallbackText(section.progressLabel, participationContent.process.progressLabel)}
{/* Cards strip */} @@ -144,14 +145,14 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce itemsLength={steps.length} index={index} variant="bmp" - className="min-w-[70%] max-w-[70%] flex-shrink-0" + className="min-w-[70%] max-w-[70%] flex-shrink-0 bg-[#FFF8E1] text-[#101828] border-[rgba(17,29,85,0.16)]" > {/* Left accent strip with step number */} - -
- + +
+
- + {fallbackText(item.step, '')}
@@ -160,24 +161,24 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
- + {stepPrefix} {fallbackText(item.step, '')} - - {fallbackText(item.date, '')} + + {fallbackText(item.date, '')}
-

+

{fallbackText(item.title, '')}

-

+

{fallbackText(item.desc, '')}

{/* Step progress indicator */}
{steps.map((_, i) => ( -
+
))}
diff --git a/src/spa/components/forms/ApplicationFormSection.tsx b/src/spa/components/forms/ApplicationFormSection.tsx new file mode 100644 index 0000000..63cf790 --- /dev/null +++ b/src/spa/components/forms/ApplicationFormSection.tsx @@ -0,0 +1,115 @@ +'use client' + +import React from 'react' + +import BewerbungsForm from '@/spa/components/forms/BewerbungsForm' +import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm' +import Image from '@/spa/components/ui/UnoptimizedImage' +import { useApplicationForm, useApplicationPhase, useNewsletterForm } from '@/spa/cmsRoute' +import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField' +import { homeContent } from '@/spa/homeContent' +import { useIsMobile } from '@/spa/hooks/useIsMobile' +import { + newsletterFormCopyForPhase, + newsletterPhaseForApplicationPhase, + type NewsletterBenefit, + type NewsletterSource, +} from '@/spa/newsletterForm' + +const FF = '"IBM Plex Sans", sans-serif' +const APPLICATION_PHASE_OPEN = '0' + +const fallbackText = (value: unknown, fallback: string) => + typeof value === 'string' && value.length > 0 ? value : fallback + +const fallbackArray = (value: unknown, fallback: readonly T[]) => + Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback + +function Lines({ text }: { text: string }) { + return <>{text.split('\n').map((line, index) => {index > 0 &&
}{line}
)} +} + +export function ApplicationFormSection({ source }: { source: NewsletterSource }) { + const isMobile = useIsMobile() + const sharedForm = useApplicationForm() + const applicationPhase = useApplicationPhase() + const newsletterForm = useNewsletterForm() + const section = { ...homeContent.application, ...(sharedForm?.section || {}) } + const form = sharedForm?.form || homeContent.form + const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN + const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN + const newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase) + const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase) + const benefits = fallbackArray(section.benefits, homeContent.application.benefits) + const finalBenefits = showApplicationForm ? benefits : newsletterCopy.benefits + + return ( +
+
+ +
+
+ {showApplicationForm ? fallbackText(section.eyebrow, homeContent.application.eyebrow) : newsletterCopy.eyebrow} +

+ +

+
+

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

+ +
+ {finalBenefits.map((item) => ( +
+ {item.num} +
+ {item.label} + {item.desc} +
+
+ ))} +
+
+ + {!isMobile &&
} + +
+ +
+ + {!isMobile && ( +
+ {mediaAlt(section.awardImage, +
+
+
+ )} + +
+ {showApplicationForm ? fallbackText(section.formEyebrow, homeContent.application.formEyebrow) : newsletterCopy.formEyebrow} +

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

+ {showApplicationForm ? : } +
+
+
+ +
+ {showApplicationForm ? fallbackText(section.footnote, homeContent.application.footnote) : newsletterCopy.footnote} + {showApplicationForm ? fallbackText(section.footnoteStrong, homeContent.application.footnoteStrong) : newsletterCopy.footnoteStrong} +
+
+
+ ) +} diff --git a/src/spa/pages/Home.tsx b/src/spa/pages/Home.tsx index 0079620..d9d6062 100644 --- a/src/spa/pages/Home.tsx +++ b/src/spa/pages/Home.tsx @@ -4,21 +4,19 @@ import { Play, ChevronLeft, ChevronRight, X, Star } from 'lucide-react'; 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 } from '@/spa/components/forms/NewsletterInterestForm'; +import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection'; import TestimonialsSection from '@/spa/components/TestimonialsSection'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; import Image from '@/spa/components/ui/UnoptimizedImage' import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase' -import { useApplicationPhase, useCmsCollection, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute' +import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute' import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField' import { homeContent } from '@/spa/homeContent' import { resolveHomeWinners } from '@/spa/homeWinnerSelection' import { netzwerkContent } from '@/spa/netzwerkContent' import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder' import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo' -import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm' const FF = '"IBM Plex Sans", sans-serif'; @@ -82,7 +80,6 @@ type HomeTestimonialsCms = { type HomeCms = { application?: HomeSectionCms; benefits?: HomeSectionCms; - form?: Partial; hero?: HomeHeroCms; intro?: HomeSectionCms; quickCheck?: HomeSectionCms; @@ -117,7 +114,6 @@ 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 STATUS_SECTION_BACKGROUND = '#111D55'; const LEGACY_STATUS_SECTION_BACKGROUNDS = new Set(['#020A1E', '#060D1C', '#06090F']); @@ -242,15 +238,8 @@ const Home: React.FC = () => { const intro = home.intro || {}; const winnersSection = home.winners || {}; const benefits = home.benefits || {}; - const application = home.application || {}; - const form = home.form || {}; const videoModal = home.videoModal || {}; const applicationPhase = useApplicationPhase(); - const newsletterForm = useNewsletterForm(); - const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN; - const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN; - const newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase); - const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase); const cmsPreistraeger = useCmsCollection('preistraeger'); const heroSecondaryCta = { label: fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label), @@ -263,8 +252,6 @@ const Home: React.FC = () => { const quickCriteria = fallbackArray(quickCheck.criteria, homeContent.quickCheck.criteria); 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 = placeholderImage; const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title); const videoSrc = mediaUrl(videoModal.video, ''); @@ -662,92 +649,7 @@ const Home: React.FC = () => {
- {/* CTA Section – with phase-aware embedded form */} -
- {/* Subtle radial glow behind left copy */} -
- -
- - {/* Left – pitch copy */} -
- {showApplicationForm ? fallbackText(application.eyebrow, homeContent.application.eyebrow) : newsletterCopy.eyebrow} -

- -

-
-

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

- - {/* Benefit rows */} -
- {finalSectionBenefits.map(item => ( -
- {item.num} -
- {item.label} - {item.desc} -
-
- ))} -
-
- - {/* Divider */} - {!isMobile &&
} - - {/* Right – gold premium panel */} -
- - {/* Premium award crosshatch pattern */} - - - {/* Inner vignette – edges slightly deeper gold */} -
- - {/* Award ceremony photo */} - {!isMobile && ( -
- {mediaAlt(application.awardImage, - {/* Blend photo into gold panel */} -
-
-
-
- {fallbackText(application.awardCaption, homeContent.application.awardCaption)} -
-
- )} - - {/* Form area */} -
- {showApplicationForm ? fallbackText(application.formEyebrow, homeContent.application.formEyebrow) : newsletterCopy.formEyebrow} -

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

- {showApplicationForm ? : } -
-
- -
- - {/* Footnote row */} -
- {showApplicationForm ? fallbackText(application.footnote, homeContent.application.footnote) : newsletterCopy.footnote} - {showApplicationForm ? fallbackText(application.footnoteStrong, homeContent.application.footnoteStrong) : newsletterCopy.footnoteStrong} -
-
-
+ {/* Video Modal */} {showVideo && ( diff --git a/src/spa/pages/Participation.tsx b/src/spa/pages/Participation.tsx index f559813..67e9320 100644 --- a/src/spa/pages/Participation.tsx +++ b/src/spa/pages/Participation.tsx @@ -1,16 +1,14 @@ import React, { useRef } from '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 } from '@/spa/components/forms/NewsletterInterestForm'; +import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection'; import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection'; import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; -import { useApplicationPhase, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute'; +import { useCmsRoute } from '@/spa/cmsRoute'; import { participationContent } from '@/spa/participationContent'; import Image from '@/spa/components/ui/UnoptimizedImage' -import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm'; const FF = '"IBM Plex Sans", sans-serif'; const FB = '"Inter", sans-serif'; @@ -20,11 +18,6 @@ const GOLD = '#EFBF04'; type ParticipationCms = Partial & { hero?: Partial & { backgroundImage?: unknown }; awardsGrid?: AwardsGridContent; - applicationForm?: ParticipationApplicationFormCms; -}; - -type ParticipationApplicationFormCms = Partial & { - image?: unknown; }; const ICONS = { @@ -43,8 +36,6 @@ const fallbackText = (value: unknown, fallback: string) => const fallbackArray = (value: unknown, fallback: readonly T[]) => Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; -const APPLICATION_PHASE_OPEN = '0'; - 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() : ''; @@ -60,26 +51,16 @@ const Participation: React.FC = () => { const isMobile = useIsMobile(); const qualSectionRef = useRef(null); const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms; - const applicationPhase = useApplicationPhase(); - const newsletterForm = useNewsletterForm(); const hero = { ...participationContent.hero, ...(cms.hero || {}) }; const process = { ...participationContent.process, ...(cms.process || {}) }; const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) }; const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) }; const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) }; 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 = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase); - const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase); 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 (
@@ -219,98 +200,7 @@ const Participation: React.FC = () => { {/* ── AWARDS GRID ─────────────────────────────────────────────────────── */} - {/* ── FORM SECTION ─────────────────────────────────────────────────────── */} -
-
- {/* Left – copy */} -
- {showApplicationForm ? fallbackText(applicationForm.eyebrow, participationContent.applicationForm.eyebrow) : newsletterCopy.eyebrow} -

- -

-
-

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

-
- {finalSectionFacts.map((item, i) => ( -
- {fallbackText(item.num, '')} -
- {fallbackText(item.label, '')} - {fallbackText(item.desc, '')} -
-
- ))} -
-
- - {/* Divider */} - {!isMobile &&
} - - {/* Right – gold form panel */} -
- -
- - {!isMobile && ( -
- {mediaAlt(applicationForm.image, -
-
-
-
- {fallbackText(applicationForm.imageCaption, participationContent.applicationForm.imageCaption)} -
-
- )} -
- {showApplicationForm ? ( - <> -
- {fallbackText(applicationForm.formEyebrow, participationContent.applicationForm.formEyebrow)} -
-

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

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

{newsletterCopy.formTitle}

- - - )} -
-
-
- -
- {showApplicationForm ? ( - <> - - {fallbackText(applicationForm.uploadPrompt, participationContent.applicationForm.uploadPrompt)} - - - ) : ( -
- {newsletterCopy.footnote} - {newsletterCopy.footnoteStrong} -
- )} -
-
-
+
); };