Compare commits

..

2 Commits

Author SHA1 Message Date
syntaxbullet
bd2e99ca91 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.
2026-07-13 13:37:03 +02:00
syntaxbullet
64049baef0 fix: retry production health checks during startup 2026-07-13 12:33:09 +02:00
22 changed files with 1065 additions and 570 deletions

View File

@@ -81,7 +81,7 @@ The deploy script:
4. writes the deployed Git revision to `.deploy-revision`
5. builds the Docker image on the VPS
6. recreates only the `bmp_website` app container
7. verifies `/` and `/admin`
7. verifies `/` and `/admin`, retrying transient startup failures for up to 60 seconds
The required workflow is:
@@ -114,6 +114,18 @@ If the Caddy container also needs to be recreated:
DEPLOY_CADDY=1 pnpm deploy:vps
```
The post-deploy verification tolerates brief `502` responses while Next/Payload
starts. Its retry behavior can be adjusted when needed:
```bash
VERIFY_RETRIES=30
VERIFY_RETRY_DELAY_SECONDS=2
VERIFY_RETRY_MAX_SECONDS=60
VERIFY_REQUEST_TIMEOUT_SECONDS=20
```
Set `SKIP_VERIFY=1` only when verification is being performed separately.
## Payload Migrations
Routine deploys do not run migrations. For schema-changing commits, create and

View File

@@ -14,6 +14,10 @@ PUBLIC_URL="${PUBLIC_URL:-https://$APP_DOMAIN}"
DEPLOY_CADDY="${DEPLOY_CADDY:-0}"
RUN_MIGRATIONS="${RUN_MIGRATIONS:-0}"
SKIP_VERIFY="${SKIP_VERIFY:-0}"
VERIFY_RETRIES="${VERIFY_RETRIES:-30}"
VERIFY_RETRY_DELAY_SECONDS="${VERIFY_RETRY_DELAY_SECONDS:-2}"
VERIFY_RETRY_MAX_SECONDS="${VERIFY_RETRY_MAX_SECONDS:-60}"
VERIFY_REQUEST_TIMEOUT_SECONDS="${VERIFY_REQUEST_TIMEOUT_SECONDS:-20}"
SSH_TARGET="$SSH_USER@$VPS_HOST"
COMPOSE_FILE="$APP_ROOT/current/deploy/vps/docker-compose.yml"
@@ -86,10 +90,22 @@ ssh -t -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
"
verify_url() {
local url="$1"
echo "Verifying $url"
curl -fsSIL \
--retry "$VERIFY_RETRIES" \
--retry-all-errors \
--retry-delay "$VERIFY_RETRY_DELAY_SECONDS" \
--retry-max-time "$VERIFY_RETRY_MAX_SECONDS" \
--max-time "$VERIFY_REQUEST_TIMEOUT_SECONDS" \
"$url" | sed -n '1,12p'
}
if [[ "$SKIP_VERIFY" != "1" ]]; then
echo "Verifying $PUBLIC_URL"
curl -fsSIL --max-time 20 "$PUBLIC_URL/" | sed -n '1,12p'
curl -fsSIL --max-time 20 "$PUBLIC_URL/admin" | sed -n '1,12p'
verify_url "$PUBLIC_URL/"
verify_url "$PUBLIC_URL/admin"
fi
echo "Deploy complete: $REVISION"

View 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'),
},
],
}

View File

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

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

View File

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

View File

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

View File

@@ -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`;'))
}

View File

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

View File

@@ -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".

View File

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

View File

@@ -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.')

View File

@@ -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',
},
{

View File

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

View File

@@ -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',

View File

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

View File

@@ -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('');
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',
}}
>

View File

@@ -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
View 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'
}

View File

@@ -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,41 +296,37 @@ 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)}
@@ -359,7 +338,16 @@ const Home: React.FC = () => {
</div>
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
</button>
</div>
{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)'; }}
>
{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,
}

View File

@@ -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>

View File

@@ -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: '✓',