Files
sharper.ly/apps/web/app/routes/contact.tsx
T

602 lines
22 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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.
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.
========================================================================= */
import { useEffect, useRef } from "react";
import { data, Form, Link, useLocation, useNavigation } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input, Textarea } from "~/components/ui/Input";
import { createTranslator, sectionPath, type Locale } from "~/i18n";
import { getServerEnv } from "~/lib/env.server";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
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 {
EmptyState,
InfoPanel,
PageHero,
PageSection,
Prose,
SectionHeader,
} from "./_page-kit/sections";
/* ---------------------------------------------------------------------------
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,
consortiumContacts: 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,
})),
consortiumContacts: list(page?.consortiumContacts).map((address) => ({
id: String(address.id ?? address.organisation),
organisation: address.organisation,
addressLine: address.addressLine ?? null,
city: address.city ?? null,
country: address.country ?? null,
email: address.email ?? null,
phone: address.phone ?? null,
})),
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): 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}` } : {}),
},
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 },
);
}
try {
await createContactSubmission(values);
} 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
------------------------------------------------------------------------ */
export default function Contact({
loaderData,
actionData,
}: Route.ComponentProps) {
const { locale, hero, intro, generalEmail, socialLinks, consortiumContacts } =
loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
const navigation = useNavigation();
const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting";
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
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.engagementAndOpportunities")}
lead={hero.subheading ?? copy.lead}
{...(hero.media ? { media: hero.media } : {})}
/>
<PageSection className="pt-0 md:pt-0">
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
{/* ---------------------------------------------------------------
THE FORM
--------------------------------------------------------------- */}
<div className="flex flex-col gap-lg">
<SectionHeader
id="contact-form-heading"
title={copy.formHeading}
lead={copy.formIntro}
/>
{isSuccess ? (
<div
className="flex flex-col items-start gap-md rounded-card border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] p-lg"
role="status"
>
<p className="body-lg-bold text-[var(--text-primary)]">
{t("form.successTitle")}
</p>
<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>
<div className="grid grid-cols-1 gap-lg md:grid-cols-2">
<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 } : {})}
/>
<Button
type="submit"
size="lg"
variant="primary"
isLoading={isSubmitting}
iconEnd={<ArrowIcon />}
className="self-start"
>
{isSubmitting ? t("form.sending") : t("form.send")}
</Button>
</Form>
)}
</div>
{/* ---------------------------------------------------------------
CONTACT DETAILS
--------------------------------------------------------------- */}
<div className="flex flex-col gap-lg">
<SectionHeader id="contact-details-heading" title={copy.detailsHeading} />
{intro ? (
<Prose>
<RichText markdown={intro} />
</Prose>
) : null}
<dl className="flex flex-col gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg">
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{copy.generalEnquiries}
</dt>
<dd className="body-md text-[var(--text-secondary)]">
{generalEmail ? (
<a
href={`mailto:${generalEmail}`}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{generalEmail}
</a>
) : (
copy.contactPending
)}
</dd>
</div>
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{copy.websiteLabel}
</dt>
<dd className="body-md">
<a
href={copy.websiteHref}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{loaderData.websiteLabel ?? copy.websiteText}
</a>
</dd>
</div>
{socialLinks.length > 0 ? (
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{t("site.followUs")}
</dt>
<dd>
<ul className="flex flex-wrap gap-md body-md">
{socialLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
{...(link.isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{link.label}
</a>
</li>
))}
</ul>
</dd>
</div>
) : null}
</dl>
</div>
</div>
</PageSection>
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them. */}
<PageSection aria-labelledby="contact-consortium-heading">
<div className="flex flex-col gap-lg">
<SectionHeader
id="contact-consortium-heading"
title={copy.consortiumHeading}
/>
{consortiumContacts.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{consortiumContacts.map((contact) => (
<li key={contact.id}>
<InfoPanel title={contact.organisation}>
<address className="flex flex-col gap-2xs not-italic">
{contact.addressLine ? <span>{contact.addressLine}</span> : null}
{contact.city || contact.country ? (
<span>
{[contact.city, contact.country]
.filter(Boolean)
.join(", ")}
</span>
) : null}
{contact.email ? (
<a
href={`mailto:${contact.email}`}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{contact.email}
</a>
) : null}
{contact.phone ? (
<a
href={`tel:${contact.phone.replace(/\s+/g, "")}`}
dir="ltr"
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{contact.phone}
</a>
) : null}
</address>
</InfoPanel>
</li>
))}
</ul>
) : (
<EmptyState message={copy.consortiumEmpty} />
)}
</div>
</PageSection>
</>
);
}