/* ============================================================================ 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
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 ( ); } /** The draft's upload glyph over the documents note. */ function DocumentsMark() { return ( ); } /* --------------------------------------------------------------------------- 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 ( <>
{/* The draft opens straight onto the three cards, so the heading is kept for the document outline and not drawn. */}

{copy.opportunities.pathwaysHeading}

    {pathways.map((pathway) => ( {pathway.status ? ( {pathway.status === "open" ? ( ) : null}

    {pathway.title}

    {pathway.body}

    {pathway.dateLabel ? (

    ) : 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. */}
    ))}
{openCalls.length > 0 ? (

{copy.opportunities.currentCallsLabel}{" "} {openCalls.map((call, index) => ( {index > 0 ? : null} {call.title} ))}

) : null}
{/* 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`. */}
{/* THE RAIL - the draft's sticky left column. */}

{interpolate(copy.apply.stepOf, { current: currentStep(isSubmitted), total: TOTAL_STEPS, })}

{routeLabel(shownRoute, copy)}

{copy.apply.standfirst}

{/* THE PANEL - the fields, or the acknowledgement in their place. */}
{isSubmitted ? ( ) : ( )}
); } /* --------------------------------------------------------------------------- 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 (
    {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 (
  1. ); })}
); } /* --------------------------------------------------------------------------- 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(null); useEffect(() => { if (!result || result.ok) return; summaryRef.current?.focus(); }, [result]); const routeOptions = APPLICATION_ROUTES.map((value) => ({ value, label: routeLabel(value, copy), })); return ( {invalidFields.length > 0 || hasFailed || hasFailedVerification ? (

{hasFailed ? t("form.errorGeneric") : hasFailedVerification ? t("form.errorVerification") : copy.apply.errorSummaryTitle}

{hasFailed ? (

{copy.form.submissionFailed}

) : 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. */ ) : null}
) : null} {/* Honeypot - off-screen, out of the tab order and out of the accessibility tree, so only an automated client fills it. */} {/* The fieldset itself stays a plain block and the flex column lives inside it: a is not a reliable flex item across browsers. */}
{copy.apply.fieldsetLegend}
{/* Free text, not a number input: "under a year" and "since 2016" are both true answers to the draft's question. */}