/* ============================================================================ 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("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; type ContactErrors = Partial>; /** * 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 = { 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 { 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 ( ); } /** One label-and-value row of the channels list. */ function Channel({ label, children, }: { label: string; children: ReactNode; }) { return (
{label}
{/* The draft sets the value itself in the page's primary tone, a step above the label. */}
{children}
); } 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(null); useEffect(() => { if (!actionData || actionData.status === "success") return; errorSummaryRef.current?.focus(); }, [actionData]); return ( <> {/* Two equal columns, as the draft sets them - the form beside the channels, each starting at the top of the band. */}
{/* --------------------------------------------------------------- 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. --------------------------------------------------------------- */}
{/* The draft opens the panel straight onto the fields; the heading is what labels the form in the outline and for a screen reader. */}

{copy.formHeading}

{isSuccess ? ( /* The draft swaps the fields for this acknowledgement inside the same panel - no box of its own. */

{t("form.successTitle")}

{/* The draft promises a reply "within five working days"; no approved document does, so the generic acknowledgement from the message catalogue stands instead. */}

{t("form.successBody")}

) : (
{formError || errorMessages.length > 0 ? (

{formError ?? copy.errorSummaryTitle}

{errorMessages.length > 0 ? (
    {/* 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) => (
  • {t(`form.${field}`)}: {errors[field]}
  • ))}
) : null}
) : null} {/* Honeypot - off-screen, out of the tab order and out of the accessibility tree, so only an automated client fills it. */} {/* One column at every width - the draft stacks the four fields above the message. */}