feat: update Preistraeger page to include a simple winner grid for 2023 and remove unused components

fix: change featured status for participation content and remove deprecated award card

feat: add migration for new participation phase form fields and backfill existing data

feat: create AwardsGridSection component for displaying awards with improved layout

feat: implement NewsletterInterestForm component for newsletter sign-up with validation
This commit is contained in:
syntaxbullet
2026-07-01 18:02:12 +02:00
parent 2fda7e8122
commit 2842caa936
15 changed files with 1274 additions and 255 deletions

View File

@@ -60,6 +60,36 @@ const iconField = (defaultValue = 'star'): Field => ({
options: iconOptions, options: iconOptions,
}) })
const newsletterVariantFields = (
defaults: typeof homeContent.application.newsletterPhaseEvaluation,
benefitsDbName: string,
): Field[] => [
text('eyebrow', 'Left eyebrow', defaults.eyebrow),
textarea('heading', 'Left heading', defaults.heading),
textarea('body', 'Left body copy', defaults.body),
{
name: 'benefits',
label: 'Benefit rows',
type: 'array',
dbName: benefitsDbName,
defaultValue: defaults.benefits,
fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')],
},
text('formEyebrow', 'Form eyebrow', defaults.formEyebrow),
text('formTitle', 'Form title', defaults.formTitle),
textarea('formBody', 'Form body copy', defaults.formBody),
text('emailLabel', 'Email label', defaults.emailLabel),
text('emailPlaceholder', 'Email placeholder', defaults.emailPlaceholder),
text('submitLabel', 'Submit button label', defaults.submitLabel),
textarea('privacy', 'Privacy note', defaults.privacy),
text('validationRequired', 'Required email validation message', defaults.validationRequired),
text('validationInvalid', 'Invalid email validation message', defaults.validationInvalid),
text('successHeading', 'Success heading', defaults.successHeading),
textarea('successBody', 'Success body copy', defaults.successBody),
text('footnote', 'Footnote normal text', defaults.footnote),
text('footnoteStrong', 'Footnote highlighted text', defaults.footnoteStrong),
]
export const homeFields: Field[] = [ export const homeFields: Field[] = [
{ {
name: 'home', name: 'home',
@@ -236,6 +266,20 @@ export const homeFields: Field[] = [
text('formTitle', 'Form title', homeContent.application.formTitle), text('formTitle', 'Form title', homeContent.application.formTitle),
text('footnote', 'Footnote normal text', homeContent.application.footnote), text('footnote', 'Footnote normal text', homeContent.application.footnote),
text('footnoteStrong', 'Footnote highlighted text', homeContent.application.footnoteStrong), text('footnoteStrong', 'Footnote highlighted text', homeContent.application.footnoteStrong),
{
name: 'newsletterPhaseEvaluation',
label: 'Phase 02 · Newsletter form',
type: 'group',
admin: sectionAdmin('Homepage newsletter copy used when the active application phase is Auswertung / Jury bewertet.'),
fields: newsletterVariantFields(homeContent.application.newsletterPhaseEvaluation, 'home_news_eval_bens'),
},
{
name: 'newsletterPhaseCompleted',
label: 'Phase 03 · Newsletter form',
type: 'group',
admin: sectionAdmin('Homepage newsletter copy used when the active application phase is Preisverleihung abgeschlossen.'),
fields: newsletterVariantFields(homeContent.application.newsletterPhaseCompleted, 'home_news_done_bens'),
},
], ],
}, },
{ {

View File

@@ -76,6 +76,35 @@ const ctaGroup = (name: string, label: string, labelDefault: string, urlDefault:
type: 'group', type: 'group',
fields: linkGroup(labelDefault, urlDefault), fields: linkGroup(labelDefault, urlDefault),
}) })
const newsletterVariantFields = (
defaults: typeof participationContent.applicationForm.newsletterPhaseEvaluation,
benefitsDbName: string,
): Field[] => [
text('eyebrow', 'Left eyebrow', defaults.eyebrow),
textarea('heading', 'Left heading', defaults.heading),
textarea('body', 'Left body copy', defaults.body),
{
name: 'benefits',
label: 'Benefit rows',
type: 'array',
dbName: benefitsDbName,
defaultValue: defaults.benefits,
fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')],
},
text('formEyebrow', 'Form eyebrow', defaults.formEyebrow),
text('formTitle', 'Form title', defaults.formTitle),
textarea('formBody', 'Form body copy', defaults.formBody),
text('emailLabel', 'Email label', defaults.emailLabel),
text('emailPlaceholder', 'Email placeholder', defaults.emailPlaceholder),
text('submitLabel', 'Submit button label', defaults.submitLabel),
textarea('privacy', 'Privacy note', defaults.privacy),
text('validationRequired', 'Required email validation message', defaults.validationRequired),
text('validationInvalid', 'Invalid email validation message', defaults.validationInvalid),
text('successHeading', 'Success heading', defaults.successHeading),
textarea('successBody', 'Success body copy', defaults.successBody),
text('footnote', 'Footnote normal text', defaults.footnote),
text('footnoteStrong', 'Footnote highlighted text', defaults.footnoteStrong),
]
const awardCardDefaults = participationContent.awardsGrid.cards.map(({ imageFilename: _imageFilename, ...card }) => card) const awardCardDefaults = participationContent.awardsGrid.cards.map(({ imageFilename: _imageFilename, ...card }) => card)
export const participationFields: Field[] = [ export const participationFields: Field[] = [
@@ -158,7 +187,7 @@ export const participationFields: Field[] = [
name: 'applicationWays', name: 'applicationWays',
label: '04 · Application paths', label: '04 · Application paths',
type: 'group', type: 'group',
admin: sectionAdmin('Cream section explaining proposal, self-application, and special award paths.'), admin: sectionAdmin('Cream section explaining proposal and self-application paths.'),
fields: [ fields: [
text('eyebrow', 'Eyebrow', participationContent.applicationWays.eyebrow), text('eyebrow', 'Eyebrow', participationContent.applicationWays.eyebrow),
textarea('heading', 'Heading', participationContent.applicationWays.heading), textarea('heading', 'Heading', participationContent.applicationWays.heading),
@@ -270,7 +299,7 @@ export const participationFields: Field[] = [
name: 'applicationForm', name: 'applicationForm',
label: '07 · Application form wrapper', label: '07 · Application form wrapper',
type: 'group', type: 'group',
admin: sectionAdmin('Navy/gold section containing the application form and PDF upload links.'), admin: sectionAdmin('Navy/gold section containing the phase-aware application form or newsletter prompt.'),
fields: [ fields: [
uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`), uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`),
text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt), text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt),
@@ -287,9 +316,24 @@ export const participationFields: Field[] = [
fields: [text('num', 'Number'), text('label', 'Label'), textarea('desc', 'Description')], fields: [text('num', 'Number'), text('label', 'Label'), textarea('desc', 'Description')],
}, },
text('formEyebrow', 'Form eyebrow', participationContent.applicationForm.formEyebrow), text('formEyebrow', 'Form eyebrow', participationContent.applicationForm.formEyebrow),
text('formTitle', 'Form title', participationContent.applicationForm.formTitle),
ctaGroup('uploadCta', 'Top upload CTA', participationContent.applicationForm.uploadCta.label, participationContent.applicationForm.uploadCta.url), ctaGroup('uploadCta', 'Top upload CTA', participationContent.applicationForm.uploadCta.label, participationContent.applicationForm.uploadCta.url),
textarea('uploadPrompt', 'Bottom upload prompt', participationContent.applicationForm.uploadPrompt), textarea('uploadPrompt', 'Bottom upload prompt', participationContent.applicationForm.uploadPrompt),
ctaGroup('uploadFooterCta', 'Bottom upload CTA', participationContent.applicationForm.uploadFooterCta.label, participationContent.applicationForm.uploadFooterCta.url), ctaGroup('uploadFooterCta', 'Bottom upload CTA', participationContent.applicationForm.uploadFooterCta.label, participationContent.applicationForm.uploadFooterCta.url),
{
name: 'newsletterPhaseEvaluation',
label: 'Phase 02 · Newsletter form',
type: 'group',
admin: sectionAdmin('Participation page newsletter copy used when the active application phase is Auswertung / Jury bewertet.'),
fields: newsletterVariantFields(participationContent.applicationForm.newsletterPhaseEvaluation, 'part_news_eval_bens'),
},
{
name: 'newsletterPhaseCompleted',
label: 'Phase 03 · Newsletter form',
type: 'group',
admin: sectionAdmin('Participation page newsletter copy used when the active application phase is Preisverleihung abgeschlossen.'),
fields: newsletterVariantFields(participationContent.applicationForm.newsletterPhaseCompleted, 'part_news_done_bens'),
},
], ],
}, },
{ {

View File

@@ -0,0 +1,218 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
type MigrationDB = MigrateUpArgs['db']
type TextColumn = {
name: string
defaultValue: string
}
type BenefitRow = {
num: string
label: string
desc: string
}
const participationPageWhere = "(`spa_path` = '/teilnahme' OR `slug` IN ('teilnahme', 'participation'))"
const textColumns: TextColumn[] = [
{ name: 'participation_application_form_form_title', defaultValue: 'Kostenlos bewerben' },
{ name: 'participation_application_form_newsletter_phase_evaluation_eyebrow', defaultValue: 'Updates erhalten' },
{ name: 'participation_application_form_newsletter_phase_evaluation_heading', defaultValue: 'DIE BEWERBUNG\nIST GESCHLOSSEN.' },
{
name: 'participation_application_form_newsletter_phase_evaluation_body',
defaultValue:
'Die Bewerbungsphase 2026 ist geschlossen. Hinterlassen Sie Ihre E-Mail und erhalten Sie Updates zur Juryphase, zur Preisverleihung und zur nächsten Runde.',
},
{ name: 'participation_application_form_newsletter_phase_evaluation_form_eyebrow', defaultValue: 'Newsletter abonnieren' },
{ name: 'participation_application_form_newsletter_phase_evaluation_form_title', defaultValue: 'Updates erhalten' },
{
name: 'participation_application_form_newsletter_phase_evaluation_form_body',
defaultValue:
'Tragen Sie Ihre E-Mail ein und erhalten Sie relevante Informationen zum weiteren Verlauf des Bayerischen Mittelstandspreises.',
},
{ name: 'participation_application_form_newsletter_phase_evaluation_email_label', defaultValue: 'E-Mail-Adresse' },
{ name: 'participation_application_form_newsletter_phase_evaluation_email_placeholder', defaultValue: 'name@unternehmen.de' },
{ name: 'participation_application_form_newsletter_phase_evaluation_submit_label', defaultValue: 'Eintragen' },
{
name: 'participation_application_form_newsletter_phase_evaluation_privacy',
defaultValue: 'Kein Spam. Nur relevante Informationen rund um den Bayerischen Mittelstandspreis.',
},
{
name: 'participation_application_form_newsletter_phase_evaluation_validation_required',
defaultValue: 'Bitte geben Sie Ihre E-Mail-Adresse ein.',
},
{
name: 'participation_application_form_newsletter_phase_evaluation_validation_invalid',
defaultValue: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
},
{ name: 'participation_application_form_newsletter_phase_evaluation_success_heading', defaultValue: 'Danke' },
{
name: 'participation_application_form_newsletter_phase_evaluation_success_body',
defaultValue: 'Ihre E-Mail wurde für BMP-Updates eingetragen.',
},
{ name: 'participation_application_form_newsletter_phase_evaluation_footnote', defaultValue: 'BMP-Updates -' },
{ name: 'participation_application_form_newsletter_phase_evaluation_footnote_strong', defaultValue: 'Juryphase und Termine' },
{ name: 'participation_application_form_newsletter_phase_completed_eyebrow', defaultValue: 'Nächste Runde' },
{ name: 'participation_application_form_newsletter_phase_completed_heading', defaultValue: 'DEN START\nNICHT\nVERPASSEN.' },
{
name: 'participation_application_form_newsletter_phase_completed_body',
defaultValue:
'Der aktuelle Preisjahrgang ist abgeschlossen. Hinterlassen Sie Ihre E-Mail und wir informieren Sie, sobald es Neuigkeiten zur nächsten Bewerbungsphase gibt.',
},
{ name: 'participation_application_form_newsletter_phase_completed_form_eyebrow', defaultValue: 'Newsletter abonnieren' },
{ name: 'participation_application_form_newsletter_phase_completed_form_title', defaultValue: 'Informiert bleiben' },
{
name: 'participation_application_form_newsletter_phase_completed_form_body',
defaultValue: 'Tragen Sie sich ein und erhalten Sie Hinweise zum Bewerbungsstart, zu Veranstaltungen und zu ausgezeichneten Unternehmen.',
},
{ name: 'participation_application_form_newsletter_phase_completed_email_label', defaultValue: 'E-Mail-Adresse' },
{ name: 'participation_application_form_newsletter_phase_completed_email_placeholder', defaultValue: 'name@unternehmen.de' },
{ name: 'participation_application_form_newsletter_phase_completed_submit_label', defaultValue: 'Abonnieren' },
{
name: 'participation_application_form_newsletter_phase_completed_privacy',
defaultValue: 'Sie erhalten nur BMP-relevante Informationen. Eine Abmeldung ist jederzeit möglich.',
},
{
name: 'participation_application_form_newsletter_phase_completed_validation_required',
defaultValue: 'Bitte geben Sie Ihre E-Mail-Adresse ein.',
},
{
name: 'participation_application_form_newsletter_phase_completed_validation_invalid',
defaultValue: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
},
{ name: 'participation_application_form_newsletter_phase_completed_success_heading', defaultValue: 'Angemeldet' },
{
name: 'participation_application_form_newsletter_phase_completed_success_body',
defaultValue: 'Danke. Sie erhalten BMP-Updates an die angegebene E-Mail-Adresse.',
},
{ name: 'participation_application_form_newsletter_phase_completed_footnote', defaultValue: 'Bewerbungsstart im Blick -' },
{ name: 'participation_application_form_newsletter_phase_completed_footnote_strong', defaultValue: 'BMP-Updates erhalten' },
]
const evaluationBenefits: BenefitRow[] = [
{ num: '01', label: 'Juryphase', desc: 'Updates zum Verlauf' },
{ num: '02', label: 'Preisverleihung', desc: 'Termine und Einblicke' },
{ num: '03', label: 'Nächste Runde', desc: 'Start nicht verpassen' },
]
const completedBenefits: BenefitRow[] = [
{ num: '01', label: 'Startsignal', desc: 'Neue Bewerbungsphase' },
{ num: '02', label: 'BMP-News', desc: 'Preisträger und Termine' },
{ num: '03', label: 'Erinnerung', desc: 'Rechtzeitig vorbereitet' },
]
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
async function tableExists(db: MigrationDB, tableName: string) {
const rows = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
if (!(await tableExists(db, tableName))) return false
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }>
return columns.some((column) => column.name === columnName)
}
async function addTextColumn(db: MigrationDB, tableName: string, column: TextColumn) {
if (await columnExists(db, tableName, column.name)) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(column.name)} text DEFAULT ${literal(column.defaultValue)};`))
}
async function dropColumn(db: MigrationDB, tableName: string, columnName: string) {
if (!(await columnExists(db, tableName, columnName))) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident(columnName)};`))
}
async function backfillParticipationTextColumn(db: MigrationDB, column: TextColumn) {
if (!(await columnExists(db, 'pages', column.name))) return
await db.run(
sql.raw(
`UPDATE ${ident('pages')} SET ${ident(column.name)} = ${literal(column.defaultValue)} WHERE ${ident(column.name)} IS NULL AND ${participationPageWhere};`,
),
)
}
async function createBenefitTables(db: MigrationDB, tableName: string, versionTableName: string) {
await db.run(sql.raw(`CREATE TABLE IF NOT EXISTS ${ident(tableName)} (
${ident('_order')} integer NOT NULL,
${ident('_parent_id')} integer NOT NULL,
${ident('id')} text PRIMARY KEY NOT NULL,
${ident('num')} text,
${ident('label')} text,
${ident('desc')} text,
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('pages')}(${ident('id')}) ON UPDATE no action ON DELETE cascade
);`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_order_idx`)} ON ${ident(tableName)} (${ident('_order')});`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_parent_id_idx`)} ON ${ident(tableName)} (${ident('_parent_id')});`))
await db.run(sql.raw(`CREATE TABLE IF NOT EXISTS ${ident(versionTableName)} (
${ident('_order')} integer NOT NULL,
${ident('_parent_id')} integer NOT NULL,
${ident('id')} integer PRIMARY KEY NOT NULL,
${ident('num')} text,
${ident('label')} text,
${ident('desc')} text,
${ident('_uuid')} text,
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('_pages_v')}(${ident('id')}) ON UPDATE no action ON DELETE cascade
);`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${versionTableName}_order_idx`)} ON ${ident(versionTableName)} (${ident('_order')});`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${versionTableName}_parent_id_idx`)} ON ${ident(versionTableName)} (${ident('_parent_id')});`))
}
async function seedBenefitRows(db: MigrationDB, tableName: string, rows: BenefitRow[]) {
if (!(await tableExists(db, tableName))) return
for (const [index, row] of rows.entries()) {
await db.run(
sql.raw(`INSERT INTO ${ident(tableName)} (${ident('_order')}, ${ident('_parent_id')}, ${ident('id')}, ${ident('num')}, ${ident('label')}, ${ident('desc')})
SELECT ${index + 1}, ${ident('pages')}.${ident('id')}, lower(hex(randomblob(12))), ${literal(row.num)}, ${literal(row.label)}, ${literal(row.desc)}
FROM ${ident('pages')}
WHERE ${participationPageWhere}
AND NOT EXISTS (
SELECT 1
FROM ${ident(tableName)}
WHERE ${ident('_parent_id')} = ${ident('pages')}.${ident('id')}
AND ${ident('_order')} = ${index + 1}
);`),
)
}
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
for (const column of textColumns) {
await addTextColumn(db, 'pages', column)
await addTextColumn(db, '_pages_v', {
name: `version_${column.name}`,
defaultValue: column.defaultValue,
})
await backfillParticipationTextColumn(db, column)
}
await createBenefitTables(db, 'part_news_eval_bens', '_part_news_eval_bens_v')
await createBenefitTables(db, 'part_news_done_bens', '_part_news_done_bens_v')
await seedBenefitRows(db, 'part_news_eval_bens', evaluationBenefits)
await seedBenefitRows(db, 'part_news_done_bens', completedBenefits)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('part_news_eval_bens')};`))
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('_part_news_eval_bens_v')};`))
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('part_news_done_bens')};`))
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('_part_news_done_bens_v')};`))
for (const column of [...textColumns].reverse()) {
await dropColumn(db, 'pages', column.name)
await dropColumn(db, '_pages_v', `version_${column.name}`)
}
}

View File

@@ -3,6 +3,7 @@ import * as migration_20260630_165002_participation_awards_grid from './20260630
import * as migration_20260630_172754_network_partner_logo_uploads from './20260630_172754_network_partner_logo_uploads'; import * as migration_20260630_172754_network_partner_logo_uploads from './20260630_172754_network_partner_logo_uploads';
import * as migration_20260630_175614_partners_collection from './20260630_175614_partners_collection'; import * as migration_20260630_175614_partners_collection from './20260630_175614_partners_collection';
import * as migration_20260630_182219_prune_unused_admin_collections from './20260630_182219_prune_unused_admin_collections'; import * as migration_20260630_182219_prune_unused_admin_collections from './20260630_182219_prune_unused_admin_collections';
import * as migration_20260701_163206_participation_phase_form_fields from './20260701_163206_participation_phase_form_fields';
export const migrations = [ export const migrations = [
{ {
@@ -30,4 +31,9 @@ export const migrations = [
down: migration_20260630_182219_prune_unused_admin_collections.down, down: migration_20260630_182219_prune_unused_admin_collections.down,
name: '20260630_182219_prune_unused_admin_collections', name: '20260630_182219_prune_unused_admin_collections',
}, },
{
up: migration_20260701_163206_participation_phase_form_fields.up,
down: migration_20260701_163206_participation_phase_form_fields.down,
name: '20260701_163206_participation_phase_form_fields',
},
]; ];

View File

@@ -380,6 +380,64 @@ export interface Page {
formTitle?: string | null; formTitle?: string | null;
footnote?: string | null; footnote?: string | null;
footnoteStrong?: string | null; footnoteStrong?: string | null;
/**
* Homepage newsletter copy used when the active application phase is Auswertung / Jury bewertet.
*/
newsletterPhaseEvaluation?: {
eyebrow?: string | null;
heading?: string | null;
body?: string | null;
benefits?:
| {
num?: string | null;
label?: string | null;
desc?: string | null;
id?: string | null;
}[]
| null;
formEyebrow?: string | null;
formTitle?: string | null;
formBody?: string | null;
emailLabel?: string | null;
emailPlaceholder?: string | null;
submitLabel?: string | null;
privacy?: string | null;
validationRequired?: string | null;
validationInvalid?: string | null;
successHeading?: string | null;
successBody?: string | null;
footnote?: string | null;
footnoteStrong?: string | null;
};
/**
* Homepage newsletter copy used when the active application phase is Preisverleihung abgeschlossen.
*/
newsletterPhaseCompleted?: {
eyebrow?: string | null;
heading?: string | null;
body?: string | null;
benefits?:
| {
num?: string | null;
label?: string | null;
desc?: string | null;
id?: string | null;
}[]
| null;
formEyebrow?: string | null;
formTitle?: string | null;
formBody?: string | null;
emailLabel?: string | null;
emailPlaceholder?: string | null;
submitLabel?: string | null;
privacy?: string | null;
validationRequired?: string | null;
validationInvalid?: string | null;
successHeading?: string | null;
successBody?: string | null;
footnote?: string | null;
footnoteStrong?: string | null;
};
}; };
/** /**
* Text, options, and eligibility thresholds used inside the home page multi-step application form. * Text, options, and eligibility thresholds used inside the home page multi-step application form.
@@ -808,7 +866,7 @@ export interface Page {
}; };
}; };
/** /**
* Cream section explaining proposal, self-application, and special award paths. * Cream section explaining proposal and self-application paths.
*/ */
applicationWays?: { applicationWays?: {
eyebrow?: string | null; eyebrow?: string | null;
@@ -915,7 +973,7 @@ export interface Page {
| null; | null;
}; };
/** /**
* Navy/gold section containing the application form and PDF upload links. * Navy/gold section containing the phase-aware application form or newsletter prompt.
*/ */
applicationForm?: { applicationForm?: {
/** /**
@@ -936,6 +994,7 @@ export interface Page {
}[] }[]
| null; | null;
formEyebrow?: string | null; formEyebrow?: string | null;
formTitle?: string | null;
uploadCta?: { uploadCta?: {
label?: string | null; label?: string | null;
url?: string | null; url?: string | null;
@@ -945,6 +1004,64 @@ export interface Page {
label?: string | null; label?: string | null;
url?: string | null; url?: string | null;
}; };
/**
* Participation page newsletter copy used when the active application phase is Auswertung / Jury bewertet.
*/
newsletterPhaseEvaluation?: {
eyebrow?: string | null;
heading?: string | null;
body?: string | null;
benefits?:
| {
num?: string | null;
label?: string | null;
desc?: string | null;
id?: string | null;
}[]
| null;
formEyebrow?: string | null;
formTitle?: string | null;
formBody?: string | null;
emailLabel?: string | null;
emailPlaceholder?: string | null;
submitLabel?: string | null;
privacy?: string | null;
validationRequired?: string | null;
validationInvalid?: string | null;
successHeading?: string | null;
successBody?: string | null;
footnote?: string | null;
footnoteStrong?: string | null;
};
/**
* Participation page newsletter copy used when the active application phase is Preisverleihung abgeschlossen.
*/
newsletterPhaseCompleted?: {
eyebrow?: string | null;
heading?: string | null;
body?: string | null;
benefits?:
| {
num?: string | null;
label?: string | null;
desc?: string | null;
id?: string | null;
}[]
| null;
formEyebrow?: string | null;
formTitle?: string | null;
formBody?: string | null;
emailLabel?: string | null;
emailPlaceholder?: string | null;
submitLabel?: string | null;
privacy?: string | null;
validationRequired?: string | null;
validationInvalid?: string | null;
successHeading?: string | null;
successBody?: string | null;
footnote?: string | null;
footnoteStrong?: string | null;
};
}; };
/** /**
* Text, options, and eligibility thresholds used inside the multi-step application form. * Text, options, and eligibility thresholds used inside the multi-step application form.
@@ -3097,6 +3214,62 @@ export interface PagesSelect<T extends boolean = true> {
formTitle?: T; formTitle?: T;
footnote?: T; footnote?: T;
footnoteStrong?: T; footnoteStrong?: T;
newsletterPhaseEvaluation?:
| T
| {
eyebrow?: T;
heading?: T;
body?: T;
benefits?:
| T
| {
num?: T;
label?: T;
desc?: T;
id?: T;
};
formEyebrow?: T;
formTitle?: T;
formBody?: T;
emailLabel?: T;
emailPlaceholder?: T;
submitLabel?: T;
privacy?: T;
validationRequired?: T;
validationInvalid?: T;
successHeading?: T;
successBody?: T;
footnote?: T;
footnoteStrong?: T;
};
newsletterPhaseCompleted?:
| T
| {
eyebrow?: T;
heading?: T;
body?: T;
benefits?:
| T
| {
num?: T;
label?: T;
desc?: T;
id?: T;
};
formEyebrow?: T;
formTitle?: T;
formBody?: T;
emailLabel?: T;
emailPlaceholder?: T;
submitLabel?: T;
privacy?: T;
validationRequired?: T;
validationInvalid?: T;
successHeading?: T;
successBody?: T;
footnote?: T;
footnoteStrong?: T;
};
}; };
form?: form?:
| T | T
@@ -3550,6 +3723,7 @@ export interface PagesSelect<T extends boolean = true> {
id?: T; id?: T;
}; };
formEyebrow?: T; formEyebrow?: T;
formTitle?: T;
uploadCta?: uploadCta?:
| T | T
| { | {
@@ -3563,6 +3737,62 @@ export interface PagesSelect<T extends boolean = true> {
label?: T; label?: T;
url?: T; url?: T;
}; };
newsletterPhaseEvaluation?:
| T
| {
eyebrow?: T;
heading?: T;
body?: T;
benefits?:
| T
| {
num?: T;
label?: T;
desc?: T;
id?: T;
};
formEyebrow?: T;
formTitle?: T;
formBody?: T;
emailLabel?: T;
emailPlaceholder?: T;
submitLabel?: T;
privacy?: T;
validationRequired?: T;
validationInvalid?: T;
successHeading?: T;
successBody?: T;
footnote?: T;
footnoteStrong?: T;
};
newsletterPhaseCompleted?:
| T
| {
eyebrow?: T;
heading?: T;
body?: T;
benefits?:
| T
| {
num?: T;
label?: T;
desc?: T;
id?: T;
};
formEyebrow?: T;
formTitle?: T;
formBody?: T;
emailLabel?: T;
emailPlaceholder?: T;
submitLabel?: T;
privacy?: T;
validationRequired?: T;
validationInvalid?: T;
successHeading?: T;
successBody?: T;
footnote?: T;
footnoteStrong?: T;
};
}; };
form?: form?:
| T | T

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { ArrowRight, Mail } from 'lucide-react';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import Image from '@/spa/components/ui/UnoptimizedImage';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { participationContent } from '@/spa/participationContent';
import { Link } from '@/spa/router';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
type AwardCard = (typeof participationContent.awardsGrid.cards)[number];
type AwardCardCms = Partial<AwardCard> & { image?: unknown };
export type AwardsGridContent = Partial<typeof participationContent.awardsGrid> & {
cards?: AwardCardCms[];
};
const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback;
const mergeAwardCards = (value: unknown, fallback: AwardCard[]): (AwardCard & { image?: unknown })[] => {
const cards = Array.isArray(value) && value.length > 0 ? (value as AwardCardCms[]) : fallback;
return cards.map((card, index) => ({ ...(fallback[index] || fallback[0]), ...card }));
};
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
export default function AwardsGridSection({ content }: { content?: AwardsGridContent }) {
const isMobile = useIsMobile();
const section = { ...participationContent.awardsGrid, ...(content || {}) };
const cards = mergeAwardCards(section.cards, participationContent.awardsGrid.cards);
return (
<section id="auszeichnungen" style={{ background: '#F7F7F5', position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>
{fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)}
</span>
<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(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0, overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(section.heading, participationContent.awardsGrid.heading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.55)', lineHeight: 1.8, margin: 0 }}>
{fallbackText(section.description, participationContent.awardsGrid.description)}
</p>
</div>
</div>
<div style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', borderBottom: '1px solid rgba(3,9,58,0.08)' }}>
{cards.map((card, index) => {
const imageFilename = fallbackText(card.imageFilename, participationContent.awardsGrid.cards[index]?.imageFilename || participationContent.awardsGrid.cards[0].imageFilename);
const isContainedImage = imageFilename.endsWith('.png') || imageFilename.includes('roland-berger');
const isLast = index === cards.length - 1;
return (
<article
key={`${fallbackText(card.title, 'award')}-${index}`}
style={{
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 'auto' : 620,
borderRight: !isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
borderBottom: isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
background: index === 1 ? NAVY : 'rgba(255,255,255,0.72)',
}}
>
<div style={{ height: isMobile ? 220 : 260, background: imageFilename.includes('roland-berger') ? '#DDE7E2' : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<Image unoptimized
src={mediaUrl(card.image, `/images/${imageFilename}`)}
alt={mediaAlt(card.image, fallbackText(card.imageAlt, fallbackText(card.title, 'Auszeichnung')))}
style={{
width: '100%',
height: '100%',
objectFit: isContainedImage ? 'contain' : 'cover',
objectPosition: index === 1 ? 'center top' : 'center',
padding: imageFilename.endsWith('.png') ? 22 : 0,
boxSizing: 'border-box',
display: 'block',
}}
/>
</div>
<div style={{ padding: isMobile ? '28px 24px 32px' : '36px 36px 40px', display: 'flex', flexDirection: 'column', flex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.26em', textTransform: 'uppercase', color: index === 1 ? 'rgba(239,191,4,0.8)' : '#4A8FC9', marginBottom: 12 }}>
{fallbackText(card.label, '')}
</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.25rem, 2vw, 1.7rem)', fontWeight: 900, color: index === 1 ? '#fff' : '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 16px', overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(card.title, '')} />
</h3>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 22 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: index === 1 ? 'rgba(255,255,255,0.62)' : 'rgba(16,24,40,0.55)', lineHeight: 1.7, margin: 0, flex: 1 }}>
{fallbackText(card.description, '')}
</p>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 10, marginTop: 32 }}>
<Link
to={fallbackText(card.articleCta?.url, '#bewerben')}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
background: GOLD,
color: '#101828',
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
{fallbackText(card.articleCta?.label, 'Artikel lesen')} <ArrowRight size={13} />
</Link>
<a
href={fallbackText(card.mailtoCta?.url, `mailto:${participationContent.form.eligibility.ineligibleContactEmail}`)}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: index === 1 ? '#fff' : NAVY,
border: `1px solid ${index === 1 ? 'rgba(255,255,255,0.22)' : 'rgba(3,9,58,0.18)'}`,
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
<Mail size={13} /> {fallbackText(card.mailtoCta?.label, 'Kontakt aufnehmen')}
</a>
</div>
</div>
</article>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { Check, Mail } from 'lucide-react';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
const FF = '"IBM Plex Sans", sans-serif';
export type NewsletterBenefit = { num: string; label: string; desc: string };
export type NewsletterInterestCopy = {
eyebrow: string;
heading: string;
body: string;
benefits: readonly NewsletterBenefit[];
formEyebrow: string;
formTitle: string;
formBody: string;
emailLabel: string;
emailPlaceholder: string;
submitLabel: string;
privacy: string;
validationRequired: string;
validationInvalid: string;
successHeading: string;
successBody: string;
footnote: string;
footnoteStrong: string;
};
export type NewsletterInterestCms = Partial<Omit<NewsletterInterestCopy, 'benefits'>> & {
benefits?: unknown;
};
export function NewsletterInterestForm({ copy }: { copy: NewsletterInterestCopy }) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [submitted, setSubmitted] = useState(false);
const isMobile = useIsMobile();
const submit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedEmail = email.trim();
if (!trimmedEmail) {
setError(copy.validationRequired);
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
setError(copy.validationInvalid);
return;
}
setError('');
setSubmitted(true);
};
if (submitted) {
return (
<div
aria-live="polite"
style={{
borderTop: '1px solid rgba(17,29,85,0.15)',
paddingTop: isMobile ? 28 : 36,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
flex: 1,
}}
>
<div style={{ width: 52, height: 52, background: '#111D55', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 22 }}>
<Check size={22} color="#EFBF04" strokeWidth={3} />
</div>
<h4 style={{ fontFamily: FF, fontSize: isMobile ? 20 : 24, fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', margin: '0 0 12px' }}>
{copy.successHeading}
</h4>
<p style={{ fontFamily: FF, fontSize: 16, color: 'rgba(17,29,85,0.66)', lineHeight: 1.7, margin: 0, maxWidth: 430 }}>
{copy.successBody}
</p>
</div>
);
}
return (
<form
onSubmit={submit}
style={{
borderTop: '1px solid rgba(17,29,85,0.15)',
paddingTop: isMobile ? 24 : 30,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
flex: 1,
minHeight: 0,
}}
>
<p style={{ fontFamily: FF, fontSize: 16, color: 'rgba(17,29,85,0.68)', lineHeight: 1.75, margin: '0 0 28px', maxWidth: 520 }}>
{copy.formBody}
</p>
<label style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', color: 'rgba(17,29,85,0.5)', marginBottom: 8 }}>
{copy.emailLabel}
</label>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 10, width: '100%', maxWidth: 560 }}>
<div style={{ flex: 1 }}>
<input
type="email"
value={email}
onChange={(event) => {
setEmail(event.target.value);
if (error) setError('');
}}
placeholder={copy.emailPlaceholder}
aria-invalid={Boolean(error)}
style={{
width: '100%',
height: 50,
padding: '0 16px',
border: `1.5px solid ${error ? '#b91c1c' : 'rgba(17,29,85,0.22)'}`,
background: 'rgba(255,255,255,0.55)',
color: '#111D55',
fontFamily: FF,
fontSize: 16,
outline: 'none',
boxSizing: 'border-box',
}}
/>
{error && <div style={{ fontFamily: FF, fontSize: 12, color: '#b91c1c', marginTop: 7 }}>{error}</div>}
</div>
<button
type="submit"
style={{
height: 50,
padding: '0 24px',
border: 'none',
background: '#111D55',
color: '#fff',
fontFamily: FF,
fontSize: 13,
fontWeight: 800,
letterSpacing: '0.12em',
textTransform: 'uppercase',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
{copy.submitLabel} <Mail size={14} />
</button>
</div>
<p style={{ fontFamily: FF, fontSize: 13, color: 'rgba(17,29,85,0.48)', lineHeight: 1.6, margin: '18px 0 0', maxWidth: 520 }}>
{copy.privacy}
</p>
</form>
);
}

View File

@@ -7,6 +7,7 @@ import { Link, useLocation } from '@/spa/router';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import LoadingScreen from '@/spa/components/ui/LoadingScreen'; import LoadingScreen from '@/spa/components/ui/LoadingScreen';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useApplicationPhase } from '@/spa/cmsRoute';
import { import {
defaultFooterData, defaultFooterData,
defaultHeaderData, defaultHeaderData,
@@ -37,6 +38,7 @@ const LINE = 'rgba(239,191,4,0.35)';
const FF_DISPLAY = '"IBM Plex Sans", sans-serif'; const FF_DISPLAY = '"IBM Plex Sans", sans-serif';
const FF_BODY = '"Inter", sans-serif'; const FF_BODY = '"Inter", sans-serif';
const HIDE_MEMBERSHIP_FOOTER_ENTRY = true; const HIDE_MEMBERSHIP_FOOTER_ENTRY = true;
const APPLICATION_PHASE_OPEN = '0';
const isMembershipFooterLink = (link?: { label?: string; path?: string }) => const isMembershipFooterLink = (link?: { label?: string; path?: string }) =>
link?.path === '/mitglied-werden' || link?.label?.trim().toLowerCase() === 'mitglied werden'; link?.path === '/mitglied-werden' || link?.label?.trim().toLowerCase() === 'mitglied werden';
@@ -72,6 +74,8 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
const footerCtas = footer.ctas; const footerCtas = footer.ctas;
const fallbackHeaderCtas = defaultHeaderData.ctas; const fallbackHeaderCtas = defaultHeaderData.ctas;
const fallbackFooterCtas = defaultFooterData.ctas; const fallbackFooterCtas = defaultFooterData.ctas;
const applicationPhase = useApplicationPhase();
const showFloatingApplicationCta = applicationPhase?.activePhase === APPLICATION_PHASE_OPEN;
const footerMembershipCta = footerCtas[1] || fallbackFooterCtas[1]; const footerMembershipCta = footerCtas[1] || fallbackFooterCtas[1];
const showFooterMembershipCta = const showFooterMembershipCta =
!HIDE_MEMBERSHIP_FOOTER_ENTRY || !isMembershipFooterLink(footerMembershipCta); !HIDE_MEMBERSHIP_FOOTER_ENTRY || !isMembershipFooterLink(footerMembershipCta);
@@ -565,7 +569,7 @@ const Layout: React.FC<LayoutProps> = ({ children, header = defaultHeaderData, f
{/* ── FLOATING CTA ─────────────────────────────── */} {/* ── FLOATING CTA ─────────────────────────────── */}
{location.pathname === '/' && !formVisible && !isMobile && ( {location.pathname === '/' && showFloatingApplicationCta && !formVisible && !isMobile && (
<Link <Link
to="/teilnahme" to="/teilnahme"
style={{ style={{

View File

@@ -35,6 +35,23 @@ const processCardVariants = cva("flex border backdrop-blur-lg", {
}, },
}) })
const subscribeToViewportWidth = (onStoreChange: () => void) => {
window.addEventListener("resize", onStoreChange)
return () => window.removeEventListener("resize", onStoreChange)
}
const getViewportWidth = () => window.innerWidth
const getServerViewportWidth = () => 0
function useViewportWidth() {
return React.useSyncExternalStore(
subscribeToViewportWidth,
getViewportWidth,
getServerViewportWidth
)
}
interface ContainerScrollContextValue { interface ContainerScrollContextValue {
scrollYProgress: MotionValue<number> scrollYProgress: MotionValue<number>
} }
@@ -128,13 +145,13 @@ export const ProcessCard: React.FC<ProcessCardProps> = ({
const { scrollYProgress } = useContainerScrollContext() const { scrollYProgress } = useContainerScrollContext()
const start = index / itemsLength const start = index / itemsLength
const end = start + 1 / itemsLength const end = start + 1 / itemsLength
const innerWidth = typeof window === "undefined" ? 0 : window.innerWidth const viewportWidth = useViewportWidth()
const [ref, { width }] = useMeasure() const [ref, { width }] = useMeasure()
const x = useTransform( const x = useTransform(
scrollYProgress, scrollYProgress,
[start, end], [start, end],
[innerWidth, -((width ?? 0) * index) + 64 * index] [viewportWidth, -((width ?? 0) * index) + 64 * index]
) )
return ( return (
<motion.div <motion.div

View File

@@ -111,6 +111,56 @@ export const homeContent = {
formTitle: 'Kostenlos bewerben', formTitle: 'Kostenlos bewerben',
footnote: 'Kostenlos und unverbindlich ', footnote: 'Kostenlos und unverbindlich ',
footnoteStrong: 'keine Teilnahmegebühr', footnoteStrong: 'keine Teilnahmegebühr',
newsletterPhaseEvaluation: {
eyebrow: 'Updates erhalten',
heading: 'BLEIBEN\nSIE AUF\nDEM LAUFENDEN.',
body:
'Die Bewerbungsphase 2026 ist geschlossen. Hinterlassen Sie Ihre E-Mail und erhalten Sie Updates zur Juryphase, zur Preisverleihung und zum Start der nächsten Runde.',
benefits: [
{ num: '01', label: 'Juryphase', desc: 'Updates zum Verlauf' },
{ num: '02', label: 'Preisverleihung', desc: 'Einladungen und Termine' },
{ num: '03', label: 'Nächste Runde', desc: 'Start nicht verpassen' },
],
formEyebrow: 'Newsletter abonnieren',
formTitle: 'Updates erhalten',
formBody:
'Tragen Sie Ihre E-Mail ein und erhalten Sie Updates zu Terminen, Preisträgern und zur nächsten Bewerbungsphase.',
emailLabel: 'E-Mail-Adresse',
emailPlaceholder: 'name@unternehmen.de',
submitLabel: 'Eintragen',
privacy: 'Kein Spam. Nur relevante Informationen rund um den Bayerischen Mittelstandspreis.',
validationRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein.',
validationInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
successHeading: 'Danke',
successBody: 'Ihre E-Mail wurde für BMP-Updates eingetragen.',
footnote: 'BMP-Updates -',
footnoteStrong: 'Termine und nächste Runde',
},
newsletterPhaseCompleted: {
eyebrow: 'Nächste Runde',
heading: 'DEN START\nNICHT\nVERPASSEN.',
body:
'Der aktuelle Preisjahrgang ist abgeschlossen. Hinterlassen Sie Ihre E-Mail und wir informieren Sie, sobald es Neuigkeiten zur nächsten Bewerbungsphase gibt.',
benefits: [
{ num: '01', label: 'Startsignal', desc: 'Neue Bewerbungsphase' },
{ num: '02', label: 'BMP-News', desc: 'Preisträger und Termine' },
{ num: '03', label: 'Erinnerung', desc: 'Rechtzeitig vorbereitet' },
],
formEyebrow: 'Newsletter abonnieren',
formTitle: 'Informiert bleiben',
formBody:
'Tragen Sie sich ein und erhalten Sie Hinweise zum Bewerbungsstart, zu Veranstaltungen und zu ausgezeichneten Unternehmen.',
emailLabel: 'E-Mail-Adresse',
emailPlaceholder: 'name@unternehmen.de',
submitLabel: 'Abonnieren',
privacy: 'Sie erhalten nur BMP-relevante Informationen. Eine Abmeldung ist jederzeit möglich.',
validationRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein.',
validationInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
successHeading: 'Angemeldet',
successBody: 'Danke. Sie erhalten BMP-Updates an die angegebene E-Mail-Adresse.',
footnote: 'Bewerbungsstart im Blick -',
footnoteStrong: 'BMP-Updates erhalten',
},
}, },
form: participationContent.form, form: participationContent.form,
videoModal: { videoModal: {

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { ChevronRight, Star } from 'lucide-react' import { ChevronRight, Star } from 'lucide-react'
import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection'
import TestimonialsSection from '@/spa/components/TestimonialsSection' import TestimonialsSection from '@/spa/components/TestimonialsSection'
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg' import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
@@ -61,6 +62,12 @@ const paragraphs = (value: unknown, fallback: string[]) =>
? (value as Array<string | TextRow>).map((item) => (typeof item === 'string' ? item : fallbackText(item.text, ''))).filter(Boolean) ? (value as Array<string | TextRow>).map((item) => (typeof item === 'string' ? item : fallbackText(item.text, ''))).filter(Boolean)
: fallback : fallback
const isParticipationPage = (page: CmsRouteDoc) => {
const title = String(page.title || '').toLowerCase()
return page.spaPath === '/teilnahme' || page.slug === 'teilnahme' || page.slug === 'participation' || title === 'teilnahme' || title === 'participation'
}
function HighlightedText({ text, highlight }: { text: string; highlight: string }) { function HighlightedText({ text, highlight }: { text: string; highlight: string }) {
const index = text.indexOf(highlight) const index = text.indexOf(highlight)
if (!highlight || index < 0) return <>{text}</> if (!highlight || index < 0) return <>{text}</>
@@ -115,6 +122,7 @@ const About: React.FC = () => {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms
const cmsWinners = useCmsCollection('preistraeger') const cmsWinners = useCmsCollection('preistraeger')
const cmsPages = useCmsCollection('pages')
const hero = { ...aboutContent.hero, ...(cms.hero || {}) } const hero = { ...aboutContent.hero, ...(cms.hero || {}) }
const prize = { ...aboutContent.prize, ...(cms.prize || {}) } const prize = { ...aboutContent.prize, ...(cms.prize || {}) }
const mittelstand = { ...aboutContent.mittelstand, ...(cms.mittelstand || {}) } const mittelstand = { ...aboutContent.mittelstand, ...(cms.mittelstand || {}) }
@@ -124,6 +132,8 @@ const About: React.FC = () => {
const goals = { ...aboutContent.goals, ...(cms.goals || {}) } const goals = { ...aboutContent.goals, ...(cms.goals || {}) }
const values = { ...aboutContent.values, ...(cms.values || {}) } const values = { ...aboutContent.values, ...(cms.values || {}) }
const cta = { ...aboutContent.cta, ...(cms.cta || {}) } const cta = { ...aboutContent.cta, ...(cms.cta || {}) }
const participationPage = cmsPages.find(isParticipationPage)
const participationAwardsGrid = (participationPage?.participation as { awardsGrid?: AwardsGridContent } | undefined)?.awardsGrid
const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta) const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta)
const highlightFallbackImage = mediaUrl( const highlightFallbackImage = mediaUrl(
highlights.fallbackImage, highlights.fallbackImage,
@@ -407,6 +417,8 @@ const About: React.FC = () => {
))} ))}
</section> </section>
<AwardsGridSection content={participationAwardsGrid} />
<section style={{ background: NAVY, padding: isMobile ? '48px 24px' : '100px 80px', position: 'relative', overflow: 'hidden' }}> <section style={{ background: NAVY, padding: isMobile ? '48px 24px' : '100px 80px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)', width: '60%', height: 1, background: `linear-gradient(to right, transparent, ${GOLD}, transparent)`, opacity: 0.3 }} /> <div style={{ position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)', width: '60%', height: 1, background: `linear-gradient(to right, transparent, ${GOLD}, transparent)`, opacity: 0.3 }} />
<div style={{ position: 'absolute', bottom: 0, left: '50%', transform: 'translateX(-50%)', width: '40%', height: 1, background: `linear-gradient(to right, transparent, ${GOLD}, transparent)`, opacity: 0.15 }} /> <div style={{ position: 'absolute', bottom: 0, left: '50%', transform: 'translateX(-50%)', width: '40%', height: 1, background: `linear-gradient(to right, transparent, ${GOLD}, transparent)`, opacity: 0.15 }} />

View File

@@ -5,6 +5,7 @@ import { Link } from '@/spa/router';
import { PartnerTicker } from '@/spa/components/ui/partner-ticker'; import { PartnerTicker } from '@/spa/components/ui/partner-ticker';
import { WINNERS } from '@/spa/data/winners'; import { WINNERS } from '@/spa/data/winners';
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm'; import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
import { NewsletterInterestForm, type NewsletterBenefit, type NewsletterInterestCms, type NewsletterInterestCopy } from '@/spa/components/forms/NewsletterInterestForm';
import TestimonialsSection from '@/spa/components/TestimonialsSection'; import TestimonialsSection from '@/spa/components/TestimonialsSection';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
@@ -55,6 +56,8 @@ type HomeSectionCms = CmsRecord & {
imageAlt?: string | null; imageAlt?: string | null;
imageLabel?: string | null; imageLabel?: string | null;
items?: unknown; items?: unknown;
newsletterPhaseCompleted?: NewsletterInterestCms | null;
newsletterPhaseEvaluation?: NewsletterInterestCms | null;
note?: string | null; note?: string | null;
quote?: string | null; quote?: string | null;
stats?: unknown; stats?: unknown;
@@ -116,6 +119,41 @@ const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const HIDE_MEMBERSHIP_HOME_HERO_CTA = true; const HIDE_MEMBERSHIP_HOME_HERO_CTA = true;
const APPLICATION_PHASE_OPEN = '0';
const newsletterInterestCopy: Record<string, NewsletterInterestCopy> = {
'1': homeContent.application.newsletterPhaseEvaluation,
'2': homeContent.application.newsletterPhaseCompleted,
};
const mergeNewsletterInterestCopy = (
value: NewsletterInterestCms | null | undefined,
fallback: NewsletterInterestCopy,
): NewsletterInterestCopy => ({
eyebrow: fallbackText(value?.eyebrow, fallback.eyebrow),
heading: fallbackText(value?.heading, fallback.heading),
body: fallbackText(value?.body, fallback.body),
benefits: fallbackArray<NewsletterBenefit>(value?.benefits, fallback.benefits),
formEyebrow: fallbackText(value?.formEyebrow, fallback.formEyebrow),
formTitle: fallbackText(value?.formTitle, fallback.formTitle),
formBody: fallbackText(value?.formBody, fallback.formBody),
emailLabel: fallbackText(value?.emailLabel, fallback.emailLabel),
emailPlaceholder: fallbackText(value?.emailPlaceholder, fallback.emailPlaceholder),
submitLabel: fallbackText(value?.submitLabel, fallback.submitLabel),
privacy: fallbackText(value?.privacy, fallback.privacy),
validationRequired: fallbackText(value?.validationRequired, fallback.validationRequired),
validationInvalid: fallbackText(value?.validationInvalid, fallback.validationInvalid),
successHeading: fallbackText(value?.successHeading, fallback.successHeading),
successBody: fallbackText(value?.successBody, fallback.successBody),
footnote: fallbackText(value?.footnote, fallback.footnote),
footnoteStrong: fallbackText(value?.footnoteStrong, fallback.footnoteStrong),
});
const getNewsletterInterestCopy = (phase: string | undefined, application: HomeSectionCms) => {
const fallback = newsletterInterestCopy[phase || ''] || newsletterInterestCopy['2'];
const cmsCopy = phase === '1' ? application.newsletterPhaseEvaluation : application.newsletterPhaseCompleted;
return mergeNewsletterInterestCopy(cmsCopy, fallback);
};
const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | null) => const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | null) =>
cta?.url === '/mitglied-werden' || cta?.url === '/mitglied-werden' ||
@@ -238,6 +276,9 @@ const Home: React.FC = () => {
const form = home.form || {}; const form = home.form || {};
const videoModal = home.videoModal || {}; const videoModal = home.videoModal || {};
const applicationPhase = useApplicationPhase(); const applicationPhase = useApplicationPhase();
const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN;
const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN;
const newsletterCopy = getNewsletterInterestCopy(activeApplicationPhase, application);
const cmsPreistraeger = useCmsCollection('preistraeger'); const cmsPreistraeger = useCmsCollection('preistraeger');
const selectedWinners = fallbackArray(winnersSection.featured, []); const selectedWinners = fallbackArray(winnersSection.featured, []);
const heroSecondaryCta = { const heroSecondaryCta = {
@@ -260,6 +301,7 @@ const Home: React.FC = () => {
const introStats = fallbackArray(intro.stats, homeContent.intro.stats); const introStats = fallbackArray(intro.stats, homeContent.intro.stats);
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items); const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits); const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
const finalSectionBenefits = showApplicationForm ? applicationBenefits : newsletterCopy.benefits;
const winnerFallbackImage = mediaUrl(winnersSection.fallbackImage, `/images/${homeContent.winners.fallbackImageFilename}`); const winnerFallbackImage = mediaUrl(winnersSection.fallbackImage, `/images/${homeContent.winners.fallbackImageFilename}`);
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title); const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
const videoSrc = mediaUrl(videoModal.video, ''); const videoSrc = mediaUrl(videoModal.video, '');
@@ -354,6 +396,9 @@ const Home: React.FC = () => {
</div> </div>
</section> </section>
{/* Status Phase Slider */}
<StatusSlider data={applicationPhase} />
{/* Schnell-Check Section */} {/* Schnell-Check Section */}
<section style={{ overflow: 'hidden', position: 'relative', isolation: 'isolate' }}> <section style={{ overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg /> <MunichSkylineBg />
@@ -503,9 +548,6 @@ const Home: React.FC = () => {
</div> </div>
</section> </section>
{/* Status Phase Slider */}
<StatusSlider data={applicationPhase} />
{/* Winners Grid neue Preisträger-Übersicht */} {/* Winners Grid neue Preisträger-Übersicht */}
<section style={{ background: '#111D55' }}> <section style={{ background: '#111D55' }}>
<div style={{ padding: isMobile ? '48px 24px 32px' : '72px 80px 48px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'flex-end', justifyContent: 'space-between', gap: isMobile ? 16 : 0 }}> <div style={{ padding: isMobile ? '48px 24px 32px' : '72px 80px 48px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'flex-end', justifyContent: 'space-between', gap: isMobile ? 16 : 0 }}>
@@ -617,8 +659,8 @@ const Home: React.FC = () => {
</div> </div>
</section> </section>
{/* CTA Section with embedded form */} {/* CTA Section with phase-aware embedded form */}
<section style={{ background: '#24366A', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben"> <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 */} {/* 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={{ 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' }} />
@@ -626,18 +668,18 @@ const Home: React.FC = () => {
{/* Left pitch copy */} {/* Left pitch copy */}
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', 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 }}>{fallbackText(application.eyebrow, homeContent.application.eyebrow)}</span> <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 }}> <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={fallbackText(application.heading, homeContent.application.heading)} /> <Lines text={showApplicationForm ? fallbackText(application.heading, homeContent.application.heading) : newsletterCopy.heading} />
</h2> </h2>
<div style={{ width: 32, height: 2, background: '#EFBF04', marginBottom: 20 }} /> <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 }}> <p style={{ fontFamily: FF, fontSize: 15, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, marginBottom: 32 }}>
{fallbackText(application.body, homeContent.application.body)} {showApplicationForm ? fallbackText(application.body, homeContent.application.body) : newsletterCopy.body}
</p> </p>
{/* Benefit rows */} {/* Benefit rows */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}> <div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
{applicationBenefits.map(item => ( {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' }}> <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> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
@@ -687,10 +729,10 @@ const Home: React.FC = () => {
)} )}
{/* Form area */} {/* Form area */}
<div style={{ padding: isMobile ? '32px 24px' : '24px 48px 32px 40px', flex: isMobile ? 'none' : 1, height: isMobile ? '82svh' : undefined, display: 'flex', flexDirection: 'column', minHeight: 0, position: 'relative', zIndex: 1 }}> <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 }}>{fallbackText(application.formEyebrow, homeContent.application.formEyebrow)}</span> <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 }}>{fallbackText(application.formTitle, homeContent.application.formTitle)}</h3> <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>
<BewerbungsForm theme="gold" content={form} /> {showApplicationForm ? <BewerbungsForm theme="gold" content={form} /> : <NewsletterInterestForm copy={newsletterCopy} />}
</div> </div>
</div> </div>
@@ -698,8 +740,8 @@ const Home: React.FC = () => {
{/* Footnote row */} {/* 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 }}> <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 }}>{fallbackText(application.footnote, homeContent.application.footnote)}</span> <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)' }}>{fallbackText(application.footnoteStrong, homeContent.application.footnoteStrong)}</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>
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} /> <div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section> </section>

View File

@@ -1,12 +1,14 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star, Mail } from 'lucide-react'; import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm'; import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
import { NewsletterInterestForm, type NewsletterBenefit, type NewsletterInterestCms, type NewsletterInterestCopy } from '@/spa/components/forms/NewsletterInterestForm';
import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection';
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'; import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useCmsRoute } from '@/spa/cmsRoute'; import { useApplicationPhase, useCmsRoute } from '@/spa/cmsRoute';
import { participationContent } from '@/spa/participationContent'; import { participationContent } from '@/spa/participationContent';
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
@@ -18,12 +20,15 @@ const CREAM = '#E4E2E3';
type ParticipationCms = Partial<typeof participationContent> & { type ParticipationCms = Partial<typeof participationContent> & {
hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown }; hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown };
awardsGrid?: Partial<typeof participationContent.awardsGrid> & { cards?: AwardCardCms[] }; awardsGrid?: AwardsGridContent;
applicationForm?: Partial<typeof participationContent.applicationForm> & { image?: unknown }; applicationForm?: ParticipationApplicationFormCms;
}; };
type AwardCard = (typeof participationContent.awardsGrid.cards)[number]; type ParticipationApplicationFormCms = Partial<typeof participationContent.applicationForm> & {
type AwardCardCms = Partial<AwardCard> & { image?: unknown }; image?: unknown;
newsletterPhaseCompleted?: NewsletterInterestCms | null;
newsletterPhaseEvaluation?: NewsletterInterestCms | null;
};
const ICONS = { const ICONS = {
building2: Building2, building2: Building2,
@@ -38,12 +43,50 @@ const ICONS = {
const fallbackText = (value: unknown, fallback: string) => const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback; typeof value === 'string' && value.length > 0 ? value : fallback;
const fallbackArray = <T,>(value: unknown, fallback: T[]) => const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback; Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
const mergeAwardCards = (value: unknown, fallback: AwardCard[]): (AwardCard & { image?: unknown })[] => { const APPLICATION_PHASE_OPEN = '0';
const cards = Array.isArray(value) && value.length > 0 ? (value as AwardCardCms[]) : fallback;
return cards.map((card, index) => ({ ...(fallback[index] || fallback[0]), ...card })); const newsletterInterestCopy: Record<string, NewsletterInterestCopy> = {
'1': participationContent.applicationForm.newsletterPhaseEvaluation,
'2': participationContent.applicationForm.newsletterPhaseCompleted,
};
const mergeNewsletterInterestCopy = (
value: NewsletterInterestCms | null | undefined,
fallback: NewsletterInterestCopy,
): NewsletterInterestCopy => ({
eyebrow: fallbackText(value?.eyebrow, fallback.eyebrow),
heading: fallbackText(value?.heading, fallback.heading),
body: fallbackText(value?.body, fallback.body),
benefits: fallbackArray<NewsletterBenefit>(value?.benefits, fallback.benefits),
formEyebrow: fallbackText(value?.formEyebrow, fallback.formEyebrow),
formTitle: fallbackText(value?.formTitle, fallback.formTitle),
formBody: fallbackText(value?.formBody, fallback.formBody),
emailLabel: fallbackText(value?.emailLabel, fallback.emailLabel),
emailPlaceholder: fallbackText(value?.emailPlaceholder, fallback.emailPlaceholder),
submitLabel: fallbackText(value?.submitLabel, fallback.submitLabel),
privacy: fallbackText(value?.privacy, fallback.privacy),
validationRequired: fallbackText(value?.validationRequired, fallback.validationRequired),
validationInvalid: fallbackText(value?.validationInvalid, fallback.validationInvalid),
successHeading: fallbackText(value?.successHeading, fallback.successHeading),
successBody: fallbackText(value?.successBody, fallback.successBody),
footnote: fallbackText(value?.footnote, fallback.footnote),
footnoteStrong: fallbackText(value?.footnoteStrong, fallback.footnoteStrong),
});
const getNewsletterInterestCopy = (phase: string | undefined, applicationForm: ParticipationApplicationFormCms) => {
const fallback = newsletterInterestCopy[phase || ''] || newsletterInterestCopy['2'];
const cmsCopy = phase === '1' ? applicationForm.newsletterPhaseEvaluation : applicationForm.newsletterPhaseCompleted;
return mergeNewsletterInterestCopy(cmsCopy, fallback);
};
const isFutureAwardApplicationWay = (value: { label?: unknown; title?: unknown }) => {
const label = typeof value.label === 'string' ? value.label.toLowerCase() : '';
const title = typeof value.title === 'string' ? value.title.toLowerCase() : '';
return label.includes('sonderpreis') || title.includes('bavarian') || title.includes('future award');
}; };
function Lines({ text }: { text: string }) { function Lines({ text }: { text: string }) {
@@ -54,6 +97,7 @@ const Participation: React.FC = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const qualSectionRef = useRef<HTMLElement>(null); const qualSectionRef = useRef<HTMLElement>(null);
const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms; const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms;
const applicationPhase = useApplicationPhase();
const hero = { ...participationContent.hero, ...(cms.hero || {}) }; const hero = { ...participationContent.hero, ...(cms.hero || {}) };
const process = { ...participationContent.process, ...(cms.process || {}) }; const process = { ...participationContent.process, ...(cms.process || {}) };
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) }; const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
@@ -62,11 +106,15 @@ const Participation: React.FC = () => {
const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) }; const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) };
const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) }; const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) };
const form = { ...participationContent.form, ...(cms.form || {}) }; const form = { ...participationContent.form, ...(cms.form || {}) };
const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN;
const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN;
const newsletterCopy = getNewsletterInterestCopy(activeApplicationPhase, applicationForm);
const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria); const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes); const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes);
const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems); const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems);
const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems); const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems);
const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts); const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts);
const finalSectionFacts = showApplicationForm ? applicationFacts : newsletterCopy.benefits;
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -224,21 +272,21 @@ const Participation: React.FC = () => {
<AwardsGridSection content={awardsGrid} /> <AwardsGridSection content={awardsGrid} />
{/* ── FORM SECTION ─────────────────────────────────────────────────────── */} {/* ── FORM SECTION ─────────────────────────────────────────────────────── */}
<section style={{ background: NAVY, position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben"> <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' }}> <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', flex: 1, minHeight: 0, overflow: isMobile ? 'visible' : 'hidden' }}>
{/* Left copy */} {/* Left copy */}
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: isMobile ? 'visible' : 'hidden' }}> <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 }}>{fallbackText(applicationForm.eyebrow, participationContent.applicationForm.eyebrow)}</span> <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' }}> <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={fallbackText(applicationForm.heading, participationContent.applicationForm.heading)} /> <Lines text={showApplicationForm ? fallbackText(applicationForm.heading, participationContent.applicationForm.heading) : newsletterCopy.heading} />
</h2> </h2>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} /> <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 }}> <p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.68)', lineHeight: 1.8, maxWidth: 400, marginBottom: 48 }}>
{fallbackText(applicationForm.description, participationContent.applicationForm.description)} {showApplicationForm ? fallbackText(applicationForm.description, participationContent.applicationForm.description) : newsletterCopy.body}
</p> </p>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}> <div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
{applicationFacts.map((item, i) => ( {finalSectionFacts.map((item, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '36px 1fr', gap: '0 16px', padding: '16px 0', borderBottom: '1px solid rgba(255,255,255,0.06)', alignItems: 'center' }}> <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> <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' }}> <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: FF, fontSize: 16, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{fallbackText(item.label, '')}</span>
@@ -279,31 +327,51 @@ const Participation: React.FC = () => {
</div> </div>
</div> </div>
)} )}
<div style={{ padding: isMobile ? '28px 20px' : '24px 48px 32px 40px', flex: 1, display: 'flex', flexDirection: 'column', minHeight: isMobile ? 560 : 0, position: 'relative', zIndex: 1 }}> <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 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}> {showApplicationForm ? (
<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> <>
<Link <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 10 }}>
to={fallbackText(applicationForm.uploadCta?.url, participationContent.applicationForm.uploadCta.url)} <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>
style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(17,29,85,0.55)', border: '1px solid rgba(17,29,85,0.2)', padding: '7px 14px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, transition: 'color 0.15s, border-color 0.15s', whiteSpace: 'nowrap', flexShrink: 0 }} <Link
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = '#111D55'; (e.currentTarget as HTMLElement).style.borderColor = '#111D55'; }} to={fallbackText(applicationForm.uploadCta?.url, participationContent.applicationForm.uploadCta.url)}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = 'rgba(17,29,85,0.55)'; (e.currentTarget as HTMLElement).style.borderColor = 'rgba(17,29,85,0.2)'; }} style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(17,29,85,0.55)', border: '1px solid rgba(17,29,85,0.2)', padding: '7px 14px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, transition: 'color 0.15s, border-color 0.15s', whiteSpace: 'nowrap', flexShrink: 0 }}
> onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = '#111D55'; (e.currentTarget as HTMLElement).style.borderColor = '#111D55'; }}
<svg width="10" height="10" viewBox="0 0 12 12" fill="none"><path d="M6 1v7M3 4l3-3 3 3M1 10h10" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg> onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = 'rgba(17,29,85,0.55)'; (e.currentTarget as HTMLElement).style.borderColor = 'rgba(17,29,85,0.2)'; }}
{fallbackText(applicationForm.uploadCta?.label, participationContent.applicationForm.uploadCta.label)} >
</Link> <svg width="10" height="10" viewBox="0 0 12 12" fill="none"><path d="M6 1v7M3 4l3-3 3 3M1 10h10" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div> {fallbackText(applicationForm.uploadCta?.label, participationContent.applicationForm.uploadCta.label)}
<BewerbungsForm theme="gold" content={form} /> </Link>
</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} />
</>
)}
</div> </div>
</div> </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 }}> <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 }}>
<span style={{ fontFamily: FB, fontSize: 14, color: 'rgba(255,255,255,0.55)', lineHeight: 1.6, maxWidth: 520 }}> {showApplicationForm ? (
{fallbackText(applicationForm.uploadPrompt, participationContent.applicationForm.uploadPrompt)} <>
</span> <span style={{ fontFamily: FB, fontSize: 14, color: 'rgba(255,255,255,0.55)', lineHeight: 1.6, maxWidth: 520 }}>
<Link to={fallbackText(applicationForm.uploadFooterCta?.url, participationContent.applicationForm.uploadFooterCta.url)} style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1 }}> {fallbackText(applicationForm.uploadPrompt, participationContent.applicationForm.uploadPrompt)}
{fallbackText(applicationForm.uploadFooterCta?.label, participationContent.applicationForm.uploadFooterCta.label)} </span>
</Link> <Link to={fallbackText(applicationForm.uploadFooterCta?.url, participationContent.applicationForm.uploadFooterCta.url)} style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1 }}>
{fallbackText(applicationForm.uploadFooterCta?.label, participationContent.applicationForm.uploadFooterCta.label)}
</Link>
</>
) : (
<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>
<div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} /> <div style={{ height: 2, background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.25), transparent)', flexShrink: 0 }} />
</section> </section>
@@ -349,7 +417,9 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const section = { ...participationContent.applicationWays, ...(content || {}) }; const section = { ...participationContent.applicationWays, ...(content || {}) };
const institutions = fallbackArray(section.institutions, participationContent.applicationWays.institutions); const institutions = fallbackArray(section.institutions, participationContent.applicationWays.institutions);
const ways = fallbackArray(section.ways, participationContent.applicationWays.ways); const ways = fallbackArray(section.ways, participationContent.applicationWays.ways).filter(
(way) => !isFutureAwardApplicationWay(way),
);
const fallbackCta = { ...participationContent.applicationWays.fallbackCta, ...(section.fallbackCta || {}) }; const fallbackCta = { ...participationContent.applicationWays.fallbackCta, ...(section.fallbackCta || {}) };
return ( return (
<section style={{ background: CREAM, position: 'relative', overflow: 'hidden', isolation: 'isolate' }}> <section style={{ background: CREAM, position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
@@ -368,24 +438,21 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
</div> </div>
</div> </div>
{/* 3 Wege premium swipe cards on mobile, 3-col grid on desktop */} {/* Wege premium swipe cards on mobile, equal grid columns on desktop */}
<div style={{ position: 'relative', zIndex: 1, display: isMobile ? 'flex' : 'grid', gridTemplateColumns: isMobile ? undefined : 'repeat(3, 1fr)', overflowX: isMobile ? 'auto' : undefined, scrollSnapType: isMobile ? 'x mandatory' : undefined, WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none', gap: isMobile ? 14 : 0, padding: isMobile ? '28px 24px 36px' : 0 }}> <div style={{ position: 'relative', zIndex: 1, display: isMobile ? 'flex' : 'grid', gridTemplateColumns: isMobile ? undefined : `repeat(${Math.max(ways.length, 1)}, minmax(0, 1fr))`, overflowX: isMobile ? 'auto' : undefined, scrollSnapType: isMobile ? 'x mandatory' : undefined, WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none', gap: isMobile ? 14 : 0, padding: isMobile ? '28px 24px 36px' : 0 }}>
{/* ── MOBILE: unified premium cards ── */} {/* ── MOBILE: unified premium cards ── */}
{isMobile && ways.map((w) => { {isMobile && ways.map((w) => {
const Icon = ICONS[w.icon as keyof typeof ICONS] || Send; const Icon = ICONS[w.icon as keyof typeof ICONS] || Send;
const facts = fallbackArray<{ label?: string | null; desc?: string | null }>(w.facts, []); const facts = fallbackArray<{ label?: string | null; desc?: string | null }>(w.facts, []);
return ( return (
<div key={fallbackText(w.label, '')} style={{ flexShrink: 0, minWidth: '85vw', maxWidth: '85vw', scrollSnapAlign: 'center', display: 'flex', flexDirection: 'column', background: '#fff', borderRadius: 18, overflow: 'hidden', border: w.featured ? `1.5px solid ${GOLD}` : '1px solid rgba(17,29,85,0.10)', boxShadow: w.featured ? '0 14px 34px rgba(239,191,4,0.20), 0 6px 16px rgba(3,9,58,0.10)' : '0 10px 28px rgba(3,9,58,0.08)' }}> <div key={fallbackText(w.label, '')} style={{ flexShrink: 0, minWidth: '85vw', maxWidth: '85vw', scrollSnapAlign: 'center', display: 'flex', flexDirection: 'column', background: '#fff', borderRadius: 18, overflow: 'hidden', border: '1px solid rgba(17,29,85,0.10)', boxShadow: '0 10px 28px rgba(3,9,58,0.08)' }}>
{/* Header */} {/* Header */}
<div style={{ padding: '22px 22px 0', position: 'relative' }}> <div style={{ padding: '22px 22px 0', position: 'relative' }}>
{w.featured && ( <div style={{ width: 46, height: 46, borderRadius: 12, background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 18 }}>
<span style={{ position: 'absolute', top: 22, right: 22, fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#101828', background: GOLD, padding: '5px 10px', borderRadius: 100 }}>{fallbackText(section.recommendedLabel, participationContent.applicationWays.recommendedLabel)}</span> <Icon size={20} style={{ color: GOLD }} strokeWidth={1.6} />
)}
<div style={{ width: 46, height: 46, borderRadius: 12, background: w.featured ? GOLD : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 18 }}>
<Icon size={20} style={{ color: w.featured ? '#101828' : GOLD }} strokeWidth={1.6} />
</div> </div>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.28em', textTransform: 'uppercase', color: w.featured ? '#A87800' : 'rgba(16,24,40,0.4)', display: 'block', marginBottom: 8 }}>{fallbackText(w.label, '')}</span> <span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.28em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.4)', display: 'block', marginBottom: 8 }}>{fallbackText(w.label, '')}</span>
<h3 style={{ fontFamily: FF, fontSize: '1.45rem', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 10px' }}><Lines text={fallbackText(w.title, '')} /></h3> <h3 style={{ fontFamily: FF, fontSize: '1.45rem', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 10px' }}><Lines text={fallbackText(w.title, '')} /></h3>
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(16,24,40,0.55)', lineHeight: 1.6, margin: '0 0 20px' }}>{fallbackText(w.desc, '')}</p> <p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(16,24,40,0.55)', lineHeight: 1.6, margin: '0 0 20px' }}>{fallbackText(w.desc, '')}</p>
<div style={{ width: 36, height: 2, background: GOLD }} /> <div style={{ width: 36, height: 2, background: GOLD }} />
@@ -420,23 +487,22 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
{ways.map((w, index) => { {ways.map((w, index) => {
const Icon = ICONS[w.icon as keyof typeof ICONS] || Send; const Icon = ICONS[w.icon as keyof typeof ICONS] || Send;
const isFeatured = Boolean(w.featured);
const facts = fallbackArray<{ label?: string | null; desc?: string | null }>(w.facts, []); const facts = fallbackArray<{ label?: string | null; desc?: string | null }>(w.facts, []);
const isLast = index === ways.length - 1; const isLast = index === ways.length - 1;
return ( return (
<div key={`${w.label}-${index}`} style={{ padding: isMobile ? '32px 24px' : '56px 48px 56px', borderRight: !isLast ? '1px solid #D0D5DD' : 'none', display: 'flex', flexDirection: 'column', background: isFeatured ? NAVY : undefined, ...(isMobile ? { minWidth: '83vw', maxWidth: '83vw', flexShrink: 0, scrollSnapAlign: 'start' } : {}) }}> <div key={`${w.label}-${index}`} style={{ padding: isMobile ? '32px 24px' : '56px 48px 56px', borderRight: !isLast ? '1px solid #D0D5DD' : 'none', display: 'flex', flexDirection: 'column', background: '#fff', ...(isMobile ? { minWidth: '83vw', maxWidth: '83vw', flexShrink: 0, scrollSnapAlign: 'start' } : {}) }}>
<div style={{ width: 44, height: 44, background: isFeatured ? GOLD : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 28 }}> <div style={{ width: 44, height: 44, background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 28 }}>
<Icon size={18} style={{ color: isFeatured ? '#101828' : GOLD }} strokeWidth={1.5} /> <Icon size={18} style={{ color: GOLD }} strokeWidth={1.5} />
</div> </div>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: isFeatured ? 'rgba(255,255,255,0.35)' : 'rgba(16,24,40,0.4)', display: 'block', marginBottom: 10 }}>{fallbackText(w.label, '')}</span> <span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.4)', display: 'block', marginBottom: 10 }}>{fallbackText(w.label, '')}</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.3rem, 2vw, 1.7rem)', fontWeight: 900, color: isFeatured ? '#fff' : '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: '0 0 8px' }}> <h3 style={{ fontFamily: FF, fontSize: 'clamp(1.3rem, 2vw, 1.7rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, margin: '0 0 8px' }}>
<Lines text={fallbackText(w.title, '')} /> <Lines text={fallbackText(w.title, '')} />
</h3> </h3>
<p style={{ fontFamily: FB, fontSize: 18, color: isFeatured ? 'rgba(255,255,255,0.62)' : 'rgba(16,24,40,0.5)', lineHeight: 1.7, margin: '0 0 32px' }}> <p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.7, margin: '0 0 32px' }}>
{fallbackText(w.desc, '')} {fallbackText(w.desc, '')}
</p> </p>
<div style={{ borderTop: isFeatured ? '1px solid rgba(255,255,255,0.1)' : '1px solid #D0D5DD', flex: 1 }}> <div style={{ borderTop: '1px solid #D0D5DD', flex: 1 }}>
{w.listType === 'institutions' ? institutions.map((inst, i) => ( {w.listType === 'institutions' ? institutions.map((inst, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 0', borderBottom: '1px solid #D0D5DD' }}> <div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 0', borderBottom: '1px solid #D0D5DD' }}>
<div style={{ width: 16, height: 16, background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 2 }}> <div style={{ width: 16, height: 16, background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 2 }}>
@@ -445,9 +511,9 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
<span style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.65)', lineHeight: 1.55 }}>{fallbackText(inst.text, '')}</span> <span style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.65)', lineHeight: 1.55 }}>{fallbackText(inst.text, '')}</span>
</div> </div>
)) : facts.map((item, i) => ( )) : facts.map((item, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', padding: '14px 0', borderBottom: isFeatured ? '1px solid rgba(255,255,255,0.07)' : '1px solid #D0D5DD' }}> <div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', padding: '14px 0', borderBottom: '1px solid #D0D5DD' }}>
<span style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: isFeatured ? '#fff' : '#101828', textTransform: 'uppercase', letterSpacing: '0.07em' }}>{fallbackText(item.label, '')}</span> <span style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.07em' }}>{fallbackText(item.label, '')}</span>
<span style={{ fontFamily: FB, fontSize: 16, color: isFeatured ? 'rgba(255,255,255,0.50)' : 'rgba(16,24,40,0.45)' }}>{fallbackText(item.desc, '')}</span> <span style={{ fontFamily: FB, fontSize: 16, color: 'rgba(16,24,40,0.45)' }}>{fallbackText(item.desc, '')}</span>
</div> </div>
))} ))}
</div> </div>
@@ -471,131 +537,4 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
); );
} }
type AwardsGridContent = Partial<typeof participationContent.awardsGrid>;
function AwardsGridSection({ content }: { content?: AwardsGridContent }) {
const isMobile = useIsMobile();
const section = { ...participationContent.awardsGrid, ...(content || {}) };
const cards = mergeAwardCards(section.cards, participationContent.awardsGrid.cards);
return (
<section id="auszeichnungen" style={{ background: '#F7F7F5', position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>
{fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)}
</span>
<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(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0, overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(section.heading, participationContent.awardsGrid.heading)} />
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.55)', lineHeight: 1.8, margin: 0 }}>
{fallbackText(section.description, participationContent.awardsGrid.description)}
</p>
</div>
</div>
<div style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', borderBottom: '1px solid rgba(3,9,58,0.08)' }}>
{cards.map((card, index) => {
const imageFilename = fallbackText(card.imageFilename, participationContent.awardsGrid.cards[index]?.imageFilename || participationContent.awardsGrid.cards[0].imageFilename);
const isContainedImage = imageFilename.endsWith('.png') || imageFilename.includes('roland-berger');
const isLast = index === cards.length - 1;
return (
<article
key={`${fallbackText(card.title, 'award')}-${index}`}
style={{
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 'auto' : 620,
borderRight: !isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
borderBottom: isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
background: index === 1 ? NAVY : 'rgba(255,255,255,0.72)',
}}
>
<div style={{ height: isMobile ? 220 : 260, background: imageFilename.includes('roland-berger') ? '#DDE7E2' : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<Image unoptimized
src={mediaUrl(card.image, `/images/${imageFilename}`)}
alt={mediaAlt(card.image, fallbackText(card.imageAlt, fallbackText(card.title, 'Auszeichnung')))}
style={{
width: '100%',
height: '100%',
objectFit: isContainedImage ? 'contain' : 'cover',
objectPosition: index === 1 ? 'center top' : 'center',
padding: imageFilename.endsWith('.png') ? 22 : 0,
boxSizing: 'border-box',
display: 'block',
}}
/>
</div>
<div style={{ padding: isMobile ? '28px 24px 32px' : '36px 36px 40px', display: 'flex', flexDirection: 'column', flex: 1 }}>
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.26em', textTransform: 'uppercase', color: index === 1 ? 'rgba(239,191,4,0.8)' : '#4A8FC9', marginBottom: 12 }}>
{fallbackText(card.label, '')}
</span>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.25rem, 2vw, 1.7rem)', fontWeight: 900, color: index === 1 ? '#fff' : '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 16px', overflowWrap: 'anywhere' }}>
<Lines text={fallbackText(card.title, '')} />
</h3>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 22 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: index === 1 ? 'rgba(255,255,255,0.62)' : 'rgba(16,24,40,0.55)', lineHeight: 1.7, margin: 0, flex: 1 }}>
{fallbackText(card.description, '')}
</p>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 10, marginTop: 32 }}>
<Link
to={fallbackText(card.articleCta?.url, '#bewerben')}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
background: GOLD,
color: '#101828',
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
{fallbackText(card.articleCta?.label, 'Artikel lesen')} <ArrowRight size={13} />
</Link>
<a
href={fallbackText(card.mailtoCta?.url, `mailto:${participationContent.form.eligibility.ineligibleContactEmail}`)}
style={{
minHeight: 46,
padding: '0 18px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: index === 1 ? '#fff' : NAVY,
border: `1px solid ${index === 1 ? 'rgba(255,255,255,0.22)' : 'rgba(3,9,58,0.18)'}`,
fontFamily: FF,
fontSize: 13,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
textDecoration: 'none',
whiteSpace: 'normal',
textAlign: 'center',
}}
>
<Mail size={13} /> {fallbackText(card.mailtoCta?.label, 'Kontakt aufnehmen')}
</a>
</div>
</div>
</article>
);
})}
</div>
</section>
);
}
export default Participation; export default Participation;

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router'; import { Link } from '@/spa/router';
import { Trophy, ChevronRight, ArrowRight } from 'lucide-react'; import { Trophy, ArrowRight } from 'lucide-react';
import { WINNERS, type Winner } from '@/spa/data/winners'; import { WINNERS, type Winner } from '@/spa/data/winners';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'; import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
@@ -14,6 +14,7 @@ const GOLD = '#EFBF04';
const BORDER = '#D0D5DD'; const BORDER = '#D0D5DD';
const GRAY = '#666666'; const GRAY = '#666666';
const BG_ALT = '#E4E2E3'; const BG_ALT = '#E4E2E3';
const SIMPLE_LIST_YEAR = 2023;
type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & { type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
hero?: Partial<typeof preistraegerIndexContent.hero> & { image?: unknown } hero?: Partial<typeof preistraegerIndexContent.hero> & { image?: unknown }
@@ -69,6 +70,7 @@ export default function Preistraeger() {
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear(); const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]); const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
const usesSimpleWinnerGrid = selectedYear === SIMPLE_LIST_YEAR;
return ( return (
<div style={{ background: '#fff', minHeight: '100vh' }}> <div style={{ background: '#fff', minHeight: '100vh' }}>
@@ -129,6 +131,8 @@ export default function Preistraeger() {
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}> <div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
{fallbackText(empty.message, preistraegerIndexContent.empty.message)} {fallbackText(empty.message, preistraegerIndexContent.empty.message)}
</div> </div>
) : usesSimpleWinnerGrid ? (
<SimpleWinnerGrid winners={filtered} />
) : ( ) : (
<div style={{ <div style={{
display: 'grid', display: 'grid',
@@ -165,38 +169,6 @@ export default function Preistraeger() {
> >
{fallbackText(cta.primaryCta?.label, preistraegerIndexContent.cta.primaryCta.label)} <ArrowRight size={14} /> {fallbackText(cta.primaryCta?.label, preistraegerIndexContent.cta.primaryCta.label)} <ArrowRight size={14} />
</Link> </Link>
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : undefined, flexWrap: isMobile ? 'wrap' : 'nowrap', gap: 8 }}>
<span style={{ width: 20, height: 1, background: 'rgba(239,191,4,0.3)', display: 'inline-block' }} />
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 11, color: 'rgba(16,24,40,0.4)' }}>{fallbackText(cta.secondaryPrefix, preistraegerIndexContent.cta.secondaryPrefix)}</span>
<Link
to={fallbackText(cta.secondaryCta?.url, preistraegerIndexContent.cta.secondaryCta.url)}
style={{
fontFamily: '"IBM Plex Sans", sans-serif',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'rgba(239,191,4,0.7)',
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderBottom: '1px solid rgba(239,191,4,0.3)',
paddingBottom: 1,
transition: 'color 0.15s, borderColor 0.15s',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.color = '#EFBF04';
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
}}
>
{fallbackText(cta.secondaryCta?.label, preistraegerIndexContent.cta.secondaryCta.label)} <ChevronRight size={10} />
</Link>
</div>
</div> </div>
</div> </div>
); );
@@ -257,3 +229,91 @@ function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string
</Link> </Link>
); );
} }
function SimpleWinnerGrid({ winners }: { winners: Winner[] }) {
const isMobile = useIsMobile();
return (
<div style={{
background: '#F7F7F8',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
gap: isMobile ? 12 : 16,
padding: isMobile ? '24px' : '40px 80px 56px',
}}>
{winners.map(winner => (
<SimpleWinnerCard key={winner.id} winner={winner} />
))}
</div>
);
}
function SimpleWinnerCard({ winner }: { winner: Winner }) {
const isMobile = useIsMobile();
const hasDescription = winner.shortDesc.trim().length > 0;
return (
<article
style={{
background: '#fff',
border: `1px solid ${BORDER}`,
color: '#101828',
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 0 : 220,
padding: isMobile ? '20px' : '24px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 18 }}>
<span style={{
color: GOLD,
fontFamily: FF,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}>
{winner.year} · {winner.type}
</span>
{winner.category ? (
<span style={{
border: `1px solid ${BORDER}`,
color: GRAY,
fontFamily: FF,
fontSize: 12,
fontWeight: 600,
lineHeight: 1.2,
padding: '5px 8px',
}}>
{winner.category}
</span>
) : null}
</div>
<h3 style={{
color: NAVY,
fontFamily: FF,
fontSize: isMobile ? 20 : 22,
fontWeight: 700,
lineHeight: 1.18,
margin: 0,
overflowWrap: 'anywhere',
}}>
{winner.name}
</h3>
{hasDescription ? (
<p style={{
color: GRAY,
fontFamily: FF,
fontSize: 15,
lineHeight: 1.55,
margin: '16px 0 0',
overflowWrap: 'anywhere',
}}>
{winner.shortDesc}
</p>
) : null}
</article>
);
}

View File

@@ -107,7 +107,7 @@ export const participationContent = {
title: 'Initiativ­bewerbung', title: 'Initiativ­bewerbung',
desc: 'Für Unternehmer, die selbst die Initiative ergreifen.', desc: 'Für Unternehmer, die selbst die Initiative ergreifen.',
icon: 'userCheck', icon: 'userCheck',
featured: true, featured: false,
cta: { label: 'Jetzt bewerben', url: '#bewerben' }, cta: { label: 'Jetzt bewerben', url: '#bewerben' },
listType: 'facts', listType: 'facts',
facts: [ facts: [
@@ -117,21 +117,6 @@ export const participationContent = {
{ label: 'Deadline', desc: '30. Juni 2026' }, { label: 'Deadline', desc: '30. Juni 2026' },
], ],
}, },
{
label: 'Weg 03 · Sonderpreis',
title: 'Bavarian\nFuture Award',
desc: 'Sonderpreis, initiiert durch die Studierenden der HAM, Hochschule für angewandtes Management.',
icon: 'star',
featured: false,
cta: { label: 'Zum Formular', url: '#bewerben' },
listType: 'facts',
facts: [
{ label: 'Vorschlag möglich', desc: 'Durch HAM & Partner' },
{ label: 'Selbstbewerbung', desc: 'Direkte Einreichung' },
{ label: 'Initiiert von', desc: 'Studierenden der HAM' },
{ label: 'Kategorie', desc: 'Zukunft & Innovation' },
],
},
], ],
recommendedLabel: 'Empfohlen', recommendedLabel: 'Empfohlen',
}, },
@@ -164,9 +149,60 @@ export const participationContent = {
{ num: '03', label: 'Mehrstufig', desc: 'Transparenter Prozess' }, { num: '03', label: 'Mehrstufig', desc: 'Transparenter Prozess' },
], ],
formEyebrow: 'Bewerbung 2026', formEyebrow: 'Bewerbung 2026',
formTitle: 'Kostenlos bewerben',
uploadCta: { label: 'PDF hochladen', url: '/formular-hochladen' }, uploadCta: { label: 'PDF hochladen', url: '/formular-hochladen' },
uploadPrompt: 'Sie haben Ihre Unterlagen bereits als PDF vorbereitet? Laden Sie sie direkt hoch, statt das Formular auszufüllen.', uploadPrompt: 'Sie haben Ihre Unterlagen bereits als PDF vorbereitet? Laden Sie sie direkt hoch, statt das Formular auszufüllen.',
uploadFooterCta: { label: 'Formular hochladen →', url: '/formular-hochladen' }, uploadFooterCta: { label: 'Formular hochladen →', url: '/formular-hochladen' },
newsletterPhaseEvaluation: {
eyebrow: 'Updates erhalten',
heading: 'DIE BEWERBUNG\nIST GESCHLOSSEN.',
body:
'Die Bewerbungsphase 2026 ist geschlossen. Hinterlassen Sie Ihre E-Mail und erhalten Sie Updates zur Juryphase, zur Preisverleihung und zur nächsten Runde.',
benefits: [
{ num: '01', label: 'Juryphase', desc: 'Updates zum Verlauf' },
{ num: '02', label: 'Preisverleihung', desc: 'Termine und Einblicke' },
{ num: '03', label: 'Nächste Runde', desc: 'Start nicht verpassen' },
],
formEyebrow: 'Newsletter abonnieren',
formTitle: 'Updates erhalten',
formBody:
'Tragen Sie Ihre E-Mail ein und erhalten Sie relevante Informationen zum weiteren Verlauf des Bayerischen Mittelstandspreises.',
emailLabel: 'E-Mail-Adresse',
emailPlaceholder: 'name@unternehmen.de',
submitLabel: 'Eintragen',
privacy: 'Kein Spam. Nur relevante Informationen rund um den Bayerischen Mittelstandspreis.',
validationRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein.',
validationInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
successHeading: 'Danke',
successBody: 'Ihre E-Mail wurde für BMP-Updates eingetragen.',
footnote: 'BMP-Updates -',
footnoteStrong: 'Juryphase und Termine',
},
newsletterPhaseCompleted: {
eyebrow: 'Nächste Runde',
heading: 'DEN START\nNICHT\nVERPASSEN.',
body:
'Der aktuelle Preisjahrgang ist abgeschlossen. Hinterlassen Sie Ihre E-Mail und wir informieren Sie, sobald es Neuigkeiten zur nächsten Bewerbungsphase gibt.',
benefits: [
{ num: '01', label: 'Startsignal', desc: 'Neue Bewerbungsphase' },
{ num: '02', label: 'BMP-News', desc: 'Preisträger und Termine' },
{ num: '03', label: 'Erinnerung', desc: 'Rechtzeitig vorbereitet' },
],
formEyebrow: 'Newsletter abonnieren',
formTitle: 'Informiert bleiben',
formBody:
'Tragen Sie sich ein und erhalten Sie Hinweise zum Bewerbungsstart, zu Veranstaltungen und zu ausgezeichneten Unternehmen.',
emailLabel: 'E-Mail-Adresse',
emailPlaceholder: 'name@unternehmen.de',
submitLabel: 'Abonnieren',
privacy: 'Sie erhalten nur BMP-relevante Informationen. Eine Abmeldung ist jederzeit möglich.',
validationRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein.',
validationInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
successHeading: 'Angemeldet',
successBody: 'Danke. Sie erhalten BMP-Updates an die angegebene E-Mail-Adresse.',
footnote: 'Bewerbungsstart im Blick -',
footnoteStrong: 'BMP-Updates erhalten',
},
}, },
form: { form: {
stepCompleteLabel: '✓', stepCompleteLabel: '✓',