2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
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
|
2026-10-24 12:05:51 +02:00
|
|
|
client mirror of these rules to drift out of sync. (The Cloudflare
|
|
|
|
|
Turnstile check, when configured, is the one part that needs scripting.)
|
2026-09-11 02:46:56 +02:00
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-16 20:32:56 +02:00
|
|
|
/** Free text - "Tripoli", "Sabha and the south". Not a taxonomy. */
|
|
|
|
|
region: 160,
|
|
|
|
|
/** Free text on purpose: "4", "under a year", "since 2016" are all answers. */
|
|
|
|
|
years: 40,
|
2026-09-11 02:46:56 +02:00
|
|
|
} 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 };
|
|
|
|
|
}
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
GET INVOLVED - the application form on the section's own page
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
The redesign draft puts a single application form under the three pathway
|
|
|
|
|
cards, with five fields and a proposal summary. It is the same contract as
|
|
|
|
|
the per-call form above, plus the two answers the draft asks for (region of
|
|
|
|
|
operation, years of operating history) and the pathway being applied under.
|
|
|
|
|
|
|
|
|
|
WHAT IS REQUIRED, AND WHY EXACTLY THOSE THREE
|
|
|
|
|
`application.applicantName` and `application.email` are `required: true` in
|
|
|
|
|
the CMS, so a submission without them is a 400 that costs the applicant
|
|
|
|
|
everything they typed - they are validated here first. The summary is
|
|
|
|
|
required because an application with no case in it cannot be reviewed.
|
|
|
|
|
|
|
|
|
|
Everything else is optional BY DESIGN, not by omission: the page invites
|
|
|
|
|
"a civil society organisation or an individual", so an organisation name, a
|
|
|
|
|
region and a number of years of operating history cannot be demanded
|
|
|
|
|
without shutting the individuals out of the form that invites them.
|
|
|
|
|
|
|
|
|
|
`yearsOperating` is a STRING, deliberately. "4", "under a year" and "since
|
|
|
|
|
2016" are all true answers to the draft's question, and a number input would
|
|
|
|
|
reject two of them; it reaches the CMS inside `answers`, where a reviewer
|
|
|
|
|
reads it, and nothing computes on it.
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The three ways in, as the draft names them. The value is carried in the
|
|
|
|
|
* query string (`?route=`) so a pathway card can open the form already set to
|
|
|
|
|
* its own route, and it is submitted as a real `<select>` so the reader can
|
|
|
|
|
* correct it - and so the choice still travels with scripting off.
|
|
|
|
|
*/
|
|
|
|
|
export type ApplicationRoute = "calls" | "network" | "sessions";
|
|
|
|
|
|
|
|
|
|
export const APPLICATION_ROUTES = [
|
|
|
|
|
"calls",
|
|
|
|
|
"network",
|
|
|
|
|
"sessions",
|
|
|
|
|
] as const satisfies readonly ApplicationRoute[];
|
|
|
|
|
|
|
|
|
|
export function isApplicationRoute(value: unknown): value is ApplicationRoute {
|
|
|
|
|
return (
|
|
|
|
|
typeof value === "string" &&
|
|
|
|
|
(APPLICATION_ROUTES as readonly string[]).includes(value)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PathwayApplicationValues {
|
|
|
|
|
readonly route: ApplicationRoute;
|
|
|
|
|
readonly organisation: string;
|
|
|
|
|
readonly fullName: string;
|
|
|
|
|
readonly email: string;
|
|
|
|
|
readonly region: string;
|
|
|
|
|
readonly yearsOperating: string;
|
|
|
|
|
readonly summary: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The field name of the pathway select, shared by the form and the parser. */
|
|
|
|
|
export const ROUTE_FIELD = "route";
|
|
|
|
|
|
|
|
|
|
export function validatePathwayApplication(
|
|
|
|
|
form: FormData,
|
|
|
|
|
fallbackRoute: ApplicationRoute = "calls",
|
|
|
|
|
): Validated<PathwayApplicationValues> {
|
|
|
|
|
const submittedRoute = form.get(ROUTE_FIELD);
|
|
|
|
|
|
|
|
|
|
const values: PathwayApplicationValues = {
|
|
|
|
|
// An unknown value is not an error the reader can act on - it cannot be
|
|
|
|
|
// produced by the select - so it falls back rather than blocking a submit.
|
|
|
|
|
route: isApplicationRoute(submittedRoute) ? submittedRoute : fallbackRoute,
|
|
|
|
|
organisation: field(form, "organisation", LIMITS.organisation),
|
|
|
|
|
fullName: field(form, "fullName", LIMITS.name),
|
|
|
|
|
email: field(form, "email", LIMITS.email),
|
|
|
|
|
region: field(form, "region", LIMITS.region),
|
|
|
|
|
yearsOperating: field(form, "yearsOperating", LIMITS.years),
|
|
|
|
|
summary: field(form, "summary", 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.summary) errors.summary = "required";
|
|
|
|
|
|
|
|
|
|
return { values, errors, valid: Object.keys(errors).length === 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The form's fields in reading order - the error summary lists them in it. */
|
|
|
|
|
export const PATHWAY_APPLICATION_FIELDS = [
|
|
|
|
|
"organisation",
|
|
|
|
|
"fullName",
|
|
|
|
|
"email",
|
|
|
|
|
"region",
|
|
|
|
|
"yearsOperating",
|
|
|
|
|
"summary",
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
export type PathwayApplicationField =
|
|
|
|
|
(typeof PATHWAY_APPLICATION_FIELDS)[number];
|