/* ============================================================================ FORM PARSING AND VALIDATION - pure ---------------------------------------------------------------------------- Actions validate, components render. The action returns error CODES, never sentences, so the same result is correct on /en and /ar and the message catalogue stays the only place a wording lives. Both forms work without JavaScript: they are plain
submissions, so validation has to be complete on the server. There is no client mirror of these rules to drift out of sync. ========================================================================= */ import type { TranslationKey } from "~/i18n"; /** * The failures a reader can act on. Codes, not sentences. * * `consent` is its own code because "This field is required." is the wrong * sentence for an unticked checkbox: an unticked box is a decision, not an * omission, so it gets its own wording. */ export type FieldErrorCode = "required" | "email" | "consent"; export type FieldErrors = Readonly>; /** * Message catalogue key for each code - TOTAL over `FieldErrorCode`, so a new * code cannot be added without giving it a sentence in both languages. The * catalogue is the only place a wording lives (see the header), which is why * `consent` resolves here and not out of this section's own copy. */ export const FIELD_ERROR_KEYS: Readonly< Record > = { required: "form.errorRequired", email: "form.errorEmail", consent: "form.errorConsent", }; /** * A field no human fills in. A bot that fills every input gets a 200 and an * acknowledgement, and nothing is written to the CMS. */ export const HONEYPOT_FIELD = "website"; /* Deliberately permissive: one @, no spaces, a dot in the domain. Anything stricter rejects valid addresses, and only a delivered email proves one. */ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; /** Upper bounds, applied by truncation rather than by an error message. */ const LIMITS = { name: 120, organisation: 160, email: 254, phone: 40, message: 4000, } as const; function field(form: FormData, name: string, limit: number): string { const value = form.get(name); return typeof value === "string" ? value.trim().slice(0, limit) : ""; } export function isHoneypotFilled(form: FormData): boolean { const value = form.get(HONEYPOT_FIELD); return typeof value === "string" && value.trim().length > 0; } /* --------------------------------------------------------------------------- EVENT REGISTRATION - draft 4 ------------------------------------------------------------------------ */ export interface RegistrationValues { readonly fullName: string; readonly email: string; readonly organisation: string; readonly phone: string; /** * `event-registration.consentGdpr` is REQUIRED in the CMS: a submission * without it is rejected with a 400, so the checkbox is a real, labelled * control on the form and a real validation rule here - never a hidden * input that consents on the reader's behalf. */ readonly consentGdpr: boolean; } /** The name of the consent checkbox, shared by the form and the validator. */ export const CONSENT_FIELD = "consentGdpr"; /** A checkbox is either absent or carries its value ("on" by default). */ function checkbox(form: FormData, name: string): boolean { const value = form.get(name); return typeof value === "string" && value.length > 0 && value !== "false"; } export interface Validated { readonly values: T; readonly errors: FieldErrors; readonly valid: boolean; } export function validateRegistration( form: FormData, ): Validated { const values: RegistrationValues = { fullName: field(form, "fullName", LIMITS.name), email: field(form, "email", LIMITS.email), organisation: field(form, "organisation", LIMITS.organisation), phone: field(form, "phone", LIMITS.phone), consentGdpr: checkbox(form, CONSENT_FIELD), }; const errors: Record = {}; if (!values.fullName) errors.fullName = "required"; if (!values.email) errors.email = "required"; else if (!EMAIL.test(values.email)) errors.email = "email"; if (!values.consentGdpr) errors[CONSENT_FIELD] = "consent"; return { values, errors, valid: Object.keys(errors).length === 0 }; } /* --------------------------------------------------------------------------- OPPORTUNITY APPLICATION - draft 8 ------------------------------------------------------------------------ */ export interface ApplicationValues { readonly fullName: string; readonly email: string; readonly organisation: string; readonly message: string; } export function validateApplication( form: FormData, ): Validated { const values: ApplicationValues = { fullName: field(form, "fullName", LIMITS.name), email: field(form, "email", LIMITS.email), organisation: field(form, "organisation", LIMITS.organisation), message: field(form, "message", LIMITS.message), }; const errors: Record = {}; if (!values.fullName) errors.fullName = "required"; if (!values.email) errors.email = "required"; else if (!EMAIL.test(values.email)) errors.email = "email"; if (!values.message) errors.message = "required"; return { values, errors, valid: Object.keys(errors).length === 0 }; }