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

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

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

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

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

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

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

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

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

958 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 modelled (`application.acknowledgementSentAt`) and survives; 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 });
} 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>
);
}