portfolio-page/src/hooks/useAuditForm.ts

199 lines
5.2 KiB
TypeScript

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<keyof AuditFormValues, 'website'>;
export type AuditFormErrors = Partial<Record<AuditFieldName, string>>;
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<AuditFormValues>(EMPTY_VALUES);
const [errors, setErrors] = useState<AuditFormErrors>({});
const [status, setStatus] = useState<SubmitStatus>('idle');
/** Bumped on every rejected submit so the summary can re-take focus. */
const [failedSubmitCount, setFailedSubmitCount] = useState(0);
const setField = useCallback(
<K extends keyof AuditFormValues>(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 };
}