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>
693 lines
26 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|