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.
|
2026-10-24 12:05:51 +02:00
|
|
|
The one exception is the Cloudflare Turnstile check, which needs scripting
|
|
|
|
|
whenever it is configured (see ~/components/ui/Turnstile).
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
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.
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
========================================================================= */
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
import { useEffect, useRef, type ReactNode } from "react";
|
|
|
|
|
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
|
2026-09-24 11:51:24 +02:00
|
|
|
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";
|
2026-09-16 20:32:56 +02:00
|
|
|
import { EuBadge } from "~/components/ui/EuBadge";
|
2026-09-11 02:46:56 +02:00
|
|
|
import { Input, Textarea } from "~/components/ui/Input";
|
2026-10-24 12:05:51 +02:00
|
|
|
import { Turnstile } from "~/components/ui/Turnstile";
|
2026-09-11 02:46:56 +02:00
|
|
|
import { createTranslator, sectionPath, type Locale } from "~/i18n";
|
|
|
|
|
import { getServerEnv } from "~/lib/env.server";
|
|
|
|
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
2026-10-24 12:05:51 +02:00
|
|
|
import { verifyTurnstile } from "~/lib/turnstile.server";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
import "./_page-kit/motion.css";
|
2026-09-24 11:51:24 +02:00
|
|
|
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
|
2026-09-16 20:32:56 +02:00
|
|
|
|
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.
|
|
|
|
|
*/
|
2026-09-24 11:51:24 +02:00
|
|
|
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}` } : {}),
|
2026-09-24 11:51:24 +02:00
|
|
|
...(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 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:05:51 +02:00
|
|
|
if (!(await verifyTurnstile(form))) {
|
|
|
|
|
const t = createTranslator(locale);
|
|
|
|
|
|
|
|
|
|
return data(
|
|
|
|
|
{
|
|
|
|
|
status: "failed" as const,
|
|
|
|
|
errors: {} as ContactErrors,
|
|
|
|
|
values,
|
|
|
|
|
formError: t("form.errorVerification"),
|
|
|
|
|
},
|
|
|
|
|
{ status: 403 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
try {
|
2026-09-24 11:51:24 +02:00
|
|
|
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
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/** 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) {
|
2026-10-05 17:32:06 +02:00
|
|
|
const { locale, hero, intro, generalEmail, socialLinks } = loaderData;
|
2026-09-11 02:46:56 +02:00
|
|
|
const t = createTranslator(locale);
|
|
|
|
|
const copy = pageCopy(locale).contact;
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/* 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";
|
|
|
|
|
|
2026-09-24 11:51:24 +02:00
|
|
|
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
|
2026-09-16 20:32:56 +02:00
|
|
|
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}
|
2026-09-16 20:32:56 +02:00
|
|
|
eyebrow={hero.eyebrow ?? t("nav.sections.contact")}
|
2026-09-11 02:46:56 +02:00
|
|
|
lead={hero.subheading ?? copy.lead}
|
2026-09-16 20:32:56 +02:00
|
|
|
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 } : {})}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
<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
|
|
|
{/* ---------------------------------------------------------------
|
2026-09-16 20:32: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
|
|
|
--------------------------------------------------------------- */}
|
2026-09-16 20:32: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 ? (
|
2026-09-16 20:32:56 +02:00
|
|
|
/* 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
|
2026-09-16 20:32:56 +02:00
|
|
|
className="sh-rise flex flex-col items-start gap-md"
|
2026-09-11 02:46:56 +02:00
|
|
|
role="status"
|
|
|
|
|
>
|
2026-09-16 20:32:56 +02:00
|
|
|
<SentMark />
|
|
|
|
|
|
|
|
|
|
<p className="heading-h3 text-[var(--text-primary)]">
|
2026-09-11 02:46:56 +02:00
|
|
|
{t("form.successTitle")}
|
|
|
|
|
</p>
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* 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>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* 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 } : {})}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-10-24 12:05:51 +02:00
|
|
|
<Turnstile action="contact" locale={locale} resetKey={actionData} />
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
size="lg"
|
|
|
|
|
variant="primary"
|
|
|
|
|
isLoading={isSubmitting}
|
|
|
|
|
iconEnd={<ArrowIcon />}
|
|
|
|
|
className="self-start"
|
|
|
|
|
>
|
|
|
|
|
{isSubmitting ? t("form.sending") : t("form.send")}
|
|
|
|
|
</Button>
|
|
|
|
|
</Form>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ---------------------------------------------------------------
|
2026-09-16 20:32:56 +02:00
|
|
|
THE CHANNELS COLUMN
|
2026-09-11 02:46:56 +02:00
|
|
|
--------------------------------------------------------------- */}
|
2026-09-16 20:32: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 ? (
|
2026-09-16 20:32:56 +02:00
|
|
|
<a href={`mailto:${generalEmail}`} className={LINK_CLASS}>
|
2026-09-11 02:46:56 +02:00
|
|
|
{generalEmail}
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
2026-09-16 20:32:56 +02:00
|
|
|
/* 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
|
|
|
)}
|
2026-09-16 20:32:56 +02:00
|
|
|
</Channel>
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-16 20:32: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>
|
2026-09-16 20:32:56 +02:00
|
|
|
</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" }
|
|
|
|
|
: {})}
|
2026-09-16 20:32:56 +02:00
|
|
|
className={LINK_CLASS}
|
2026-09-11 02:46:56 +02:00
|
|
|
>
|
|
|
|
|
{link.label}
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
2026-09-16 20:32:56 +02:00
|
|
|
</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}
|
2026-09-24 11:51:24 +02:00
|
|
|
surface={theme}
|
2026-09-16 20:32:56 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-09-11 02:46:56 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PageSection>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|