Render the widget on the contact, event registration and both application forms, and verify the token with Siteverify in each action before anything is written to the CMS. Verification fails closed. The check is off unless both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set. Allow challenges.cloudflare.com in script-src and frame-src. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
998 lines
34 KiB
TypeScript
998 lines
34 KiB
TypeScript
/* ============================================================================
|
|
APPLICATIONS AND OPPORTUNITIES - /{locale}/applications-and-opportunities
|
|
----------------------------------------------------------------------------
|
|
Draft section 8 ("Get Involved"), laid out to the 2026 redesign draft
|
|
("Applications-and-Opportunities"). The page is three bands and nothing
|
|
else, exactly as the draft has it:
|
|
|
|
1. the hero;
|
|
2. the three ways into the project, which are editorial and permanent -
|
|
a Call for Expression of Interest, the SHARP network, and the SUQ
|
|
dialogues and trainings. Each is drawn as the draft's card: a status
|
|
pill, the title, the approved description, a date line and one action;
|
|
3. `#apply` - the application form, with the draft's progress rail beside
|
|
it and the acknowledgement panel in its place once it is sent.
|
|
|
|
THE CURRENT CALLS BAND IS GONE, on the client's decision. Its filter pills,
|
|
pagination and empty states went with it, and so did the six copy keys only
|
|
it used. The calls themselves are untouched: they are still published, still
|
|
fetched here for the pathway cards' status and deadline, and their detail
|
|
pages (applications-and-opportunities.$slug.tsx) still carry the per-call
|
|
application form.
|
|
|
|
THE STATUS PILLS AND DATE LINES ARE READ OUT OF STRAPI, NOT WRITTEN HERE.
|
|
The draft labels the three cards "Open", "Rolling" and "By session" and
|
|
dates them "Closes 15 August 2026", "Eligibility criteria — TBD" and "Next
|
|
session 4 March 2026". None of that is in an approved document: the pill is
|
|
derived from the calls the CMS actually holds, the date line is the nearest
|
|
real deadline, and a card with nothing behind it simply shows neither.
|
|
|
|
The draft's network card ends "Membership involves [eligibility criteria -
|
|
TBD]". That sentence is NOT rendered: eligibility is a CMS field on the
|
|
network entry, and the page shows it only once it exists.
|
|
|
|
THE FORM
|
|
A real <Form method="post"> against this route's `action`, which validates
|
|
on the SERVER and writes an api::application.application entry. Scripting is
|
|
an enhancement only: all three steps are in the server-rendered HTML, no
|
|
field is hidden behind a step, and the whole thing submits and reports its
|
|
errors with JavaScript switched off. `useNavigation()` only swaps the button
|
|
into its loading state. The one exception is the Cloudflare Turnstile check,
|
|
which needs scripting whenever it is configured (~/components/ui/Turnstile).
|
|
|
|
WHAT THE DRAFT PROMISES AND THIS PAGE DOES NOT (CLAUDE.md rule 5 - the
|
|
wording lives in ./_events-opportunities/copy.ts, where each omission is
|
|
annotated):
|
|
* "PDF or DOCX, up to 10 MB" - not this project's rule, and nothing is
|
|
uploaded here at all (see `documentsNote`);
|
|
* "an automatic acknowledgment with your reference number" - no
|
|
reference-number field exists anywhere in the CMS. The acknowledgement
|
|
IS sent (the CMS mails it on submission) and the number does not;
|
|
* "follows up within four weeks" - a response time that appears in no
|
|
approved document. §8 of the draft marks it "[X weeks] - TBD".
|
|
|
|
Open / Upcoming / Closed is derived from `openDate` and `deadline` (see
|
|
./_events-opportunities/opportunities.ts); an editorial `opportunityStatus`
|
|
of "closed" wins over the dates, for a call suspended or closed early.
|
|
|
|
EMPTY BY DESIGN: with an empty CMS the three pathway cards still render -
|
|
they are approved copy, not content - and the form still works, because it
|
|
is not tied to any one call.
|
|
========================================================================= */
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { data, Form, Link, useActionData, useNavigation } from "react-router";
|
|
|
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
|
import { CardLink, CardShell } from "~/components/ui/Card";
|
|
import { Input, Textarea } from "~/components/ui/Input";
|
|
import { Turnstile } from "~/components/ui/Turnstile";
|
|
import {
|
|
interpolate,
|
|
localizedPath,
|
|
sectionPath,
|
|
toIsoDate,
|
|
translate,
|
|
useTranslation,
|
|
type Locale,
|
|
} from "~/i18n";
|
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
|
import { isStrapiError } from "~/lib/strapi";
|
|
import { verifyTurnstile } from "~/lib/turnstile.server";
|
|
|
|
import type { Route } from "./+types/applications-and-opportunities";
|
|
import { Container } from "./_page-kit/sections";
|
|
import { getRouteCopy, type RouteCopy } from "./_events-opportunities/copy";
|
|
import {
|
|
APPLICATION_ROUTES,
|
|
FIELD_ERROR_KEYS,
|
|
HONEYPOT_FIELD,
|
|
isHoneypotFilled,
|
|
PATHWAY_APPLICATION_FIELDS,
|
|
ROUTE_FIELD,
|
|
validatePathwayApplication,
|
|
type ApplicationRoute,
|
|
type FieldErrors,
|
|
type PathwayApplicationField,
|
|
type PathwayApplicationValues,
|
|
} from "./_events-opportunities/forms";
|
|
import {
|
|
filterOpportunities,
|
|
findNetworkOpportunity,
|
|
formatOpportunityDate,
|
|
opportunityStatusKey,
|
|
sortOpportunities,
|
|
type OpportunityStatus,
|
|
} from "./_events-opportunities/opportunities";
|
|
import { fetchOpportunities } from "./_events-opportunities/opportunities.server";
|
|
import { readRootSeo } from "./_events-opportunities/root-seo";
|
|
import { submitPathwayApplication } from "./_events-opportunities/submissions.server";
|
|
import { parseEnum, requireLocale } from "./_shared/listing";
|
|
import { FormSelect, ListingHero, PageShell } from "./_shared/listing-ui";
|
|
import { getListingChrome } from "./_shared/page-copy";
|
|
|
|
import "./_page-kit/motion.css";
|
|
|
|
/** Anchor of the form band - every "apply" action jumps to it. */
|
|
const FORM_ANCHOR = "apply";
|
|
|
|
/** The draft's rail: Eligibility - Application - Review. */
|
|
const TOTAL_STEPS = 3;
|
|
|
|
/**
|
|
* The step the reader is on, following the draft's own rule: the form itself
|
|
* is step 2, and sending it moves them to step 3. Step 1 (eligibility) is
|
|
* behind them either way - it is the pathway cards above.
|
|
*/
|
|
function currentStep(submitted: boolean): number {
|
|
return submitted ? 3 : 2;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
LOADER
|
|
------------------------------------------------------------------------ */
|
|
|
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
const locale = requireLocale(params.lang);
|
|
const url = new URL(request.url);
|
|
const now = new Date();
|
|
|
|
/* Which pathway the form opens on. A pathway card links here with its own
|
|
route already set, so the form is correct before the reader touches the
|
|
select - and it still is with scripting off. */
|
|
const route = parseEnum(
|
|
url.searchParams,
|
|
ROUTE_FIELD,
|
|
APPLICATION_ROUTES,
|
|
"calls",
|
|
);
|
|
|
|
const { opportunities } = await fetchOpportunities(locale, now);
|
|
|
|
/* "Join the SHARP Network" points at the network entry when the CMS has one,
|
|
at the form when it does not - never at a URL that does not exist. */
|
|
const network = findNetworkOpportunity(opportunities);
|
|
|
|
/* The pathway card's own status and date line, both read off the calls the
|
|
CMS holds right now. `sortOpportunities` puts open calls first with the
|
|
nearest deadline at the top, so the first open call IS the next deadline. */
|
|
const openCalls = sortOpportunities(
|
|
filterOpportunities(opportunities, "open"),
|
|
).filter((entry) => entry.opportunityType !== "network-membership");
|
|
const nextDeadline = openCalls.find((entry) => entry.deadline)?.deadline;
|
|
|
|
return {
|
|
locale,
|
|
route,
|
|
networkSlug: network?.slug ?? null,
|
|
networkStatus: network?.status ?? null,
|
|
callsStatus: openCalls.length > 0 ? ("open" as OpportunityStatus) : null,
|
|
nextDeadline: nextDeadline ?? null,
|
|
/* The draft draws no call list. Without one the call pages would have no
|
|
link anywhere on the site, so the open calls are named once, quietly,
|
|
under the pathway cards. Capped: this is a line, not a listing. */
|
|
openCalls: openCalls.slice(0, 4).map((entry) => ({
|
|
slug: entry.slug,
|
|
title: entry.title,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
ACTION
|
|
----------------------------------------------------------------------------
|
|
Server-side validation is the only validation: `noValidate` on the form
|
|
means the browser never blocks a submit, and a reader with scripting off
|
|
posts straight here. The result carries error CODES, so /en and /ar resolve
|
|
the same answer through the message catalogue.
|
|
------------------------------------------------------------------------ */
|
|
|
|
type ActionResult =
|
|
| { readonly ok: true; readonly route: ApplicationRoute }
|
|
| {
|
|
readonly ok: false;
|
|
readonly errors: FieldErrors;
|
|
readonly values: PathwayApplicationValues;
|
|
/** True when validation passed but the CMS refused the write. */
|
|
readonly failed: boolean;
|
|
/** True when validation passed but the Turnstile check did not. */
|
|
readonly verificationFailed: boolean;
|
|
};
|
|
|
|
export async function action({ params, request }: Route.ActionArgs) {
|
|
const locale = requireLocale(params.lang);
|
|
const url = new URL(request.url);
|
|
const fallbackRoute = parseEnum(
|
|
url.searchParams,
|
|
ROUTE_FIELD,
|
|
APPLICATION_ROUTES,
|
|
"calls",
|
|
);
|
|
|
|
const form = await request.formData();
|
|
|
|
/* Honeypot: answered exactly like a success, and nothing is written. Telling
|
|
a bot that it was caught only teaches it to stop filling the field. */
|
|
if (isHoneypotFilled(form)) {
|
|
return { ok: true, route: fallbackRoute } satisfies ActionResult;
|
|
}
|
|
|
|
const { values, errors, valid } = validatePathwayApplication(
|
|
form,
|
|
fallbackRoute,
|
|
);
|
|
|
|
if (!valid) {
|
|
return data(
|
|
{
|
|
ok: false,
|
|
errors,
|
|
values,
|
|
failed: false,
|
|
verificationFailed: false,
|
|
} satisfies ActionResult,
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
if (!(await verifyTurnstile(form))) {
|
|
return data(
|
|
{
|
|
ok: false,
|
|
errors: {},
|
|
values,
|
|
failed: false,
|
|
verificationFailed: true,
|
|
} satisfies ActionResult,
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
try {
|
|
await submitPathwayApplication({
|
|
values,
|
|
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
|
});
|
|
} catch (error) {
|
|
if (!isStrapiError(error)) throw error;
|
|
console.error(
|
|
`[opportunities] application from /${locale} was rejected: ${error.status} ${error.message}`,
|
|
);
|
|
return data(
|
|
{
|
|
ok: false,
|
|
errors: {},
|
|
values,
|
|
failed: true,
|
|
verificationFailed: false,
|
|
} satisfies ActionResult,
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
|
|
return { ok: true, route: values.route } satisfies ActionResult;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
META
|
|
------------------------------------------------------------------------ */
|
|
|
|
export function meta({ location, matches }: Route.MetaArgs) {
|
|
const root = readRootSeo(matches);
|
|
const copy = getRouteCopy(root.locale);
|
|
const search = new URLSearchParams(location.search);
|
|
|
|
return buildMeta({
|
|
locale: root.locale,
|
|
pathname: root.pathname,
|
|
siteUrl: root.siteUrl,
|
|
title: translate(root.locale, "nav.sections.applications"),
|
|
description: copy.opportunities.metaDescription,
|
|
// `?route=` only preselects a control; indexing it would offer three URLs
|
|
// for one page.
|
|
noIndex: search.has(ROUTE_FIELD),
|
|
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
|
{
|
|
name: translate(root.locale, "nav.sections.home"),
|
|
pathname: localizedPath("/", root.locale),
|
|
},
|
|
{
|
|
name: translate(root.locale, "nav.sections.applications"),
|
|
pathname: sectionPath("applications", root.locale),
|
|
},
|
|
]),
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
HELPERS
|
|
------------------------------------------------------------------------ */
|
|
|
|
/** The short name of a pathway - the draft's form title and select options. */
|
|
function routeLabel(route: ApplicationRoute, copy: RouteCopy): string {
|
|
switch (route) {
|
|
case "network":
|
|
return copy.apply.routeNetwork;
|
|
case "sessions":
|
|
return copy.apply.routeSessions;
|
|
default:
|
|
return copy.apply.routeCalls;
|
|
}
|
|
}
|
|
|
|
/** The id handed to a control, so the error summary can link at it. */
|
|
function fieldDomId(
|
|
field: PathwayApplicationField | typeof ROUTE_FIELD,
|
|
): string {
|
|
return `apply-${field}`;
|
|
}
|
|
|
|
/** Label of a field, for its control AND for its line in the error summary. */
|
|
function fieldLabel(field: PathwayApplicationField, copy: RouteCopy): string {
|
|
switch (field) {
|
|
case "organisation":
|
|
return copy.apply.organisationLabel;
|
|
case "fullName":
|
|
return copy.apply.contactLabel;
|
|
case "region":
|
|
return copy.apply.regionLabel;
|
|
case "yearsOperating":
|
|
return copy.apply.yearsLabel;
|
|
case "summary":
|
|
return copy.apply.summaryLabel;
|
|
default:
|
|
return copy.apply.emailLabel;
|
|
}
|
|
}
|
|
|
|
const EMPTY_VALUES: PathwayApplicationValues = {
|
|
route: "calls",
|
|
organisation: "",
|
|
fullName: "",
|
|
email: "",
|
|
region: "",
|
|
yearsOperating: "",
|
|
summary: "",
|
|
};
|
|
|
|
/** The tick the draft draws in a filled circle over the acknowledgement. */
|
|
function ReceivedMark() {
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
className="flex size-12 shrink-0 items-center justify-center rounded-pill bg-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>
|
|
);
|
|
}
|
|
|
|
/** The draft's upload glyph over the documents note. */
|
|
function DocumentsMark() {
|
|
return (
|
|
<svg
|
|
width="22"
|
|
height="22"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
className="mt-2xs shrink-0 text-[var(--text-accent)]"
|
|
>
|
|
<path
|
|
d="M12 16V4M7 8.5L12 3.5l5 5M4 20h16"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
PAGE
|
|
------------------------------------------------------------------------ */
|
|
|
|
export default function ApplicationsAndOpportunities({
|
|
loaderData,
|
|
}: Route.ComponentProps) {
|
|
const { route, networkSlug, networkStatus, callsStatus, nextDeadline, openCalls } =
|
|
loaderData;
|
|
const { locale, t } = useTranslation();
|
|
const copy = getRouteCopy(locale);
|
|
const chrome = getListingChrome(locale).applications;
|
|
|
|
const pathname = sectionPath("applications", locale);
|
|
/** This page, with the form preselected to one pathway. */
|
|
const applyHref = (value: ApplicationRoute) =>
|
|
`${pathname}?${ROUTE_FIELD}=${value}#${FORM_ANCHOR}`;
|
|
|
|
const result = useActionData() as ActionResult | undefined;
|
|
const isSubmitted = result?.ok === true;
|
|
|
|
/* The form's route: what was submitted when a submit failed, so a corrected
|
|
resubmit keeps the reader's choice; otherwise the one in the URL. */
|
|
const formRoute = result && !result.ok ? result.values.route : route;
|
|
const shownRoute = result?.ok ? result.route : formRoute;
|
|
|
|
const pathways = [
|
|
{
|
|
id: "calls",
|
|
title: copy.opportunities.pathwayCallsTitle,
|
|
body: copy.opportunities.pathwayCallsBody,
|
|
/* The draft's card opens the form on its own route. It used to jump to
|
|
the calls list, which this page no longer carries. */
|
|
href: applyHref("calls"),
|
|
actionLabel: t("actions.apply"),
|
|
status: callsStatus,
|
|
date: nextDeadline,
|
|
dateLabel: nextDeadline
|
|
? t("opportunities.deadline", {
|
|
date: formatOpportunityDate(nextDeadline, locale),
|
|
})
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "network",
|
|
/* The network entry, when the CMS holds one, carries the real
|
|
eligibility and deadline - a better destination than the general
|
|
form. Without one, the form is where joining starts. */
|
|
title: copy.opportunities.pathwayNetworkTitle,
|
|
body: copy.opportunities.pathwayNetworkBody,
|
|
href: networkSlug
|
|
? `${pathname}/${encodeURIComponent(networkSlug)}`
|
|
: applyHref("network"),
|
|
actionLabel: t("actions.applyToJoin"),
|
|
status: networkStatus,
|
|
date: undefined,
|
|
dateLabel: undefined,
|
|
},
|
|
{
|
|
id: "sessions",
|
|
title: copy.opportunities.pathwaySessionsTitle,
|
|
body: copy.opportunities.pathwaySessionsBody,
|
|
/* Sessions are registered for on the event itself, which is where the
|
|
places, the waitlist and the joining details live. */
|
|
href: sectionPath("events", locale),
|
|
actionLabel: t("actions.viewUpcomingSessions"),
|
|
status: null,
|
|
date: undefined,
|
|
dateLabel: undefined,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<ListingHero
|
|
eyebrow={chrome.eyebrow}
|
|
heading={copy.opportunities.heading}
|
|
intro={copy.opportunities.intro}
|
|
ambient={[
|
|
{
|
|
top: -250,
|
|
from: "end",
|
|
offset: "8%",
|
|
width: 720,
|
|
height: 560,
|
|
durationSeconds: 20,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<PageShell>
|
|
<section aria-labelledby="pathways" className="flex flex-col gap-lg">
|
|
{/* The draft opens straight onto the three cards, so the heading is
|
|
kept for the document outline and not drawn. */}
|
|
<h2 id="pathways" className="sr-only">
|
|
{copy.opportunities.pathwaysHeading}
|
|
</h2>
|
|
|
|
<ul
|
|
aria-label={chrome.pathwayAllLabel}
|
|
className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3"
|
|
>
|
|
{pathways.map((pathway) => (
|
|
<CardShell
|
|
key={pathway.id}
|
|
as="li"
|
|
linked
|
|
className="sh-reveal sh-reveal--in flex-col gap-md p-xl"
|
|
>
|
|
{pathway.status ? (
|
|
<span className="inline-flex w-fit items-center gap-xs rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
|
|
{pathway.status === "open" ? (
|
|
<span
|
|
aria-hidden="true"
|
|
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
|
/>
|
|
) : null}
|
|
{t(opportunityStatusKey(pathway.status))}
|
|
</span>
|
|
) : null}
|
|
|
|
<h3 className="sh-card-title heading-h3 text-text-primary">
|
|
<CardLink href={pathway.href} className="sh-card-link">
|
|
{pathway.title}
|
|
</CardLink>
|
|
</h3>
|
|
|
|
<p className="body-md text-text-secondary">{pathway.body}</p>
|
|
|
|
{pathway.dateLabel ? (
|
|
<p className="label-md text-text-tertiary">
|
|
<time dateTime={toIsoDate(pathway.date)}>
|
|
{pathway.dateLabel}
|
|
</time>
|
|
</p>
|
|
) : null}
|
|
|
|
{/* The draft closes each card with the filled accent action,
|
|
full width at the foot. `sh-card-above` keeps it clickable
|
|
through the stretched title link. */}
|
|
<Button
|
|
as={Link}
|
|
to={pathway.href}
|
|
variant="primary"
|
|
size="md"
|
|
iconEnd={<ArrowIcon />}
|
|
fullWidth
|
|
className="sh-card-above mt-auto"
|
|
>
|
|
{pathway.actionLabel}
|
|
</Button>
|
|
</CardShell>
|
|
))}
|
|
</ul>
|
|
|
|
{openCalls.length > 0 ? (
|
|
<p className="body-sm text-[var(--text-secondary)]">
|
|
{copy.opportunities.currentCallsLabel}{" "}
|
|
{openCalls.map((call, index) => (
|
|
<span key={call.slug}>
|
|
{index > 0 ? <span aria-hidden="true"> · </span> : null}
|
|
<Link
|
|
to={`${pathname}/${call.slug}`}
|
|
prefetch="intent"
|
|
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
|
>
|
|
{call.title}
|
|
</Link>
|
|
</span>
|
|
))}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
</PageShell>
|
|
|
|
{/* The draft's second band. It lifts off the #0B0B0A ground with
|
|
#1D1D1B, so the surface is stated rather than inherited, and the
|
|
panel inside it is drawn a step darker again by `.sh-band`. */}
|
|
<section
|
|
id={FORM_ANCHOR}
|
|
aria-labelledby="apply-heading"
|
|
className="sh-band scroll-mt-2xl py-3xl md:py-4xl"
|
|
>
|
|
<Container>
|
|
<div className="grid items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
|
|
{/* THE RAIL - the draft's sticky left column. */}
|
|
<div className="flex flex-col gap-md lg:sticky lg:top-[var(--layout-sticky-top)]">
|
|
<p className="label-md uppercase text-text-accent">
|
|
{interpolate(copy.apply.stepOf, {
|
|
current: currentStep(isSubmitted),
|
|
total: TOTAL_STEPS,
|
|
})}
|
|
</p>
|
|
|
|
<h2 id="apply-heading" className="heading-h2 text-text-primary">
|
|
{routeLabel(shownRoute, copy)}
|
|
</h2>
|
|
|
|
<p className="body-md text-text-secondary">
|
|
{copy.apply.standfirst}
|
|
</p>
|
|
|
|
<StepRail copy={copy} submitted={isSubmitted} />
|
|
</div>
|
|
|
|
{/* THE PANEL - the fields, or the acknowledgement in their place. */}
|
|
<div className="rounded-card border border-border-subtle bg-[var(--surface-card)] p-lg md:p-xl">
|
|
{isSubmitted ? (
|
|
<ReceivedPanel
|
|
copy={copy}
|
|
locale={locale}
|
|
route={shownRoute}
|
|
submitAnotherHref={applyHref(shownRoute)}
|
|
viewSessionsLabel={t("actions.viewUpcomingSessions")}
|
|
/>
|
|
) : (
|
|
<ApplicationForm
|
|
copy={copy}
|
|
result={result}
|
|
route={formRoute}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
</section>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
THE PROGRESS RAIL
|
|
----------------------------------------------------------------------------
|
|
An indicator, NOT a gate: every field of the form is in the same server-
|
|
rendered HTML whichever step is current, so nothing is hidden from a reader
|
|
without JavaScript. The bar is decorative - the state each step is in is
|
|
also spelled out for a screen reader, and the current one carries
|
|
`aria-current="step"`.
|
|
------------------------------------------------------------------------ */
|
|
|
|
function StepRail({
|
|
copy,
|
|
submitted,
|
|
}: {
|
|
copy: RouteCopy;
|
|
submitted: boolean;
|
|
}) {
|
|
const step = currentStep(submitted);
|
|
const labels = [
|
|
copy.apply.stepEligibility,
|
|
copy.apply.stepApplication,
|
|
copy.apply.stepReview,
|
|
];
|
|
|
|
return (
|
|
<ol aria-label={copy.apply.stepsLabel} className="mt-xs flex gap-xs">
|
|
{labels.map((label, index) => {
|
|
const isCurrent = index + 1 === step;
|
|
// The draft fills the bar of every step up to and including the
|
|
// current one, and leaves the rest grey.
|
|
const isReached = index + 1 <= step;
|
|
|
|
return (
|
|
<li
|
|
key={label}
|
|
className="flex flex-1 flex-col gap-xs"
|
|
{...(isCurrent ? { "aria-current": "step" as const } : {})}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`sh-bar-grow h-[4px] w-full rounded-pill ${
|
|
isReached ? "bg-surface-accent" : "bg-border-default"
|
|
}`}
|
|
/>
|
|
<span className="label-md uppercase text-text-tertiary">
|
|
{label}
|
|
{/* The bar is decorative, so the state each step is in is also
|
|
spelled out - one text node, not three, so it is read as one
|
|
phrase rather than three fragments. */}
|
|
{isReached ? (
|
|
<span className="sr-only">
|
|
{` (${isCurrent ? copy.apply.stepCurrent : copy.apply.stepDone})`}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
THE FORM
|
|
------------------------------------------------------------------------ */
|
|
|
|
function ApplicationForm({
|
|
copy,
|
|
result,
|
|
route,
|
|
}: {
|
|
copy: RouteCopy;
|
|
result: ActionResult | undefined;
|
|
route: ApplicationRoute;
|
|
}) {
|
|
const { locale, t } = useTranslation();
|
|
const navigation = useNavigation();
|
|
const isSubmitting = navigation.state === "submitting";
|
|
|
|
const errors = result && !result.ok ? result.errors : {};
|
|
const values = result && !result.ok ? result.values : EMPTY_VALUES;
|
|
const hasFailed = result !== undefined && !result.ok && result.failed;
|
|
const hasFailedVerification =
|
|
result !== undefined && !result.ok && result.verificationFailed;
|
|
|
|
/* This form has no consent checkbox - only the event registration does - so
|
|
the "consent" code cannot occur here; it is narrowed away rather than
|
|
indexed into a table that has no entry for it. */
|
|
const fieldError = (field: PathwayApplicationField) => {
|
|
const code = errors[field];
|
|
if (!code || code === "consent") return undefined;
|
|
return t(FIELD_ERROR_KEYS[code]);
|
|
};
|
|
|
|
const invalidFields = PATHWAY_APPLICATION_FIELDS.filter((field) =>
|
|
Boolean(errors[field]),
|
|
);
|
|
|
|
/* 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 `result`, which is a fresh object
|
|
per submission, so a repeated failure focuses again. */
|
|
const summaryRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!result || result.ok) return;
|
|
summaryRef.current?.focus();
|
|
}, [result]);
|
|
|
|
const routeOptions = APPLICATION_ROUTES.map((value) => ({
|
|
value,
|
|
label: routeLabel(value, copy),
|
|
}));
|
|
|
|
return (
|
|
<Form
|
|
method="post"
|
|
aria-label={copy.apply.formLabel}
|
|
className="flex flex-col gap-lg"
|
|
noValidate
|
|
>
|
|
{invalidFields.length > 0 || hasFailed || hasFailedVerification ? (
|
|
<div
|
|
ref={summaryRef}
|
|
tabIndex={-1}
|
|
role="alert"
|
|
className="flex flex-col gap-xs rounded-card border border-border-danger bg-surface-danger-subtle p-md"
|
|
>
|
|
<p className="body-md-bold text-text-danger">
|
|
{hasFailed
|
|
? t("form.errorGeneric")
|
|
: hasFailedVerification
|
|
? t("form.errorVerification")
|
|
: copy.apply.errorSummaryTitle}
|
|
</p>
|
|
|
|
{hasFailed ? (
|
|
<p className="body-sm text-text-secondary">
|
|
{copy.form.submissionFailed}
|
|
</p>
|
|
) : null}
|
|
|
|
{invalidFields.length > 0 ? (
|
|
/* 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
|
|
still identifies the field when it is read out of context. */
|
|
<ul className="flex list-disc flex-col gap-2xs ps-lg body-sm text-text-secondary">
|
|
{invalidFields.map((field) => (
|
|
<li key={field}>
|
|
<a
|
|
href={`#${fieldDomId(field)}`}
|
|
className="underline underline-offset-2 hover:text-text-primary"
|
|
>
|
|
{fieldLabel(field, copy)}: {fieldError(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={`apply-${HONEYPOT_FIELD}`}>
|
|
{copy.form.honeypotLabel}
|
|
</label>
|
|
<input
|
|
id={`apply-${HONEYPOT_FIELD}`}
|
|
name={HONEYPOT_FIELD}
|
|
type="text"
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
defaultValue=""
|
|
/>
|
|
</div>
|
|
|
|
{/* The fieldset itself stays a plain block and the flex column lives
|
|
inside it: a <legend> is not a reliable flex item across browsers. */}
|
|
<fieldset className="m-0 min-w-0 border-0 p-0">
|
|
<legend className="mb-md label-md uppercase text-text-tertiary">
|
|
{copy.apply.fieldsetLegend}
|
|
</legend>
|
|
|
|
<div className="flex flex-col gap-lg">
|
|
<FormSelect
|
|
id={fieldDomId(ROUTE_FIELD)}
|
|
name={ROUTE_FIELD}
|
|
label={copy.apply.routeLabel}
|
|
options={routeOptions}
|
|
defaultValue={route}
|
|
required
|
|
/>
|
|
|
|
<div className="grid gap-lg sm:grid-cols-2">
|
|
<Input
|
|
id={fieldDomId("organisation")}
|
|
name="organisation"
|
|
label={copy.apply.organisationLabel}
|
|
hint={t("form.optional")}
|
|
placeholder={copy.apply.organisationPlaceholder}
|
|
autoComplete="organization"
|
|
defaultValue={values.organisation}
|
|
className="sm:col-span-2"
|
|
{...(fieldError("organisation")
|
|
? { error: fieldError("organisation") }
|
|
: {})}
|
|
/>
|
|
|
|
<Input
|
|
id={fieldDomId("fullName")}
|
|
name="fullName"
|
|
label={copy.apply.contactLabel}
|
|
placeholder={copy.apply.contactPlaceholder}
|
|
required
|
|
autoComplete="name"
|
|
defaultValue={values.fullName}
|
|
{...(fieldError("fullName")
|
|
? { error: fieldError("fullName") }
|
|
: {})}
|
|
/>
|
|
|
|
<Input
|
|
id={fieldDomId("email")}
|
|
name="email"
|
|
type="email"
|
|
label={copy.apply.emailLabel}
|
|
placeholder={copy.apply.emailPlaceholder}
|
|
required
|
|
autoComplete="email"
|
|
inputMode="email"
|
|
defaultValue={values.email}
|
|
{...(fieldError("email") ? { error: fieldError("email") } : {})}
|
|
/>
|
|
|
|
<Input
|
|
id={fieldDomId("region")}
|
|
name="region"
|
|
label={copy.apply.regionLabel}
|
|
hint={t("form.optional")}
|
|
placeholder={copy.apply.regionPlaceholder}
|
|
defaultValue={values.region}
|
|
{...(fieldError("region") ? { error: fieldError("region") } : {})}
|
|
/>
|
|
|
|
{/* Free text, not a number input: "under a year" and "since 2016"
|
|
are both true answers to the draft's question. */}
|
|
<Input
|
|
id={fieldDomId("yearsOperating")}
|
|
name="yearsOperating"
|
|
label={copy.apply.yearsLabel}
|
|
hint={t("form.optional")}
|
|
placeholder={copy.apply.yearsPlaceholder}
|
|
defaultValue={values.yearsOperating}
|
|
{...(fieldError("yearsOperating")
|
|
? { error: fieldError("yearsOperating") }
|
|
: {})}
|
|
/>
|
|
</div>
|
|
|
|
<Textarea
|
|
id={fieldDomId("summary")}
|
|
name="summary"
|
|
label={copy.apply.summaryLabel}
|
|
hint={copy.apply.summaryHint}
|
|
required
|
|
rows={5}
|
|
defaultValue={values.summary}
|
|
{...(fieldError("summary") ? { error: fieldError("summary") } : {})}
|
|
/>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{/* The draft's dashed attachment panel. It says what this form does with
|
|
documents instead of offering an upload that has nowhere to go - see
|
|
the note on `documentsNote` in ./_events-opportunities/copy.ts. */}
|
|
<div className="flex items-start gap-md rounded-card border border-dashed border-border-default p-md">
|
|
<DocumentsMark />
|
|
<div className="flex flex-col gap-2xs">
|
|
<p className="body-sm-bold text-text-primary">
|
|
{copy.apply.documentsTitle}
|
|
</p>
|
|
<p className="body-sm text-text-secondary">
|
|
{copy.apply.documentsNote}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Turnstile action="get-involved" locale={locale} resetKey={result} />
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-md border-t border-border-subtle pt-lg">
|
|
<p className="body-sm max-w-[26rem] text-text-tertiary">
|
|
{copy.apply.acknowledgement}
|
|
</p>
|
|
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
size="lg"
|
|
isLoading={isSubmitting}
|
|
iconEnd={<ArrowIcon />}
|
|
>
|
|
{isSubmitting ? t("form.sending") : copy.apply.submit}
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
THE ACKNOWLEDGEMENT
|
|
----------------------------------------------------------------------------
|
|
The draft's "Application received" panel, in the place the fields were.
|
|
`role="status"` so it is announced: without it a reader who cannot see the
|
|
page is left on a form that silently emptied itself.
|
|
------------------------------------------------------------------------ */
|
|
|
|
function ReceivedPanel({
|
|
copy,
|
|
locale,
|
|
route,
|
|
submitAnotherHref,
|
|
viewSessionsLabel,
|
|
}: {
|
|
copy: RouteCopy;
|
|
locale: Locale;
|
|
route: ApplicationRoute;
|
|
submitAnotherHref: string;
|
|
viewSessionsLabel: string;
|
|
}) {
|
|
return (
|
|
<div className="sh-rise flex flex-col items-start gap-md" role="status">
|
|
<ReceivedMark />
|
|
|
|
<p className="heading-h3 text-text-primary">{copy.apply.receivedTitle}</p>
|
|
|
|
{/* The draft's "within four weeks" and "reference number" are both
|
|
dropped here - see the head of this file. */}
|
|
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
|
|
{interpolate(copy.apply.receivedBody, {
|
|
route: routeLabel(route, copy),
|
|
})}
|
|
</p>
|
|
|
|
<div className="flex flex-wrap gap-sm">
|
|
<Button as={Link} to={submitAnotherHref} variant="secondary">
|
|
{copy.apply.submitAnother}
|
|
</Button>
|
|
<Button
|
|
as={Link}
|
|
to={sectionPath("events", locale)}
|
|
variant="primary"
|
|
iconEnd={<ArrowIcon />}
|
|
>
|
|
{viewSessionsLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|