feat: manage contact form copy via payload

This commit is contained in:
syntaxbullet
2026-06-22 11:57:59 +02:00
parent aa839fcebc
commit 8dbe129ff2
6 changed files with 275 additions and 36 deletions

View File

@@ -1,5 +1,7 @@
import type { Field } from 'payload'
import { contactFormContent } from '@/spa/contactFormContent'
const uploadField = (name: string, label: string, description?: string): Field => ({
name,
type: 'upload',
@@ -96,6 +98,82 @@ export const contactFields: Field[] = [
text('formImageLabel', 'Form image label', 'Gala 2025 München'),
text('formEyebrow', 'Form eyebrow', 'Kontaktformular'),
text('formTitle', 'Form title', 'Schreiben Sie uns'),
{
name: 'formCopy',
label: 'Contact form copy',
type: 'group',
admin: sectionAdmin('Step labels, subject choices, field labels, validation messages, and success copy.'),
fields: [
{
name: 'steps',
label: 'Step labels',
type: 'array',
defaultValue: contactFormContent.steps,
fields: [text('label', 'Label')],
},
{
name: 'subjects',
label: 'Subject choices',
type: 'array',
defaultValue: contactFormContent.subjects,
fields: [text('value', 'Value'), text('label', 'Label'), textarea('desc', 'Description')],
},
{
name: 'prompts',
label: 'Prompts',
type: 'group',
fields: [
text('subject', 'Subject step prompt', contactFormContent.prompts.subject),
text('contact', 'Contact step prompt', contactFormContent.prompts.contact),
text('message', 'Message step prompt', contactFormContent.prompts.message),
],
},
{
name: 'fields',
label: 'Fields',
type: 'group',
fields: [
text('nameLabel', 'Name label', contactFormContent.fields.nameLabel),
text('namePlaceholder', 'Name placeholder', contactFormContent.fields.namePlaceholder),
text('emailLabel', 'Email label', contactFormContent.fields.emailLabel),
text('emailPlaceholder', 'Email placeholder', contactFormContent.fields.emailPlaceholder),
text('messageLabel', 'Message label', contactFormContent.fields.messageLabel),
text('messagePlaceholder', 'Message placeholder', contactFormContent.fields.messagePlaceholder),
],
},
{
name: 'validation',
label: 'Validation messages',
type: 'group',
fields: [
text('nameRequired', 'Name required', contactFormContent.validation.nameRequired),
text('emailRequired', 'Email required', contactFormContent.validation.emailRequired),
text('emailInvalid', 'Email invalid', contactFormContent.validation.emailInvalid),
text('messageRequired', 'Message required', contactFormContent.validation.messageRequired),
],
},
{
name: 'navigation',
label: 'Navigation labels',
type: 'group',
fields: [
text('back', 'Back', contactFormContent.navigation.back),
text('next', 'Next', contactFormContent.navigation.next),
text('submit', 'Submit', contactFormContent.navigation.submit),
],
},
{
name: 'success',
label: 'Success copy',
type: 'group',
fields: [
text('heading', 'Heading', contactFormContent.success.heading),
text('prefix', 'Prefix', contactFormContent.success.prefix),
text('suffixBeforeEmail', 'Suffix before email', contactFormContent.success.suffixBeforeEmail),
],
},
],
},
],
},
{

View File

@@ -456,6 +456,54 @@ export interface Page {
formImageLabel?: string | null;
formEyebrow?: string | null;
formTitle?: string | null;
/**
* Step labels, subject choices, field labels, validation messages, and success copy.
*/
formCopy?: {
steps?:
| {
label?: string | null;
id?: string | null;
}[]
| null;
subjects?:
| {
value?: string | null;
label?: string | null;
desc?: string | null;
id?: string | null;
}[]
| null;
prompts?: {
subject?: string | null;
contact?: string | null;
message?: string | null;
};
fields?: {
nameLabel?: string | null;
namePlaceholder?: string | null;
emailLabel?: string | null;
emailPlaceholder?: string | null;
messageLabel?: string | null;
messagePlaceholder?: string | null;
};
validation?: {
nameRequired?: string | null;
emailRequired?: string | null;
emailInvalid?: string | null;
messageRequired?: string | null;
};
navigation?: {
back?: string | null;
next?: string | null;
submit?: string | null;
};
success?: {
heading?: string | null;
prefix?: string | null;
suffixBeforeEmail?: string | null;
};
};
};
/**
* Dark 2026 timeline with three deadline columns.
@@ -3211,6 +3259,63 @@ export interface PagesSelect<T extends boolean = true> {
formImageLabel?: T;
formEyebrow?: T;
formTitle?: T;
formCopy?:
| T
| {
steps?:
| T
| {
label?: T;
id?: T;
};
subjects?:
| T
| {
value?: T;
label?: T;
desc?: T;
id?: T;
};
prompts?:
| T
| {
subject?: T;
contact?: T;
message?: T;
};
fields?:
| T
| {
nameLabel?: T;
namePlaceholder?: T;
emailLabel?: T;
emailPlaceholder?: T;
messageLabel?: T;
messagePlaceholder?: T;
};
validation?:
| T
| {
nameRequired?: T;
emailRequired?: T;
emailInvalid?: T;
messageRequired?: T;
};
navigation?:
| T
| {
back?: T;
next?: T;
submit?: T;
};
success?:
| T
| {
heading?: T;
prefix?: T;
suffixBeforeEmail?: T;
};
};
};
deadlines?:
| T

View File

@@ -3,6 +3,8 @@ import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { contactFormContent } from '@/spa/contactFormContent'
const mediaByFilename = async (payload: Payload, filename: string) => {
const result = await payload.find({
collection: 'media',
@@ -65,6 +67,7 @@ async function main() {
formImageLabel: 'Gala 2025 München',
formEyebrow: 'Kontaktformular',
formTitle: 'Schreiben Sie uns',
formCopy: contactFormContent,
},
deadlines: {
watermark: '2026',

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowRight, ArrowLeft, Check, MessageSquare, User, Send } from 'lucide-react';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { contactFormContent } from '@/spa/contactFormContent';
const FF = '"IBM Plex Sans", sans-serif';
@@ -94,18 +95,27 @@ type FormData = {
nachricht: string;
};
const STEPS = [
{ label: 'Betreff', icon: MessageSquare },
{ label: 'Kontakt', icon: User },
{ label: 'Nachricht', icon: Send },
];
type ContactFormCopy = typeof contactFormContent;
type SubjectChoice = (typeof contactFormContent.subjects)[number];
const BETREFFS = [
{ value: 'allgemein', label: 'Allgemeine Anfrage', desc: 'Ich habe eine allgemeine Frage.' },
{ value: 'bewerbung', label: 'Frage zur Bewerbung', desc: 'Ich möchte mich bewerben oder habe Rückfragen.' },
{ value: 'presse', label: 'Presseanfrage', desc: 'Ich bin Journalist:in und recherchiere zum BMP.' },
{ value: 'sponsoring', label: 'Sponsoring', desc: 'Ich interessiere mich für eine Partnerschaft.' },
];
const STEP_ICONS = [MessageSquare, User, Send];
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
function mergeCopy(content?: unknown): ContactFormCopy {
const data = (content && typeof content === 'object' ? content : {}) as Partial<ContactFormCopy>;
return {
...contactFormContent,
...data,
steps: fallbackArray(data.steps, contactFormContent.steps),
subjects: fallbackArray(data.subjects, contactFormContent.subjects),
prompts: { ...contactFormContent.prompts, ...data.prompts },
fields: { ...contactFormContent.fields, ...data.fields },
validation: { ...contactFormContent.validation, ...data.validation },
navigation: { ...contactFormContent.navigation, ...data.navigation },
success: { ...contactFormContent.success, ...data.success },
};
}
const variants = {
enter: (dir: number) => ({ x: dir > 0 ? 40 : -40, opacity: 0 }),
@@ -156,7 +166,7 @@ function StyledTextarea({ c, ...props }: React.TextareaHTMLAttributes<HTMLTextAr
);
}
function BetreffCard({ c, item, selected, onClick }: { c: C; item: typeof BETREFFS[0]; selected: boolean; onClick: () => void }) {
function BetreffCard({ c, item, selected, onClick }: { c: C; item: SubjectChoice; selected: boolean; onClick: () => void }) {
return (
<button
type="button" onClick={onClick}
@@ -184,7 +194,8 @@ function BetreffCard({ c, item, selected, onClick }: { c: C; item: typeof BETREF
);
}
export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold' }) {
export default function KontaktForm({ theme = 'dark', content }: { theme?: 'dark' | 'gold'; content?: unknown }) {
const copy = mergeCopy(content);
const c = theme === 'gold' ? GOLD : DARK;
const isMobile = useIsMobile();
const [step, setStep] = useState(0);
@@ -201,11 +212,11 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
const validate = () => {
const e: typeof errors = {};
if (step === 1) {
if (!data.name) e.name = 'Bitte Namen angeben';
if (!data.email) e.email = 'Bitte E-Mail angeben';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = 'Ungültige E-Mail';
if (!data.name) e.name = copy.validation.nameRequired;
if (!data.email) e.email = copy.validation.emailRequired;
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = copy.validation.emailInvalid;
}
if (step === 2 && !data.nachricht) e.nachricht = 'Bitte Nachricht verfassen.';
if (step === 2 && !data.nachricht) e.nachricht = copy.validation.messageRequired;
setErrors(e);
return Object.keys(e).length === 0;
};
@@ -223,10 +234,10 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
<Check size={24} color={c.successIconIn} strokeWidth={2.5} />
</div>
<h3 style={{ fontSize: 20, fontWeight: 800, color: c.successHeading, marginBottom: 10, letterSpacing: '-0.02em', fontFamily: FF }}>
Nachricht gesendet
{fallbackText(copy.success.heading, contactFormContent.success.heading)}
</h3>
<p style={{ fontSize: 15, color: c.successBody, lineHeight: 1.7, maxWidth: 280, margin: '0 auto', fontFamily: FF, overflowWrap: 'anywhere' }}>
Danke, <strong style={{ color: c.successHeading }}>{data.name}</strong>. Wir melden uns zeitnah bei{' '}
{fallbackText(copy.success.prefix, contactFormContent.success.prefix)} <strong style={{ color: c.successHeading }}>{data.name}</strong>. {fallbackText(copy.success.suffixBeforeEmail, contactFormContent.success.suffixBeforeEmail)}{' '}
<strong style={{ color: c.successIcon }}>{data.email}</strong>.
</p>
</motion.div>
@@ -237,13 +248,13 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* Progress */}
<div style={{ height: 3, background: c.progressBg, flexShrink: 0 }}>
<div style={{ height: '100%', background: c.progressFill, width: step === 0 ? '0%' : `${(step / (STEPS.length - 1)) * 100}%`, transition: 'width 0.4s ease' }} />
<div style={{ height: '100%', background: c.progressFill, width: step === 0 ? '0%' : `${(step / (copy.steps.length - 1)) * 100}%`, transition: 'width 0.4s ease' }} />
</div>
{/* Step tabs */}
<div style={{ display: 'flex', borderBottom: `1px solid ${c.border}`, flexShrink: 0 }}>
{STEPS.map((s, i) => {
const Icon = s.icon;
{copy.steps.map((s, i) => {
const Icon = STEP_ICONS[i] || Send;
const done = i < step, active = i === step;
return (
<div key={i} style={{
@@ -256,7 +267,7 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
marginBottom: -1, transition: 'color 0.2s',
}}>
<Icon size={11} strokeWidth={active ? 2 : 1.5} />
<span>{done ? '✓' : s.label}</span>
<span>{done ? '✓' : fallbackText(s.label, contactFormContent.steps[i]?.label || '')}</span>
</div>
);
})}
@@ -271,8 +282,8 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
{step === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 12, fontFamily: FF }}>Womit können wir Ihnen helfen?</p>
{BETREFFS.map(item => (
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 12, fontFamily: FF }}>{fallbackText(copy.prompts.subject, contactFormContent.prompts.subject)}</p>
{copy.subjects.map(item => (
<BetreffCard key={item.value} c={c} item={item} selected={data.betreff === item.value}
onClick={() => { set('betreff', item.value); setDir(1); setStep(1); }} />
))}
@@ -281,10 +292,10 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 4, fontFamily: FF }}>Wie dürfen wir Sie kontaktieren?</p>
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 4, fontFamily: FF }}>{fallbackText(copy.prompts.contact, contactFormContent.prompts.contact)}</p>
{([
{ label: 'Name', key: 'name' as const, type: 'text', placeholder: 'Vor- und Nachname', error: errors.name },
{ label: 'E-Mail', key: 'email' as const, type: 'email', placeholder: 'ihre@email.de', error: errors.email },
{ label: copy.fields.nameLabel, key: 'name' as const, type: 'text', placeholder: copy.fields.namePlaceholder, error: errors.name },
{ label: copy.fields.emailLabel, key: 'email' as const, type: 'email', placeholder: copy.fields.emailPlaceholder, error: errors.email },
] as const).map(f => (
<div key={f.key} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: c.labelText, fontFamily: FF }}>{f.label}</label>
@@ -297,10 +308,10 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
{step === 2 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 4, fontFamily: FF }}>Was möchten Sie uns mitteilen?</p>
<p style={{ fontSize: 14, color: c.subtext, marginBottom: 4, fontFamily: FF }}>{fallbackText(copy.prompts.message, contactFormContent.prompts.message)}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: c.labelText, fontFamily: FF }}>Nachricht</label>
<StyledTextarea c={c} value={data.nachricht} onChange={e => set('nachricht', e.target.value)} rows={5} placeholder="Schreiben Sie Ihre Nachricht…" />
<label style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: c.labelText, fontFamily: FF }}>{fallbackText(copy.fields.messageLabel, contactFormContent.fields.messageLabel)}</label>
<StyledTextarea c={c} value={data.nachricht} onChange={e => set('nachricht', e.target.value)} rows={5} placeholder={fallbackText(copy.fields.messagePlaceholder, contactFormContent.fields.messagePlaceholder)} />
{errors.nachricht && <span style={{ fontSize: 11, color: c.errorText, fontFamily: FF }}>{errors.nachricht}</span>}
</div>
</div>
@@ -320,10 +331,10 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
onMouseEnter={e => (e.currentTarget.style.color = c.backTextHover)}
onMouseLeave={e => (e.currentTarget.style.color = c.backText)}
>
<ArrowLeft size={13} /> Zurück
<ArrowLeft size={13} /> {fallbackText(copy.navigation.back, contactFormContent.navigation.back)}
</button>
{step < STEPS.length - 1 ? (
{step < copy.steps.length - 1 ? (
<button type="button" onClick={next} style={{
fontSize: 14, fontWeight: 700, color: c.btnText, background: c.btnBg, border: 'none', cursor: 'pointer',
padding: '12px 28px', fontFamily: FF, display: 'flex', alignItems: 'center', gap: 8,
@@ -333,7 +344,7 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
onMouseEnter={e => (e.currentTarget.style.background = c.btnBgHover)}
onMouseLeave={e => (e.currentTarget.style.background = c.btnBg)}
>
Weiter <ArrowRight size={14} />
{fallbackText(copy.navigation.next, contactFormContent.navigation.next)} <ArrowRight size={14} />
</button>
) : (
<button type="button" onClick={submit} style={{
@@ -345,7 +356,7 @@ export default function KontaktForm({ theme = 'dark' }: { theme?: 'dark' | 'gold
onMouseEnter={e => (e.currentTarget.style.background = c.btnBgHover)}
onMouseLeave={e => (e.currentTarget.style.background = c.btnBg)}
>
Anfrage senden <Send size={14} />
{fallbackText(copy.navigation.submit, contactFormContent.navigation.submit)} <Send size={14} />
</button>
)}
</div>

View File

@@ -0,0 +1,42 @@
export const contactFormContent = {
steps: [
{ label: 'Betreff' },
{ label: 'Kontakt' },
{ label: 'Nachricht' },
],
subjects: [
{ value: 'allgemein', label: 'Allgemeine Anfrage', desc: 'Ich habe eine allgemeine Frage.' },
{ value: 'bewerbung', label: 'Frage zur Bewerbung', desc: 'Ich möchte mich bewerben oder habe Rückfragen.' },
{ value: 'presse', label: 'Presseanfrage', desc: 'Ich bin Journalist:in und recherchiere zum BMP.' },
{ value: 'sponsoring', label: 'Sponsoring', desc: 'Ich interessiere mich für eine Partnerschaft.' },
],
prompts: {
subject: 'Womit können wir Ihnen helfen?',
contact: 'Wie dürfen wir Sie kontaktieren?',
message: 'Was möchten Sie uns mitteilen?',
},
fields: {
nameLabel: 'Name',
namePlaceholder: 'Vor- und Nachname',
emailLabel: 'E-Mail',
emailPlaceholder: 'ihre@email.de',
messageLabel: 'Nachricht',
messagePlaceholder: 'Schreiben Sie Ihre Nachricht…',
},
validation: {
nameRequired: 'Bitte Namen angeben',
emailRequired: 'Bitte E-Mail angeben',
emailInvalid: 'Ungültige E-Mail',
messageRequired: 'Bitte Nachricht verfassen.',
},
navigation: {
back: 'Zurück',
next: 'Weiter',
submit: 'Anfrage senden',
},
success: {
heading: 'Nachricht gesendet',
prefix: 'Danke,',
suffixBeforeEmail: 'Wir melden uns zeitnah bei',
},
}

View File

@@ -192,7 +192,7 @@ const Contact: React.FC = () => {
<h3 style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 'clamp(1.2rem, 1.8vw, 1.6rem)', fontWeight: 900, color: '#111D55', letterSpacing: '-0.025em', textTransform: 'uppercase', lineHeight: 1.1, marginBottom: 24 }}>
{fallbackText(info.formTitle, 'Schreiben Sie uns')}
</h3>
<KontaktForm theme="gold" />
<KontaktForm theme="gold" content={info.formCopy || undefined} />
</div>
</div>
</div>