feat: manage contact form copy via payload
This commit is contained in:
@@ -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>
|
||||
|
||||
42
src/spa/contactFormContent.ts
Normal file
42
src/spa/contactFormContent.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user