Files
IllusionandClaude Opus 5.5 709a978da5 feat(web): add Cloudflare Turnstile to the public forms
Render the widget on the contact, event registration and both application
forms, and verify the token with Siteverify in each action before anything
is written to the CMS. Verification fails closed. The check is off unless
both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set.

Allow challenges.cloudflare.com in script-src and frame-src.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 12:05:51 +02:00

667 lines
25 KiB
TypeScript

/* ============================================================================
CONTACT - /en/contact and /ar/contact
----------------------------------------------------------------------------
Section 9 of docs/SHARPER_Website_Draft_Section_EN_AR.docx.
The form is a real one: it posts to this route's `action`, which validates
every field on the SERVER (so validation cannot be skipped by disabling
JavaScript or by posting directly) and writes an
api::contact-submission.contact-submission entry in Strapi.
Client-side JS is an enhancement only - the page works as a plain HTML form
post, and `useNavigation()` merely swaps the button into its loading state.
The one exception is the Cloudflare Turnstile check, which needs scripting
whenever it is configured (see ~/components/ui/Turnstile).
Every message the reader can see is localised: field labels and the generic
errors come from the i18n catalogue, the page prose from the approved draft.
THE REDESIGN
------------
The Claude Design draft ("Contact") lays the page out as two columns on the
sunken surface: the form in a raised panel, and beside it the channels, the
consortium disclosure list and the EU funding note. Motion is the classes in
_page-kit/motion.css - the hero entrance and washes, the band reveals, the
panel that rises when the message is sent, and the answer that opens under a
question. The draft's coloured hover glow is deliberately dropped.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
* "The project team replies within five working days." No approved
document makes that commitment, so it is not published (CLAUDE.md 5).
* "info@sharper.ly" and "Facebook - SHARP-ER Libya". The approved draft
writes both as placeholders ("[project email - e.g. ...]", "[Facebook
page link placeholder]"), so the address and the social links come from
the CMS and the page says plainly that they are still to be published.
* The three consortium addresses, which the draft fills with "Contact
details TBD" - the disclosure list renders only what the CMS holds.
========================================================================= */
import { useEffect, useRef, type ReactNode } from "react";
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge";
import { Input, Textarea } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import { createTranslator, sectionPath, type Locale } from "~/i18n";
import { getServerEnv } from "~/lib/env.server";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/contact";
import type { CmsContactPage } from "./_page-kit/cms-types";
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
PageHero,
PageSection,
Prose,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const page = await loadSingle<CmsContactPage>("contact-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
socialLinks: true,
seo: { populate: { ogImage: true } },
},
});
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
intro: page?.intro ?? null,
generalEmail: page?.generalEmail ?? null,
websiteLabel: page?.websiteLabel ?? null,
socialLinks: list(page?.socialLinks).map((link) => ({
label: link.label,
href: link.href,
isExternal: link.isExternal ?? true,
})),
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: loaderData?.seo.title ?? t("nav.sections.contact"),
description: loaderData?.seo.description ?? copy.lead,
...(loaderData?.seo.image
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
: {}),
noIndex: loaderData?.seo.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{
name: t("nav.sections.contact"),
pathname: sectionPath("contact", locale),
},
]),
});
}
/* ---------------------------------------------------------------------------
FORM CONTRACT
------------------------------------------------------------------------ */
const FIELD_NAMES = ["name", "organisation", "email", "subject", "message"] as const;
type ContactField = (typeof FIELD_NAMES)[number];
type ContactValues = Record<ContactField, string>;
type ContactErrors = Partial<Record<ContactField, string>>;
/**
* A field bots fill in and people never see. Named like a real field so a
* naive spam bot completes it; a submission that carries a value is accepted
* with a success response and quietly dropped, which tells the bot nothing.
*/
const HONEYPOT_FIELD = "website";
/**
* The `id` handed to each control. It has to be fixed rather than the `useId()`
* one `Input` generates, because the error summary links at it with a fragment.
*/
function fieldDomId(field: ContactField): string {
return `contact-${field}`;
}
/** Mirrors the maxLength constraints on the Strapi content type. */
const MAX_LENGTH: Record<ContactField, number> = {
name: 200,
organisation: 200,
email: 254,
subject: 250,
message: 5000,
};
/** Deliberately permissive - RFC-valid addresses vary far more than any regex. */
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const EMPTY_VALUES: ContactValues = {
name: "",
organisation: "",
email: "",
subject: "",
message: "",
};
function readValues(form: FormData): ContactValues {
const values = { ...EMPTY_VALUES };
for (const field of FIELD_NAMES) {
const raw = form.get(field);
values[field] = typeof raw === "string" ? raw.trim() : "";
}
return values;
}
function validate(values: ContactValues, locale: Locale): ContactErrors {
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
const errors: ContactErrors = {};
const required: ContactField[] = ["name", "email", "subject", "message"];
for (const field of required) {
if (values[field] === "") errors[field] = t("form.errorRequired");
}
if (!errors.email && !EMAIL_PATTERN.test(values.email)) {
errors.email = t("form.errorEmail");
}
for (const field of FIELD_NAMES) {
if (!errors[field] && values[field].length > MAX_LENGTH[field]) {
errors[field] = copy.errorTooLong;
}
}
return errors;
}
/**
* Write the message to Strapi. Server-only: `getServerEnv` lives in a
* `.server` module, so the token cannot reach the browser bundle.
*
* THE PAYLOAD IS THE WHITELIST, NOTHING MORE
* `contact-submission` has a `sanitize-submission` middleware
* (apps/cms/src/api/contact-submission/middlewares/sanitize-submission.ts)
* that copies out exactly `name · organisation · email · subject · message`
* and drops the rest, then stamps `submissionStatus` and `submittedAt`
* itself. Sending those two from here is not merely redundant - it reads as
* though the browser decides triage state, and the value would be silently
* discarded anyway - so the body below is the whitelist and only it.
*/
async function createContactSubmission(
values: ContactValues,
forwardedFor?: string,
): Promise<void> {
const env = getServerEnv();
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
...(forwardedFor ? { "x-forwarded-for": forwardedFor } : {}),
},
body: JSON.stringify({
data: {
name: values.name,
// An empty optional field is stored as null, not as "".
organisation: values.organisation === "" ? null : values.organisation,
email: values.email,
subject: values.subject,
message: values.message,
},
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(
`Strapi responded ${response.status} ${response.statusText} for ${url}`,
);
}
}
export async function action({ params, request }: Route.ActionArgs) {
const locale = routeLocale(params);
const form = await request.formData();
// Honeypot: answer exactly like a success, store nothing.
const trap = form.get(HONEYPOT_FIELD);
if (typeof trap === "string" && trap.trim() !== "") {
return data({ status: "success" as const, errors: {} as ContactErrors, values: EMPTY_VALUES });
}
const values = readValues(form);
const errors = validate(values, locale);
if (Object.keys(errors).length > 0) {
return data(
{ status: "invalid" as const, errors, values },
{ status: 400 },
);
}
if (!(await verifyTurnstile(form))) {
const t = createTranslator(locale);
return data(
{
status: "failed" as const,
errors: {} as ContactErrors,
values,
formError: t("form.errorVerification"),
},
{ status: 403 },
);
}
try {
await createContactSubmission(
values,
request.headers.get("x-forwarded-for") ?? undefined,
);
} catch (error) {
console.error("[contact] submission failed", error);
const t = createTranslator(locale);
return data(
{
status: "failed" as const,
errors: {} as ContactErrors,
values,
formError: t("form.errorGeneric"),
},
{ status: 502 },
);
}
return data({ status: "success" as const, errors: {} as ContactErrors, values: EMPTY_VALUES });
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
/** See the note on the same constant in about.tsx. */
const ACCENT_EDGE =
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
/** The tick the draft draws in a filled circle over the "message sent" panel. */
function SentMark() {
return (
<span
aria-hidden="true"
className="flex size-12 shrink-0 items-center justify-center rounded-pill bg-[var(--surface-accent)] text-[var(--text-on-accent)]"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 13l4.5 4.5L19 7"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
);
}
/** One label-and-value row of the channels list. */
function Channel({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-2xs border-b border-[var(--border-subtle)] pb-md">
<dt className="label-md uppercase text-[var(--text-tertiary)]">{label}</dt>
{/* The draft sets the value itself in the page's primary tone, a step
above the label. */}
<dd className="body-lg-bold text-[var(--text-primary)]">{children}</dd>
</div>
);
}
const LINK_CLASS =
"text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]";
export default function Contact({
loaderData,
actionData,
}: Route.ComponentProps) {
const { locale, hero, intro, generalEmail, socialLinks } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
/* The CMS wins; until an editor fills the row, the project's own confirmed
channel stands in (lib/site-links.ts). */
const channels =
socialLinks.length > 0
? socialLinks
: [{ label: copy.facebookLabel, href: SITE_FACEBOOK_URL, isExternal: true }];
const navigation = useNavigation();
const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting";
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
const errors = actionData?.errors ?? {};
const values = actionData?.values ?? EMPTY_VALUES;
const formError =
actionData && "formError" in actionData ? actionData.formError : undefined;
const isSuccess = actionData?.status === "success";
const errorMessages = Object.values(errors).filter(
(message): message is string => typeof message === "string",
);
/* A failed submit re-renders the page with the summary at the top of the
form. Screen-reader users are left wherever they were, and keyboard users
below the fold, so focus is moved onto the summary itself - it is
tabIndex={-1} for exactly this. Keyed on `actionData`, which is a fresh
object per submission, so a repeated failure focuses again. */
const errorSummaryRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!actionData || actionData.status === "success") return;
errorSummaryRef.current?.focus();
}, [actionData]);
return (
<>
<PageHero
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(2.5rem,5vw,3.75rem)]"
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.contact")}
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -250, from: "start", offset: "6%", width: 720, height: 560, durationSeconds: 21 }]}
reveal
{...(hero.media ? { media: hero.media } : {})}
/>
<PageSection className="bg-[var(--surface-sunken)]">
{/* Two equal columns, as the draft sets them - the form beside the
channels, each starting at the top of the band. */}
<div className="grid grid-cols-1 items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
{/* ---------------------------------------------------------------
THE FORM - one raised panel on the sunken band, as the draft has
it. The mechanism underneath is unchanged: a real HTML form post
that the server validates.
--------------------------------------------------------------- */}
<div className="sh-reveal sh-reveal--in flex flex-col gap-lg rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg md:p-xl">
{/* The draft opens the panel straight onto the fields; the heading
is what labels the form in the outline and for a screen
reader. */}
<h2 id="contact-form-heading" className="sr-only">
{copy.formHeading}
</h2>
{isSuccess ? (
/* The draft swaps the fields for this acknowledgement inside the
same panel - no box of its own. */
<div
className="sh-rise flex flex-col items-start gap-md"
role="status"
>
<SentMark />
<p className="heading-h3 text-[var(--text-primary)]">
{t("form.successTitle")}
</p>
{/* The draft promises a reply "within five working days"; no
approved document does, so the generic acknowledgement from
the message catalogue stands instead. */}
<p className="body-md text-[var(--text-secondary)]">
{t("form.successBody")}
</p>
<Button as={Link} to={pathname} variant="secondary">
{copy.sendAnother}
</Button>
</div>
) : (
<Form
method="post"
aria-labelledby="contact-form-heading"
className="flex flex-col gap-lg"
noValidate
>
{formError || errorMessages.length > 0 ? (
<div
ref={errorSummaryRef}
tabIndex={-1}
role="alert"
className="flex flex-col gap-xs rounded-card border border-[var(--border-danger)] bg-[var(--surface-danger-subtle)] p-md"
>
<p className="body-md-bold text-[var(--text-danger)]">
{formError ?? copy.errorSummaryTitle}
</p>
{errorMessages.length > 0 ? (
<ul className="flex list-disc flex-col gap-2xs ps-lg body-sm text-[var(--text-secondary)]">
{/* Listed in form order, not object order, and each one
is a link at the control it is about. The label is
repeated so the link text still identifies the field
when the messages are read out of context. */}
{FIELD_NAMES.filter((field) => errors[field]).map((field) => (
<li key={field}>
<a
href={`#${fieldDomId(field)}`}
className="underline underline-offset-2 hover:text-[var(--text-primary)]"
>
{t(`form.${field}`)}: {errors[field]}
</a>
</li>
))}
</ul>
) : null}
</div>
) : null}
{/* Honeypot - off-screen, out of the tab order and out of the
accessibility tree, so only an automated client fills it. */}
<div aria-hidden="true" className="sr-only">
<label htmlFor="contact-website">{copy.honeypotLabel}</label>
<input
id="contact-website"
type="text"
name={HONEYPOT_FIELD}
tabIndex={-1}
autoComplete="off"
defaultValue=""
/>
</div>
{/* One column at every width - the draft stacks the four
fields above the message. */}
<div className="flex flex-col gap-lg">
<Input
name="name"
id={fieldDomId("name")}
label={t("form.name")}
required
autoComplete="name"
maxLength={MAX_LENGTH.name}
defaultValue={values.name}
{...(errors.name ? { error: errors.name } : {})}
/>
<Input
name="organisation"
id={fieldDomId("organisation")}
label={t("form.organisation")}
hint={copy.organisationHint}
autoComplete="organization"
maxLength={MAX_LENGTH.organisation}
defaultValue={values.organisation}
{...(errors.organisation ? { error: errors.organisation } : {})}
/>
<Input
name="email"
id={fieldDomId("email")}
type="email"
label={t("form.email")}
required
autoComplete="email"
maxLength={MAX_LENGTH.email}
defaultValue={values.email}
{...(errors.email ? { error: errors.email } : {})}
/>
<Input
name="subject"
id={fieldDomId("subject")}
label={t("form.subject")}
required
maxLength={MAX_LENGTH.subject}
defaultValue={values.subject}
{...(errors.subject ? { error: errors.subject } : {})}
/>
</div>
<Textarea
name="message"
id={fieldDomId("message")}
label={t("form.message")}
required
rows={6}
maxLength={MAX_LENGTH.message}
defaultValue={values.message}
{...(errors.message ? { error: errors.message } : {})}
/>
<Turnstile action="contact" locale={locale} resetKey={actionData} />
<Button
type="submit"
size="lg"
variant="primary"
isLoading={isSubmitting}
iconEnd={<ArrowIcon />}
className="self-start"
>
{isSubmitting ? t("form.sending") : t("form.send")}
</Button>
</Form>
)}
</div>
{/* ---------------------------------------------------------------
THE CHANNELS COLUMN
--------------------------------------------------------------- */}
<div className="flex flex-col gap-xl">
<div className="sh-reveal flex flex-col gap-md">
{/* The draft opens this column straight onto the channel rows;
the heading is here for the outline only. */}
<h2 id="contact-details-heading" className="sr-only">
{copy.detailsHeading}
</h2>
{intro ? (
<Prose>
<RichText markdown={intro} />
</Prose>
) : null}
<dl className="flex flex-col gap-md">
<Channel label={copy.generalEnquiries}>
{generalEmail ? (
<a href={`mailto:${generalEmail}`} className={LINK_CLASS}>
{generalEmail}
</a>
) : (
/* The approved draft writes the address as a placeholder,
so nothing stands in for it. A sentence is not a channel
value, so it keeps the quieter body tone. */
<span className="body-md text-[var(--text-secondary)]">
{copy.contactPending}
</span>
)}
</Channel>
<Channel label={copy.websiteLabel}>
<a href={copy.websiteHref} className={LINK_CLASS}>
{loaderData.websiteLabel ?? copy.websiteText}
</a>
</Channel>
{/* The CMS owns this row; until an editor fills it, the
project's own confirmed Facebook page stands in. */}
{channels.length > 0 ? (
<Channel label={t("site.followUs")}>
<ul className="flex flex-wrap gap-md">
{channels.map((link) => (
<li key={link.href}>
<a
href={link.href}
{...(link.isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
className={LINK_CLASS}
>
{link.label}
</a>
</li>
))}
</ul>
</Channel>
) : null}
</dl>
</div>
{/* EU VISIBILITY - the funding note the draft prints at the foot of
this column. The emblem and the statement come from the shared
badge, so the wording and the artwork stay contractual. */}
<div
className={`sh-reveal flex items-center gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-md ${ACCENT_EDGE}`}
>
<EuBadge
size="sm"
statement={t("site.euStatement")}
locale={locale}
surface={theme}
/>
</div>
</div>
</div>
</PageSection>
</>
);
}