Files
sharper.ly/apps/web/app/routes/contact.tsx
IllusionandClaude Opus 5 aedef6daeb Rebuild the site to the Claude Design drafts
Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the
project's own design system: tier-2 tokens, the published type roles, logical
properties throughout, and both locales.

Surfaces
- The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts
  do; the body previously painted the band colour, so every band was a step
  too light.
- A card is drawn one step off whatever it stands on (#1D1D1B on the ground,
  #141413 inside a band). Components paint --surface-card and a band re-points
  it for its subtree, so a card can never come out the same colour as its band.

Shell
- Nav: translucent ground with a blur, chevrons on group triggers, an accent
  rule under the current section, the drafts' compact dropdown card, and an
  EN | AR toggle in place of the globe menu.
- Footer: the drafts' four-column grid, one-line brand text, Facebook as a
  bordered button, the EU lockup, the implemented-by row and the funding
  disclaimer.

Pages
- Home: hero with counting figures, launch band, three areas of change,
  newsroom, FAQ accordion and the consortium band.
- The nine interior pages rebuilt and then audited section by section against
  their drafts.
- Applications: the three-step application form now posts real submissions to
  the Strapi `application` type, validated server-side and working without
  JavaScript.

Motion
- Every animation in the drafts, as classes in _page-kit/motion.css: staggered
  entrances, scroll-driven reveals, the drifting washes (each page carries its
  own geometry), the sheen, the counting figures and the accordion. The
  drafts' coloured hover glow is deliberately not shipped.

CMS
- New `faq` collection type behind the home page's accordion.
- `role` added to the consortium member component so an editor can supply the
  chip and the deliverable separately.

Content
- No invented figure, date, name or commitment ships: the five-working-day
  reply, the placeholder email and Facebook handle, the sample news, events,
  publications and FAQs, the invented application rules and the TBD counters
  are all withheld pending real content. Figures that survived (3 consortium
  partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec
  2027) are verified against docs/ and the bundled draft copy.

Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20
page/locale URLs render 200 through the built SSR handler, including with the
CMS unreachable; no raw hex or physical-direction utility in components or
routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:32:56 +02:00

693 lines
26 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.
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 } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge";
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 { Accordion, type AccordionItem } from "./_page-kit/accordion";
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,
PageHero,
PageSection,
Prose,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
/* ---------------------------------------------------------------------------
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
------------------------------------------------------------------------ */
/** 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, consortiumContacts } =
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 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",
);
/* The draft draws the consortium addresses as a disclosure list, which the
approved draft also calls for ("optional accordion"). The answers are
Markdown because that is what <Accordion /> renders; every value in them
comes from the CMS, and the whole list is replaced by the localised empty
state while the addresses are still "TBD". */
const consortiumItems: readonly AccordionItem[] = consortiumContacts.map(
(contact) => {
const place = [contact.city, contact.country].filter(Boolean).join(", ");
return {
id: contact.id,
question: contact.organisation,
answer: [
contact.addressLine,
place === "" ? null : place,
contact.email ? `[${contact.email}](mailto:${contact.email})` : null,
contact.phone
? `[${contact.phone}](tel:${contact.phone.replace(/\s+/g, "")})`
: null,
]
.filter((line): line is string => typeof line === "string")
.join("\n\n"),
};
},
);
/* 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 } : {})}
/>
<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>
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them.
The draft labels the list with an accent eyebrow rather than a
section heading, so the heading takes the label role. */}
<div className="sh-reveal flex flex-col gap-sm">
<h2
id="contact-consortium-heading"
className="label-md uppercase text-[var(--text-accent)]"
>
{copy.consortiumHeading}
</h2>
{consortiumItems.length > 0 ? (
<Accordion items={consortiumItems} defaultOpenIndex={null} />
) : (
<EmptyState message={copy.consortiumEmpty} />
)}
</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="dark"
/>
</div>
</div>
</div>
</PageSection>
</>
);
}