602 lines
22 KiB
TypeScript
602 lines
22 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.
|
|
|
|
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>
|
|
</>
|
|
);
|
|
}
|