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

647 lines
24 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.
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.
2026-09-11 02:46:56 +02:00
========================================================================= */
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";
2026-09-11 02:46:56 +02:00
import { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge";
2026-09-11 02:46:56 +02:00
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 {
PageHero,
PageSection,
Prose,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
2026-09-11 02:46:56 +02:00
/* ---------------------------------------------------------------------------
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> {
2026-09-11 02:46:56 +02:00
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 } : {}),
2026-09-11 02:46:56 +02:00
},
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,
request.headers.get("x-forwarded-for") ?? undefined,
);
2026-09-11 02:46:56 +02:00
} 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)]";
2026-09-11 02:46:56 +02:00
export default function Contact({
loaderData,
actionData,
}: Route.ComponentProps) {
const { locale, hero, intro, generalEmail, socialLinks } = loaderData;
2026-09-11 02:46:56 +02:00
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 }];
2026-09-11 02:46:56 +02:00
const navigation = useNavigation();
const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting";
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
2026-09-11 02:46:56 +02:00
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)]"
2026-09-11 02:46:56 +02:00
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.contact")}
2026-09-11 02:46:56 +02:00
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -250, from: "start", offset: "6%", width: 720, height: 560, durationSeconds: 21 }]}
reveal
2026-09-11 02:46:56 +02:00
{...(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">
2026-09-11 02:46:56 +02:00
{/* ---------------------------------------------------------------
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.
2026-09-11 02:46:56 +02:00
--------------------------------------------------------------- */}
<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>
2026-09-11 02:46:56 +02:00
{isSuccess ? (
/* The draft swaps the fields for this acknowledgement inside the
same panel - no box of its own. */
2026-09-11 02:46:56 +02:00
<div
className="sh-rise flex flex-col items-start gap-md"
2026-09-11 02:46:56 +02:00
role="status"
>
<SentMark />
<p className="heading-h3 text-[var(--text-primary)]">
2026-09-11 02:46:56 +02:00
{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. */}
2026-09-11 02:46:56 +02:00
<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">
2026-09-11 02:46:56 +02:00
<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>
{/* ---------------------------------------------------------------
THE CHANNELS COLUMN
2026-09-11 02:46:56 +02:00
--------------------------------------------------------------- */}
<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}>
2026-09-11 02:46:56 +02:00
{generalEmail ? (
<a href={`mailto:${generalEmail}`} className={LINK_CLASS}>
2026-09-11 02:46:56 +02:00
{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>
2026-09-11 02:46:56 +02:00
)}
</Channel>
2026-09-11 02:46:56 +02:00
<Channel label={copy.websiteLabel}>
<a href={copy.websiteHref} className={LINK_CLASS}>
2026-09-11 02:46:56 +02:00
{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) => (
2026-09-11 02:46:56 +02:00
<li key={link.href}>
<a
href={link.href}
{...(link.isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
className={LINK_CLASS}
2026-09-11 02:46:56 +02:00
>
{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>
2026-09-11 02:46:56 +02:00
</div>
</div>
</PageSection>
</>
);
}