199 lines
5.2 KiB
TypeScript
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 };
|
|
}
|