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.
This commit is contained in:
syntaxbullet
2026-07-13 16:07:58 +02:00
parent ee7e891a7d
commit 9ebe544987
19 changed files with 1026 additions and 259 deletions

View File

@@ -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,
},
],
}

View File

@@ -5,6 +5,7 @@ import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import NextApp from '@/spa/NextApp' import NextApp from '@/spa/NextApp'
import { normalizeApplicationFormData, type SpaApplicationFormData } from '@/spa/applicationForm'
import { normalizeApplicationPhaseData, type SpaApplicationPhaseData } from '@/spa/applicationPhase' import { normalizeApplicationPhaseData, type SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation' import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
import type { CmsRouteCollection, CmsRouteDoc } from '@/spa/cmsRoute' import type { CmsRouteCollection, CmsRouteDoc } from '@/spa/cmsRoute'
@@ -104,6 +105,7 @@ export default async function Page({ params }: Args) {
header, header,
footer, footer,
siteSettings, siteSettings,
applicationForm,
applicationPhase, applicationPhase,
newsletterForm, newsletterForm,
route, route,
@@ -117,6 +119,7 @@ export default async function Page({ params }: Args) {
payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'site-settings', 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: 'application-phase', depth: 0 }).catch(() => undefined),
payload.findGlobal({ slug: 'newsletter-form', depth: 0 }).catch(() => undefined), payload.findGlobal({ slug: 'newsletter-form', depth: 0 }).catch(() => undefined),
findRouteDoc(payload, pathname, draft), findRouteDoc(payload, pathname, draft),
@@ -136,6 +139,7 @@ export default async function Page({ params }: Args) {
header={normalizeHeaderData(header)} header={normalizeHeaderData(header)}
footer={normalizeFooterData(footer)} footer={normalizeFooterData(footer)}
siteSettings={normalizeSiteSettings(siteSettings as Partial<SpaSiteSettingsData> | undefined)} siteSettings={normalizeSiteSettings(siteSettings as Partial<SpaSiteSettingsData> | undefined)}
applicationForm={normalizeApplicationFormData(applicationForm as SpaApplicationFormData | undefined)}
applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial<SpaApplicationPhaseData> | undefined)} applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial<SpaApplicationPhaseData> | undefined)}
newsletterForm={normalizeNewsletterFormData(newsletterForm as Partial<SpaNewsletterFormData> | undefined)} newsletterForm={normalizeNewsletterFormData(newsletterForm as Partial<SpaNewsletterFormData> | undefined)}
cmsRoute={{ cmsRoute={{

View File

@@ -223,7 +223,7 @@ export const homeFields: Field[] = [
name: 'application', name: 'application',
label: '07 · Application form section', label: '07 · Application form section',
type: 'group', 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: [ fields: [
text('eyebrow', 'Eyebrow', homeContent.application.eyebrow), text('eyebrow', 'Eyebrow', homeContent.application.eyebrow),
textarea('heading', 'Heading', homeContent.application.heading), textarea('heading', 'Heading', homeContent.application.heading),
@@ -242,7 +242,7 @@ export const homeFields: Field[] = [
name: 'form', name: 'form',
label: '08 · Embedded application form copy and logic labels', label: '08 · Embedded application form copy and logic labels',
type: 'group', 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: [ fields: [
text('stepCompleteLabel', 'Completed step label', homeContent.form.stepCompleteLabel), text('stepCompleteLabel', 'Completed step label', homeContent.form.stepCompleteLabel),
text('backLabel', 'Back button label', homeContent.form.backLabel), text('backLabel', 'Back button label', homeContent.form.backLabel),

View File

@@ -248,6 +248,8 @@ export const participationFields: Field[] = [
text('eyebrow', 'Eyebrow', participationContent.awardsGrid.eyebrow), text('eyebrow', 'Eyebrow', participationContent.awardsGrid.eyebrow),
textarea('heading', 'Heading', participationContent.awardsGrid.heading), textarea('heading', 'Heading', participationContent.awardsGrid.heading),
textarea('description', 'Intro paragraph', participationContent.awardsGrid.description), 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', name: 'cards',
label: 'Award cards', label: 'Award cards',
@@ -270,7 +272,7 @@ export const participationFields: Field[] = [
name: 'applicationForm', name: 'applicationForm',
label: '07 · Application form wrapper', label: '07 · Application form wrapper',
type: 'group', 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: [ fields: [
uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`), uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`),
text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt), text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt),
@@ -297,7 +299,7 @@ export const participationFields: Field[] = [
name: 'form', name: 'form',
label: '08 · Application form copy and logic labels', label: '08 · Application form copy and logic labels',
type: 'group', 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: [ fields: [
text('stepCompleteLabel', 'Completed step label', participationContent.form.stepCompleteLabel), text('stepCompleteLabel', 'Completed step label', participationContent.form.stepCompleteLabel),
text('backLabel', 'Back button label', participationContent.form.backLabel), text('backLabel', 'Back button label', participationContent.form.backLabel),

View File

@@ -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<Record<string, unknown>>,
) {
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<void> {
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<void> {
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`;'))
}

View File

@@ -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<void> {
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<void> {
for (const column of [...columns].reverse()) {
await dropColumn(db, '_pages_v', column.version)
await dropColumn(db, 'pages', column.page)
}
}

View File

@@ -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_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_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_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 = [ export const migrations = [
{ {
@@ -72,4 +74,14 @@ export const migrations = [
down: migration_20260713_133000_newsletter_form_and_subscriptions.down, down: migration_20260713_133000_newsletter_form_and_subscriptions.down,
name: '20260713_133000_newsletter_form_and_subscriptions', 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',
},
]; ];

View File

@@ -114,6 +114,7 @@ export interface Config {
footer: Footer; footer: Footer;
'site-settings': SiteSetting; 'site-settings': SiteSetting;
'application-phase': ApplicationPhase; 'application-phase': ApplicationPhase;
'application-form': ApplicationForm;
'newsletter-form': NewsletterForm; 'newsletter-form': NewsletterForm;
}; };
globalsSelect: { globalsSelect: {
@@ -121,6 +122,7 @@ export interface Config {
footer: FooterSelect<false> | FooterSelect<true>; footer: FooterSelect<false> | FooterSelect<true>;
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>; 'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
'application-phase': ApplicationPhaseSelect<false> | ApplicationPhaseSelect<true>; 'application-phase': ApplicationPhaseSelect<false> | ApplicationPhaseSelect<true>;
'application-form': ApplicationFormSelect<false> | ApplicationFormSelect<true>;
'newsletter-form': NewsletterFormSelect<false> | NewsletterFormSelect<true>; 'newsletter-form': NewsletterFormSelect<false> | NewsletterFormSelect<true>;
}; };
locale: null; locale: null;
@@ -362,7 +364,7 @@ export interface Page {
| null; | null;
}; };
/** /**
* Final application pitch, award image, embedded form heading, and footnote. * Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.
*/ */
application?: { application?: {
eyebrow?: string | null; eyebrow?: string | null;
@@ -388,7 +390,7 @@ export interface Page {
footnoteStrong?: string | null; 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?: { form?: {
stepCompleteLabel?: string | null; stepCompleteLabel?: string | null;
@@ -901,6 +903,8 @@ export interface Page {
eyebrow?: string | null; eyebrow?: string | null;
heading?: string | null; heading?: string | null;
description?: string | null; description?: string | null;
supportingHeading?: string | null;
supportingDescription?: string | null;
cards?: cards?:
| { | {
label?: string | null; label?: string | null;
@@ -921,7 +925,7 @@ export interface Page {
| null; | 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?: { 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?: { form?: {
stepCompleteLabel?: string | null; stepCompleteLabel?: string | null;
@@ -3624,6 +3628,8 @@ export interface PagesSelect<T extends boolean = true> {
eyebrow?: T; eyebrow?: T;
heading?: T; heading?: T;
description?: T; description?: T;
supportingHeading?: T;
supportingDescription?: T;
cards?: cards?:
| T | T
| { | {
@@ -5507,6 +5513,161 @@ export interface ApplicationPhase {
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "newsletter-form". * via the `definition` "newsletter-form".
@@ -5745,6 +5906,174 @@ export interface ApplicationPhaseSelect<T extends boolean = true> {
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "application-form_select".
*/
export interface ApplicationFormSelect<T extends boolean = true> {
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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "newsletter-form_select". * via the `definition` "newsletter-form_select".

View File

@@ -14,6 +14,7 @@ import { Posts } from './collections/Posts'
import { Preistraeger } from './collections/Preistraeger' import { Preistraeger } from './collections/Preistraeger'
import { Users } from './collections/Users' import { Users } from './collections/Users'
import { ApplicationPhase } from './ApplicationPhase/config' import { ApplicationPhase } from './ApplicationPhase/config'
import { ApplicationForm } from './ApplicationForm/config'
import { Footer } from './Footer/config' import { Footer } from './Footer/config'
import { Header } from './Header/config' import { Header } from './Header/config'
import { NewsletterForm } from './NewsletterForm/config' import { NewsletterForm } from './NewsletterForm/config'
@@ -71,7 +72,7 @@ export default buildConfig({
}), }),
collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, NewsletterSubscriptions, Media, Users], collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, NewsletterSubscriptions, Media, Users],
cors: [getServerSideURL()].filter(Boolean), cors: [getServerSideURL()].filter(Boolean),
globals: [Header, Footer, SiteSettings, ApplicationPhase, NewsletterForm], globals: [Header, Footer, SiteSettings, ApplicationPhase, ApplicationForm, NewsletterForm],
plugins, plugins,
secret: process.env.PAYLOAD_SECRET, secret: process.env.PAYLOAD_SECRET,
sharp, sharp,

View File

@@ -4,6 +4,7 @@ import config from '@payload-config'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { mediaByFilename } from './mediaByFilename' import { mediaByFilename } from './mediaByFilename'
import { defaultApplicationFormData } from '@/spa/applicationForm'
import { defaultApplicationPhaseData } from '@/spa/applicationPhase' import { defaultApplicationPhaseData } from '@/spa/applicationPhase'
import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation' import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation'
import { defaultNewsletterFormData } from '@/spa/newsletterForm' import { defaultNewsletterFormData } from '@/spa/newsletterForm'
@@ -12,11 +13,12 @@ import { defaultSiteSettings } from '@/spa/siteSettings'
async function main() { async function main() {
const payload = await getPayload({ config }) 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, 'bmp-logo.png'),
mediaByFilename(payload, 'ewif-logo.png'), mediaByFilename(payload, 'ewif-logo.png'),
mediaByFilename(payload, 'partner-logos.png'), mediaByFilename(payload, 'partner-logos.png'),
mediaByFilename(payload, 'munich-skyline.jpg'), mediaByFilename(payload, 'munich-skyline.jpg'),
mediaByFilename(payload, 'preisuebergabe.jpg'),
]) ])
const { logo: _headerLogo, ...headerData } = defaultHeaderData const { logo: _headerLogo, ...headerData } = defaultHeaderData
@@ -60,6 +62,17 @@ async function main() {
context: { disableRevalidate: true }, context: { disableRevalidate: true },
data: defaultApplicationPhaseData as never, data: defaultApplicationPhaseData as never,
}), }),
payload.updateGlobal({
slug: 'application-form',
context: { disableRevalidate: true },
data: {
...defaultApplicationFormData,
section: {
...defaultApplicationFormData.section,
awardImage: applicationFormImage,
},
} as never,
}),
payload.updateGlobal({ payload.updateGlobal({
slug: 'newsletter-form', slug: 'newsletter-form',
context: { disableRevalidate: true }, context: { disableRevalidate: true },

View File

@@ -4,6 +4,7 @@ import React from 'react'
import Layout from '@/spa/components/layout/Layout' import Layout from '@/spa/components/layout/Layout'
import ScrollToTop from '@/spa/components/layout/ScrollToTop' import ScrollToTop from '@/spa/components/layout/ScrollToTop'
import type { SpaApplicationFormData } from '@/spa/applicationForm'
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase' import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation' import { type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute' import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
@@ -62,6 +63,7 @@ export default function NextApp({
footer, footer,
cmsRoute, cmsRoute,
siteSettings, siteSettings,
applicationForm,
applicationPhase, applicationPhase,
newsletterForm, newsletterForm,
}: { }: {
@@ -70,6 +72,7 @@ export default function NextApp({
footer?: SpaFooterData footer?: SpaFooterData
cmsRoute?: CmsRouteData cmsRoute?: CmsRouteData
siteSettings?: SpaSiteSettingsData siteSettings?: SpaSiteSettingsData
applicationForm?: SpaApplicationFormData
applicationPhase?: SpaApplicationPhaseData applicationPhase?: SpaApplicationPhaseData
newsletterForm?: SpaNewsletterFormData newsletterForm?: SpaNewsletterFormData
}) { }) {
@@ -77,7 +80,7 @@ export default function NextApp({
return ( return (
<> <>
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationPhase, newsletterForm } : undefined}> <CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationForm, applicationPhase, newsletterForm } : undefined}>
<ScrollToTop /> <ScrollToTop />
<Layout header={header} footer={footer}> <Layout header={header} footer={footer}>
{page} {page}

View File

@@ -0,0 +1,21 @@
import { homeContent } from '@/spa/homeContent'
export type SpaApplicationFormData = {
section?: Partial<typeof homeContent.application> & { awardImage?: unknown }
form?: Partial<typeof homeContent.form>
}
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,
}
}

View File

@@ -176,6 +176,9 @@ export const participationAwardsGrid = {
heading: 'DREI AUSZEICHNUNGEN.\nEIN ANSPRUCH.', heading: 'DREI AUSZEICHNUNGEN.\nEIN ANSPRUCH.',
description: description:
'Direkt im Bewerbungsjahr 2026 stehen drei Auszeichnungen im Fokus: die Goldene Bavaria, der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award.', '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 }) => ({ cards: awardArticles.map(({ imageFilename, imageAlt, card, articleCta, mailtoCta }) => ({
...card, ...card,
imageFilename, imageFilename,

View File

@@ -2,6 +2,7 @@
import React, { createContext, useContext } from 'react' import React, { createContext, useContext } from 'react'
import type { SpaApplicationFormData } from '@/spa/applicationForm'
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase' import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import type { SpaNewsletterFormData } from '@/spa/newsletterForm' import type { SpaNewsletterFormData } from '@/spa/newsletterForm'
import type { SpaSiteSettingsData } from '@/spa/siteSettings' import type { SpaSiteSettingsData } from '@/spa/siteSettings'
@@ -26,6 +27,7 @@ export type CmsRouteData = {
doc: CmsRouteDoc doc: CmsRouteDoc
lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>> lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>>
siteSettings?: SpaSiteSettingsData siteSettings?: SpaSiteSettingsData
applicationForm?: SpaApplicationFormData
applicationPhase?: SpaApplicationPhaseData applicationPhase?: SpaApplicationPhaseData
newsletterForm?: SpaNewsletterFormData newsletterForm?: SpaNewsletterFormData
} }
@@ -56,6 +58,10 @@ export function useApplicationPhase() {
return useCmsRoute()?.applicationPhase return useCmsRoute()?.applicationPhase
} }
export function useApplicationForm() {
return useCmsRoute()?.applicationForm
}
export function useNewsletterForm() { export function useNewsletterForm() {
return useCmsRoute()?.newsletterForm return useCmsRoute()?.newsletterForm
} }

View File

@@ -318,6 +318,16 @@ export default function AwardsGridSection({ content, featuredGoldeneBavaria = fa
{featuredCard && supportingCards.length > 0 ? ( {featuredCard && supportingCards.length > 0 ? (
<section aria-label="Weitere Auszeichnungen" style={{ background: '#fff', position: 'relative', overflow: 'hidden', padding: isMobile ? '40px 0 56px' : '64px 0 80px', borderTop: '1px solid rgba(17,29,85,0.08)' }}> <section aria-label="Weitere Auszeichnungen" style={{ background: '#fff', position: 'relative', overflow: 'hidden', padding: isMobile ? '40px 0 56px' : '64px 0 80px', borderTop: '1px solid rgba(17,29,85,0.08)' }}>
<div style={{ padding: isMobile ? '0 24px 32px' : '0 80px 48px' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.9fr) minmax(0, 1fr)', gap: isMobile ? 16 : 56, alignItems: 'end' }}>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 3.2vw, 2.7rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0, overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(section.supportingHeading, participationContent.awardsGrid.supportingHeading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.55)', lineHeight: 1.8, margin: 0 }}>
{fallbackText(section.supportingDescription, participationContent.awardsGrid.supportingDescription)}
</p>
</div>
</div>
<SupportingAwardCards cards={supportingCards} allCards={cards} followsFeaturedCard /> <SupportingAwardCards cards={supportingCards} allCards={cards} followsFeaturedCard />
</section> </section>
) : null} ) : null}

View File

@@ -14,6 +14,7 @@ const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif'; const FB = '"Inter", sans-serif';
const NAVY = '#111D55'; const NAVY = '#111D55';
const GOLD = '#EFBF04'; const GOLD = '#EFBF04';
const CARD_BACKGROUND = '#FFF8E1';
type ProcessContent = Partial<typeof participationContent.process>; type ProcessContent = Partial<typeof participationContent.process>;
@@ -43,14 +44,14 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
// ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ── // ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ──
if (isMobile) { if (isMobile) {
return ( return (
<section id="schritte" style={{ background: NAVY, padding: '56px 22px' }}> <section id="schritte" style={{ background: GOLD, padding: '56px 22px' }}>
{/* Section header */} {/* Section header */}
<div style={{ marginBottom: 36 }}> <div style={{ marginBottom: 36 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span> <span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.72)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.9rem, 8vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.05, margin: '0 0 16px' }}> <h2 style={{ fontFamily: FF, fontSize: 'clamp(1.9rem, 8vw, 2.6rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.05, margin: '0 0 16px' }}>
<Lines text={fallbackText(section.heading, participationContent.process.heading)} /> <Lines text={fallbackText(section.heading, participationContent.process.heading)} />
</h2> </h2>
<p style={{ fontFamily: FB, fontSize: 15, color: 'rgba(255,255,255,0.65)', lineHeight: 1.7, margin: 0 }}> <p style={{ fontFamily: FB, fontSize: 15, color: 'rgba(16,24,40,0.68)', lineHeight: 1.7, margin: 0 }}>
{fallbackText(section.description, participationContent.process.description)} {fallbackText(section.description, participationContent.process.description)}
</p> </p>
</div> </div>
@@ -60,29 +61,29 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
{steps.map((item, index) => { {steps.map((item, index) => {
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText; const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
return ( return (
<div key={`${item.step}-${index}`} style={{ border: '1px solid rgba(239,191,4,0.25)', background: '#111D55', padding: '24px 22px' }}> <div key={`${item.step}-${index}`} style={{ border: '1px solid rgba(17,29,85,0.16)', background: CARD_BACKGROUND, padding: '24px 22px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div style={{ width: 44, height: 44, border: '1px solid rgba(239,191,4,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 44, height: 44, border: '1px solid rgba(17,29,85,0.22)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon size={18} style={{ color: GOLD }} strokeWidth={1.5} /> <Icon size={18} style={{ color: NAVY }} strokeWidth={1.5} />
</div> </div>
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: GOLD, letterSpacing: '0.18em', textTransform: 'uppercase' }}>{stepPrefix} {fallbackText(item.step, '')}</span> <span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.78)', letterSpacing: '0.18em', textTransform: 'uppercase' }}>{stepPrefix} {fallbackText(item.step, '')}</span>
<span style={{ width: 1, height: 11, background: 'rgba(255,255,255,0.2)' }} /> <span style={{ width: 1, height: 11, background: 'rgba(17,29,85,0.18)' }} />
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(255,255,255,0.62)', letterSpacing: '0.06em' }}>{fallbackText(item.date, '')}</span> <span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.62)', letterSpacing: '0.06em' }}>{fallbackText(item.date, '')}</span>
</div> </div>
<h3 style={{ fontFamily: FF, fontSize: '1.35rem', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}> <h3 style={{ fontFamily: FF, fontSize: '1.35rem', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}>
{fallbackText(item.title, '')} {fallbackText(item.title, '')}
</h3> </h3>
</div> </div>
</div> </div>
<p style={{ fontFamily: FB, fontSize: 17, color: 'rgba(255,255,255,0.68)', lineHeight: 1.7, margin: 0 }}> <p style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.68)', lineHeight: 1.7, margin: 0 }}>
{fallbackText(item.desc, '')} {fallbackText(item.desc, '')}
</p> </p>
{/* Step progress indicator */} {/* Step progress indicator */}
<div style={{ display: 'flex', gap: 4, marginTop: 20 }}> <div style={{ display: 'flex', gap: 4, marginTop: 20 }}>
{steps.map((_, i) => ( {steps.map((_, i) => (
<div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(255,255,255,0.1)' }} /> <div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(17,29,85,0.12)' }} />
))} ))}
</div> </div>
</div> </div>
@@ -94,18 +95,18 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
} }
return ( return (
<ContainerScroll id="schritte" className="h-[380vh]" style={{ background: NAVY }}> <ContainerScroll id="schritte" className="h-[380vh]" style={{ background: GOLD }}>
<ContainerSticky className="top-0 h-screen flex flex-col"> <ContainerSticky className="top-0 h-screen flex flex-col">
{/* Section header */} {/* Section header */}
<div style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)', flexShrink: 0 }}> <div style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(17,29,85,0.14)', flexShrink: 0 }}>
<div> <div>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span> <span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.72)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 3vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0 }}> <h2 style={{ fontFamily: FF, fontSize: 'clamp(1.8rem, 3vw, 2.8rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0 }}>
<Lines text={fallbackText(section.heading, participationContent.process.heading)} /> <Lines text={fallbackText(section.heading, participationContent.process.heading)} />
</h2> </h2>
</div> </div>
<p style={{ fontFamily: FB, fontSize: 14, color: 'rgba(255,255,255,0.65)', lineHeight: 1.8, margin: 0 }}> <p style={{ fontFamily: FB, fontSize: 14, color: 'rgba(16,24,40,0.68)', lineHeight: 1.8, margin: 0 }}>
{fallbackText(section.description, participationContent.process.description)} {fallbackText(section.description, participationContent.process.description)}
</p> </p>
</div> </div>
@@ -120,18 +121,18 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
width="12" height="7" viewBox="0 0 12 7" fill="none" 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` }} style={{ opacity: 1 - i * 0.3, animation: `scrollBounce 1.6s ease-in-out ${i * 0.18}s infinite` }}
> >
<polyline points="1,1 6,6 11,1" stroke={GOLD} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <polyline points="1,1 6,6 11,1" stroke={NAVY} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
))} ))}
</div> </div>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)' }}> <span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(17,29,85,0.68)' }}>
{fallbackText(section.scrollHint, participationContent.process.scrollHint)} {fallbackText(section.scrollHint, participationContent.process.scrollHint)}
</span> </span>
{/* Progress bar */} {/* Progress bar */}
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)', position: 'relative', overflow: 'hidden', maxWidth: 200 }}> <div style={{ flex: 1, height: 1, background: 'rgba(17,29,85,0.14)', position: 'relative', overflow: 'hidden', maxWidth: 200 }}>
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: '25%', background: GOLD, opacity: 0.6 }} /> <div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: '25%', background: NAVY, opacity: 0.6 }} />
</div> </div>
<span style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em' }}>{fallbackText(section.progressLabel, participationContent.process.progressLabel)}</span> <span style={{ fontFamily: FF, fontSize: 9, color: 'rgba(17,29,85,0.62)', letterSpacing: '0.1em' }}>{fallbackText(section.progressLabel, participationContent.process.progressLabel)}</span>
</div> </div>
{/* Cards strip */} {/* Cards strip */}
@@ -144,14 +145,14 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
itemsLength={steps.length} itemsLength={steps.length}
index={index} index={index}
variant="bmp" 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 */} {/* Left accent strip with step number */}
<ProcessCardTitle className="border-r border-[rgba(239,191,4,0.2)] flex flex-col items-center justify-between py-8 px-5 min-w-[80px]"> <ProcessCardTitle className="border-r border-[rgba(17,29,85,0.16)] flex flex-col items-center justify-between py-8 px-5 min-w-[80px]">
<div style={{ width: 40, height: 40, border: '1px solid rgba(239,191,4,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ width: 40, height: 40, border: '1px solid rgba(17,29,85,0.22)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon size={16} style={{ color: GOLD }} strokeWidth={1.5} /> <Icon size={16} style={{ color: NAVY }} strokeWidth={1.5} />
</div> </div>
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 900, color: 'rgba(239,191,4,0.4)', letterSpacing: '0.1em', writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}> <span style={{ fontFamily: FF, fontSize: 11, fontWeight: 900, color: 'rgba(17,29,85,0.38)', letterSpacing: '0.1em', writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
{fallbackText(item.step, '')} {fallbackText(item.step, '')}
</span> </span>
</ProcessCardTitle> </ProcessCardTitle>
@@ -160,24 +161,24 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
<ProcessCardBody className="flex flex-col justify-between py-10 px-10 gap-6"> <ProcessCardBody className="flex flex-col justify-between py-10 px-10 gap-6">
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: GOLD, letterSpacing: '0.22em', textTransform: 'uppercase' }}> <span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.78)', letterSpacing: '0.22em', textTransform: 'uppercase' }}>
{stepPrefix} {fallbackText(item.step, '')} {stepPrefix} {fallbackText(item.step, '')}
</span> </span>
<span style={{ width: 1, height: 12, background: 'rgba(255,255,255,0.2)' }} /> <span style={{ width: 1, height: 12, background: 'rgba(17,29,85,0.18)' }} />
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(255,255,255,0.62)', letterSpacing: '0.08em' }}>{fallbackText(item.date, '')}</span> <span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.62)', letterSpacing: '0.08em' }}>{fallbackText(item.date, '')}</span>
</div> </div>
<div style={{ width: 32, height: 1, background: GOLD, marginBottom: 20, opacity: 0.5 }} /> <div style={{ width: 32, height: 1, background: GOLD, marginBottom: 20, opacity: 0.5 }} />
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.5rem, 2.5vw, 2.2rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}> <h3 style={{ fontFamily: FF, fontSize: 'clamp(1.5rem, 2.5vw, 2.2rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: 0 }}>
{fallbackText(item.title, '')} {fallbackText(item.title, '')}
</h3> </h3>
</div> </div>
<p style={{ fontFamily: FB, fontSize: 17, color: 'rgba(255,255,255,0.68)', lineHeight: 1.7, margin: 0 }}> <p style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.68)', lineHeight: 1.7, margin: 0 }}>
{fallbackText(item.desc, '')} {fallbackText(item.desc, '')}
</p> </p>
{/* Step progress indicator */} {/* Step progress indicator */}
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
{steps.map((_, i) => ( {steps.map((_, i) => (
<div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(255,255,255,0.1)', transition: 'flex 0.3s' }} /> <div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(17,29,85,0.12)', transition: 'flex 0.3s' }} />
))} ))}
</div> </div>
</ProcessCardBody> </ProcessCardBody>

View File

@@ -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 = <T,>(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) => <React.Fragment key={`${line}-${index}`}>{index > 0 && <br />}{line}</React.Fragment>)}</>
}
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<NewsletterBenefit>(section.benefits, homeContent.application.benefits)
const finalBenefits = showApplicationForm ? benefits : newsletterCopy.benefits
return (
<section style={{ background: '#24366A', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : showApplicationForm ? 'calc(100vh - 60px)' : 'auto', minHeight: isMobile ? undefined : showApplicationForm ? undefined : 560, display: 'flex', flexDirection: 'column' }} id="bewerben">
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%)', pointerEvents: 'none' }} />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: 'hidden' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.36em', fontWeight: 700, display: 'block', marginBottom: 16, opacity: 0.8 }}>{showApplicationForm ? fallbackText(section.eyebrow, homeContent.application.eyebrow) : newsletterCopy.eyebrow}</span>
<h2 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.8rem, 8vw, 2.6rem)' : 'clamp(1.8rem, 2.8vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, marginBottom: 16 }}>
<Lines text={showApplicationForm ? fallbackText(section.heading, homeContent.application.heading) : newsletterCopy.heading} />
</h2>
<div style={{ width: 32, height: 2, background: '#EFBF04', marginBottom: 20 }} />
<p style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, marginBottom: 32 }}>
{showApplicationForm ? fallbackText(section.body, homeContent.application.body) : newsletterCopy.body}
</p>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
{finalBenefits.map((item) => (
<div key={item.num} style={{ display: 'grid', gridTemplateColumns: '24px 1fr', gap: '0 14px', padding: '13px 0', borderBottom: '1px solid rgba(255,255,255,0.06)', alignItems: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, color: 'rgba(239,191,4,0.55)', letterSpacing: '0.1em' }}>{item.num}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{item.label}</span>
<span style={{ fontFamily: FF, fontSize: 14, color: 'rgba(255,255,255,0.45)', textAlign: 'right' }}>{item.desc}</span>
</div>
</div>
))}
</div>
</div>
{!isMobile && <div style={{ background: 'rgba(255,255,255,0.07)' }} />}
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }} aria-hidden="true">
<defs>
<pattern id={`applicationFormCross-${source}`} width="18" height="18" patternUnits="userSpaceOnUse">
<path d="M0,0 L18,18 M18,0 L0,18" stroke="rgba(17,29,85,0.055)" strokeWidth="0.65" />
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#applicationFormCross-${source})`} />
</svg>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(100,70,0,0.25) 100%)', pointerEvents: 'none', zIndex: 0 }} />
{!isMobile && (
<div style={{ height: 220, position: 'relative', overflow: 'hidden', flexShrink: 0, zIndex: 1 }}>
<Image
unoptimized
src={mediaUrl(section.awardImage, `/images/${homeContent.application.awardImageFilename}`)}
alt={mediaAlt(section.awardImage, fallbackText(section.awardImageAlt, homeContent.application.awardImageAlt))}
style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 25%', filter: 'sepia(0.18) brightness(0.92)' }}
/>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(168,120,0,0.1) 0%, rgba(168,120,0,0.25) 50%, rgba(168,120,0,0.92) 88%, #A87800 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'rgba(17,29,85,0.35)' }} />
</div>
)}
<div style={{ padding: isMobile ? '32px 24px' : '24px 48px 32px 40px', flex: isMobile ? 'none' : 1, height: isMobile && showApplicationForm ? '82svh' : undefined, display: 'flex', flexDirection: 'column', minHeight: 0, position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{showApplicationForm ? fallbackText(section.formEyebrow, homeContent.application.formEyebrow) : newsletterCopy.formEyebrow}</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 24 }}>{showApplicationForm ? fallbackText(section.formTitle, homeContent.application.formTitle) : newsletterCopy.formTitle}</h3>
{showApplicationForm ? <BewerbungsForm theme="gold" content={form} /> : <NewsletterInterestForm copy={newsletterCopy} phase={newsletterPhase} source={source} />}
</div>
</div>
</div>
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 24px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', textAlign: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
<span style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: isMobile ? 6 : 10 }}>{showApplicationForm ? fallbackText(section.footnote, homeContent.application.footnote) : newsletterCopy.footnote}</span>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: 'rgba(239,191,4,0.8)' }}>{showApplicationForm ? fallbackText(section.footnoteStrong, homeContent.application.footnoteStrong) : newsletterCopy.footnoteStrong}</span>
</div>
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section>
)
}

View File

@@ -4,21 +4,19 @@ import { Play, ChevronLeft, ChevronRight, X, Star } from 'lucide-react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
import { PartnerTicker } from '@/spa/components/ui/partner-ticker'; import { PartnerTicker } from '@/spa/components/ui/partner-ticker';
import { WINNERS } from '@/spa/data/winners'; import { WINNERS } from '@/spa/data/winners';
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm'; import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection';
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm';
import TestimonialsSection from '@/spa/components/TestimonialsSection'; import TestimonialsSection from '@/spa/components/TestimonialsSection';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase' 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 { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
import { homeContent } from '@/spa/homeContent' import { homeContent } from '@/spa/homeContent'
import { resolveHomeWinners } from '@/spa/homeWinnerSelection' import { resolveHomeWinners } from '@/spa/homeWinnerSelection'
import { netzwerkContent } from '@/spa/netzwerkContent' import { netzwerkContent } from '@/spa/netzwerkContent'
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder' import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder'
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo' import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'
import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm'
const FF = '"IBM Plex Sans", sans-serif'; const FF = '"IBM Plex Sans", sans-serif';
@@ -82,7 +80,6 @@ type HomeTestimonialsCms = {
type HomeCms = { type HomeCms = {
application?: HomeSectionCms; application?: HomeSectionCms;
benefits?: HomeSectionCms; benefits?: HomeSectionCms;
form?: Partial<typeof homeContent.form>;
hero?: HomeHeroCms; hero?: HomeHeroCms;
intro?: HomeSectionCms; intro?: HomeSectionCms;
quickCheck?: HomeSectionCms; quickCheck?: HomeSectionCms;
@@ -117,7 +114,6 @@ const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const HIDE_MEMBERSHIP_HOME_HERO_CTA = true; const HIDE_MEMBERSHIP_HOME_HERO_CTA = true;
const APPLICATION_PHASE_OPEN = '0';
const STATUS_SECTION_BACKGROUND = '#111D55'; const STATUS_SECTION_BACKGROUND = '#111D55';
const LEGACY_STATUS_SECTION_BACKGROUNDS = new Set(['#020A1E', '#060D1C', '#06090F']); const LEGACY_STATUS_SECTION_BACKGROUNDS = new Set(['#020A1E', '#060D1C', '#06090F']);
@@ -242,15 +238,8 @@ const Home: React.FC = () => {
const intro = home.intro || {}; const intro = home.intro || {};
const winnersSection = home.winners || {}; const winnersSection = home.winners || {};
const benefits = home.benefits || {}; const benefits = home.benefits || {};
const application = home.application || {};
const form = home.form || {};
const videoModal = home.videoModal || {}; const videoModal = home.videoModal || {};
const applicationPhase = useApplicationPhase(); 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 cmsPreistraeger = useCmsCollection('preistraeger');
const heroSecondaryCta = { const heroSecondaryCta = {
label: fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label), 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 quickCriteria = fallbackArray(quickCheck.criteria, homeContent.quickCheck.criteria);
const introStats = fallbackArray(intro.stats, homeContent.intro.stats); const introStats = fallbackArray(intro.stats, homeContent.intro.stats);
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items); 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 winnerFallbackImage = placeholderImage;
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title); const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
const videoSrc = mediaUrl(videoModal.video, ''); const videoSrc = mediaUrl(videoModal.video, '');
@@ -662,92 +649,7 @@ const Home: React.FC = () => {
</div> </div>
</section> </section>
{/* CTA Section – with phase-aware embedded form */} <ApplicationFormSection source="homepage" />
<section style={{ background: '#24366A', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : showApplicationForm ? 'calc(100vh - 60px)' : 'auto', minHeight: isMobile ? undefined : showApplicationForm ? undefined : 560, display: 'flex', flexDirection: 'column' }} id="bewerben">
{/* Subtle radial glow behind left copy */}
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%)', pointerEvents: 'none' }} />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}>
{/* Left – pitch copy */}
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: 'hidden' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.36em', fontWeight: 700, display: 'block', marginBottom: 16, opacity: 0.8 }}>{showApplicationForm ? fallbackText(application.eyebrow, homeContent.application.eyebrow) : newsletterCopy.eyebrow}</span>
<h2 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.8rem, 8vw, 2.6rem)' : 'clamp(1.8rem, 2.8vw, 2.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, marginBottom: 16 }}>
<Lines text={showApplicationForm ? fallbackText(application.heading, homeContent.application.heading) : newsletterCopy.heading} />
</h2>
<div style={{ width: 32, height: 2, background: '#EFBF04', marginBottom: 20 }} />
<p style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, marginBottom: 32 }}>
{showApplicationForm ? fallbackText(application.body, homeContent.application.body) : newsletterCopy.body}
</p>
{/* Benefit rows */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
{finalSectionBenefits.map(item => (
<div key={item.num} style={{ display: 'grid', gridTemplateColumns: '24px 1fr', gap: '0 14px', padding: '13px 0', borderBottom: '1px solid rgba(255,255,255,0.06)', alignItems: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, color: 'rgba(239,191,4,0.55)', letterSpacing: '0.1em' }}>{item.num}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{item.label}</span>
<span style={{ fontFamily: FF, fontSize: 14, color: 'rgba(255,255,255,0.45)', textAlign: 'right' }}>{item.desc}</span>
</div>
</div>
))}
</div>
</div>
{/* Divider */}
{!isMobile && <div style={{ background: 'rgba(255,255,255,0.07)' }} />}
{/* Right – gold premium panel */}
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
{/* Premium award crosshatch pattern */}
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }} aria-hidden="true">
<defs>
<pattern id="bmpCross" width="18" height="18" patternUnits="userSpaceOnUse">
<path d="M0,0 L18,18 M18,0 L0,18" stroke="rgba(17,29,85,0.055)" strokeWidth="0.65" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#bmpCross)" />
</svg>
{/* Inner vignette – edges slightly deeper gold */}
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(100,70,0,0.25) 100%)', pointerEvents: 'none', zIndex: 0 }} />
{/* Award ceremony photo */}
{!isMobile && (
<div style={{ height: 220, position: 'relative', overflow: 'hidden', flexShrink: 0, zIndex: 1 }}>
<Image unoptimized
src={mediaUrl(application.awardImage, `/images/${homeContent.application.awardImageFilename}`)}
alt={mediaAlt(application.awardImage, fallbackText(application.awardImageAlt, homeContent.application.awardImageAlt))}
style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 25%', filter: 'sepia(0.18) brightness(0.92)' }}
/>
{/* Blend photo into gold panel */}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(168,120,0,0.1) 0%, rgba(168,120,0,0.25) 50%, rgba(168,120,0,0.92) 88%, #A87800 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'rgba(17,29,85,0.35)' }} />
<div style={{ position: 'absolute', bottom: 14, left: 40, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(17,29,85,0.7)' }} />
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.75)', textTransform: 'uppercase', letterSpacing: '0.2em' }}>{fallbackText(application.awardCaption, homeContent.application.awardCaption)}</span>
</div>
</div>
)}
{/* Form area */}
<div style={{ padding: isMobile ? '32px 24px' : '24px 48px 32px 40px', flex: isMobile ? 'none' : 1, height: isMobile && showApplicationForm ? '82svh' : undefined, display: 'flex', flexDirection: 'column', minHeight: 0, position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{showApplicationForm ? fallbackText(application.formEyebrow, homeContent.application.formEyebrow) : newsletterCopy.formEyebrow}</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 24 }}>{showApplicationForm ? fallbackText(application.formTitle, homeContent.application.formTitle) : newsletterCopy.formTitle}</h3>
{showApplicationForm ? <BewerbungsForm theme="gold" content={form} /> : <NewsletterInterestForm copy={newsletterCopy} phase={newsletterPhase} source="homepage" />}
</div>
</div>
</div>
{/* Footnote row */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 24px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', textAlign: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
<span style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: isMobile ? 6 : 10 }}>{showApplicationForm ? fallbackText(application.footnote, homeContent.application.footnote) : newsletterCopy.footnote}</span>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: 'rgba(239,191,4,0.8)' }}>{showApplicationForm ? fallbackText(application.footnoteStrong, homeContent.application.footnoteStrong) : newsletterCopy.footnoteStrong}</span>
</div>
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section>
{/* Video Modal */} {/* Video Modal */}
{showVideo && ( {showVideo && (

View File

@@ -1,16 +1,14 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react'; import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm'; import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection';
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm';
import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection'; import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection';
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'; import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useApplicationPhase, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute'; import { useCmsRoute } from '@/spa/cmsRoute';
import { participationContent } from '@/spa/participationContent'; import { participationContent } from '@/spa/participationContent';
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm';
const FF = '"IBM Plex Sans", sans-serif'; const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif'; const FB = '"Inter", sans-serif';
@@ -20,11 +18,6 @@ const GOLD = '#EFBF04';
type ParticipationCms = Partial<typeof participationContent> & { type ParticipationCms = Partial<typeof participationContent> & {
hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown }; hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown };
awardsGrid?: AwardsGridContent; awardsGrid?: AwardsGridContent;
applicationForm?: ParticipationApplicationFormCms;
};
type ParticipationApplicationFormCms = Partial<typeof participationContent.applicationForm> & {
image?: unknown;
}; };
const ICONS = { const ICONS = {
@@ -43,8 +36,6 @@ const fallbackText = (value: unknown, fallback: string) =>
const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) => const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const APPLICATION_PHASE_OPEN = '0';
const isFutureAwardApplicationWay = (value: { label?: unknown; title?: unknown }) => { const isFutureAwardApplicationWay = (value: { label?: unknown; title?: unknown }) => {
const label = typeof value.label === 'string' ? value.label.toLowerCase() : ''; const label = typeof value.label === 'string' ? value.label.toLowerCase() : '';
const title = typeof value.title === 'string' ? value.title.toLowerCase() : ''; const title = typeof value.title === 'string' ? value.title.toLowerCase() : '';
@@ -60,26 +51,16 @@ const Participation: React.FC = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const qualSectionRef = useRef<HTMLElement>(null); const qualSectionRef = useRef<HTMLElement>(null);
const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms; const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms;
const applicationPhase = useApplicationPhase();
const newsletterForm = useNewsletterForm();
const hero = { ...participationContent.hero, ...(cms.hero || {}) }; const hero = { ...participationContent.hero, ...(cms.hero || {}) };
const process = { ...participationContent.process, ...(cms.process || {}) }; const process = { ...participationContent.process, ...(cms.process || {}) };
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) }; const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) }; const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) };
const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) }; const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) };
const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) }; 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 eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes); const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes);
const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems); const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems);
const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems); const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems);
const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts);
const finalSectionFacts = showApplicationForm ? applicationFacts : newsletterCopy.benefits;
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -219,98 +200,7 @@ const Participation: React.FC = () => {
{/* ── AWARDS GRID ─────────────────────────────────────────────────────── */} {/* ── AWARDS GRID ─────────────────────────────────────────────────────── */}
<AwardsGridSection content={awardsGrid} featuredGoldeneBavaria /> <AwardsGridSection content={awardsGrid} featuredGoldeneBavaria />
{/* ── FORM SECTION ─────────────────────────────────────────────────────── */} <ApplicationFormSection source="participation" />
<section style={{ background: NAVY, position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : showApplicationForm ? 'calc(100vh - 60px)' : 'auto', minHeight: isMobile ? undefined : showApplicationForm ? undefined : 560, display: 'flex', flexDirection: 'column' }} id="bewerben">
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', flex: 1, minHeight: 0, overflow: isMobile ? 'visible' : 'hidden' }}>
{/* Left – copy */}
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: isMobile ? 'visible' : 'hidden' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{showApplicationForm ? fallbackText(applicationForm.eyebrow, participationContent.applicationForm.eyebrow) : newsletterCopy.eyebrow}</span>
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.2vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.04, margin: '0 0 20px' }}>
<Lines text={showApplicationForm ? fallbackText(applicationForm.heading, participationContent.applicationForm.heading) : newsletterCopy.heading} />
</h2>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.68)', lineHeight: 1.8, maxWidth: 400, marginBottom: 48 }}>
{showApplicationForm ? fallbackText(applicationForm.description, participationContent.applicationForm.description) : newsletterCopy.body}
</p>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
{finalSectionFacts.map((item, i) => (
<div key={`${fallbackText(item.num, String(i))}-${i}`} style={{ display: 'grid', gridTemplateColumns: '36px 1fr', gap: '0 16px', padding: '16px 0', borderBottom: '1px solid rgba(255,255,255,0.06)', alignItems: 'center' }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(239,191,4,0.5)', letterSpacing: '0.1em' }}>{fallbackText(item.num, '')}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{fallbackText(item.label, '')}</span>
<span style={{ fontFamily: FB, fontSize: 16, color: 'rgba(255,255,255,0.62)' }}>{fallbackText(item.desc, '')}</span>
</div>
</div>
))}
</div>
</div>
{/* Divider */}
{!isMobile && <div style={{ background: 'rgba(255,255,255,0.07)' }} />}
{/* Right – gold form panel */}
<div style={{ display: 'flex', flexDirection: 'column', overflow: isMobile ? 'visible' : 'hidden', minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }} aria-hidden="true">
<defs>
<pattern id="participationCross" width="18" height="18" patternUnits="userSpaceOnUse">
<path d="M0,0 L18,18 M18,0 L0,18" stroke="rgba(17,29,85,0.055)" strokeWidth="0.65" />
</pattern>
<pattern id="participationDiamond" width="36" height="36" patternUnits="userSpaceOnUse">
<polygon points="18,2 34,18 18,34 2,18" fill="none" stroke="rgba(17,29,85,0.045)" strokeWidth="0.6" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#participationCross)" />
<rect width="100%" height="100%" fill="url(#participationDiamond)" />
</svg>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(100,70,0,0.25) 100%)', pointerEvents: 'none', zIndex: 0 }} />
{!isMobile && (
<div style={{ height: 220, position: 'relative', overflow: 'hidden', flexShrink: 0, zIndex: 1 }}>
<Image unoptimized src={mediaUrl(applicationForm.image, `/images/${participationContent.applicationForm.imageFilename}`)} alt={mediaAlt(applicationForm.image, fallbackText(applicationForm.imageAlt, participationContent.applicationForm.imageAlt))} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 30%', filter: 'sepia(0.18) brightness(0.92)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(168,120,0,0.1) 0%, rgba(168,120,0,0.25) 50%, rgba(168,120,0,0.92) 88%, #A87800 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'rgba(17,29,85,0.35)' }} />
<div style={{ position: 'absolute', bottom: 16, left: 40, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(17,29,85,0.7)' }} />
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.75)', textTransform: 'uppercase', letterSpacing: '0.2em' }}>{fallbackText(applicationForm.imageCaption, participationContent.applicationForm.imageCaption)}</span>
</div>
</div>
)}
<div style={{ padding: isMobile ? '28px 20px' : '24px 48px 32px 40px', flex: 1, display: 'flex', flexDirection: 'column', minHeight: isMobile && showApplicationForm ? 560 : 0, position: 'relative', zIndex: 1 }}>
{showApplicationForm ? (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 10 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.28em', fontWeight: 700 }}>{fallbackText(applicationForm.formEyebrow, participationContent.applicationForm.formEyebrow)}</span>
</div>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 24 }}>{fallbackText(applicationForm.formTitle, participationContent.applicationForm.formTitle)}</h3>
<BewerbungsForm theme="gold" content={form} />
</>
) : (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{newsletterCopy.formEyebrow}</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 24 }}>{newsletterCopy.formTitle}</h3>
<NewsletterInterestForm copy={newsletterCopy} phase={newsletterPhase} source="participation" />
</>
)}
</div>
</div>
</div>
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, justifyContent: 'center', textAlign: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
{showApplicationForm ? (
<>
<span style={{ fontFamily: FB, fontSize: 14, color: 'rgba(255,255,255,0.55)', lineHeight: 1.6, maxWidth: 520 }}>
{fallbackText(applicationForm.uploadPrompt, participationContent.applicationForm.uploadPrompt)}
</span>
</>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: isMobile ? '0 6px' : '0 10px' }}>
<span style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.45)' }}>{newsletterCopy.footnote}</span>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: 'rgba(239,191,4,0.8)' }}>{newsletterCopy.footnoteStrong}</span>
</div>
)}
</div>
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section>
</div> </div>
); );
}; };