Files
sharper.ly/apps/web/app/routes/_events-opportunities/forms.ts
T

153 lines
5.4 KiB
TypeScript
Raw Normal View History

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
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 };
}