From 5bd1977c63cba44b62f6d763d28734c5c3c116c4 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 22 Jun 2026 11:44:35 +0200 Subject: [PATCH] feat: manage membership page via payload --- package.json | 1 + src/collections/Pages/index.ts | 19 +- src/collections/Pages/mitgliedWerdenFields.ts | 303 ++++++++++++ src/payload-types.ts | 362 ++++++++++++++ .../preload-mitglied-werden-page-cms.ts | 86 ++++ .../membership/MembershipWizard.tsx | 396 ++++++++------- src/spa/mitgliedWerdenContent.ts | 347 +++++++++++++ src/spa/pages/MitgliedWerden.tsx | 456 +++++++----------- 8 files changed, 1506 insertions(+), 464 deletions(-) create mode 100644 src/collections/Pages/mitgliedWerdenFields.ts create mode 100644 src/scripts/preload-mitglied-werden-page-cms.ts create mode 100644 src/spa/mitgliedWerdenContent.ts diff --git a/package.json b/package.json index a6dad41..c2f5238 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "preload:formular-upload-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-formular-upload-page-cms.ts", "preload:home-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-home-page-cms.ts", "preload:impressum-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-impressum-page-cms.ts", + "preload:mitglied-werden-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-mitglied-werden-page-cms.ts", "preload:netzwerk-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-netzwerk-page-cms.ts", "preload:participation-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-participation-page-cms.ts", "preload:preistraeger-index-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-preistraeger-index-page-cms.ts", diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index ba5e7e9..4dcb04c 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -14,6 +14,7 @@ import { datenschutzFields } from './datenschutzFields' import { formularUploadFields } from './formularUploadFields' import { homeFields } from './homeFields' import { impressumFields } from './impressumFields' +import { mitgliedWerdenFields } from './mitgliedWerdenFields' import { netzwerkFields } from './netzwerkFields' import { participationFields } from './participationFields' import { preistraegerIndexFields } from './preistraegerIndexFields' @@ -111,6 +112,14 @@ const isNetzwerkPage = (_: unknown, siblingData?: { slug?: string; spaPath?: str return spaPath === '/netzwerk' || slug === 'netzwerk' || slug === 'network' || title === 'netzwerk' || title === 'network' } +const isMitgliedWerdenPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => { + const slug = siblingData?.slug + const spaPath = siblingData?.spaPath + const title = siblingData?.title?.toLowerCase() + + return spaPath === '/mitglied-werden' || slug === 'mitglied-werden' || slug === 'membership' || title === 'mitglied werden' +} + const isManagedSpaPage = (_: unknown, siblingData?: { slug?: string; spaPath?: string; title?: string }) => isHomePage(undefined, siblingData) || isContactPage(undefined, siblingData) || @@ -121,7 +130,8 @@ const isManagedSpaPage = (_: unknown, siblingData?: { slug?: string; spaPath?: s isPreistraegerIndexPage(undefined, siblingData) || isPressIndexPage(undefined, siblingData) || isFormularUploadPage(undefined, siblingData) || - isNetzwerkPage(undefined, siblingData) + isNetzwerkPage(undefined, siblingData) || + isMitgliedWerdenPage(undefined, siblingData) export const Pages: CollectionConfig<'pages'> = { slug: 'pages', @@ -259,6 +269,13 @@ export const Pages: CollectionConfig<'pages'> = { fields: netzwerkFields, label: 'Network Page', }, + { + admin: { + condition: (data) => isMitgliedWerdenPage(undefined, data), + }, + fields: mitgliedWerdenFields, + label: 'Membership Page', + }, { name: 'meta', label: 'SEO', diff --git a/src/collections/Pages/mitgliedWerdenFields.ts b/src/collections/Pages/mitgliedWerdenFields.ts new file mode 100644 index 0000000..f170e9d --- /dev/null +++ b/src/collections/Pages/mitgliedWerdenFields.ts @@ -0,0 +1,303 @@ +import type { Field } from 'payload' + +import { mitgliedWerdenContent } from '@/spa/mitgliedWerdenContent' + +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 numberField = (name: string, label: string, defaultValue?: number): Field => ({ + name, + type: 'number', + label, + defaultValue, +}) + +const checkbox = (name: string, label: string, defaultValue?: boolean): Field => ({ + name, + type: 'checkbox', + label, + defaultValue, +}) + +const uploadField = (name: string, label: string, description?: string): Field => ({ + name, + type: 'upload', + relationTo: 'media', + label, + admin: description ? { description } : undefined, +}) + +const sectionAdmin = (description: string) => ({ + description, + initCollapsed: true, +}) + +const statFields: Field[] = [text('value', 'Value'), text('label', 'Label')] +const factFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')] +const faqFields: Field[] = [text('q', 'Question'), textarea('a', 'Answer')] +const testimonialFields: Field[] = [ + textarea('quote', 'Quote'), + text('initials', 'Initials'), + text('name', 'Name'), + text('role', 'Role'), +] + +export const mitgliedWerdenFields: Field[] = [ + { + name: 'mitgliedWerden', + label: 'Membership page content', + type: 'group', + admin: { + description: + 'Edit the Mitglied werden page sections, pricing model, application choices, FAQ, testimonials, and wizard copy.', + }, + fields: [ + { + name: 'header', + label: '00 · Header', + type: 'group', + admin: sectionAdmin('Small top bar brand and return link.'), + fields: [ + text('brandLabel', 'Brand label', mitgliedWerdenContent.header.brandLabel), + text('backLabel', 'Back link label', mitgliedWerdenContent.header.backLabel), + text('backUrl', 'Back link URL', mitgliedWerdenContent.header.backUrl), + ], + }, + { + name: 'hero', + label: '01 · Hero', + type: 'group', + admin: sectionAdmin('Hero image, headline, description, and desktop stats.'), + fields: [ + uploadField('image', 'Hero image', `Current frontend image: /images/${mitgliedWerdenContent.hero.imageFilename}`), + text('imageAlt', 'Image alt text', mitgliedWerdenContent.hero.imageAlt), + text('eyebrow', 'Eyebrow', mitgliedWerdenContent.hero.eyebrow), + textarea('heading', 'Heading', mitgliedWerdenContent.hero.heading), + textarea('description', 'Description', mitgliedWerdenContent.hero.description), + { + name: 'stats', + label: 'Stats', + type: 'array', + dbName: 'member_hero_stats', + defaultValue: mitgliedWerdenContent.hero.stats, + fields: statFields, + }, + ], + }, + { + name: 'benefits', + label: '02 · Benefits', + type: 'group', + admin: sectionAdmin('Benefit section heading and cards. Icon choices map to Lucide icons in the SPA.'), + fields: [ + text('eyebrow', 'Eyebrow', mitgliedWerdenContent.benefits.eyebrow), + textarea('heading', 'Heading', mitgliedWerdenContent.benefits.heading), + { + name: 'cards', + label: 'Cards', + type: 'array', + dbName: 'member_benefits', + defaultValue: mitgliedWerdenContent.benefits.cards, + fields: [ + { + name: 'icon', + label: 'Icon', + type: 'select', + defaultValue: 'users', + options: [ + { label: 'Users', value: 'users' }, + { label: 'Star', value: 'star' }, + { label: 'Eye', value: 'eye' }, + { label: 'Heart', value: 'heart' }, + ], + }, + text('title', 'Title'), + textarea('body', 'Body'), + ], + }, + ], + }, + { + name: 'about', + label: '03 · About', + type: 'group', + admin: sectionAdmin('Association explainer, descriptor, and fact rows.'), + fields: [ + text('eyebrow', 'Eyebrow', mitgliedWerdenContent.about.eyebrow), + textarea('heading', 'Heading', mitgliedWerdenContent.about.heading), + { + name: 'paragraphs', + label: 'Paragraphs', + type: 'array', + dbName: 'member_about_copy', + defaultValue: mitgliedWerdenContent.about.paragraphs.map((text) => ({ text })), + fields: [textarea('text', 'Text')], + }, + text('descriptor', 'Descriptor', mitgliedWerdenContent.about.descriptor), + { + name: 'facts', + label: 'Facts', + type: 'array', + dbName: 'member_about_facts', + defaultValue: mitgliedWerdenContent.about.facts, + fields: factFields, + }, + ], + }, + { + name: 'pricing', + label: '04 · Pricing calculator', + type: 'group', + admin: sectionAdmin('Calculator labels, table copy, VAT rate, admission fee, and tier rows.'), + fields: [ + text('eyebrow', 'Eyebrow', mitgliedWerdenContent.pricing.eyebrow), + textarea('heading', 'Heading', mitgliedWerdenContent.pricing.heading), + text('employeeLabel', 'Employee select label', mitgliedWerdenContent.pricing.employeeLabel), + text('selectPlaceholder', 'Select placeholder', mitgliedWerdenContent.pricing.selectPlaceholder), + text('optionSuffix', 'Option suffix', mitgliedWerdenContent.pricing.optionSuffix), + text('resultEyebrow', 'Result eyebrow', mitgliedWerdenContent.pricing.resultEyebrow), + text('annualNetLabel', 'Annual net label', mitgliedWerdenContent.pricing.annualNetLabel), + text('annualGrossLabel', 'Annual gross label', mitgliedWerdenContent.pricing.annualGrossLabel), + text('annualGrossShortLabel', 'Annual gross short label', mitgliedWerdenContent.pricing.annualGrossShortLabel), + text('admissionGrossLabel', 'Admission gross label', mitgliedWerdenContent.pricing.admissionGrossLabel), + text('totalYearOneLabel', 'Total year-one label', mitgliedWerdenContent.pricing.totalYearOneLabel), + textarea('footnote', 'Footnote', mitgliedWerdenContent.pricing.footnote), + text('ctaLabel', 'CTA label', mitgliedWerdenContent.pricing.ctaLabel), + text('ctaHref', 'CTA URL', mitgliedWerdenContent.pricing.ctaHref), + text('tableToggleLabel', 'Table toggle label', mitgliedWerdenContent.pricing.tableToggleLabel), + text('tableEmployeeHeader', 'Table employees header', mitgliedWerdenContent.pricing.tableEmployeeHeader), + text('tableNetHeader', 'Table net header', mitgliedWerdenContent.pricing.tableNetHeader), + text('tableGrossHeader', 'Table gross header', mitgliedWerdenContent.pricing.tableGrossHeader), + text('tableNotePrefix', 'Table note prefix', mitgliedWerdenContent.pricing.tableNotePrefix), + text('tableNoteSuffix', 'Table note suffix', mitgliedWerdenContent.pricing.tableNoteSuffix), + numberField('vatRate', 'VAT rate', mitgliedWerdenContent.pricing.vatRate), + numberField('admissionFeeNet', 'Admission fee net', mitgliedWerdenContent.pricing.admissionFeeNet), + { + name: 'tiers', + label: 'Tiers', + type: 'array', + dbName: 'member_pricing', + defaultValue: mitgliedWerdenContent.pricing.tiers, + fields: [numberField('max', 'Maximum employees'), text('label', 'Label'), numberField('annual', 'Annual net amount')], + }, + ], + }, + { + name: 'applicationOptions', + label: '05 · Application options', + type: 'group', + admin: sectionAdmin('Online and PDF application strip.'), + fields: [ + { + name: 'items', + label: 'Options', + type: 'array', + dbName: 'member_app_options', + defaultValue: mitgliedWerdenContent.applicationOptions.items, + fields: [ + text('id', 'Stable ID'), + text('number', 'Number'), + text('eyebrow', 'Eyebrow'), + text('title', 'Title'), + textarea('description', 'Description'), + text('href', 'URL'), + uploadField('file', 'Download file'), + checkbox('download', 'Download link'), + ], + }, + ], + }, + { + name: 'formIntro', + label: '06 · Form intro', + type: 'group', + admin: sectionAdmin('Left-hand pitch copy and wizard image/caption.'), + fields: [ + text('eyebrow', 'Eyebrow', mitgliedWerdenContent.formIntro.eyebrow), + textarea('heading', 'Heading', mitgliedWerdenContent.formIntro.heading), + textarea('description', 'Description', mitgliedWerdenContent.formIntro.description), + { + name: 'promises', + label: 'Promise rows', + type: 'array', + dbName: 'member_promises', + defaultValue: mitgliedWerdenContent.formIntro.promises, + fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')], + }, + uploadField('image', 'Wizard image', `Current frontend image: /images/${mitgliedWerdenContent.formIntro.imageFilename}`), + text('imageAlt', 'Image alt text', mitgliedWerdenContent.formIntro.imageAlt), + text('imageLabel', 'Image caption', mitgliedWerdenContent.formIntro.imageLabel), + ], + }, + { + name: 'wizard', + label: '07 · Wizard copy JSON', + type: 'json', + admin: { + description: + 'Deep wizard labels, options, validation messages, legal text, consent copy, success copy, and duplicated pricing defaults. Keep the object shape aligned with src/spa/mitgliedWerdenContent.ts.', + }, + defaultValue: mitgliedWerdenContent.wizard, + }, + { + name: 'testimonials', + label: '08 · Testimonials', + type: 'group', + admin: sectionAdmin('Member quotes and attribution cards.'), + fields: [ + text('eyebrow', 'Eyebrow', mitgliedWerdenContent.testimonials.eyebrow), + textarea('heading', 'Heading', mitgliedWerdenContent.testimonials.heading), + { + name: 'items', + label: 'Quotes', + type: 'array', + dbName: 'member_quotes', + defaultValue: mitgliedWerdenContent.testimonials.items, + fields: testimonialFields, + }, + ], + }, + { + name: 'faq', + label: '09 · FAQ', + type: 'group', + admin: sectionAdmin('FAQ heading and question/answer rows.'), + fields: [ + text('eyebrow', 'Eyebrow', mitgliedWerdenContent.faq.eyebrow), + textarea('heading', 'Heading', mitgliedWerdenContent.faq.heading), + { + name: 'items', + label: 'Items', + type: 'array', + dbName: 'member_faq', + defaultValue: mitgliedWerdenContent.faq.items, + fields: faqFields, + }, + ], + }, + { + name: 'bottomCta', + label: '10 · Bottom CTA', + type: 'group', + admin: sectionAdmin('Final call-to-action band.'), + fields: [ + text('eyebrow', 'Eyebrow', mitgliedWerdenContent.bottomCta.eyebrow), + textarea('heading', 'Heading', mitgliedWerdenContent.bottomCta.heading), + text('ctaLabel', 'CTA label', mitgliedWerdenContent.bottomCta.ctaLabel), + text('ctaHref', 'CTA URL', mitgliedWerdenContent.bottomCta.ctaHref), + ], + }, + ], + }, +] diff --git a/src/payload-types.ts b/src/payload-types.ts index 64d6825..3a620b8 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1629,6 +1629,201 @@ export interface Page { footerText?: string | null; }; }; + /** + * Edit the Mitglied werden page sections, pricing model, application choices, FAQ, testimonials, and wizard copy. + */ + mitgliedWerden?: { + /** + * Small top bar brand and return link. + */ + header?: { + brandLabel?: string | null; + backLabel?: string | null; + backUrl?: string | null; + }; + /** + * Hero image, headline, description, and desktop stats. + */ + hero?: { + /** + * Current frontend image: /images/mitglied-hero.jpg + */ + image?: (number | null) | Media; + imageAlt?: string | null; + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + stats?: + | { + value?: string | null; + label?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Benefit section heading and cards. Icon choices map to Lucide icons in the SPA. + */ + benefits?: { + eyebrow?: string | null; + heading?: string | null; + cards?: + | { + icon?: ('users' | 'star' | 'eye' | 'heart') | null; + title?: string | null; + body?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Association explainer, descriptor, and fact rows. + */ + about?: { + eyebrow?: string | null; + heading?: string | null; + paragraphs?: + | { + text?: string | null; + id?: string | null; + }[] + | null; + descriptor?: string | null; + facts?: + | { + num?: string | null; + label?: string | null; + body?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Calculator labels, table copy, VAT rate, admission fee, and tier rows. + */ + pricing?: { + eyebrow?: string | null; + heading?: string | null; + employeeLabel?: string | null; + selectPlaceholder?: string | null; + optionSuffix?: string | null; + resultEyebrow?: string | null; + annualNetLabel?: string | null; + annualGrossLabel?: string | null; + annualGrossShortLabel?: string | null; + admissionGrossLabel?: string | null; + totalYearOneLabel?: string | null; + footnote?: string | null; + ctaLabel?: string | null; + ctaHref?: string | null; + tableToggleLabel?: string | null; + tableEmployeeHeader?: string | null; + tableNetHeader?: string | null; + tableGrossHeader?: string | null; + tableNotePrefix?: string | null; + tableNoteSuffix?: string | null; + vatRate?: number | null; + admissionFeeNet?: number | null; + tiers?: + | { + max?: number | null; + label?: string | null; + annual?: number | null; + id?: string | null; + }[] + | null; + }; + /** + * Online and PDF application strip. + */ + applicationOptions?: { + items?: + | { + id?: string | null; + number?: string | null; + eyebrow?: string | null; + title?: string | null; + description?: string | null; + href?: string | null; + file?: (number | null) | Media; + download?: boolean | null; + }[] + | null; + }; + /** + * Left-hand pitch copy and wizard image/caption. + */ + formIntro?: { + eyebrow?: string | null; + heading?: string | null; + description?: string | null; + promises?: + | { + num?: string | null; + label?: string | null; + desc?: string | null; + id?: string | null; + }[] + | null; + /** + * Current frontend image: /images/gewinner-gruppenfoto.jpg + */ + image?: (number | null) | Media; + imageAlt?: string | null; + imageLabel?: string | null; + }; + /** + * Deep wizard labels, options, validation messages, legal text, consent copy, success copy, and duplicated pricing defaults. Keep the object shape aligned with src/spa/mitgliedWerdenContent.ts. + */ + wizard?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + /** + * Member quotes and attribution cards. + */ + testimonials?: { + eyebrow?: string | null; + heading?: string | null; + items?: + | { + quote?: string | null; + initials?: string | null; + name?: string | null; + role?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * FAQ heading and question/answer rows. + */ + faq?: { + eyebrow?: string | null; + heading?: string | null; + items?: + | { + q?: string | null; + a?: string | null; + id?: string | null; + }[] + | null; + }; + /** + * Final call-to-action band. + */ + bottomCta?: { + eyebrow?: string | null; + heading?: string | null; + ctaLabel?: string | null; + ctaHref?: string | null; + }; + }; meta?: { title?: string | null; /** @@ -3867,6 +4062,173 @@ export interface PagesSelect { footerText?: T; }; }; + mitgliedWerden?: + | T + | { + header?: + | T + | { + brandLabel?: T; + backLabel?: T; + backUrl?: T; + }; + hero?: + | T + | { + image?: T; + imageAlt?: T; + eyebrow?: T; + heading?: T; + description?: T; + stats?: + | T + | { + value?: T; + label?: T; + id?: T; + }; + }; + benefits?: + | T + | { + eyebrow?: T; + heading?: T; + cards?: + | T + | { + icon?: T; + title?: T; + body?: T; + id?: T; + }; + }; + about?: + | T + | { + eyebrow?: T; + heading?: T; + paragraphs?: + | T + | { + text?: T; + id?: T; + }; + descriptor?: T; + facts?: + | T + | { + num?: T; + label?: T; + body?: T; + id?: T; + }; + }; + pricing?: + | T + | { + eyebrow?: T; + heading?: T; + employeeLabel?: T; + selectPlaceholder?: T; + optionSuffix?: T; + resultEyebrow?: T; + annualNetLabel?: T; + annualGrossLabel?: T; + annualGrossShortLabel?: T; + admissionGrossLabel?: T; + totalYearOneLabel?: T; + footnote?: T; + ctaLabel?: T; + ctaHref?: T; + tableToggleLabel?: T; + tableEmployeeHeader?: T; + tableNetHeader?: T; + tableGrossHeader?: T; + tableNotePrefix?: T; + tableNoteSuffix?: T; + vatRate?: T; + admissionFeeNet?: T; + tiers?: + | T + | { + max?: T; + label?: T; + annual?: T; + id?: T; + }; + }; + applicationOptions?: + | T + | { + items?: + | T + | { + id?: T; + number?: T; + eyebrow?: T; + title?: T; + description?: T; + href?: T; + file?: T; + download?: T; + }; + }; + formIntro?: + | T + | { + eyebrow?: T; + heading?: T; + description?: T; + promises?: + | T + | { + num?: T; + label?: T; + desc?: T; + id?: T; + }; + image?: T; + imageAlt?: T; + imageLabel?: T; + }; + wizard?: T; + testimonials?: + | T + | { + eyebrow?: T; + heading?: T; + items?: + | T + | { + quote?: T; + initials?: T; + name?: T; + role?: T; + id?: T; + }; + }; + faq?: + | T + | { + eyebrow?: T; + heading?: T; + items?: + | T + | { + q?: T; + a?: T; + id?: T; + }; + }; + bottomCta?: + | T + | { + eyebrow?: T; + heading?: T; + ctaLabel?: T; + ctaHref?: T; + }; + }; meta?: | T | { diff --git a/src/scripts/preload-mitglied-werden-page-cms.ts b/src/scripts/preload-mitglied-werden-page-cms.ts new file mode 100644 index 0000000..a5e6579 --- /dev/null +++ b/src/scripts/preload-mitglied-werden-page-cms.ts @@ -0,0 +1,86 @@ +import 'dotenv/config' + +import config from '@payload-config' +import { getPayload, type Payload } from 'payload' + +import { mitgliedWerdenContent } from '@/spa/mitgliedWerdenContent' + +const mediaByFilename = async (payload: Payload, filename: string) => { + const result = await payload.find({ + collection: 'media', + limit: 1, + pagination: false, + where: { filename: { equals: filename } }, + }) + return result.docs[0]?.id +} + +async function main() { + const payload = await getPayload({ config }) + + const pageResult = await payload.find({ + collection: 'pages', + limit: 1, + pagination: false, + draft: true, + overrideAccess: true, + where: { + or: [ + { spaPath: { equals: '/mitglied-werden' } }, + { slug: { equals: 'mitglied-werden' } }, + { slug: { equals: 'membership' } }, + ], + }, + }) + + const page = pageResult.docs[0] + if (!page) { + throw new Error('Mitglied werden page not found. Expected spaPath=/mitglied-werden or slug=mitglied-werden/membership.') + } + + const heroImage = await mediaByFilename(payload, mitgliedWerdenContent.hero.imageFilename) + const formImage = await mediaByFilename(payload, mitgliedWerdenContent.formIntro.imageFilename) + const applicationItems = await Promise.all( + mitgliedWerdenContent.applicationOptions.items.map(async (item) => { + const { fileFilename, ...rest } = item + return { + ...rest, + file: fileFilename ? await mediaByFilename(payload, fileFilename) : undefined, + } + }), + ) + + const { imageFilename: _heroFilename, ...heroContent } = mitgliedWerdenContent.hero + const { imageFilename: _formFilename, ...formIntroContent } = mitgliedWerdenContent.formIntro + + await payload.update({ + collection: 'pages', + id: page.id, + overrideAccess: true, + context: { disableRevalidate: true }, + data: { + mitgliedWerden: { + ...mitgliedWerdenContent, + hero: { + ...heroContent, + image: heroImage, + }, + applicationOptions: { + ...mitgliedWerdenContent.applicationOptions, + items: applicationItems, + }, + formIntro: { + ...formIntroContent, + image: formImage, + }, + }, + } as never, + }) + + payload.logger.info(`Preloaded Mitglied werden CMS fields for page ${page.id}`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/src/spa/components/membership/MembershipWizard.tsx b/src/spa/components/membership/MembershipWizard.tsx index 9a70581..2ae7492 100644 --- a/src/spa/components/membership/MembershipWizard.tsx +++ b/src/spa/components/membership/MembershipWizard.tsx @@ -1,5 +1,6 @@ import { useState, useRef } from "react"; import { useIsMobile } from "@/spa/hooks/useIsMobile"; +import { mitgliedWerdenContent } from "@/spa/mitgliedWerdenContent"; // ─── CONSTANTS ──────────────────────────────────────────────────────────────── @@ -7,25 +8,7 @@ const NAVY = "#111D55"; const GOLD = "#EFBF04"; const WHITE = "#fff"; -const TIERS = [ - { max: 10, label: "bis 10", annual: 480 }, - { max: 20, label: "bis 20", annual: 600 }, - { max: 50, label: "bis 50", annual: 900 }, - { max: 100, label: "bis 100", annual: 1200 }, - { max: 250, label: "bis 250", annual: 2400 }, - { max: 1000, label: "bis 1.000", annual: 3600 }, -]; - -const VAT = 0.19; -const ADMISSION = 500; - -const STEPS = [ - { id: 1, label: "Unternehmen" }, - { id: 2, label: "Kontakt" }, - { id: 3, label: "Details" }, - { id: 4, label: "Zahlung" }, - { id: 5, label: "Abschluss" }, -]; +const DEFAULT_WIZARD = mitgliedWerdenContent.wizard; // ─── TYPES ──────────────────────────────────────────────────────────────────── @@ -59,6 +42,44 @@ interface FormData { } type Errors = Partial>; +type WizardCopy = typeof mitgliedWerdenContent.wizard; +type PricingCopy = typeof mitgliedWerdenContent.pricing; +type WizardTier = WizardCopy["tiers"][number]; + +const fallbackNumber = (value: unknown, fallback: number) => typeof value === "number" && Number.isFinite(value) ? value : fallback; +const fallbackArray = (value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback; +const optionsFromStrings = (items: string[]) => items.map((v) => ({ value: v, label: v })); + +function mergeWizardCopy(content?: Partial, pricing?: Partial): WizardCopy { + return { + ...DEFAULT_WIZARD, + ...content, + nav: { ...DEFAULT_WIZARD.nav, ...content?.nav }, + validation: { ...DEFAULT_WIZARD.validation, ...content?.validation }, + pricingSummary: { ...DEFAULT_WIZARD.pricingSummary, ...content?.pricingSummary }, + fields: { + company: { ...DEFAULT_WIZARD.fields.company, ...content?.fields?.company }, + contact: { ...DEFAULT_WIZARD.fields.contact, ...content?.fields?.contact }, + details: { ...DEFAULT_WIZARD.fields.details, ...content?.fields?.details }, + payment: { ...DEFAULT_WIZARD.fields.payment, ...content?.fields?.payment }, + summary: { + ...DEFAULT_WIZARD.fields.summary, + ...content?.fields?.summary, + labels: { ...DEFAULT_WIZARD.fields.summary.labels, ...content?.fields?.summary?.labels }, + consents: { ...DEFAULT_WIZARD.fields.summary.consents, ...content?.fields?.summary?.consents }, + }, + }, + success: { + ...DEFAULT_WIZARD.success, + ...content?.success, + steps: fallbackArray(content?.success?.steps, DEFAULT_WIZARD.success.steps), + }, + steps: fallbackArray(content?.steps, DEFAULT_WIZARD.steps), + tiers: fallbackArray(pricing?.tiers || content?.tiers, DEFAULT_WIZARD.tiers), + vatRate: fallbackNumber(pricing?.vatRate ?? content?.vatRate, DEFAULT_WIZARD.vatRate), + admissionFeeNet: fallbackNumber(pricing?.admissionFeeNet ?? content?.admissionFeeNet, DEFAULT_WIZARD.admissionFeeNet), + }; +} // ─── HELPERS ────────────────────────────────────────────────────────────────── @@ -72,11 +93,11 @@ function formatEur(n: number): string { return n.toLocaleString("de-DE", { style: "currency", currency: "EUR" }); } -function calcBeitrag(mitarbeiter: string) { - const tier = TIERS.find((t) => t.label === mitarbeiter) ?? TIERS[0]; +function calcBeitrag(mitarbeiter: string, tiers: WizardTier[], vatRate: number, admissionFeeNet: number) { + const tier = tiers.find((t) => t.label === mitarbeiter) ?? tiers[0]; const annualNet = tier.annual; - const annualGross = annualNet * (1 + VAT); - const admissionGross = ADMISSION * (1 + VAT); + const annualGross = annualNet * (1 + vatRate); + const admissionGross = admissionFeeNet * (1 + vatRate); const today = new Date(); const monthsLeft = 12 - today.getMonth(); const anteilig = (annualGross / 12) * monthsLeft + admissionGross; @@ -105,39 +126,40 @@ function maskIBAN(iban: string): string { return clean.slice(0, 4) + " **** **** **** " + clean.slice(-4); } -function validateStep(step: number, data: FormData): Errors { +function validateStep(step: number, data: FormData, copy: WizardCopy): Errors { const e: Errors = {}; + const validation = copy.validation; if (step === 1) { - if (!data.firmenname.trim()) e.firmenname = "Pflichtfeld"; - if (!data.rechtsform) e.rechtsform = "Bitte wählen"; - if (!data.strasse.trim()) e.strasse = "Pflichtfeld"; - if (!/^\d{5}$/.test(data.plz)) e.plz = "5-stellige PLZ erforderlich"; - if (!data.ort.trim()) e.ort = "Pflichtfeld"; - if (!data.mitarbeiter) e.mitarbeiter = "Bitte wählen"; + if (!data.firmenname.trim()) e.firmenname = validation.required; + if (!data.rechtsform) e.rechtsform = validation.select; + if (!data.strasse.trim()) e.strasse = validation.required; + if (!/^\d{5}$/.test(data.plz)) e.plz = validation.postalCode; + if (!data.ort.trim()) e.ort = validation.required; + if (!data.mitarbeiter) e.mitarbeiter = validation.select; } if (step === 2) { - if (!data.anrede) e.anrede = "Bitte wählen"; - if (!data.vorname.trim()) e.vorname = "Pflichtfeld"; - if (!data.nachname.trim()) e.nachname = "Pflichtfeld"; - if (!data.position.trim()) e.position = "Pflichtfeld"; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = "Gültige E-Mail erforderlich"; - if (!data.telefon.trim()) e.telefon = "Pflichtfeld"; + if (!data.anrede) e.anrede = validation.select; + if (!data.vorname.trim()) e.vorname = validation.required; + if (!data.nachname.trim()) e.nachname = validation.required; + if (!data.position.trim()) e.position = validation.required; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = validation.email; + if (!data.telefon.trim()) e.telefon = validation.required; } if (step === 3) { - if (!data.eintrittsdatum) e.eintrittsdatum = "Pflichtfeld"; - if (!data.aufmerksam) e.aufmerksam = "Bitte wählen"; + if (!data.eintrittsdatum) e.eintrittsdatum = validation.required; + if (!data.aufmerksam) e.aufmerksam = validation.select; } if (step === 4) { - if (!data.kontoinhaber.trim()) e.kontoinhaber = "Pflichtfeld"; - if (!validateIBAN(data.iban)) e.iban = "Ungültige IBAN (DE, 22 Zeichen)"; - if (!data.bic.trim()) e.bic = "Pflichtfeld"; - if (!data.bank.trim()) e.bank = "Pflichtfeld"; - if (!data.sepaMandat) e.sepaMandat = "Bitte SEPA-Mandat bestätigen"; + if (!data.kontoinhaber.trim()) e.kontoinhaber = validation.required; + if (!validateIBAN(data.iban)) e.iban = validation.iban; + if (!data.bic.trim()) e.bic = validation.required; + if (!data.bank.trim()) e.bank = validation.required; + if (!data.sepaMandat) e.sepaMandat = validation.sepa; } if (step === 5) { - if (!data.datenschutz) e.datenschutz = "Pflichtfeld"; - if (!data.satzung) e.satzung = "Pflichtfeld"; - if (!data.widerruf) e.widerruf = "Pflichtfeld"; + if (!data.datenschutz) e.datenschutz = validation.required; + if (!data.satzung) e.satzung = validation.required; + if (!data.widerruf) e.widerruf = validation.required; } return e; } @@ -176,9 +198,10 @@ const fieldBase: React.CSSProperties = { }; function Field({ - label, required, error, children, + label, required, error, children, requiredSuffix = DEFAULT_WIZARD.requiredSuffix, }: { label: string; required?: boolean; error?: string; children: React.ReactNode; + requiredSuffix?: string; }) { return (
@@ -192,7 +215,7 @@ function Field({ marginBottom: 7, fontFamily: '"IBM Plex Sans", sans-serif', }}> - {label}{required && *} + {label}{required && {requiredSuffix}} {children} @@ -306,10 +329,11 @@ function Textarea({ } function Checkbox({ - checked, onChange, label, error, optional, + checked, onChange, label, error, optional, optionalSuffix = DEFAULT_WIZARD.optionalSuffix, }: { checked: boolean; onChange: (v: boolean) => void; label: React.ReactNode; error?: string; optional?: boolean; + optionalSuffix?: string; }) { return (
@@ -349,7 +373,7 @@ function Checkbox({ fontFamily: '"IBM Plex Sans", sans-serif', }}> {label} - {optional && (optional)} + {optional && {optionalSuffix}} {error && } @@ -406,7 +430,7 @@ function Accordion({ title, children }: { title: string; children: React.ReactNo ); } -function ReviewRow({ label, value }: { label: string; value: string }) { +function ReviewRow({ label, value, fallback = DEFAULT_WIZARD.reviewFallback }: { label: string; value: string; fallback?: string }) { return (
- {value || "–"} + {value || fallback}
); @@ -426,7 +450,7 @@ function ReviewRow({ label, value }: { label: string; value: string }) { // ─── PROGRESS BAR ───────────────────────────────────────────────────────────── -function ProgressBar({ current, total }: { current: number; total: number }) { +function ProgressBar({ current, total, steps }: { current: number; total: number; steps: WizardCopy["steps"] }) { const isMobile = useIsMobile(); return (
@@ -468,7 +492,7 @@ function ProgressBar({ current, total }: { current: number; total: number }) { }} /> {/* Dots */} - {STEPS.map((step) => { + {steps.map((step) => { const done = step.id < current; const active = step.id === current; return ( @@ -523,11 +547,11 @@ function ProgressBar({ current, total }: { current: number; total: number }) { color: GOLD, fontFamily: '"IBM Plex Sans", sans-serif', }}> - {STEPS.find((s) => s.id === current)?.label} · {current}/{total} + {steps.find((s) => s.id === current)?.label} · {current}/{total}
) : (
- {STEPS.map((step) => { + {steps.map((step) => { const done = step.id < current; const active = step.id === current; return ( @@ -554,7 +578,7 @@ function ProgressBar({ current, total }: { current: number; total: number }) { // ─── STEP HEADING ───────────────────────────────────────────────────────────── -function StepHeading({ step, title, subtitle }: { step: number; title: string; subtitle?: string }) { +function StepHeading({ step, total, title, subtitle, template }: { step: number; total: number; title: string; subtitle?: string; template: string }) { return (
- Schritt {step} von 5 + {template.replace('{step}', String(step)).replace('{total}', String(total))}

void; onNext: () => void; onSubmit: () => void; disabled?: boolean; + total: number; copy: WizardCopy; }) { const isMobile = useIsMobile(); - const isLast = step === 5; + const isLast = step === total; return (
- ← Zurück + {copy.nav.backLabel} )}
); @@ -691,48 +716,46 @@ function NavButtons({ // ─── STEP 1 – UNTERNEHMEN ───────────────────────────────────────────────────── -function Step1({ data, onChange, errors }: { - data: FormData; onChange: (k: keyof FormData, v: string | boolean) => void; errors: Errors; +function Step1({ data, onChange, errors, copy }: { + data: FormData; onChange: (k: keyof FormData, v: string | boolean) => void; errors: Errors; copy: WizardCopy; }) { const isMobile = useIsMobile(); - const rechtsformen = [ - "GmbH","GmbH & Co. KG","AG","UG","KG","OHG","GbR", - "Einzelunternehmen","e.K.","Sonstige", - ].map((v) => ({ value: v, label: v })); + const fields = copy.fields.company; + const rechtsformen = optionsFromStrings(fallbackArray(fields.legalForms, DEFAULT_WIZARD.fields.company.legalForms)); return ( <> - +
- - onChange("firmenname", v)} placeholder="Muster GmbH" error={errors.firmenname} /> + + onChange("firmenname", v)} placeholder={fields.companyNamePlaceholder} error={errors.firmenname} />
- + onChange("mitarbeiter", v)} - options={TIERS.map((t) => ({ value: t.label, label: t.label }))} - placeholder="Bitte wählen" error={errors.mitarbeiter} /> + options={copy.tiers.map((t) => ({ value: t.label, label: t.label }))} + placeholder={copy.validation.select} error={errors.mitarbeiter} />
- - onChange("strasse", v)} placeholder="Musterstraße 42" error={errors.strasse} /> + + onChange("strasse", v)} placeholder={fields.streetPlaceholder} error={errors.strasse} />
- - onChange("plz", v)} placeholder="12345" maxLength={5} error={errors.plz} /> + + onChange("plz", v)} placeholder={fields.postalCodePlaceholder} maxLength={5} error={errors.plz} /> - - onChange("ort", v)} placeholder="Berlin" error={errors.ort} /> + + onChange("ort", v)} placeholder={fields.cityPlaceholder} error={errors.ort} />
- - onChange("website", v)} placeholder="https://www.ihrewebsite.de" type="url" /> + + onChange("website", v)} placeholder={fields.websitePlaceholder} type="url" />
@@ -742,40 +765,41 @@ function Step1({ data, onChange, errors }: { // ─── STEP 2 – KONTAKT ───────────────────────────────────────────────────────── -function Step2({ data, onChange, errors }: { - data: FormData; onChange: (k: keyof FormData, v: string | boolean) => void; errors: Errors; +function Step2({ data, onChange, errors, copy }: { + data: FormData; onChange: (k: keyof FormData, v: string | boolean) => void; errors: Errors; copy: WizardCopy; }) { const isMobile = useIsMobile(); + const fields = copy.fields.contact; return ( <> - +
- + onChange("vorname", v)} placeholder="Max" error={errors.vorname} /> + + onChange("vorname", v)} placeholder={fields.firstNamePlaceholder} error={errors.vorname} /> - - onChange("nachname", v)} placeholder="Mustermann" error={errors.nachname} /> + + onChange("nachname", v)} placeholder={fields.lastNamePlaceholder} error={errors.nachname} />
- - onChange("position", v)} placeholder="z. B. Geschäftsführer" error={errors.position} /> + + onChange("position", v)} placeholder={fields.positionPlaceholder} error={errors.position} />
- - onChange("email", v)} placeholder="max@muster.de" type="email" error={errors.email} /> + + onChange("email", v)} placeholder={fields.emailPlaceholder} type="email" error={errors.email} />
- - onChange("telefon", v)} placeholder="+49 30 123456" type="tel" error={errors.telefon} /> + + onChange("telefon", v)} placeholder={fields.phonePlaceholder} type="tel" error={errors.telefon} />
@@ -785,14 +809,16 @@ function Step2({ data, onChange, errors }: { // ─── STEP 3 – DETAILS ───────────────────────────────────────────────────────── -function Step3({ data, onChange, errors }: { - data: FormData; onChange: (k: keyof FormData, v: string | boolean) => void; errors: Errors; +function Step3({ data, onChange, errors, copy }: { + data: FormData; onChange: (k: keyof FormData, v: string | boolean) => void; errors: Errors; copy: WizardCopy; }) { const isMobile = useIsMobile(); - const beitrag = calcBeitrag(data.mitarbeiter); + const fields = copy.fields.details; + const summary = copy.pricingSummary; + const beitrag = calcBeitrag(data.mitarbeiter, copy.tiers, copy.vatRate, copy.admissionFeeNet); return ( <> - + {/* Contribution card */} {data.mitarbeiter && ( @@ -808,12 +834,12 @@ function Step3({ data, onChange, errors }: { textTransform: "uppercase", color: GOLD, marginBottom: 12, fontFamily: '"IBM Plex Sans", sans-serif', }}> - Beitragsübersicht + {summary.eyebrow}

{[ - ["Jahresbeitrag (netto)", formatEur(beitrag.annualNet)], - ["Jahresbeitrag (brutto)", formatEur(beitrag.annualGross)], - ["Aufnahmegebühr (brutto)", formatEur(beitrag.admissionGross)], + [summary.annualNetLabel, formatEur(beitrag.annualNet)], + [summary.annualGrossLabel, formatEur(beitrag.annualGross)], + [summary.admissionGrossLabel, formatEur(beitrag.admissionGross)], ].map(([l, v]) => (
{l} @@ -825,7 +851,7 @@ function Step3({ data, onChange, errors }: { }} />
- Gesamt Jahr 1 (anteilig) + {summary.totalYearOneLabel} {formatEur(beitrag.anteilig)} @@ -835,19 +861,18 @@ function Step3({ data, onChange, errors }: { )}
- + onChange("eintrittsdatum", v)} type="date" error={errors.eintrittsdatum} /> - +