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:
205
src/ApplicationForm/config.ts
Normal file
205
src/ApplicationForm/config.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { draftMode } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import NextApp from '@/spa/NextApp'
|
||||
import { normalizeApplicationFormData, type SpaApplicationFormData } from '@/spa/applicationForm'
|
||||
import { normalizeApplicationPhaseData, type SpaApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
|
||||
import type { CmsRouteCollection, CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
@@ -104,6 +105,7 @@ export default async function Page({ params }: Args) {
|
||||
header,
|
||||
footer,
|
||||
siteSettings,
|
||||
applicationForm,
|
||||
applicationPhase,
|
||||
newsletterForm,
|
||||
route,
|
||||
@@ -117,6 +119,7 @@ export default async function Page({ params }: Args) {
|
||||
payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'site-settings', depth: 1 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'application-form', depth: 1 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'application-phase', depth: 0 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'newsletter-form', depth: 0 }).catch(() => undefined),
|
||||
findRouteDoc(payload, pathname, draft),
|
||||
@@ -136,6 +139,7 @@ export default async function Page({ params }: Args) {
|
||||
header={normalizeHeaderData(header)}
|
||||
footer={normalizeFooterData(footer)}
|
||||
siteSettings={normalizeSiteSettings(siteSettings as Partial<SpaSiteSettingsData> | undefined)}
|
||||
applicationForm={normalizeApplicationFormData(applicationForm as SpaApplicationFormData | undefined)}
|
||||
applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial<SpaApplicationPhaseData> | undefined)}
|
||||
newsletterForm={normalizeNewsletterFormData(newsletterForm as Partial<SpaNewsletterFormData> | undefined)}
|
||||
cmsRoute={{
|
||||
|
||||
@@ -223,7 +223,7 @@ export const homeFields: Field[] = [
|
||||
name: 'application',
|
||||
label: '07 · Application form section',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Final application pitch, award image, embedded form heading, and footnote.'),
|
||||
admin: { ...sectionAdmin('Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
|
||||
fields: [
|
||||
text('eyebrow', 'Eyebrow', homeContent.application.eyebrow),
|
||||
textarea('heading', 'Heading', homeContent.application.heading),
|
||||
@@ -242,7 +242,7 @@ export const homeFields: Field[] = [
|
||||
name: 'form',
|
||||
label: '08 · Embedded application form copy and logic labels',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Text, options, and eligibility thresholds used inside the home page multi-step application form.'),
|
||||
admin: { ...sectionAdmin('Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
|
||||
fields: [
|
||||
text('stepCompleteLabel', 'Completed step label', homeContent.form.stepCompleteLabel),
|
||||
text('backLabel', 'Back button label', homeContent.form.backLabel),
|
||||
|
||||
@@ -248,6 +248,8 @@ export const participationFields: Field[] = [
|
||||
text('eyebrow', 'Eyebrow', participationContent.awardsGrid.eyebrow),
|
||||
textarea('heading', 'Heading', participationContent.awardsGrid.heading),
|
||||
textarea('description', 'Intro paragraph', participationContent.awardsGrid.description),
|
||||
textarea('supportingHeading', 'Other prizes heading', participationContent.awardsGrid.supportingHeading),
|
||||
textarea('supportingDescription', 'Other prizes subtext', participationContent.awardsGrid.supportingDescription),
|
||||
{
|
||||
name: 'cards',
|
||||
label: 'Award cards',
|
||||
@@ -270,7 +272,7 @@ export const participationFields: Field[] = [
|
||||
name: 'applicationForm',
|
||||
label: '07 · Application form wrapper',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Navy/gold section containing the phase-aware application form or newsletter prompt.'),
|
||||
admin: { ...sectionAdmin('Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
|
||||
fields: [
|
||||
uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`),
|
||||
text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt),
|
||||
@@ -297,7 +299,7 @@ export const participationFields: Field[] = [
|
||||
name: 'form',
|
||||
label: '08 · Application form copy and logic labels',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Text, options, and eligibility thresholds used inside the multi-step application form.'),
|
||||
admin: { ...sectionAdmin('Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
|
||||
fields: [
|
||||
text('stepCompleteLabel', 'Completed step label', participationContent.form.stepCompleteLabel),
|
||||
text('backLabel', 'Back button label', participationContent.form.backLabel),
|
||||
|
||||
191
src/migrations/20260713_155500_shared_application_form.ts
Normal file
191
src/migrations/20260713_155500_shared_application_form.ts
Normal 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`;'))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import * as migration_20260707_124500_site_settings_preistraeger_placeholder fro
|
||||
import * as migration_20260707_130500_preistraeger_awards_jury_members from './20260707_130500_preistraeger_awards_jury_members';
|
||||
import * as migration_20260712_120000_partner_media_only from './20260712_120000_partner_media_only';
|
||||
import * as migration_20260713_133000_newsletter_form_and_subscriptions from './20260713_133000_newsletter_form_and_subscriptions';
|
||||
import * as migration_20260713_155500_shared_application_form from './20260713_155500_shared_application_form';
|
||||
import * as migration_20260713_163000_participation_supporting_awards_copy from './20260713_163000_participation_supporting_awards_copy';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
@@ -72,4 +74,14 @@ export const migrations = [
|
||||
down: migration_20260713_133000_newsletter_form_and_subscriptions.down,
|
||||
name: '20260713_133000_newsletter_form_and_subscriptions',
|
||||
},
|
||||
{
|
||||
up: migration_20260713_155500_shared_application_form.up,
|
||||
down: migration_20260713_155500_shared_application_form.down,
|
||||
name: '20260713_155500_shared_application_form',
|
||||
},
|
||||
{
|
||||
up: migration_20260713_163000_participation_supporting_awards_copy.up,
|
||||
down: migration_20260713_163000_participation_supporting_awards_copy.down,
|
||||
name: '20260713_163000_participation_supporting_awards_copy',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface Config {
|
||||
footer: Footer;
|
||||
'site-settings': SiteSetting;
|
||||
'application-phase': ApplicationPhase;
|
||||
'application-form': ApplicationForm;
|
||||
'newsletter-form': NewsletterForm;
|
||||
};
|
||||
globalsSelect: {
|
||||
@@ -121,6 +122,7 @@ export interface Config {
|
||||
footer: FooterSelect<false> | FooterSelect<true>;
|
||||
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
||||
'application-phase': ApplicationPhaseSelect<false> | ApplicationPhaseSelect<true>;
|
||||
'application-form': ApplicationFormSelect<false> | ApplicationFormSelect<true>;
|
||||
'newsletter-form': NewsletterFormSelect<false> | NewsletterFormSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
@@ -362,7 +364,7 @@ export interface Page {
|
||||
| null;
|
||||
};
|
||||
/**
|
||||
* Final application pitch, award image, embedded form heading, and footnote.
|
||||
* Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.
|
||||
*/
|
||||
application?: {
|
||||
eyebrow?: string | null;
|
||||
@@ -388,7 +390,7 @@ export interface Page {
|
||||
footnoteStrong?: string | null;
|
||||
};
|
||||
/**
|
||||
* Text, options, and eligibility thresholds used inside the home page multi-step application form.
|
||||
* Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.
|
||||
*/
|
||||
form?: {
|
||||
stepCompleteLabel?: string | null;
|
||||
@@ -901,6 +903,8 @@ export interface Page {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
description?: string | null;
|
||||
supportingHeading?: string | null;
|
||||
supportingDescription?: string | null;
|
||||
cards?:
|
||||
| {
|
||||
label?: string | null;
|
||||
@@ -921,7 +925,7 @@ export interface Page {
|
||||
| null;
|
||||
};
|
||||
/**
|
||||
* Navy/gold section containing the phase-aware application form or newsletter prompt.
|
||||
* Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.
|
||||
*/
|
||||
applicationForm?: {
|
||||
/**
|
||||
@@ -954,7 +958,7 @@ export interface Page {
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Text, options, and eligibility thresholds used inside the multi-step application form.
|
||||
* Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.
|
||||
*/
|
||||
form?: {
|
||||
stepCompleteLabel?: string | null;
|
||||
@@ -3624,6 +3628,8 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
eyebrow?: T;
|
||||
heading?: T;
|
||||
description?: T;
|
||||
supportingHeading?: T;
|
||||
supportingDescription?: T;
|
||||
cards?:
|
||||
| T
|
||||
| {
|
||||
@@ -5507,6 +5513,161 @@ export interface ApplicationPhase {
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "application-form".
|
||||
*/
|
||||
export interface ApplicationForm {
|
||||
id: number;
|
||||
/**
|
||||
* Gemeinsamer Bereich für Startseite und Teilnahme-Seite.
|
||||
*/
|
||||
section?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
body?: string | null;
|
||||
benefits?:
|
||||
| {
|
||||
num?: string | null;
|
||||
label?: string | null;
|
||||
desc?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
/**
|
||||
* Current frontend image: /images/preisuebergabe.jpg
|
||||
*/
|
||||
awardImage?: (number | null) | Media;
|
||||
awardImageAlt?: string | null;
|
||||
formEyebrow?: string | null;
|
||||
formTitle?: string | null;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
};
|
||||
/**
|
||||
* Gemeinsame Texte, Optionen und Eignungsregeln des mehrstufigen Bewerbungsformulars.
|
||||
*/
|
||||
form?: {
|
||||
stepCompleteLabel?: string | null;
|
||||
backLabel?: string | null;
|
||||
nextLabel?: string | null;
|
||||
submitLabel?: string | null;
|
||||
yesLabel?: string | null;
|
||||
noLabel?: string | null;
|
||||
requiredSuffix?: string | null;
|
||||
validation?: {
|
||||
choose?: string | null;
|
||||
required?: string | null;
|
||||
invalidEmail?: string | null;
|
||||
};
|
||||
typeStep?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
selfTitle?: string | null;
|
||||
selfDesc?: string | null;
|
||||
nominationTitle?: string | null;
|
||||
nominationDesc?: string | null;
|
||||
};
|
||||
selfSteps?:
|
||||
| {
|
||||
label?: string | null;
|
||||
icon?: ('alertCircle' | 'arrowRight' | 'building2' | 'check' | 'send' | 'trophy' | 'user') | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
nominationSteps?:
|
||||
| {
|
||||
label?: string | null;
|
||||
icon?: ('alertCircle' | 'arrowRight' | 'building2' | 'check' | 'send' | 'trophy' | 'user') | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
employeeOptions?:
|
||||
| {
|
||||
value?: string | null;
|
||||
label?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
eligibility?: {
|
||||
allowedEmployeeValues?: string | null;
|
||||
requiredBayernValue?: string | null;
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
employeesLabel?: string | null;
|
||||
bayernLabel?: string | null;
|
||||
ownerLabel?: string | null;
|
||||
ineligibleHeading?: string | null;
|
||||
ineligibleBody?: string | null;
|
||||
ineligibleContactPrefix?: string | null;
|
||||
ineligibleContactEmail?: string | null;
|
||||
};
|
||||
selfContact?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
companyLabel?: string | null;
|
||||
companyPlaceholder?: string | null;
|
||||
nameLabel?: string | null;
|
||||
namePlaceholder?: string | null;
|
||||
emailLabel?: string | null;
|
||||
emailPlaceholder?: string | null;
|
||||
phoneLabel?: string | null;
|
||||
phonePlaceholder?: string | null;
|
||||
footnote?: string | null;
|
||||
};
|
||||
selfSummary?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
companyLabel?: string | null;
|
||||
employeesLabel?: string | null;
|
||||
locationLabel?: string | null;
|
||||
locationPrefix?: string | null;
|
||||
contactLabel?: string | null;
|
||||
};
|
||||
nominationCompany?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
companyLabel?: string | null;
|
||||
companyPlaceholder?: string | null;
|
||||
industryLabel?: string | null;
|
||||
industryPlaceholder?: string | null;
|
||||
locationLabel?: string | null;
|
||||
locationPlaceholder?: string | null;
|
||||
};
|
||||
nominationContact?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
nameLabel?: string | null;
|
||||
namePlaceholder?: string | null;
|
||||
emailLabel?: string | null;
|
||||
emailPlaceholder?: string | null;
|
||||
relationshipLabel?: string | null;
|
||||
relationshipPlaceholder?: string | null;
|
||||
footnote?: string | null;
|
||||
};
|
||||
nominationSummary?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
companyLabel?: string | null;
|
||||
industryLabel?: string | null;
|
||||
locationLabel?: string | null;
|
||||
nominatedByLabel?: string | null;
|
||||
emptyValue?: string | null;
|
||||
};
|
||||
success?: {
|
||||
selfHeading?: string | null;
|
||||
nominationHeading?: string | null;
|
||||
selfPrefix?: string | null;
|
||||
selfMiddle?: string | null;
|
||||
selfSuffix?: string | null;
|
||||
nominationPrefix?: string | null;
|
||||
nominationMiddle?: string | null;
|
||||
nominationSuffix?: string | null;
|
||||
};
|
||||
};
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "newsletter-form".
|
||||
@@ -5745,6 +5906,174 @@ export interface ApplicationPhaseSelect<T extends boolean = true> {
|
||||
createdAt?: 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
|
||||
* via the `definition` "newsletter-form_select".
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Posts } from './collections/Posts'
|
||||
import { Preistraeger } from './collections/Preistraeger'
|
||||
import { Users } from './collections/Users'
|
||||
import { ApplicationPhase } from './ApplicationPhase/config'
|
||||
import { ApplicationForm } from './ApplicationForm/config'
|
||||
import { Footer } from './Footer/config'
|
||||
import { Header } from './Header/config'
|
||||
import { NewsletterForm } from './NewsletterForm/config'
|
||||
@@ -71,7 +72,7 @@ export default buildConfig({
|
||||
}),
|
||||
collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, NewsletterSubscriptions, Media, Users],
|
||||
cors: [getServerSideURL()].filter(Boolean),
|
||||
globals: [Header, Footer, SiteSettings, ApplicationPhase, NewsletterForm],
|
||||
globals: [Header, Footer, SiteSettings, ApplicationPhase, ApplicationForm, NewsletterForm],
|
||||
plugins,
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
sharp,
|
||||
|
||||
@@ -4,6 +4,7 @@ import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { mediaByFilename } from './mediaByFilename'
|
||||
|
||||
import { defaultApplicationFormData } from '@/spa/applicationForm'
|
||||
import { defaultApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation'
|
||||
import { defaultNewsletterFormData } from '@/spa/newsletterForm'
|
||||
@@ -12,11 +13,12 @@ import { defaultSiteSettings } from '@/spa/siteSettings'
|
||||
async function main() {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const [logo, ewifLogo, partnerLogos, skylineImage] = await Promise.all([
|
||||
const [logo, ewifLogo, partnerLogos, skylineImage, applicationFormImage] = await Promise.all([
|
||||
mediaByFilename(payload, 'bmp-logo.png'),
|
||||
mediaByFilename(payload, 'ewif-logo.png'),
|
||||
mediaByFilename(payload, 'partner-logos.png'),
|
||||
mediaByFilename(payload, 'munich-skyline.jpg'),
|
||||
mediaByFilename(payload, 'preisuebergabe.jpg'),
|
||||
])
|
||||
|
||||
const { logo: _headerLogo, ...headerData } = defaultHeaderData
|
||||
@@ -60,6 +62,17 @@ async function main() {
|
||||
context: { disableRevalidate: true },
|
||||
data: defaultApplicationPhaseData as never,
|
||||
}),
|
||||
payload.updateGlobal({
|
||||
slug: 'application-form',
|
||||
context: { disableRevalidate: true },
|
||||
data: {
|
||||
...defaultApplicationFormData,
|
||||
section: {
|
||||
...defaultApplicationFormData.section,
|
||||
awardImage: applicationFormImage,
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
payload.updateGlobal({
|
||||
slug: 'newsletter-form',
|
||||
context: { disableRevalidate: true },
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react'
|
||||
|
||||
import Layout from '@/spa/components/layout/Layout'
|
||||
import ScrollToTop from '@/spa/components/layout/ScrollToTop'
|
||||
import type { SpaApplicationFormData } from '@/spa/applicationForm'
|
||||
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import { type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation'
|
||||
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
|
||||
@@ -62,6 +63,7 @@ export default function NextApp({
|
||||
footer,
|
||||
cmsRoute,
|
||||
siteSettings,
|
||||
applicationForm,
|
||||
applicationPhase,
|
||||
newsletterForm,
|
||||
}: {
|
||||
@@ -70,6 +72,7 @@ export default function NextApp({
|
||||
footer?: SpaFooterData
|
||||
cmsRoute?: CmsRouteData
|
||||
siteSettings?: SpaSiteSettingsData
|
||||
applicationForm?: SpaApplicationFormData
|
||||
applicationPhase?: SpaApplicationPhaseData
|
||||
newsletterForm?: SpaNewsletterFormData
|
||||
}) {
|
||||
@@ -77,7 +80,7 @@ export default function NextApp({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationPhase, newsletterForm } : undefined}>
|
||||
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationForm, applicationPhase, newsletterForm } : undefined}>
|
||||
<ScrollToTop />
|
||||
<Layout header={header} footer={footer}>
|
||||
{page}
|
||||
|
||||
21
src/spa/applicationForm.ts
Normal file
21
src/spa/applicationForm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,9 @@ export const participationAwardsGrid = {
|
||||
heading: 'DREI AUSZEICHNUNGEN.\nEIN ANSPRUCH.',
|
||||
description:
|
||||
'Direkt im Bewerbungsjahr 2026 stehen drei Auszeichnungen im Fokus: die Goldene Bavaria, der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award.',
|
||||
supportingHeading: 'WEITERE AUSZEICHNUNGEN.',
|
||||
supportingDescription:
|
||||
'Der Roland-Berger-Zukunftspreis und der Studentenpreis Bavarian Future Award setzen jeweils einen eigenen Schwerpunkt.',
|
||||
cards: awardArticles.map(({ imageFilename, imageAlt, card, articleCta, mailtoCta }) => ({
|
||||
...card,
|
||||
imageFilename,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { createContext, useContext } from 'react'
|
||||
|
||||
import type { SpaApplicationFormData } from '@/spa/applicationForm'
|
||||
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import type { SpaNewsletterFormData } from '@/spa/newsletterForm'
|
||||
import type { SpaSiteSettingsData } from '@/spa/siteSettings'
|
||||
@@ -26,6 +27,7 @@ export type CmsRouteData = {
|
||||
doc: CmsRouteDoc
|
||||
lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>>
|
||||
siteSettings?: SpaSiteSettingsData
|
||||
applicationForm?: SpaApplicationFormData
|
||||
applicationPhase?: SpaApplicationPhaseData
|
||||
newsletterForm?: SpaNewsletterFormData
|
||||
}
|
||||
@@ -56,6 +58,10 @@ export function useApplicationPhase() {
|
||||
return useCmsRoute()?.applicationPhase
|
||||
}
|
||||
|
||||
export function useApplicationForm() {
|
||||
return useCmsRoute()?.applicationForm
|
||||
}
|
||||
|
||||
export function useNewsletterForm() {
|
||||
return useCmsRoute()?.newsletterForm
|
||||
}
|
||||
|
||||
@@ -318,6 +318,16 @@ export default function AwardsGridSection({ content, featuredGoldeneBavaria = fa
|
||||
|
||||
{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)' }}>
|
||||
<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 />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -14,6 +14,7 @@ const FF = '"IBM Plex Sans", sans-serif';
|
||||
const FB = '"Inter", sans-serif';
|
||||
const NAVY = '#111D55';
|
||||
const GOLD = '#EFBF04';
|
||||
const CARD_BACKGROUND = '#FFF8E1';
|
||||
|
||||
type ProcessContent = Partial<typeof participationContent.process>;
|
||||
|
||||
@@ -43,14 +44,14 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
// ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ──
|
||||
if (isMobile) {
|
||||
return (
|
||||
<section id="schritte" style={{ background: NAVY, padding: '56px 22px' }}>
|
||||
<section id="schritte" style={{ background: GOLD, padding: '56px 22px' }}>
|
||||
{/* Section header */}
|
||||
<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>
|
||||
<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' }}>
|
||||
<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: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.05, margin: '0 0 16px' }}>
|
||||
<Lines text={fallbackText(section.heading, participationContent.process.heading)} />
|
||||
</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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -60,29 +61,29 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
{steps.map((item, index) => {
|
||||
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
|
||||
return (
|
||||
<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={{ width: 44, height: 44, border: '1px solid rgba(239,191,4,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon size={18} style={{ color: GOLD }} strokeWidth={1.5} />
|
||||
<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: NAVY }} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<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={{ width: 1, height: 11, background: 'rgba(255,255,255,0.2)' }} />
|
||||
<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, 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(17,29,85,0.18)' }} />
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.62)', letterSpacing: '0.06em' }}>{fallbackText(item.date, '')}</span>
|
||||
</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, '')}
|
||||
</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, '')}
|
||||
</p>
|
||||
{/* Step progress indicator */}
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 20 }}>
|
||||
{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>
|
||||
@@ -94,18 +95,18 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<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 }}>
|
||||
<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: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0 }}>
|
||||
<Lines text={fallbackText(section.heading, participationContent.process.heading)} />
|
||||
</h2>
|
||||
</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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -120,18 +121,18 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
width="12" height="7" viewBox="0 0 12 7" fill="none"
|
||||
style={{ opacity: 1 - i * 0.3, animation: `scrollBounce 1.6s ease-in-out ${i * 0.18}s infinite` }}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</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)}
|
||||
</span>
|
||||
{/* Progress bar */}
|
||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)', 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={{ 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: NAVY, opacity: 0.6 }} />
|
||||
</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>
|
||||
|
||||
{/* Cards strip */}
|
||||
@@ -144,14 +145,14 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
||||
itemsLength={steps.length}
|
||||
index={index}
|
||||
variant="bmp"
|
||||
className="min-w-[70%] max-w-[70%] flex-shrink-0"
|
||||
className="min-w-[70%] max-w-[70%] flex-shrink-0 bg-[#FFF8E1] text-[#101828] border-[rgba(17,29,85,0.16)]"
|
||||
>
|
||||
{/* Left accent strip with step number */}
|
||||
<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]">
|
||||
<div style={{ width: 40, height: 40, border: '1px solid rgba(239,191,4,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon size={16} style={{ color: GOLD }} strokeWidth={1.5} />
|
||||
<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(17,29,85,0.22)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon size={16} style={{ color: NAVY }} strokeWidth={1.5} />
|
||||
</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, '')}
|
||||
</span>
|
||||
</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">
|
||||
<div>
|
||||
<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, '')}
|
||||
</span>
|
||||
<span style={{ width: 1, height: 12, background: 'rgba(255,255,255,0.2)' }} />
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(255,255,255,0.62)', letterSpacing: '0.08em' }}>{fallbackText(item.date, '')}</span>
|
||||
<span style={{ width: 1, height: 12, background: 'rgba(17,29,85,0.18)' }} />
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.62)', letterSpacing: '0.08em' }}>{fallbackText(item.date, '')}</span>
|
||||
</div>
|
||||
<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, '')}
|
||||
</h3>
|
||||
</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, '')}
|
||||
</p>
|
||||
{/* Step progress indicator */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{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>
|
||||
</ProcessCardBody>
|
||||
|
||||
115
src/spa/components/forms/ApplicationFormSection.tsx
Normal file
115
src/spa/components/forms/ApplicationFormSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -4,21 +4,19 @@ import { Play, ChevronLeft, ChevronRight, X, Star } from 'lucide-react';
|
||||
import { Link } from '@/spa/router';
|
||||
import { PartnerTicker } from '@/spa/components/ui/partner-ticker';
|
||||
import { WINNERS } from '@/spa/data/winners';
|
||||
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
|
||||
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm';
|
||||
import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection';
|
||||
import TestimonialsSection from '@/spa/components/TestimonialsSection';
|
||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase'
|
||||
import { useApplicationPhase, useCmsCollection, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute'
|
||||
import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
||||
import { homeContent } from '@/spa/homeContent'
|
||||
import { resolveHomeWinners } from '@/spa/homeWinnerSelection'
|
||||
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder'
|
||||
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'
|
||||
import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm'
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
@@ -82,7 +80,6 @@ type HomeTestimonialsCms = {
|
||||
type HomeCms = {
|
||||
application?: HomeSectionCms;
|
||||
benefits?: HomeSectionCms;
|
||||
form?: Partial<typeof homeContent.form>;
|
||||
hero?: HomeHeroCms;
|
||||
intro?: 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;
|
||||
|
||||
const HIDE_MEMBERSHIP_HOME_HERO_CTA = true;
|
||||
const APPLICATION_PHASE_OPEN = '0';
|
||||
const STATUS_SECTION_BACKGROUND = '#111D55';
|
||||
const LEGACY_STATUS_SECTION_BACKGROUNDS = new Set(['#020A1E', '#060D1C', '#06090F']);
|
||||
|
||||
@@ -242,15 +238,8 @@ const Home: React.FC = () => {
|
||||
const intro = home.intro || {};
|
||||
const winnersSection = home.winners || {};
|
||||
const benefits = home.benefits || {};
|
||||
const application = home.application || {};
|
||||
const form = home.form || {};
|
||||
const videoModal = home.videoModal || {};
|
||||
const applicationPhase = useApplicationPhase();
|
||||
const newsletterForm = useNewsletterForm();
|
||||
const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN;
|
||||
const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN;
|
||||
const newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase);
|
||||
const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase);
|
||||
const cmsPreistraeger = useCmsCollection('preistraeger');
|
||||
const heroSecondaryCta = {
|
||||
label: fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label),
|
||||
@@ -263,8 +252,6 @@ const Home: React.FC = () => {
|
||||
const quickCriteria = fallbackArray(quickCheck.criteria, homeContent.quickCheck.criteria);
|
||||
const introStats = fallbackArray(intro.stats, homeContent.intro.stats);
|
||||
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
|
||||
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
|
||||
const finalSectionBenefits = showApplicationForm ? applicationBenefits : newsletterCopy.benefits;
|
||||
const winnerFallbackImage = placeholderImage;
|
||||
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
|
||||
const videoSrc = mediaUrl(videoModal.video, '');
|
||||
@@ -662,92 +649,7 @@ const Home: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section – with phase-aware embedded form */}
|
||||
<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>
|
||||
<ApplicationFormSection source="homepage" />
|
||||
|
||||
{/* Video Modal */}
|
||||
{showVideo && (
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react';
|
||||
import { Link } from '@/spa/router';
|
||||
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
|
||||
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm';
|
||||
import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection';
|
||||
import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection';
|
||||
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import { useApplicationPhase, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute';
|
||||
import { useCmsRoute } from '@/spa/cmsRoute';
|
||||
import { participationContent } from '@/spa/participationContent';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
const FB = '"Inter", sans-serif';
|
||||
@@ -20,11 +18,6 @@ const GOLD = '#EFBF04';
|
||||
type ParticipationCms = Partial<typeof participationContent> & {
|
||||
hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown };
|
||||
awardsGrid?: AwardsGridContent;
|
||||
applicationForm?: ParticipationApplicationFormCms;
|
||||
};
|
||||
|
||||
type ParticipationApplicationFormCms = Partial<typeof participationContent.applicationForm> & {
|
||||
image?: unknown;
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
@@ -43,8 +36,6 @@ const fallbackText = (value: unknown, fallback: string) =>
|
||||
const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
|
||||
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
|
||||
|
||||
const APPLICATION_PHASE_OPEN = '0';
|
||||
|
||||
const isFutureAwardApplicationWay = (value: { label?: unknown; title?: unknown }) => {
|
||||
const label = typeof value.label === 'string' ? value.label.toLowerCase() : '';
|
||||
const title = typeof value.title === 'string' ? value.title.toLowerCase() : '';
|
||||
@@ -60,26 +51,16 @@ const Participation: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const qualSectionRef = useRef<HTMLElement>(null);
|
||||
const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms;
|
||||
const applicationPhase = useApplicationPhase();
|
||||
const newsletterForm = useNewsletterForm();
|
||||
const hero = { ...participationContent.hero, ...(cms.hero || {}) };
|
||||
const process = { ...participationContent.process, ...(cms.process || {}) };
|
||||
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
|
||||
const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) };
|
||||
const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) };
|
||||
const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) };
|
||||
const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) };
|
||||
const form = { ...participationContent.form, ...(cms.form || {}) };
|
||||
const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN;
|
||||
const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN;
|
||||
const newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase);
|
||||
const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase);
|
||||
const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
|
||||
const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes);
|
||||
const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems);
|
||||
const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems);
|
||||
const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts);
|
||||
const finalSectionFacts = showApplicationForm ? applicationFacts : newsletterCopy.benefits;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
@@ -219,98 +200,7 @@ const Participation: React.FC = () => {
|
||||
{/* ── AWARDS GRID ─────────────────────────────────────────────────────── */}
|
||||
<AwardsGridSection content={awardsGrid} featuredGoldeneBavaria />
|
||||
|
||||
{/* ── FORM SECTION ─────────────────────────────────────────────────────── */}
|
||||
<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>
|
||||
<ApplicationFormSection source="participation" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user