960 lines
33 KiB
TypeScript
960 lines
33 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.
|
|
|
|
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 {
|
|
interpolate,
|
|
localizedPath,
|
|
sectionPath,
|
|
toIsoDate,
|
|
translate,
|
|
useTranslation,
|
|
type Locale,
|
|
} from "~/i18n";
|
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
|
import { isStrapiError } from "~/lib/strapi";
|
|
|
|
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;
|
|
};
|
|
|
|
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 } satisfies ActionResult,
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
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 } 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 { 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;
|
|
|
|
/* 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 ? (
|
|
<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") : 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>
|
|
|
|
<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>
|
|
);
|
|
}
|