feat: implement newsletter subscription feature with new forms and database structure
- Added new newsletter subscription functionality to the participation page. - Created a new `NewsletterForm` component to handle user subscriptions. - Introduced `NewsletterSubscriptions` collection to manage subscriptions in the database. - Developed migration script to set up necessary database tables and fields for the newsletter feature. - Refactored participation content to remove deprecated newsletter phase data and integrate new structure. - Updated the `Participation` page to utilize the new newsletter form and handle different application phases.
This commit is contained in:
78
src/NewsletterForm/config.ts
Normal file
78
src/NewsletterForm/config.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Field, GlobalConfig } from 'payload'
|
||||
|
||||
import { authenticated } from '@/access/authenticated'
|
||||
import { defaultNewsletterFormData, type NewsletterInterestCopy } from '@/spa/newsletterForm'
|
||||
|
||||
const text = (name: string, label: string, defaultValue: string): Field => ({
|
||||
name,
|
||||
type: 'text',
|
||||
label,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const textarea = (name: string, label: string, defaultValue: string): Field => ({
|
||||
name,
|
||||
type: 'textarea',
|
||||
label,
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const variantFields = (defaults: NewsletterInterestCopy, benefitsDbName: string): Field[] => [
|
||||
text('eyebrow', 'Linke Eyebrow', defaults.eyebrow),
|
||||
textarea('heading', 'Linke Überschrift', defaults.heading),
|
||||
textarea('body', 'Linker Beschreibungstext', defaults.body),
|
||||
{
|
||||
name: 'benefits',
|
||||
label: 'Vorteile',
|
||||
type: 'array',
|
||||
dbName: benefitsDbName,
|
||||
defaultValue: defaults.benefits,
|
||||
fields: [text('num', 'Nummer', ''), text('label', 'Titel', ''), text('desc', 'Beschreibung', '')],
|
||||
},
|
||||
text('formEyebrow', 'Formular-Eyebrow', defaults.formEyebrow),
|
||||
text('formTitle', 'Formular-Überschrift', defaults.formTitle),
|
||||
textarea('formBody', 'Formular-Beschreibung', defaults.formBody),
|
||||
text('emailLabel', 'E-Mail-Feldbezeichnung', defaults.emailLabel),
|
||||
text('emailPlaceholder', 'E-Mail-Platzhalter', defaults.emailPlaceholder),
|
||||
text('submitLabel', 'Button-Beschriftung', defaults.submitLabel),
|
||||
textarea('privacy', 'Datenschutz-Hinweis', defaults.privacy),
|
||||
text('validationRequired', 'Fehler: E-Mail fehlt', defaults.validationRequired),
|
||||
text('validationInvalid', 'Fehler: E-Mail ungültig', defaults.validationInvalid),
|
||||
textarea('submissionError', 'Fehler: Speichern fehlgeschlagen', defaults.submissionError),
|
||||
text('successHeading', 'Erfolgsüberschrift', defaults.successHeading),
|
||||
textarea('successBody', 'Erfolgstext', defaults.successBody),
|
||||
text('footnote', 'Fußzeile normal', defaults.footnote),
|
||||
text('footnoteStrong', 'Fußzeile hervorgehoben', defaults.footnoteStrong),
|
||||
]
|
||||
|
||||
export const NewsletterForm: GlobalConfig = {
|
||||
slug: 'newsletter-form',
|
||||
label: 'Newsletter-Formular',
|
||||
access: {
|
||||
read: () => true,
|
||||
update: authenticated,
|
||||
},
|
||||
admin: {
|
||||
group: 'Formulare',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'evaluation',
|
||||
label: 'Phase 02 · Jury / Auswertung',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Gemeinsamer Newsletter-Inhalt für Startseite und Teilnahme-Seite während der Auswertungsphase.',
|
||||
},
|
||||
fields: variantFields(defaultNewsletterFormData.evaluation, 'newsletter_eval_bens'),
|
||||
},
|
||||
{
|
||||
name: 'completed',
|
||||
label: 'Phase 03 · Preisverleihung abgeschlossen',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Gemeinsamer Newsletter-Inhalt für Startseite und Teilnahme-Seite nach Abschluss des Preisjahrgangs.',
|
||||
},
|
||||
fields: variantFields(defaultNewsletterFormData.completed, 'newsletter_done_bens'),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import NextApp from '@/spa/NextApp'
|
||||
import { normalizeApplicationPhaseData, type SpaApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
|
||||
import type { CmsRouteCollection, CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { normalizeNewsletterFormData, type SpaNewsletterFormData } from '@/spa/newsletterForm'
|
||||
import { normalizeSiteSettings, type SpaSiteSettingsData } from '@/spa/siteSettings'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -104,6 +105,7 @@ export default async function Page({ params }: Args) {
|
||||
footer,
|
||||
siteSettings,
|
||||
applicationPhase,
|
||||
newsletterForm,
|
||||
route,
|
||||
supportPages,
|
||||
preistraeger,
|
||||
@@ -116,6 +118,7 @@ export default async function Page({ params }: Args) {
|
||||
payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'site-settings', depth: 1 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'application-phase', depth: 0 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'newsletter-form', depth: 0 }).catch(() => undefined),
|
||||
findRouteDoc(payload, pathname, draft),
|
||||
findSupportPages(payload, draft).catch(() => []),
|
||||
findList(payload, 'preistraeger', draft).catch(() => []),
|
||||
@@ -134,6 +137,7 @@ export default async function Page({ params }: Args) {
|
||||
footer={normalizeFooterData(footer)}
|
||||
siteSettings={normalizeSiteSettings(siteSettings as Partial<SpaSiteSettingsData> | undefined)}
|
||||
applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial<SpaApplicationPhaseData> | undefined)}
|
||||
newsletterForm={normalizeNewsletterFormData(newsletterForm as Partial<SpaNewsletterFormData> | undefined)}
|
||||
cmsRoute={{
|
||||
collection: route.collection,
|
||||
doc: route.doc as unknown as CmsRouteDoc,
|
||||
|
||||
145
src/collections/NewsletterSubscriptions.ts
Normal file
145
src/collections/NewsletterSubscriptions.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { CollectionConfig, PayloadRequest } from 'payload'
|
||||
|
||||
import { authenticated } from '@/access/authenticated'
|
||||
import type { NewsletterPhase, NewsletterSource } from '@/spa/newsletterForm'
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const sources = new Set<NewsletterSource>(['homepage', 'participation'])
|
||||
const phases = new Set<NewsletterPhase>(['evaluation', 'completed'])
|
||||
|
||||
type SubscribeBody = {
|
||||
email?: unknown
|
||||
phase?: unknown
|
||||
source?: unknown
|
||||
website?: unknown
|
||||
}
|
||||
|
||||
async function subscribe(req: PayloadRequest) {
|
||||
let body: SubscribeBody
|
||||
|
||||
try {
|
||||
if (!req.json) throw new Error('Request body is unavailable.')
|
||||
body = (await req.json()) as SubscribeBody
|
||||
} catch {
|
||||
return Response.json({ success: false }, { status: 400 })
|
||||
}
|
||||
|
||||
if (typeof body.website === 'string' && body.website.length > 0) {
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
|
||||
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''
|
||||
const source = typeof body.source === 'string' ? body.source : ''
|
||||
const phase = typeof body.phase === 'string' ? body.phase : ''
|
||||
|
||||
if (
|
||||
email.length === 0 ||
|
||||
email.length > 254 ||
|
||||
!emailPattern.test(email) ||
|
||||
!sources.has(source as NewsletterSource) ||
|
||||
!phases.has(phase as NewsletterPhase)
|
||||
) {
|
||||
return Response.json({ success: false }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await req.payload.find({
|
||||
collection: 'newsletter-subscriptions',
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
where: { email: { equals: email } },
|
||||
})
|
||||
const lastSubmittedAt = new Date().toISOString()
|
||||
|
||||
if (existing.docs[0]) {
|
||||
await req.payload.update({
|
||||
collection: 'newsletter-subscriptions',
|
||||
id: existing.docs[0].id,
|
||||
data: { lastSubmittedAt, phase: phase as NewsletterPhase, source: source as NewsletterSource },
|
||||
})
|
||||
} else {
|
||||
await req.payload.create({
|
||||
collection: 'newsletter-subscriptions',
|
||||
data: {
|
||||
email,
|
||||
lastSubmittedAt,
|
||||
phase: phase as NewsletterPhase,
|
||||
source: source as NewsletterSource,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json({ success: true })
|
||||
} catch (error) {
|
||||
req.payload.logger.error({ err: error, msg: 'Newsletter subscription could not be stored.' })
|
||||
return Response.json({ success: false }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const NewsletterSubscriptions: CollectionConfig<'newsletter-subscriptions'> = {
|
||||
slug: 'newsletter-subscriptions',
|
||||
labels: {
|
||||
singular: 'Newsletter-Anmeldung',
|
||||
plural: 'Newsletter-Anmeldungen',
|
||||
},
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: authenticated,
|
||||
update: authenticated,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['email', 'source', 'phase', 'lastSubmittedAt'],
|
||||
group: 'Formulare',
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
path: '/subscribe',
|
||||
method: 'post',
|
||||
handler: subscribe,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'email',
|
||||
label: 'E-Mail-Adresse',
|
||||
type: 'email',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
label: 'Quelle',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Startseite', value: 'homepage' },
|
||||
{ label: 'Teilnahme-Seite', value: 'participation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'phase',
|
||||
label: 'Bewerbungsphase',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Jury / Auswertung', value: 'evaluation' },
|
||||
{ label: 'Preisverleihung abgeschlossen', value: 'completed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'lastSubmittedAt',
|
||||
label: 'Zuletzt angemeldet',
|
||||
type: 'date',
|
||||
required: true,
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
admin: {
|
||||
date: { displayFormat: 'dd.MM.yyyy HH:mm' },
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -60,36 +60,6 @@ const iconField = (defaultValue = 'star'): Field => ({
|
||||
options: iconOptions,
|
||||
})
|
||||
|
||||
const newsletterVariantFields = (
|
||||
defaults: typeof homeContent.application.newsletterPhaseEvaluation,
|
||||
benefitsDbName: string,
|
||||
): Field[] => [
|
||||
text('eyebrow', 'Left eyebrow', defaults.eyebrow),
|
||||
textarea('heading', 'Left heading', defaults.heading),
|
||||
textarea('body', 'Left body copy', defaults.body),
|
||||
{
|
||||
name: 'benefits',
|
||||
label: 'Benefit rows',
|
||||
type: 'array',
|
||||
dbName: benefitsDbName,
|
||||
defaultValue: defaults.benefits,
|
||||
fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')],
|
||||
},
|
||||
text('formEyebrow', 'Form eyebrow', defaults.formEyebrow),
|
||||
text('formTitle', 'Form title', defaults.formTitle),
|
||||
textarea('formBody', 'Form body copy', defaults.formBody),
|
||||
text('emailLabel', 'Email label', defaults.emailLabel),
|
||||
text('emailPlaceholder', 'Email placeholder', defaults.emailPlaceholder),
|
||||
text('submitLabel', 'Submit button label', defaults.submitLabel),
|
||||
textarea('privacy', 'Privacy note', defaults.privacy),
|
||||
text('validationRequired', 'Required email validation message', defaults.validationRequired),
|
||||
text('validationInvalid', 'Invalid email validation message', defaults.validationInvalid),
|
||||
text('successHeading', 'Success heading', defaults.successHeading),
|
||||
textarea('successBody', 'Success body copy', defaults.successBody),
|
||||
text('footnote', 'Footnote normal text', defaults.footnote),
|
||||
text('footnoteStrong', 'Footnote highlighted text', defaults.footnoteStrong),
|
||||
]
|
||||
|
||||
export const homeFields: Field[] = [
|
||||
{
|
||||
name: 'home',
|
||||
@@ -266,20 +236,6 @@ export const homeFields: Field[] = [
|
||||
text('formTitle', 'Form title', homeContent.application.formTitle),
|
||||
text('footnote', 'Footnote normal text', homeContent.application.footnote),
|
||||
text('footnoteStrong', 'Footnote highlighted text', homeContent.application.footnoteStrong),
|
||||
{
|
||||
name: 'newsletterPhaseEvaluation',
|
||||
label: 'Phase 02 · Newsletter form',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Homepage newsletter copy used when the active application phase is Auswertung / Jury bewertet.'),
|
||||
fields: newsletterVariantFields(homeContent.application.newsletterPhaseEvaluation, 'home_news_eval_bens'),
|
||||
},
|
||||
{
|
||||
name: 'newsletterPhaseCompleted',
|
||||
label: 'Phase 03 · Newsletter form',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Homepage newsletter copy used when the active application phase is Preisverleihung abgeschlossen.'),
|
||||
fields: newsletterVariantFields(homeContent.application.newsletterPhaseCompleted, 'home_news_done_bens'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -76,35 +76,6 @@ const ctaGroup = (name: string, label: string, labelDefault: string, urlDefault:
|
||||
type: 'group',
|
||||
fields: linkGroup(labelDefault, urlDefault),
|
||||
})
|
||||
const newsletterVariantFields = (
|
||||
defaults: typeof participationContent.applicationForm.newsletterPhaseEvaluation,
|
||||
benefitsDbName: string,
|
||||
): Field[] => [
|
||||
text('eyebrow', 'Left eyebrow', defaults.eyebrow),
|
||||
textarea('heading', 'Left heading', defaults.heading),
|
||||
textarea('body', 'Left body copy', defaults.body),
|
||||
{
|
||||
name: 'benefits',
|
||||
label: 'Benefit rows',
|
||||
type: 'array',
|
||||
dbName: benefitsDbName,
|
||||
defaultValue: defaults.benefits,
|
||||
fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')],
|
||||
},
|
||||
text('formEyebrow', 'Form eyebrow', defaults.formEyebrow),
|
||||
text('formTitle', 'Form title', defaults.formTitle),
|
||||
textarea('formBody', 'Form body copy', defaults.formBody),
|
||||
text('emailLabel', 'Email label', defaults.emailLabel),
|
||||
text('emailPlaceholder', 'Email placeholder', defaults.emailPlaceholder),
|
||||
text('submitLabel', 'Submit button label', defaults.submitLabel),
|
||||
textarea('privacy', 'Privacy note', defaults.privacy),
|
||||
text('validationRequired', 'Required email validation message', defaults.validationRequired),
|
||||
text('validationInvalid', 'Invalid email validation message', defaults.validationInvalid),
|
||||
text('successHeading', 'Success heading', defaults.successHeading),
|
||||
textarea('successBody', 'Success body copy', defaults.successBody),
|
||||
text('footnote', 'Footnote normal text', defaults.footnote),
|
||||
text('footnoteStrong', 'Footnote highlighted text', defaults.footnoteStrong),
|
||||
]
|
||||
const awardCardDefaults = participationContent.awardsGrid.cards.map(({ imageFilename: _imageFilename, ...card }) => card)
|
||||
|
||||
export const participationFields: Field[] = [
|
||||
@@ -320,20 +291,6 @@ export const participationFields: Field[] = [
|
||||
ctaGroup('uploadCta', 'Top upload CTA', participationContent.applicationForm.uploadCta.label, participationContent.applicationForm.uploadCta.url),
|
||||
textarea('uploadPrompt', 'Bottom upload prompt', participationContent.applicationForm.uploadPrompt),
|
||||
ctaGroup('uploadFooterCta', 'Bottom upload CTA', participationContent.applicationForm.uploadFooterCta.label, participationContent.applicationForm.uploadFooterCta.url),
|
||||
{
|
||||
name: 'newsletterPhaseEvaluation',
|
||||
label: 'Phase 02 · Newsletter form',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Participation page newsletter copy used when the active application phase is Auswertung / Jury bewertet.'),
|
||||
fields: newsletterVariantFields(participationContent.applicationForm.newsletterPhaseEvaluation, 'part_news_eval_bens'),
|
||||
},
|
||||
{
|
||||
name: 'newsletterPhaseCompleted',
|
||||
label: 'Phase 03 · Newsletter form',
|
||||
type: 'group',
|
||||
admin: sectionAdmin('Participation page newsletter copy used when the active application phase is Preisverleihung abgeschlossen.'),
|
||||
fields: newsletterVariantFields(participationContent.applicationForm.newsletterPhaseCompleted, 'part_news_done_bens'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
|
||||
|
||||
import { defaultNewsletterFormData, type NewsletterInterestCopy } from '@/spa/newsletterForm'
|
||||
|
||||
type MigrationDB = MigrateUpArgs['db']
|
||||
|
||||
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
|
||||
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
|
||||
const snake = (value: string) => value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||
|
||||
const scalarFields = [
|
||||
'eyebrow',
|
||||
'heading',
|
||||
'body',
|
||||
'formEyebrow',
|
||||
'formTitle',
|
||||
'formBody',
|
||||
'emailLabel',
|
||||
'emailPlaceholder',
|
||||
'submitLabel',
|
||||
'privacy',
|
||||
'validationRequired',
|
||||
'validationInvalid',
|
||||
'successHeading',
|
||||
'successBody',
|
||||
'footnote',
|
||||
'footnoteStrong',
|
||||
] as const
|
||||
|
||||
const sharedScalarFields = [...scalarFields, 'submissionError'] as const
|
||||
const pagePrefixes = [
|
||||
'home_application_newsletter_phase_evaluation',
|
||||
'home_application_newsletter_phase_completed',
|
||||
'participation_application_form_newsletter_phase_evaluation',
|
||||
'participation_application_form_newsletter_phase_completed',
|
||||
] as const
|
||||
const legacyBenefitTables = [
|
||||
'home_news_eval_bens',
|
||||
'home_news_done_bens',
|
||||
'part_news_eval_bens',
|
||||
'part_news_done_bens',
|
||||
'_home_news_eval_bens_v',
|
||||
'_home_news_done_bens_v',
|
||||
'_part_news_eval_bens_v',
|
||||
'_part_news_done_bens_v',
|
||||
] as const
|
||||
|
||||
async function tableExists(db: MigrationDB, tableName: string) {
|
||||
const rows = (await db.all(
|
||||
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
|
||||
)) as Array<{ name: string }>
|
||||
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
|
||||
if (!(await tableExists(db, tableName))) return false
|
||||
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }>
|
||||
return columns.some((column) => column.name === columnName)
|
||||
}
|
||||
|
||||
async function addColumn(db: MigrationDB, tableName: string, columnName: string, definition = 'text') {
|
||||
if (await columnExists(db, tableName, columnName)) return
|
||||
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} ${definition};`))
|
||||
}
|
||||
|
||||
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)};`))
|
||||
}
|
||||
|
||||
function globalColumn(phase: 'evaluation' | 'completed', field: (typeof sharedScalarFields)[number]) {
|
||||
return `${phase}_${snake(field)}`
|
||||
}
|
||||
|
||||
function pageColumn(prefix: (typeof pagePrefixes)[number], field: (typeof scalarFields)[number]) {
|
||||
return `${prefix}_${snake(field)}`
|
||||
}
|
||||
|
||||
function globalTableColumns() {
|
||||
const definitions: string[] = ['`id` integer PRIMARY KEY NOT NULL']
|
||||
|
||||
for (const phase of ['evaluation', 'completed'] as const) {
|
||||
const defaults = defaultNewsletterFormData[phase]
|
||||
for (const field of sharedScalarFields) {
|
||||
definitions.push(`${ident(globalColumn(phase, field))} text DEFAULT ${literal(defaults[field])}`)
|
||||
}
|
||||
}
|
||||
|
||||
definitions.push('`updated_at` text', '`created_at` text')
|
||||
return definitions.join(',\n')
|
||||
}
|
||||
|
||||
async function createBenefitTable(db: MigrationDB, tableName: string, parentTable: string, idType: 'text' | 'integer') {
|
||||
const versionUUID = parentTable === '_pages_v' ? ',\n `_uuid` text' : ''
|
||||
await db.run(sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS ${ident(tableName)} (
|
||||
\`_order\` integer NOT NULL,
|
||||
\`_parent_id\` integer NOT NULL,
|
||||
\`id\` ${idType} PRIMARY KEY NOT NULL,
|
||||
\`num\` text,
|
||||
\`label\` text,
|
||||
\`desc\` text${versionUUID},
|
||||
FOREIGN KEY (\`_parent_id\`) REFERENCES ${ident(parentTable)}(\`id\`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`))
|
||||
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_order_idx`)} ON ${ident(tableName)} (\`_order\`);`))
|
||||
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_parent_id_idx`)} ON ${ident(tableName)} (\`_parent_id\`);`))
|
||||
}
|
||||
|
||||
async function seedGlobalScalarsFromHome(db: MigrationDB) {
|
||||
await db.run(sql.raw(`
|
||||
INSERT OR IGNORE INTO \`newsletter_form\` (\`id\`, \`updated_at\`, \`created_at\`)
|
||||
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
`))
|
||||
|
||||
if (!(await columnExists(db, 'pages', pageColumn(pagePrefixes[0], 'eyebrow')))) return
|
||||
|
||||
const assignments: string[] = []
|
||||
for (const phase of ['evaluation', 'completed'] as const) {
|
||||
const prefix = phase === 'evaluation' ? pagePrefixes[0] : pagePrefixes[1]
|
||||
for (const field of scalarFields) {
|
||||
const target = globalColumn(phase, field)
|
||||
const source = pageColumn(prefix, field)
|
||||
assignments.push(
|
||||
`${ident(target)} = COALESCE((SELECT ${ident(source)} FROM \`pages\` WHERE \`spa_path\` = '/' OR \`slug\` IN ('startseite', 'home') ORDER BY CASE WHEN \`spa_path\` = '/' THEN 0 ELSE 1 END LIMIT 1), ${ident(target)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(sql.raw(`UPDATE \`newsletter_form\` SET ${assignments.join(',\n')} WHERE \`id\` = 1;`))
|
||||
}
|
||||
|
||||
async function seedBenefits(db: MigrationDB, phase: 'evaluation' | 'completed') {
|
||||
const target = phase === 'evaluation' ? 'newsletter_eval_bens' : 'newsletter_done_bens'
|
||||
const source = phase === 'evaluation' ? 'home_news_eval_bens' : 'home_news_done_bens'
|
||||
const existingCount = (await db.all(sql.raw(`SELECT COUNT(*) AS count FROM ${ident(target)} WHERE \`_parent_id\` = 1;`))) as Array<{
|
||||
count: number
|
||||
}>
|
||||
if (Number(existingCount[0]?.count || 0) > 0) return
|
||||
|
||||
if (await tableExists(db, source)) {
|
||||
await db.run(sql.raw(`
|
||||
INSERT OR IGNORE INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, \`num\`, \`label\`, \`desc\`)
|
||||
SELECT benefits.\`_order\`, 1, lower(hex(randomblob(16))), benefits.\`num\`, benefits.\`label\`, benefits.\`desc\`
|
||||
FROM ${ident(source)} benefits
|
||||
INNER JOIN \`pages\` page ON page.\`id\` = benefits.\`_parent_id\`
|
||||
WHERE page.\`spa_path\` = '/' OR page.\`slug\` IN ('startseite', 'home')
|
||||
ORDER BY benefits.\`_order\`;
|
||||
`))
|
||||
}
|
||||
|
||||
const count = (await db.all(sql.raw(`SELECT COUNT(*) AS count FROM ${ident(target)} WHERE \`_parent_id\` = 1;`))) as Array<{
|
||||
count: number
|
||||
}>
|
||||
if (Number(count[0]?.count || 0) > 0) return
|
||||
|
||||
for (const [index, benefit] of defaultNewsletterFormData[phase].benefits.entries()) {
|
||||
await db.run(sql.raw(`
|
||||
INSERT INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, \`num\`, \`label\`, \`desc\`)
|
||||
VALUES (${index + 1}, 1, lower(hex(randomblob(16))), ${literal(benefit.num)}, ${literal(benefit.label)}, ${literal(benefit.desc)});
|
||||
`))
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLegacyPageFields(db: MigrationDB) {
|
||||
for (const prefix of pagePrefixes) {
|
||||
for (const field of scalarFields) {
|
||||
await dropColumn(db, 'pages', pageColumn(prefix, field))
|
||||
await dropColumn(db, '_pages_v', `version_${pageColumn(prefix, field)}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const tableName of legacyBenefitTables) {
|
||||
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(tableName)};`))
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreLegacyPageFields(db: MigrationDB) {
|
||||
for (const prefix of pagePrefixes) {
|
||||
for (const field of scalarFields) {
|
||||
await addColumn(db, 'pages', pageColumn(prefix, field))
|
||||
await addColumn(db, '_pages_v', `version_${pageColumn(prefix, field)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await createBenefitTable(db, 'home_news_eval_bens', 'pages', 'text')
|
||||
await createBenefitTable(db, 'home_news_done_bens', 'pages', 'text')
|
||||
await createBenefitTable(db, 'part_news_eval_bens', 'pages', 'text')
|
||||
await createBenefitTable(db, 'part_news_done_bens', 'pages', 'text')
|
||||
await createBenefitTable(db, '_home_news_eval_bens_v', '_pages_v', 'integer')
|
||||
await createBenefitTable(db, '_home_news_done_bens_v', '_pages_v', 'integer')
|
||||
await createBenefitTable(db, '_part_news_eval_bens_v', '_pages_v', 'integer')
|
||||
await createBenefitTable(db, '_part_news_done_bens_v', '_pages_v', 'integer')
|
||||
|
||||
if (!(await tableExists(db, 'newsletter_form'))) return
|
||||
|
||||
for (const [pageKind, pageFilter] of [
|
||||
['home', "`spa_path` = '/' OR `slug` IN ('startseite', 'home')"],
|
||||
['participation', "`spa_path` = '/teilnahme' OR `slug` IN ('teilnahme', 'participation')"],
|
||||
] as const) {
|
||||
const assignments: string[] = []
|
||||
for (const phase of ['evaluation', 'completed'] as const) {
|
||||
const prefix = pageKind === 'home'
|
||||
? phase === 'evaluation' ? pagePrefixes[0] : pagePrefixes[1]
|
||||
: phase === 'evaluation' ? pagePrefixes[2] : pagePrefixes[3]
|
||||
for (const field of scalarFields) {
|
||||
assignments.push(`${ident(pageColumn(prefix, field))} = (SELECT ${ident(globalColumn(phase, field))} FROM \`newsletter_form\` WHERE \`id\` = 1)`)
|
||||
}
|
||||
}
|
||||
await db.run(sql.raw(`UPDATE \`pages\` SET ${assignments.join(',\n')} WHERE ${pageFilter};`))
|
||||
}
|
||||
|
||||
for (const [target, source, filter] of [
|
||||
['home_news_eval_bens', 'newsletter_eval_bens', "page.`spa_path` = '/' OR page.`slug` IN ('startseite', 'home')"],
|
||||
['home_news_done_bens', 'newsletter_done_bens', "page.`spa_path` = '/' OR page.`slug` IN ('startseite', 'home')"],
|
||||
['part_news_eval_bens', 'newsletter_eval_bens', "page.`spa_path` = '/teilnahme' OR page.`slug` IN ('teilnahme', 'participation')"],
|
||||
['part_news_done_bens', 'newsletter_done_bens', "page.`spa_path` = '/teilnahme' OR page.`slug` IN ('teilnahme', 'participation')"],
|
||||
] as const) {
|
||||
await db.run(sql.raw(`
|
||||
INSERT INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, \`num\`, \`label\`, \`desc\`)
|
||||
SELECT benefit.\`_order\`, page.\`id\`, lower(hex(randomblob(16))), benefit.\`num\`, benefit.\`label\`, benefit.\`desc\`
|
||||
FROM ${ident(source)} benefit CROSS JOIN \`pages\` page
|
||||
WHERE ${filter};
|
||||
`))
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildLockedDocumentRelations(db: MigrationDB, includeNewsletter: boolean) {
|
||||
const tableName = 'payload_locked_documents_rels'
|
||||
if (!(await tableExists(db, tableName))) return
|
||||
|
||||
const newsletterColumn = includeNewsletter ? ',\n `newsletter_subscriptions_id` integer' : ''
|
||||
const newsletterForeignKey = includeNewsletter
|
||||
? ',\n FOREIGN KEY (`newsletter_subscriptions_id`) REFERENCES `newsletter_subscriptions`(`id`) ON UPDATE no action ON DELETE cascade'
|
||||
: ''
|
||||
const existingNewsletterColumn = await columnExists(db, tableName, 'newsletter_subscriptions_id')
|
||||
const commonColumns = [
|
||||
'id',
|
||||
'order',
|
||||
'parent_id',
|
||||
'path',
|
||||
'pages_id',
|
||||
'preistraeger_id',
|
||||
'jury_mitglieder_id',
|
||||
'partners_id',
|
||||
'posts_id',
|
||||
'events_id',
|
||||
...(includeNewsletter && existingNewsletterColumn ? ['newsletter_subscriptions_id'] : []),
|
||||
'media_id',
|
||||
'users_id',
|
||||
'payload_folders_id',
|
||||
]
|
||||
|
||||
await db.run(sql.raw('DROP TABLE IF EXISTS `__new_payload_locked_documents_rels`;'))
|
||||
await db.run(sql.raw(`
|
||||
CREATE TABLE \`__new_payload_locked_documents_rels\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`order\` integer,
|
||||
\`parent_id\` integer NOT NULL,
|
||||
\`path\` text NOT NULL,
|
||||
\`pages_id\` integer,
|
||||
\`preistraeger_id\` integer,
|
||||
\`jury_mitglieder_id\` integer,
|
||||
\`partners_id\` integer,
|
||||
\`posts_id\` integer,
|
||||
\`events_id\` integer${newsletterColumn},
|
||||
\`media_id\` integer,
|
||||
\`users_id\` integer,
|
||||
\`payload_folders_id\` integer,
|
||||
FOREIGN KEY (\`parent_id\`) REFERENCES \`payload_locked_documents\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`pages_id\`) REFERENCES \`pages\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`preistraeger_id\`) REFERENCES \`preistraeger\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`jury_mitglieder_id\`) REFERENCES \`jury_mitglieder\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`partners_id\`) REFERENCES \`partners\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`posts_id\`) REFERENCES \`posts\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`events_id\`) REFERENCES \`events\`(\`id\`) ON UPDATE no action ON DELETE cascade${newsletterForeignKey},
|
||||
FOREIGN KEY (\`media_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`users_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`payload_folders_id\`) REFERENCES \`payload_folders\`(\`id\`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`))
|
||||
|
||||
const copiedColumns = commonColumns.map(ident).join(', ')
|
||||
await db.run(sql.raw(`INSERT INTO \`__new_payload_locked_documents_rels\` (${copiedColumns}) SELECT ${copiedColumns} FROM ${ident(tableName)};`))
|
||||
await db.run(sql.raw(`DROP TABLE ${ident(tableName)};`))
|
||||
await db.run(sql.raw(`ALTER TABLE \`__new_payload_locked_documents_rels\` RENAME TO ${ident(tableName)};`))
|
||||
|
||||
for (const [suffix, column] of [
|
||||
['order', 'order'],
|
||||
['parent', 'parent_id'],
|
||||
['path', 'path'],
|
||||
['pages_id', 'pages_id'],
|
||||
['preistraeger_id', 'preistraeger_id'],
|
||||
['jury_mitglieder_id', 'jury_mitglieder_id'],
|
||||
['partners_id', 'partners_id'],
|
||||
['posts_id', 'posts_id'],
|
||||
['events_id', 'events_id'],
|
||||
...(includeNewsletter ? [['newsletter_subscriptions_i', 'newsletter_subscriptions_id']] : []),
|
||||
['media_id', 'media_id'],
|
||||
['users_id', 'users_id'],
|
||||
['payload_folders_id', 'payload_folders_id'],
|
||||
] as string[][]) {
|
||||
await db.run(sql.raw(`CREATE INDEX ${ident(`${tableName}_${suffix}_idx`)} ON ${ident(tableName)} (${ident(column)});`))
|
||||
}
|
||||
}
|
||||
|
||||
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
|
||||
await db.run(sql.raw(`CREATE TABLE IF NOT EXISTS \`newsletter_form\` (${globalTableColumns()});`))
|
||||
await createBenefitTable(db, 'newsletter_eval_bens', 'newsletter_form', 'text')
|
||||
await createBenefitTable(db, 'newsletter_done_bens', 'newsletter_form', 'text')
|
||||
await seedGlobalScalarsFromHome(db)
|
||||
await seedBenefits(db, 'evaluation')
|
||||
await seedBenefits(db, 'completed')
|
||||
|
||||
await db.run(sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS \`newsletter_subscriptions\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`email\` text NOT NULL,
|
||||
\`source\` text NOT NULL,
|
||||
\`phase\` text NOT NULL,
|
||||
\`last_submitted_at\` text NOT NULL,
|
||||
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
|
||||
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
|
||||
);
|
||||
`))
|
||||
await db.run(sql.raw('CREATE UNIQUE INDEX IF NOT EXISTS `newsletter_subscriptions_email_idx` ON `newsletter_subscriptions` (`email`);'))
|
||||
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `newsletter_subscriptions_updated_at_idx` ON `newsletter_subscriptions` (`updated_at`);'))
|
||||
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `newsletter_subscriptions_created_at_idx` ON `newsletter_subscriptions` (`created_at`);'))
|
||||
|
||||
await rebuildLockedDocumentRelations(db, true)
|
||||
|
||||
await removeLegacyPageFields(db)
|
||||
}
|
||||
|
||||
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
|
||||
await restoreLegacyPageFields(db)
|
||||
await rebuildLockedDocumentRelations(db, false)
|
||||
await db.run(sql.raw('DROP TABLE IF EXISTS `newsletter_subscriptions`;'))
|
||||
await db.run(sql.raw('DROP TABLE IF EXISTS `newsletter_eval_bens`;'))
|
||||
await db.run(sql.raw('DROP TABLE IF EXISTS `newsletter_done_bens`;'))
|
||||
await db.run(sql.raw('DROP TABLE IF EXISTS `newsletter_form`;'))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import * as migration_20260707_123500_preistraeger_gallery from './20260707_1235
|
||||
import * as migration_20260707_124500_site_settings_preistraeger_placeholder from './20260707_124500_site_settings_preistraeger_placeholder';
|
||||
import * as migration_20260707_130500_preistraeger_awards_jury_members from './20260707_130500_preistraeger_awards_jury_members';
|
||||
import * as migration_20260712_120000_partner_media_only from './20260712_120000_partner_media_only';
|
||||
import * as migration_20260713_133000_newsletter_form_and_subscriptions from './20260713_133000_newsletter_form_and_subscriptions';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
@@ -66,4 +67,9 @@ export const migrations = [
|
||||
down: migration_20260712_120000_partner_media_only.down,
|
||||
name: '20260712_120000_partner_media_only',
|
||||
},
|
||||
{
|
||||
up: migration_20260713_133000_newsletter_form_and_subscriptions.up,
|
||||
down: migration_20260713_133000_newsletter_form_and_subscriptions.down,
|
||||
name: '20260713_133000_newsletter_form_and_subscriptions',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface Config {
|
||||
partners: Partner;
|
||||
posts: Post;
|
||||
events: Event;
|
||||
'newsletter-subscriptions': NewsletterSubscription;
|
||||
media: Media;
|
||||
users: User;
|
||||
'payload-kv': PayloadKv;
|
||||
@@ -94,6 +95,7 @@ export interface Config {
|
||||
partners: PartnersSelect<false> | PartnersSelect<true>;
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
events: EventsSelect<false> | EventsSelect<true>;
|
||||
'newsletter-subscriptions': NewsletterSubscriptionsSelect<false> | NewsletterSubscriptionsSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
@@ -112,12 +114,14 @@ export interface Config {
|
||||
footer: Footer;
|
||||
'site-settings': SiteSetting;
|
||||
'application-phase': ApplicationPhase;
|
||||
'newsletter-form': NewsletterForm;
|
||||
};
|
||||
globalsSelect: {
|
||||
header: HeaderSelect<false> | HeaderSelect<true>;
|
||||
footer: FooterSelect<false> | FooterSelect<true>;
|
||||
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
||||
'application-phase': ApplicationPhaseSelect<false> | ApplicationPhaseSelect<true>;
|
||||
'newsletter-form': NewsletterFormSelect<false> | NewsletterFormSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
widgets: {
|
||||
@@ -382,64 +386,6 @@ export interface Page {
|
||||
formTitle?: string | null;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
/**
|
||||
* Homepage newsletter copy used when the active application phase is Auswertung / Jury bewertet.
|
||||
*/
|
||||
newsletterPhaseEvaluation?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
body?: string | null;
|
||||
benefits?:
|
||||
| {
|
||||
num?: string | null;
|
||||
label?: string | null;
|
||||
desc?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
formEyebrow?: string | null;
|
||||
formTitle?: string | null;
|
||||
formBody?: string | null;
|
||||
emailLabel?: string | null;
|
||||
emailPlaceholder?: string | null;
|
||||
submitLabel?: string | null;
|
||||
privacy?: string | null;
|
||||
validationRequired?: string | null;
|
||||
validationInvalid?: string | null;
|
||||
successHeading?: string | null;
|
||||
successBody?: string | null;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
};
|
||||
/**
|
||||
* Homepage newsletter copy used when the active application phase is Preisverleihung abgeschlossen.
|
||||
*/
|
||||
newsletterPhaseCompleted?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
body?: string | null;
|
||||
benefits?:
|
||||
| {
|
||||
num?: string | null;
|
||||
label?: string | null;
|
||||
desc?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
formEyebrow?: string | null;
|
||||
formTitle?: string | null;
|
||||
formBody?: string | null;
|
||||
emailLabel?: string | null;
|
||||
emailPlaceholder?: string | null;
|
||||
submitLabel?: string | null;
|
||||
privacy?: string | null;
|
||||
validationRequired?: string | null;
|
||||
validationInvalid?: string | null;
|
||||
successHeading?: string | null;
|
||||
successBody?: string | null;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Text, options, and eligibility thresholds used inside the home page multi-step application form.
|
||||
@@ -1006,64 +952,6 @@ export interface Page {
|
||||
label?: string | null;
|
||||
url?: string | null;
|
||||
};
|
||||
/**
|
||||
* Participation page newsletter copy used when the active application phase is Auswertung / Jury bewertet.
|
||||
*/
|
||||
newsletterPhaseEvaluation?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
body?: string | null;
|
||||
benefits?:
|
||||
| {
|
||||
num?: string | null;
|
||||
label?: string | null;
|
||||
desc?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
formEyebrow?: string | null;
|
||||
formTitle?: string | null;
|
||||
formBody?: string | null;
|
||||
emailLabel?: string | null;
|
||||
emailPlaceholder?: string | null;
|
||||
submitLabel?: string | null;
|
||||
privacy?: string | null;
|
||||
validationRequired?: string | null;
|
||||
validationInvalid?: string | null;
|
||||
successHeading?: string | null;
|
||||
successBody?: string | null;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
};
|
||||
/**
|
||||
* Participation page newsletter copy used when the active application phase is Preisverleihung abgeschlossen.
|
||||
*/
|
||||
newsletterPhaseCompleted?: {
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
body?: string | null;
|
||||
benefits?:
|
||||
| {
|
||||
num?: string | null;
|
||||
label?: string | null;
|
||||
desc?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
formEyebrow?: string | null;
|
||||
formTitle?: string | null;
|
||||
formBody?: string | null;
|
||||
emailLabel?: string | null;
|
||||
emailPlaceholder?: string | null;
|
||||
submitLabel?: string | null;
|
||||
privacy?: string | null;
|
||||
validationRequired?: string | null;
|
||||
validationInvalid?: string | null;
|
||||
successHeading?: string | null;
|
||||
successBody?: string | null;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Text, options, and eligibility thresholds used inside the multi-step application form.
|
||||
@@ -2919,6 +2807,19 @@ export interface Event {
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "newsletter-subscriptions".
|
||||
*/
|
||||
export interface NewsletterSubscription {
|
||||
id: number;
|
||||
email: string;
|
||||
source: 'homepage' | 'participation';
|
||||
phase: 'evaluation' | 'completed';
|
||||
lastSubmittedAt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
@@ -3059,6 +2960,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'events';
|
||||
value: number | Event;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'newsletter-subscriptions';
|
||||
value: number | NewsletterSubscription;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
@@ -3304,62 +3209,6 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
formTitle?: T;
|
||||
footnote?: T;
|
||||
footnoteStrong?: T;
|
||||
newsletterPhaseEvaluation?:
|
||||
| T
|
||||
| {
|
||||
eyebrow?: T;
|
||||
heading?: T;
|
||||
body?: T;
|
||||
benefits?:
|
||||
| T
|
||||
| {
|
||||
num?: T;
|
||||
label?: T;
|
||||
desc?: T;
|
||||
id?: T;
|
||||
};
|
||||
formEyebrow?: T;
|
||||
formTitle?: T;
|
||||
formBody?: T;
|
||||
emailLabel?: T;
|
||||
emailPlaceholder?: T;
|
||||
submitLabel?: T;
|
||||
privacy?: T;
|
||||
validationRequired?: T;
|
||||
validationInvalid?: T;
|
||||
successHeading?: T;
|
||||
successBody?: T;
|
||||
footnote?: T;
|
||||
footnoteStrong?: T;
|
||||
};
|
||||
newsletterPhaseCompleted?:
|
||||
| T
|
||||
| {
|
||||
eyebrow?: T;
|
||||
heading?: T;
|
||||
body?: T;
|
||||
benefits?:
|
||||
| T
|
||||
| {
|
||||
num?: T;
|
||||
label?: T;
|
||||
desc?: T;
|
||||
id?: T;
|
||||
};
|
||||
formEyebrow?: T;
|
||||
formTitle?: T;
|
||||
formBody?: T;
|
||||
emailLabel?: T;
|
||||
emailPlaceholder?: T;
|
||||
submitLabel?: T;
|
||||
privacy?: T;
|
||||
validationRequired?: T;
|
||||
validationInvalid?: T;
|
||||
successHeading?: T;
|
||||
successBody?: T;
|
||||
footnote?: T;
|
||||
footnoteStrong?: T;
|
||||
};
|
||||
};
|
||||
form?:
|
||||
| T
|
||||
@@ -3827,62 +3676,6 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
label?: T;
|
||||
url?: T;
|
||||
};
|
||||
newsletterPhaseEvaluation?:
|
||||
| T
|
||||
| {
|
||||
eyebrow?: T;
|
||||
heading?: T;
|
||||
body?: T;
|
||||
benefits?:
|
||||
| T
|
||||
| {
|
||||
num?: T;
|
||||
label?: T;
|
||||
desc?: T;
|
||||
id?: T;
|
||||
};
|
||||
formEyebrow?: T;
|
||||
formTitle?: T;
|
||||
formBody?: T;
|
||||
emailLabel?: T;
|
||||
emailPlaceholder?: T;
|
||||
submitLabel?: T;
|
||||
privacy?: T;
|
||||
validationRequired?: T;
|
||||
validationInvalid?: T;
|
||||
successHeading?: T;
|
||||
successBody?: T;
|
||||
footnote?: T;
|
||||
footnoteStrong?: T;
|
||||
};
|
||||
newsletterPhaseCompleted?:
|
||||
| T
|
||||
| {
|
||||
eyebrow?: T;
|
||||
heading?: T;
|
||||
body?: T;
|
||||
benefits?:
|
||||
| T
|
||||
| {
|
||||
num?: T;
|
||||
label?: T;
|
||||
desc?: T;
|
||||
id?: T;
|
||||
};
|
||||
formEyebrow?: T;
|
||||
formTitle?: T;
|
||||
formBody?: T;
|
||||
emailLabel?: T;
|
||||
emailPlaceholder?: T;
|
||||
submitLabel?: T;
|
||||
privacy?: T;
|
||||
validationRequired?: T;
|
||||
validationInvalid?: T;
|
||||
successHeading?: T;
|
||||
successBody?: T;
|
||||
footnote?: T;
|
||||
footnoteStrong?: T;
|
||||
};
|
||||
};
|
||||
form?:
|
||||
| T
|
||||
@@ -5318,6 +5111,18 @@ export interface EventsSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "newsletter-subscriptions_select".
|
||||
*/
|
||||
export interface NewsletterSubscriptionsSelect<T extends boolean = true> {
|
||||
email?: T;
|
||||
source?: T;
|
||||
phase?: T;
|
||||
lastSubmittedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
@@ -5699,6 +5504,75 @@ export interface ApplicationPhase {
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "newsletter-form".
|
||||
*/
|
||||
export interface NewsletterForm {
|
||||
id: number;
|
||||
/**
|
||||
* Gemeinsamer Newsletter-Inhalt für Startseite und Teilnahme-Seite während der Auswertungsphase.
|
||||
*/
|
||||
evaluation?: {
|
||||
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;
|
||||
submissionError?: string | null;
|
||||
successHeading?: string | null;
|
||||
successBody?: string | null;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
};
|
||||
/**
|
||||
* Gemeinsamer Newsletter-Inhalt für Startseite und Teilnahme-Seite nach Abschluss des Preisjahrgangs.
|
||||
*/
|
||||
completed?: {
|
||||
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;
|
||||
submissionError?: string | null;
|
||||
successHeading?: string | null;
|
||||
successBody?: string | null;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
};
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "header_select".
|
||||
@@ -5868,6 +5742,73 @@ export interface ApplicationPhaseSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "newsletter-form_select".
|
||||
*/
|
||||
export interface NewsletterFormSelect<T extends boolean = true> {
|
||||
evaluation?:
|
||||
| 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;
|
||||
submissionError?: T;
|
||||
successHeading?: T;
|
||||
successBody?: T;
|
||||
footnote?: T;
|
||||
footnoteStrong?: T;
|
||||
};
|
||||
completed?:
|
||||
| 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;
|
||||
submissionError?: T;
|
||||
successHeading?: T;
|
||||
successBody?: T;
|
||||
footnote?: T;
|
||||
footnoteStrong?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'url'
|
||||
import { Events } from './collections/Events'
|
||||
import { JuryMitglieder } from './collections/JuryMitglieder'
|
||||
import { Media } from './collections/Media'
|
||||
import { NewsletterSubscriptions } from './collections/NewsletterSubscriptions'
|
||||
import { Pages } from './collections/Pages'
|
||||
import { Partners } from './collections/Partners'
|
||||
import { Posts } from './collections/Posts'
|
||||
@@ -15,6 +16,7 @@ import { Users } from './collections/Users'
|
||||
import { ApplicationPhase } from './ApplicationPhase/config'
|
||||
import { Footer } from './Footer/config'
|
||||
import { Header } from './Header/config'
|
||||
import { NewsletterForm } from './NewsletterForm/config'
|
||||
import { plugins } from './plugins'
|
||||
import { SiteSettings } from './SiteSettings/config'
|
||||
import { defaultLexical } from '@/fields/defaultLexical'
|
||||
@@ -67,9 +69,9 @@ export default buildConfig({
|
||||
url: process.env.DATABASE_URL || '',
|
||||
},
|
||||
}),
|
||||
collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, Media, Users],
|
||||
collections: [Pages, Preistraeger, JuryMitglieder, Partners, Posts, Events, NewsletterSubscriptions, Media, Users],
|
||||
cors: [getServerSideURL()].filter(Boolean),
|
||||
globals: [Header, Footer, SiteSettings, ApplicationPhase],
|
||||
globals: [Header, Footer, SiteSettings, ApplicationPhase, NewsletterForm],
|
||||
plugins,
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
sharp,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mediaByFilename } from './mediaByFilename'
|
||||
|
||||
import { defaultApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation'
|
||||
import { defaultNewsletterFormData } from '@/spa/newsletterForm'
|
||||
import { defaultSiteSettings } from '@/spa/siteSettings'
|
||||
|
||||
async function main() {
|
||||
@@ -59,6 +60,11 @@ async function main() {
|
||||
context: { disableRevalidate: true },
|
||||
data: defaultApplicationPhaseData as never,
|
||||
}),
|
||||
payload.updateGlobal({
|
||||
slug: 'newsletter-form',
|
||||
context: { disableRevalidate: true },
|
||||
data: defaultNewsletterFormData as never,
|
||||
}),
|
||||
])
|
||||
|
||||
payload.logger.info('Preloaded global CMS settings.')
|
||||
|
||||
@@ -14,7 +14,7 @@ const orderedScripts = [
|
||||
path: 'src/scripts/migrate-spa-pages.ts',
|
||||
},
|
||||
{
|
||||
label: 'Store header, footer, site settings, and application phase defaults',
|
||||
label: 'Store header, footer, site settings, application phase, and newsletter form defaults',
|
||||
path: 'src/scripts/preload-globals-cms.ts',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import ScrollToTop from '@/spa/components/layout/ScrollToTop'
|
||||
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import { type SpaFooterData, type SpaHeaderData } from '@/spa/cmsNavigation'
|
||||
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
|
||||
import type { SpaNewsletterFormData } from '@/spa/newsletterForm'
|
||||
import type { SpaSiteSettingsData } from '@/spa/siteSettings'
|
||||
import About from '@/spa/pages/About'
|
||||
import BlogDetail from '@/spa/pages/BlogDetail'
|
||||
@@ -62,6 +63,7 @@ export default function NextApp({
|
||||
cmsRoute,
|
||||
siteSettings,
|
||||
applicationPhase,
|
||||
newsletterForm,
|
||||
}: {
|
||||
pathname: string
|
||||
header?: SpaHeaderData
|
||||
@@ -69,12 +71,13 @@ export default function NextApp({
|
||||
cmsRoute?: CmsRouteData
|
||||
siteSettings?: SpaSiteSettingsData
|
||||
applicationPhase?: SpaApplicationPhaseData
|
||||
newsletterForm?: SpaNewsletterFormData
|
||||
}) {
|
||||
const page = React.createElement(resolvePage(pathname))
|
||||
|
||||
return (
|
||||
<>
|
||||
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationPhase } : undefined}>
|
||||
<CmsRouteProvider value={cmsRoute ? { ...cmsRoute, siteSettings, applicationPhase, newsletterForm } : undefined}>
|
||||
<ScrollToTop />
|
||||
<Layout header={header} footer={footer}>
|
||||
{page}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const defaultApplicationPhaseData: SpaApplicationPhaseData = {
|
||||
'Die Bewerbungsphase 2026 ist eröffnet – es ist noch nicht zu spät. Reichen Sie Ihre Unterlagen bis zum 30. Juni kostenlos ein und zeigen Sie, was den bayerischen Mittelstand ausmacht.',
|
||||
cta: { label: 'Jetzt kostenlos bewerben', url: '/teilnahme' },
|
||||
accent: '#EFBF04',
|
||||
bg: '#020A1E',
|
||||
bg: '#111D55',
|
||||
glow: 'rgba(239,191,4,0.14)',
|
||||
meta: 'Deadline: 30. Juni 2026',
|
||||
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
|
||||
@@ -60,7 +60,7 @@ export const defaultApplicationPhaseData: SpaApplicationPhaseData = {
|
||||
'Die Anmeldephase 2026 ist geschlossen. Unsere unabhängige Expertenjury aus Wissenschaft und Praxis sichtet und bewertet derzeit alle Einreichungen.',
|
||||
cta: {},
|
||||
accent: '#4A8FC9',
|
||||
bg: '#060D1C',
|
||||
bg: '#111D55',
|
||||
glow: 'rgba(74,143,201,0.12)',
|
||||
meta: 'Ergebnis: August 2026',
|
||||
membershipCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
|
||||
@@ -75,7 +75,7 @@ export const defaultApplicationPhaseData: SpaApplicationPhaseData = {
|
||||
'Wir danken allen 247 Bewerberinnen und Bewerbern für Mut und Exzellenz. Drei außergewöhnliche Unternehmen wurden in München ausgezeichnet – ein unvergesslicher Abend.',
|
||||
cta: { label: 'Alle Preisträger ansehen', url: '/preistraeger' },
|
||||
accent: '#EFBF04',
|
||||
bg: '#06090F',
|
||||
bg: '#111D55',
|
||||
glow: 'rgba(239,191,4,0.08)',
|
||||
meta: 'Gala · Oktober 2026 · München',
|
||||
successApplications: '247',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { createContext, useContext } from 'react'
|
||||
|
||||
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
|
||||
import type { SpaNewsletterFormData } from '@/spa/newsletterForm'
|
||||
import type { SpaSiteSettingsData } from '@/spa/siteSettings'
|
||||
|
||||
export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger' | 'partners' | 'jury-mitglieder'
|
||||
@@ -26,6 +27,7 @@ export type CmsRouteData = {
|
||||
lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>>
|
||||
siteSettings?: SpaSiteSettingsData
|
||||
applicationPhase?: SpaApplicationPhaseData
|
||||
newsletterForm?: SpaNewsletterFormData
|
||||
}
|
||||
|
||||
const CmsRouteContext = createContext<CmsRouteData | undefined>(undefined)
|
||||
@@ -53,3 +55,7 @@ export function resolveCmsRelationship(entry: unknown, docs: CmsRouteDoc[]) {
|
||||
export function useApplicationPhase() {
|
||||
return useCmsRoute()?.applicationPhase
|
||||
}
|
||||
|
||||
export function useNewsletterForm() {
|
||||
return useCmsRoute()?.newsletterForm
|
||||
}
|
||||
|
||||
@@ -2,42 +2,27 @@ import React, { useState } from 'react';
|
||||
import { Check, Mail } from 'lucide-react';
|
||||
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import type { NewsletterInterestCopy, NewsletterPhase, NewsletterSource } from '@/spa/newsletterForm';
|
||||
|
||||
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 }) {
|
||||
export function NewsletterInterestForm({
|
||||
copy,
|
||||
phase,
|
||||
source,
|
||||
}: {
|
||||
copy: NewsletterInterestCopy;
|
||||
phase: NewsletterPhase;
|
||||
source: NewsletterSource;
|
||||
}) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmedEmail = email.trim();
|
||||
|
||||
@@ -52,7 +37,22 @@ export function NewsletterInterestForm({ copy }: { copy: NewsletterInterestCopy
|
||||
}
|
||||
|
||||
setError('');
|
||||
setSubmitted(true);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/newsletter-subscriptions/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: trimmedEmail, phase, source, website }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Newsletter subscription failed with ${response.status}`);
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError(copy.submissionError);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
@@ -98,12 +98,26 @@ export function NewsletterInterestForm({ copy }: { copy: NewsletterInterestCopy
|
||||
<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 }}>
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-10000px', width: 1, height: 1, overflow: 'hidden' }}>
|
||||
<label htmlFor={`newsletter-website-${source}`}>Website</label>
|
||||
<input
|
||||
id={`newsletter-website-${source}`}
|
||||
name="website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={website}
|
||||
onChange={(event) => setWebsite(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label htmlFor={`newsletter-email-${source}`} 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
|
||||
id={`newsletter-email-${source}`}
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
@@ -125,10 +139,12 @@ export function NewsletterInterestForm({ copy }: { copy: NewsletterInterestCopy
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{error && <div style={{ fontFamily: FF, fontSize: 12, color: '#b91c1c', marginTop: 7 }}>{error}</div>}
|
||||
{error && <div role="alert" style={{ fontFamily: FF, fontSize: 12, color: '#b91c1c', marginTop: 7 }}>{error}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
aria-busy={submitting}
|
||||
style={{
|
||||
height: 50,
|
||||
padding: '0 24px',
|
||||
@@ -144,7 +160,8 @@ export function NewsletterInterestForm({ copy }: { copy: NewsletterInterestCopy
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
cursor: 'pointer',
|
||||
cursor: submitting ? 'wait' : 'pointer',
|
||||
opacity: submitting ? 0.72 : 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -111,56 +111,6 @@ export const homeContent = {
|
||||
formTitle: 'Kostenlos bewerben',
|
||||
footnote: 'Kostenlos und unverbindlich –',
|
||||
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,
|
||||
videoModal: {
|
||||
|
||||
146
src/spa/newsletterForm.ts
Normal file
146
src/spa/newsletterForm.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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
|
||||
submissionError: string
|
||||
successHeading: string
|
||||
successBody: string
|
||||
footnote: string
|
||||
footnoteStrong: string
|
||||
}
|
||||
|
||||
export type NewsletterInterestCms = Partial<Omit<NewsletterInterestCopy, 'benefits'>> & {
|
||||
benefits?: unknown
|
||||
}
|
||||
|
||||
export type SpaNewsletterFormData = {
|
||||
evaluation: NewsletterInterestCopy
|
||||
completed: NewsletterInterestCopy
|
||||
}
|
||||
|
||||
export type NewsletterSource = 'homepage' | 'participation'
|
||||
export type NewsletterPhase = 'evaluation' | 'completed'
|
||||
|
||||
export const defaultNewsletterFormData: SpaNewsletterFormData = {
|
||||
evaluation: {
|
||||
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.',
|
||||
submissionError: 'Die Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.',
|
||||
successHeading: 'Danke',
|
||||
successBody: 'Ihre E-Mail wurde für BMP-Updates eingetragen.',
|
||||
footnote: 'BMP-Updates -',
|
||||
footnoteStrong: 'Termine und nächste Runde',
|
||||
},
|
||||
completed: {
|
||||
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.',
|
||||
submissionError: 'Die Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.',
|
||||
successHeading: 'Angemeldet',
|
||||
successBody: 'Danke. Sie erhalten BMP-Updates an die angegebene E-Mail-Adresse.',
|
||||
footnote: 'Bewerbungsstart im Blick -',
|
||||
footnoteStrong: 'BMP-Updates erhalten',
|
||||
},
|
||||
}
|
||||
|
||||
const text = (value: unknown, fallback: string) =>
|
||||
typeof value === 'string' && value.trim().length > 0 ? value : fallback
|
||||
|
||||
function normalizeVariant(value: NewsletterInterestCms | null | undefined, fallback: NewsletterInterestCopy) {
|
||||
const benefits = Array.isArray(value?.benefits)
|
||||
? value.benefits.filter(
|
||||
(benefit): benefit is NewsletterBenefit =>
|
||||
Boolean(benefit) &&
|
||||
typeof benefit === 'object' &&
|
||||
typeof benefit.num === 'string' &&
|
||||
typeof benefit.label === 'string' &&
|
||||
typeof benefit.desc === 'string',
|
||||
)
|
||||
: []
|
||||
|
||||
return {
|
||||
eyebrow: text(value?.eyebrow, fallback.eyebrow),
|
||||
heading: text(value?.heading, fallback.heading),
|
||||
body: text(value?.body, fallback.body),
|
||||
benefits: benefits.length > 0 ? benefits : fallback.benefits,
|
||||
formEyebrow: text(value?.formEyebrow, fallback.formEyebrow),
|
||||
formTitle: text(value?.formTitle, fallback.formTitle),
|
||||
formBody: text(value?.formBody, fallback.formBody),
|
||||
emailLabel: text(value?.emailLabel, fallback.emailLabel),
|
||||
emailPlaceholder: text(value?.emailPlaceholder, fallback.emailPlaceholder),
|
||||
submitLabel: text(value?.submitLabel, fallback.submitLabel),
|
||||
privacy: text(value?.privacy, fallback.privacy),
|
||||
validationRequired: text(value?.validationRequired, fallback.validationRequired),
|
||||
validationInvalid: text(value?.validationInvalid, fallback.validationInvalid),
|
||||
submissionError: text(value?.submissionError, fallback.submissionError),
|
||||
successHeading: text(value?.successHeading, fallback.successHeading),
|
||||
successBody: text(value?.successBody, fallback.successBody),
|
||||
footnote: text(value?.footnote, fallback.footnote),
|
||||
footnoteStrong: text(value?.footnoteStrong, fallback.footnoteStrong),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNewsletterFormData(value?: Partial<SpaNewsletterFormData> | null): SpaNewsletterFormData {
|
||||
return {
|
||||
evaluation: normalizeVariant(value?.evaluation, defaultNewsletterFormData.evaluation),
|
||||
completed: normalizeVariant(value?.completed, defaultNewsletterFormData.completed),
|
||||
}
|
||||
}
|
||||
|
||||
export function newsletterFormCopyForPhase(data: SpaNewsletterFormData | undefined, activePhase?: string) {
|
||||
const normalized = data || defaultNewsletterFormData
|
||||
return activePhase === '1' ? normalized.evaluation : normalized.completed
|
||||
}
|
||||
|
||||
export function newsletterPhaseForApplicationPhase(activePhase?: string): NewsletterPhase {
|
||||
return activePhase === '1' ? 'evaluation' : 'completed'
|
||||
}
|
||||
@@ -5,18 +5,19 @@ import { Link } from '@/spa/router';
|
||||
import { PartnerTicker } from '@/spa/components/ui/partner-ticker';
|
||||
import { WINNERS } from '@/spa/data/winners';
|
||||
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
|
||||
import { NewsletterInterestForm, type NewsletterBenefit, type NewsletterInterestCms, type NewsletterInterestCopy } from '@/spa/components/forms/NewsletterInterestForm';
|
||||
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm';
|
||||
import TestimonialsSection from '@/spa/components/TestimonialsSection';
|
||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase'
|
||||
import { resolveCmsRelationship, useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
|
||||
import { resolveCmsRelationship, useApplicationPhase, useCmsCollection, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute'
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
||||
import { homeContent } from '@/spa/homeContent'
|
||||
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder'
|
||||
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'
|
||||
import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm'
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
@@ -57,8 +58,6 @@ type HomeSectionCms = CmsRecord & {
|
||||
imageAlt?: string | null;
|
||||
imageLabel?: string | null;
|
||||
items?: unknown;
|
||||
newsletterPhaseCompleted?: NewsletterInterestCms | null;
|
||||
newsletterPhaseEvaluation?: NewsletterInterestCms | null;
|
||||
note?: string | null;
|
||||
quote?: string | null;
|
||||
stats?: unknown;
|
||||
@@ -118,39 +117,17 @@ const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
|
||||
|
||||
const HIDE_MEMBERSHIP_HOME_HERO_CTA = true;
|
||||
const APPLICATION_PHASE_OPEN = '0';
|
||||
const STATUS_SECTION_BACKGROUND = '#111D55';
|
||||
const LEGACY_STATUS_SECTION_BACKGROUNDS = new Set(['#020A1E', '#060D1C', '#06090F']);
|
||||
|
||||
const newsletterInterestCopy: Record<string, NewsletterInterestCopy> = {
|
||||
'1': homeContent.application.newsletterPhaseEvaluation,
|
||||
'2': homeContent.application.newsletterPhaseCompleted,
|
||||
};
|
||||
const statusSectionBackground = (value?: string | null) => {
|
||||
const background = value?.trim();
|
||||
|
||||
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),
|
||||
});
|
||||
if (!background || LEGACY_STATUS_SECTION_BACKGROUNDS.has(background.toUpperCase())) {
|
||||
return STATUS_SECTION_BACKGROUND;
|
||||
}
|
||||
|
||||
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);
|
||||
return background;
|
||||
};
|
||||
|
||||
const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | null) =>
|
||||
@@ -199,11 +176,13 @@ function HomeWinnerCard({
|
||||
fallbackTitle,
|
||||
fallbackImage,
|
||||
hoverLabel,
|
||||
horizontalScroll = false,
|
||||
}: {
|
||||
winner: WinnerCardData;
|
||||
fallbackTitle: string;
|
||||
fallbackImage: string;
|
||||
hoverLabel: string;
|
||||
horizontalScroll?: boolean;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
@@ -220,7 +199,7 @@ function HomeWinnerCard({
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
style={{ textDecoration: 'none', display: 'block', position: 'relative', height: isMobile ? 220 : 280, overflow: 'hidden', scrollSnapAlign: isMobile ? 'start' : undefined }}
|
||||
style={{ textDecoration: 'none', display: 'block', position: 'relative', height: isMobile ? 220 : 280, overflow: 'hidden', scrollSnapAlign: isMobile || horizontalScroll ? 'start' : undefined }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
@@ -265,9 +244,11 @@ const Home: React.FC = () => {
|
||||
const form = home.form || {};
|
||||
const videoModal = home.videoModal || {};
|
||||
const applicationPhase = useApplicationPhase();
|
||||
const newsletterForm = useNewsletterForm();
|
||||
const activeApplicationPhase = applicationPhase?.activePhase || APPLICATION_PHASE_OPEN;
|
||||
const showApplicationForm = activeApplicationPhase === APPLICATION_PHASE_OPEN;
|
||||
const newsletterCopy = getNewsletterInterestCopy(activeApplicationPhase, application);
|
||||
const newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase);
|
||||
const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase);
|
||||
const cmsPreistraeger = useCmsCollection('preistraeger');
|
||||
const selectedWinners = fallbackArray(winnersSection.featured, []);
|
||||
const heroSecondaryCta = {
|
||||
@@ -281,6 +262,8 @@ const Home: React.FC = () => {
|
||||
const homeWinners = selectedWinnerDocs.length
|
||||
? selectedWinnerDocs
|
||||
: (cmsPreistraeger.length ? cmsPreistraeger : WINNERS).filter((w) => Number((w as { year?: string | number }).year) === 2025).slice(0, 8);
|
||||
const scrollDesktopWinners = !isMobile && homeWinners.length > 8;
|
||||
const scrollWinnerGrid = isMobile || scrollDesktopWinners;
|
||||
const quickCriteria = fallbackArray(quickCheck.criteria, homeContent.quickCheck.criteria);
|
||||
const introStats = fallbackArray(intro.stats, homeContent.intro.stats);
|
||||
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
|
||||
@@ -313,53 +296,58 @@ const Home: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto relative z-10" style={{ padding: isMobile ? '0 24px' : '0 24px' }}>
|
||||
<div style={{ maxWidth: isMobile ? '100%' : '48rem' }}>
|
||||
<div style={{ maxWidth: isMobile ? '100%' : '62rem' }}>
|
||||
<div className="inline-flex items-center gap-3 bg-accent/20 border border-accent/30 rounded-full px-4 py-1.5 mb-8 backdrop-blur-sm animate-fade-in" style={{ marginBottom: isMobile ? 20 : undefined }}>
|
||||
<span className="flex h-2 w-2 rounded-full bg-accent"></span>
|
||||
<span className="text-accent text-[10px] uppercase tracking-[0.2em] font-bold">{fallbackText(hero.badge, homeContent.hero.badge)}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-display font-bold text-white mb-6 leading-[0.9] tracking-tight" style={{ fontSize: isMobile ? 'clamp(1.8rem, 10vw, 3rem)' : 'clamp(3.5rem, 8vw, 6rem)' }}>
|
||||
{fallbackText(hero.headingLine1, homeContent.hero.headingLine1)} <br />
|
||||
<span className="text-accent">{fallbackText(hero.headingAccent, homeContent.hero.headingAccent)}</span>
|
||||
<h1
|
||||
className="font-display font-bold text-white mb-6 leading-[0.9] tracking-tight"
|
||||
style={{ fontSize: isMobile ? 'clamp(1.75rem, 8vw, 3rem)' : 'clamp(3.25rem, 6.5vw, 6rem)' }}
|
||||
>
|
||||
<span style={{ display: 'block', whiteSpace: 'nowrap' }}>
|
||||
{fallbackText(hero.headingLine1, homeContent.hero.headingLine1)}
|
||||
</span>
|
||||
<span className="text-accent" style={{ display: 'block', whiteSpace: 'nowrap' }}>
|
||||
{fallbackText(hero.headingAccent, homeContent.hero.headingAccent)}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-white/70 mb-12 font-light leading-relaxed" style={{ fontSize: isMobile ? '1.0625rem' : '1.375rem', maxWidth: isMobile ? '100%' : '36rem', marginBottom: isMobile ? 28 : undefined }}>
|
||||
{fallbackText(hero.description, homeContent.hero.description)}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 16, alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||
<Link
|
||||
to={fallbackText(hero.primaryCta?.url, homeContent.hero.primaryCta.url)}
|
||||
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'#101828', background:'#EFBF04', padding:'16px 36px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent: isMobile ? 'center' : 'flex-start', gap:10, transition:'background 0.15s, box-shadow 0.2s', width: isMobile ? '100%' : 'auto' }}
|
||||
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'#101828', background:'#EFBF04', padding:'16px 24px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent:'center', gap:10, transition:'background 0.15s, box-shadow 0.2s', width: isMobile ? '100%' : 'auto' }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = '#FFD130'; (e.currentTarget as HTMLElement).style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = '#EFBF04'; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||
>
|
||||
{fallbackText(hero.primaryCta?.label, homeContent.hero.primaryCta.label)} <ChevronRight size={16} />
|
||||
</Link>
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||
{showHeroSecondaryCta && (
|
||||
<Link
|
||||
to={heroSecondaryCta.url}
|
||||
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'rgba(255,255,255,0.85)', border:'1.5px solid rgba(255,255,255,0.35)', padding:'14px 32px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent: isMobile ? 'center' : 'flex-start', gap:10, transition:'border-color 0.15s, color 0.15s', background:'transparent', width: isMobile ? '100%' : 'auto' }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.35)'; (e.currentTarget as HTMLElement).style.color = 'rgba(255,255,255,0.85)'; }}
|
||||
>
|
||||
{heroSecondaryCta.label}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideo(true)}
|
||||
className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
|
||||
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideo(true)}
|
||||
className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
|
||||
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</div>
|
||||
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
|
||||
</button>
|
||||
{showHeroSecondaryCta && (
|
||||
<Link
|
||||
to={heroSecondaryCta.url}
|
||||
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'rgba(255,255,255,0.85)', border:'1.5px solid rgba(255,255,255,0.35)', padding:'14px 32px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent:'center', gap:10, transition:'border-color 0.15s, color 0.15s', background:'transparent', width: isMobile ? '100%' : 'auto' }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.35)'; (e.currentTarget as HTMLElement).style.color = 'rgba(255,255,255,0.85)'; }}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</div>
|
||||
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
|
||||
</button>
|
||||
</div>
|
||||
{heroSecondaryCta.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -402,12 +390,13 @@ const Home: React.FC = () => {
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)',
|
||||
gridAutoFlow: isMobile ? 'column' : undefined,
|
||||
gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined,
|
||||
gridAutoColumns: isMobile ? '66vw' : undefined,
|
||||
overflowX: isMobile ? 'auto' : undefined,
|
||||
scrollSnapType: isMobile ? 'x mandatory' : undefined,
|
||||
gridTemplateColumns: scrollWinnerGrid ? undefined : 'repeat(4, 1fr)',
|
||||
gridAutoFlow: scrollWinnerGrid ? 'column' : undefined,
|
||||
gridTemplateRows: scrollWinnerGrid ? `repeat(2, ${isMobile ? '1fr' : '280px'})` : undefined,
|
||||
gridAutoColumns: isMobile ? '66vw' : scrollDesktopWinners ? '25vw' : undefined,
|
||||
overflowX: scrollWinnerGrid ? 'auto' : undefined,
|
||||
scrollSnapType: scrollWinnerGrid ? 'x mandatory' : undefined,
|
||||
overscrollBehaviorX: scrollWinnerGrid ? 'contain' : undefined,
|
||||
gap: isMobile ? 8 : 0,
|
||||
padding: isMobile ? '0 16px 8px' : 0,
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
@@ -420,6 +409,7 @@ const Home: React.FC = () => {
|
||||
fallbackTitle={fallbackText(winnersSection.cardFallbackTitle, homeContent.winners.cardFallbackTitle)}
|
||||
fallbackImage={winnerFallbackImage}
|
||||
hoverLabel={fallbackText(winnersSection.hoverLabel, homeContent.winners.hoverLabel)}
|
||||
horizontalScroll={scrollDesktopWinners}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -716,7 +706,7 @@ const Home: React.FC = () => {
|
||||
<div style={{ padding: isMobile ? '32px 24px' : '24px 48px 32px 40px', flex: isMobile ? 'none' : 1, height: isMobile && showApplicationForm ? '82svh' : undefined, display: 'flex', flexDirection: 'column', minHeight: 0, position: 'relative', zIndex: 1 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{showApplicationForm ? fallbackText(application.formEyebrow, homeContent.application.formEyebrow) : newsletterCopy.formEyebrow}</span>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.1, marginBottom: 24 }}>{showApplicationForm ? fallbackText(application.formTitle, homeContent.application.formTitle) : newsletterCopy.formTitle}</h3>
|
||||
{showApplicationForm ? <BewerbungsForm theme="gold" content={form} /> : <NewsletterInterestForm copy={newsletterCopy} />}
|
||||
{showApplicationForm ? <BewerbungsForm theme="gold" content={form} /> : <NewsletterInterestForm copy={newsletterCopy} phase={newsletterPhase} source="homepage" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -784,7 +774,7 @@ function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
|
||||
return {
|
||||
...phase,
|
||||
accent: phase.accent || '#EFBF04',
|
||||
bg: phase.bg || '#020A1E',
|
||||
bg: statusSectionBackground(phase.bg),
|
||||
cta,
|
||||
membershipCta,
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@ import React, { useRef } from 'react';
|
||||
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react';
|
||||
import { Link } from '@/spa/router';
|
||||
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
|
||||
import { NewsletterInterestForm, type NewsletterBenefit, type NewsletterInterestCms, type NewsletterInterestCopy } from '@/spa/components/forms/NewsletterInterestForm';
|
||||
import { NewsletterInterestForm } from '@/spa/components/forms/NewsletterInterestForm';
|
||||
import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection';
|
||||
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
|
||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import { useApplicationPhase, useCmsRoute } from '@/spa/cmsRoute';
|
||||
import { useApplicationPhase, useCmsRoute, useNewsletterForm } from '@/spa/cmsRoute';
|
||||
import { participationContent } from '@/spa/participationContent';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import { newsletterFormCopyForPhase, newsletterPhaseForApplicationPhase } from '@/spa/newsletterForm';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
const FB = '"Inter", sans-serif';
|
||||
@@ -26,8 +27,6 @@ type ParticipationCms = Partial<typeof participationContent> & {
|
||||
|
||||
type ParticipationApplicationFormCms = Partial<typeof participationContent.applicationForm> & {
|
||||
image?: unknown;
|
||||
newsletterPhaseCompleted?: NewsletterInterestCms | null;
|
||||
newsletterPhaseEvaluation?: NewsletterInterestCms | null;
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
@@ -48,40 +47,6 @@ const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
|
||||
|
||||
const APPLICATION_PHASE_OPEN = '0';
|
||||
|
||||
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() : '';
|
||||
@@ -98,6 +63,7 @@ const Participation: React.FC = () => {
|
||||
const qualSectionRef = useRef<HTMLElement>(null);
|
||||
const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms;
|
||||
const applicationPhase = useApplicationPhase();
|
||||
const newsletterForm = useNewsletterForm();
|
||||
const hero = { ...participationContent.hero, ...(cms.hero || {}) };
|
||||
const process = { ...participationContent.process, ...(cms.process || {}) };
|
||||
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
|
||||
@@ -108,7 +74,8 @@ const Participation: React.FC = () => {
|
||||
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 newsletterCopy = newsletterFormCopyForPhase(newsletterForm, activeApplicationPhase);
|
||||
const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase);
|
||||
const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
|
||||
const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes);
|
||||
const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems);
|
||||
@@ -324,7 +291,7 @@ const Participation: React.FC = () => {
|
||||
<>
|
||||
<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} />
|
||||
<NewsletterInterestForm copy={newsletterCopy} phase={newsletterPhase} source="participation" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -153,56 +153,6 @@ export const participationContent = {
|
||||
uploadCta: { label: 'PDF hochladen', url: '/formular-hochladen' },
|
||||
uploadPrompt: 'Sie haben Ihre Unterlagen bereits als PDF vorbereitet? Laden Sie sie direkt hoch, statt das Formular auszufüllen.',
|
||||
uploadFooterCta: { label: 'Formular hochladen →', url: '/formular-hochladen' },
|
||||
newsletterPhaseEvaluation: {
|
||||
eyebrow: 'Updates erhalten',
|
||||
heading: 'DIE BEWERBUNG\nIST GESCHLOSSEN.',
|
||||
body:
|
||||
'Die Bewerbungsphase 2026 ist geschlossen. Hinterlassen Sie Ihre E-Mail und erhalten Sie Updates zur Juryphase, zur Preisverleihung und zur nächsten Runde.',
|
||||
benefits: [
|
||||
{ num: '01', label: 'Juryphase', desc: 'Updates zum Verlauf' },
|
||||
{ num: '02', label: 'Preisverleihung', desc: 'Termine und Einblicke' },
|
||||
{ num: '03', label: 'Nächste Runde', desc: 'Start nicht verpassen' },
|
||||
],
|
||||
formEyebrow: 'Newsletter abonnieren',
|
||||
formTitle: 'Updates erhalten',
|
||||
formBody:
|
||||
'Tragen Sie Ihre E-Mail ein und erhalten Sie relevante Informationen zum weiteren Verlauf des Bayerischen Mittelstandspreises.',
|
||||
emailLabel: 'E-Mail-Adresse',
|
||||
emailPlaceholder: 'name@unternehmen.de',
|
||||
submitLabel: 'Eintragen',
|
||||
privacy: 'Kein Spam. Nur relevante Informationen rund um den Bayerischen Mittelstandspreis.',
|
||||
validationRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein.',
|
||||
validationInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
|
||||
successHeading: 'Danke',
|
||||
successBody: 'Ihre E-Mail wurde für BMP-Updates eingetragen.',
|
||||
footnote: 'BMP-Updates -',
|
||||
footnoteStrong: 'Juryphase und Termine',
|
||||
},
|
||||
newsletterPhaseCompleted: {
|
||||
eyebrow: 'Nächste Runde',
|
||||
heading: 'DEN START\nNICHT\nVERPASSEN.',
|
||||
body:
|
||||
'Der aktuelle Preisjahrgang ist abgeschlossen. Hinterlassen Sie Ihre E-Mail und wir informieren Sie, sobald es Neuigkeiten zur nächsten Bewerbungsphase gibt.',
|
||||
benefits: [
|
||||
{ num: '01', label: 'Startsignal', desc: 'Neue Bewerbungsphase' },
|
||||
{ num: '02', label: 'BMP-News', desc: 'Preisträger und Termine' },
|
||||
{ num: '03', label: 'Erinnerung', desc: 'Rechtzeitig vorbereitet' },
|
||||
],
|
||||
formEyebrow: 'Newsletter abonnieren',
|
||||
formTitle: 'Informiert bleiben',
|
||||
formBody:
|
||||
'Tragen Sie sich ein und erhalten Sie Hinweise zum Bewerbungsstart, zu Veranstaltungen und zu ausgezeichneten Unternehmen.',
|
||||
emailLabel: 'E-Mail-Adresse',
|
||||
emailPlaceholder: 'name@unternehmen.de',
|
||||
submitLabel: 'Abonnieren',
|
||||
privacy: 'Sie erhalten nur BMP-relevante Informationen. Eine Abmeldung ist jederzeit möglich.',
|
||||
validationRequired: 'Bitte geben Sie Ihre E-Mail-Adresse ein.',
|
||||
validationInvalid: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
|
||||
successHeading: 'Angemeldet',
|
||||
successBody: 'Danke. Sie erhalten BMP-Updates an die angegebene E-Mail-Adresse.',
|
||||
footnote: 'Bewerbungsstart im Blick -',
|
||||
footnoteStrong: 'BMP-Updates erhalten',
|
||||
},
|
||||
},
|
||||
form: {
|
||||
stepCompleteLabel: '✓',
|
||||
|
||||
Reference in New Issue
Block a user