153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
/* ============================================================================
|
|
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 <Form method="post">
|
|
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<Record<string, FieldErrorCode>>;
|
|
|
|
/**
|
|
* 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<FieldErrorCode, TranslationKey>
|
|
> = {
|
|
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<T> {
|
|
readonly values: T;
|
|
readonly errors: FieldErrors;
|
|
readonly valid: boolean;
|
|
}
|
|
|
|
export function validateRegistration(
|
|
form: FormData,
|
|
): Validated<RegistrationValues> {
|
|
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<string, FieldErrorCode> = {};
|
|
|
|
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<ApplicationValues> {
|
|
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<string, FieldErrorCode> = {};
|
|
|
|
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 };
|
|
}
|