import { useCallback, useState } from 'react'; import type { TextConfig } from '../config/locales/en'; type FormTexts = TextConfig['audit']['form']; export type AuditRole = '' | 'agency' | 'client'; export interface AuditFormValues { name: string; company: string; email: string; url: string; role: AuditRole; motivation: string; /** * The offer is B2B only (ยง 14 BGB). This is an explicit, recorded * confirmation rather than a passive notice, so it is forwarded and stored * with the request. */ businessConfirmation: boolean; consent: boolean; /** Honeypot. Real people never see it, so anything in here is a bot. */ website: string; } /** Every key that can carry a validation message - the honeypot never does. */ export type AuditFieldName = Exclude; export type AuditFormErrors = Partial>; export type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'; /** Field order drives the order of the error summary, so it matches the form. */ export const AUDIT_FIELD_ORDER: AuditFieldName[] = [ 'name', 'company', 'email', 'url', 'role', 'motivation', 'businessConfirmation', 'consent', ]; const EMPTY_VALUES: AuditFormValues = { name: '', company: '', email: '', url: '', role: '', motivation: '', businessConfirmation: false, consent: false, website: '', }; /** * Deliberately loose: something before an @, something after it, and a dot in * the domain. Anything stricter rejects valid addresses, and the only real * proof that an address works is that the report arrives. */ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function validateAuditForm( values: AuditFormValues, t: FormTexts ): AuditFormErrors { const errors: AuditFormErrors = {}; if (!values.name.trim()) { errors.name = t.nameError; } if (!values.company.trim()) { errors.company = t.companyError; } const email = values.email.trim(); if (!email) { errors.email = t.emailErrorRequired; } else if (!EMAIL_PATTERN.test(email)) { errors.email = t.emailErrorInvalid; } const url = values.url.trim(); if (!url) { errors.url = t.urlErrorRequired; } if (!values.role) { errors.role = t.roleError; } if (!values.motivation.trim()) { errors.motivation = t.motivationError; } if (!values.businessConfirmation) { errors.businessConfirmation = t.businessError; } if (!values.consent) { errors.consent = t.consentError; } return errors; } interface UseAuditFormOptions { texts: FormTexts; onSuccess?: () => void; onSubmitStart?: () => void; } export function useAuditForm({ texts, onSuccess, onSubmitStart }: UseAuditFormOptions) { const [values, setValues] = useState(EMPTY_VALUES); const [errors, setErrors] = useState({}); const [status, setStatus] = useState('idle'); /** Bumped on every rejected submit so the summary can re-take focus. */ const [failedSubmitCount, setFailedSubmitCount] = useState(0); const setField = useCallback( (field: K, value: AuditFormValues[K]) => { setValues((previous) => ({ ...previous, [field]: value })); // Clear the message as soon as the field is touched again. Correcting a // field while its error is still shouting at you is needlessly hostile. setErrors((previous) => { if (!(field in previous)) return previous; const next = { ...previous }; delete next[field as AuditFieldName]; return next; }); }, [] ); const submit = useCallback(async () => { const validationErrors = validateAuditForm(values, texts); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); setStatus('idle'); setFailedSubmitCount((count) => count + 1); return; } // Honeypot filled means a bot. Report success so it does not retry, and // never spend a request on it. if (values.website.trim()) { setStatus('success'); return; } setErrors({}); setStatus('submitting'); onSubmitStart?.(); const endpoint = import.meta.env.VITE_AUDIT_ENDPOINT; if (!endpoint) { // Missing configuration is a deployment fault, not a user mistake - // surface it instead of pretending the request went out. console.error('VITE_AUDIT_ENDPOINT is not configured.'); setStatus('error'); return; } try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: values.name.trim(), company: values.company.trim(), email: values.email.trim(), url: values.url.trim(), role: values.role, motivation: values.motivation.trim(), businessConfirmation: 'true', gdprConsent: 'true', website: '', }), }); if (!response.ok) { setStatus('error'); return; } setValues(EMPTY_VALUES); setStatus('success'); onSuccess?.(); } catch { setStatus('error'); } }, [values, texts, onSuccess, onSubmitStart]); return { values, errors, status, failedSubmitCount, setField, submit }; }