From bd2e99ca91a39ef3402a88ea617151155d10e938 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 13 Jul 2026 13:37:03 +0200 Subject: [PATCH] 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. --- src/NewsletterForm/config.ts | 78 ++++ src/app/(frontend)/[[...slug]]/page.tsx | 4 + src/collections/NewsletterSubscriptions.ts | 145 +++++++ src/collections/Pages/homeFields.ts | 44 -- src/collections/Pages/participationFields.ts | 43 -- ...33000_newsletter_form_and_subscriptions.ts | 343 +++++++++++++++ src/migrations/index.ts | 6 + src/payload-types.ts | 397 ++++++++---------- src/payload.config.ts | 6 +- src/scripts/preload-globals-cms.ts | 6 + src/scripts/setup-fresh-database.ts | 2 +- src/spa/NextApp.tsx | 5 +- src/spa/applicationPhase.ts | 6 +- src/spa/cmsRoute.tsx | 6 + .../forms/NewsletterInterestForm.tsx | 81 ++-- src/spa/homeContent.ts | 50 --- src/spa/newsletterForm.ts | 146 +++++++ src/spa/pages/Home.tsx | 134 +++--- src/spa/pages/Participation.tsx | 47 +-- src/spa/participationContent.ts | 50 --- 20 files changed, 1033 insertions(+), 566 deletions(-) create mode 100644 src/NewsletterForm/config.ts create mode 100644 src/collections/NewsletterSubscriptions.ts create mode 100644 src/migrations/20260713_133000_newsletter_form_and_subscriptions.ts create mode 100644 src/spa/newsletterForm.ts diff --git a/src/NewsletterForm/config.ts b/src/NewsletterForm/config.ts new file mode 100644 index 0000000..1d882c2 --- /dev/null +++ b/src/NewsletterForm/config.ts @@ -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'), + }, + ], +} diff --git a/src/app/(frontend)/[[...slug]]/page.tsx b/src/app/(frontend)/[[...slug]]/page.tsx index 5f34d53..30966e9 100644 --- a/src/app/(frontend)/[[...slug]]/page.tsx +++ b/src/app/(frontend)/[[...slug]]/page.tsx @@ -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 | undefined)} applicationPhase={normalizeApplicationPhaseData(applicationPhase as Partial | undefined)} + newsletterForm={normalizeNewsletterFormData(newsletterForm as Partial | undefined)} cmsRoute={{ collection: route.collection, doc: route.doc as unknown as CmsRouteDoc, diff --git a/src/collections/NewsletterSubscriptions.ts b/src/collections/NewsletterSubscriptions.ts new file mode 100644 index 0000000..4953378 --- /dev/null +++ b/src/collections/NewsletterSubscriptions.ts @@ -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(['homepage', 'participation']) +const phases = new Set(['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, + }, + }, + ], +} diff --git a/src/collections/Pages/homeFields.ts b/src/collections/Pages/homeFields.ts index 8e855fa..01971bc 100644 --- a/src/collections/Pages/homeFields.ts +++ b/src/collections/Pages/homeFields.ts @@ -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'), - }, ], }, { diff --git a/src/collections/Pages/participationFields.ts b/src/collections/Pages/participationFields.ts index 11fbe39..f3c426e 100644 --- a/src/collections/Pages/participationFields.ts +++ b/src/collections/Pages/participationFields.ts @@ -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'), - }, ], }, { diff --git a/src/migrations/20260713_133000_newsletter_form_and_subscriptions.ts b/src/migrations/20260713_133000_newsletter_form_and_subscriptions.ts new file mode 100644 index 0000000..09dcc22 --- /dev/null +++ b/src/migrations/20260713_133000_newsletter_form_and_subscriptions.ts @@ -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 { + 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 { + 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`;')) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 946a465..7a2b361 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 73a8b4d..2a18ce0 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 | PartnersSelect; posts: PostsSelect | PostsSelect; events: EventsSelect | EventsSelect; + 'newsletter-subscriptions': NewsletterSubscriptionsSelect | NewsletterSubscriptionsSelect; media: MediaSelect | MediaSelect; users: UsersSelect | UsersSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; @@ -112,12 +114,14 @@ export interface Config { footer: Footer; 'site-settings': SiteSetting; 'application-phase': ApplicationPhase; + 'newsletter-form': NewsletterForm; }; globalsSelect: { header: HeaderSelect | HeaderSelect; footer: FooterSelect | FooterSelect; 'site-settings': SiteSettingsSelect | SiteSettingsSelect; 'application-phase': ApplicationPhaseSelect | ApplicationPhaseSelect; + 'newsletter-form': NewsletterFormSelect | NewsletterFormSelect; }; 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 { 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 { 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 { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "newsletter-subscriptions_select". + */ +export interface NewsletterSubscriptionsSelect { + 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 { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "newsletter-form_select". + */ +export interface NewsletterFormSelect { + 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". diff --git a/src/payload.config.ts b/src/payload.config.ts index 61ded20..ef9db80 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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, diff --git a/src/scripts/preload-globals-cms.ts b/src/scripts/preload-globals-cms.ts index 5b06edc..82f2f13 100644 --- a/src/scripts/preload-globals-cms.ts +++ b/src/scripts/preload-globals-cms.ts @@ -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.') diff --git a/src/scripts/setup-fresh-database.ts b/src/scripts/setup-fresh-database.ts index bb6bc47..293ccf9 100644 --- a/src/scripts/setup-fresh-database.ts +++ b/src/scripts/setup-fresh-database.ts @@ -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', }, { diff --git a/src/spa/NextApp.tsx b/src/spa/NextApp.tsx index 1918162..0e2470b 100644 --- a/src/spa/NextApp.tsx +++ b/src/spa/NextApp.tsx @@ -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 ( <> - + {page} diff --git a/src/spa/applicationPhase.ts b/src/spa/applicationPhase.ts index 0bbad6c..e26b723 100644 --- a/src/spa/applicationPhase.ts +++ b/src/spa/applicationPhase.ts @@ -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', diff --git a/src/spa/cmsRoute.tsx b/src/spa/cmsRoute.tsx index 91765a4..066f661 100644 --- a/src/spa/cmsRoute.tsx +++ b/src/spa/cmsRoute.tsx @@ -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> siteSettings?: SpaSiteSettingsData applicationPhase?: SpaApplicationPhaseData + newsletterForm?: SpaNewsletterFormData } const CmsRouteContext = createContext(undefined) @@ -53,3 +55,7 @@ export function resolveCmsRelationship(entry: unknown, docs: CmsRouteDoc[]) { export function useApplicationPhase() { return useCmsRoute()?.applicationPhase } + +export function useNewsletterForm() { + return useCmsRoute()?.newsletterForm +} diff --git a/src/spa/components/forms/NewsletterInterestForm.tsx b/src/spa/components/forms/NewsletterInterestForm.tsx index 9e59285..a8d0002 100644 --- a/src/spa/components/forms/NewsletterInterestForm.tsx +++ b/src/spa/components/forms/NewsletterInterestForm.tsx @@ -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> & { - 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) => { + const submit = async (event: React.FormEvent) => { 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

{copy.formBody}

-