/* ============================================================================ APPLICATIONS AND OPPORTUNITIES - one call ---------------------------------------------------------------------------- /{locale}/applications-and-opportunities/{slug} Draft section 8: the detail page carries the application form and the acknowledgement ("Thank you for applying to join the SHARP network. Our team will review your application and follow up within [X weeks]."). That "[X weeks]" is marked TBD in the draft and the CMS has no field for it, so the number is NEVER named: the acknowledgement says the team will follow up by email, and nothing more. ROUTE Registered in app/routes.ts as "applicationsDetail", a SIBLING of the listing route under the ":lang" layout, and typed against its generated `./+types/` module. ========================================================================= */ import { data, Form, useActionData, useLoaderData, useNavigation, } from "react-router"; import { ArrowIcon, Button } from "~/components/ui/Button"; import { Input, Textarea } from "~/components/ui/Input"; import { interpolate, localizedPath, sectionPath, toIsoDate, translate, useTranslation, } from "~/i18n"; import { safeHref } from "~/lib/safe-href"; import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import { isStrapiError, requireEntity } from "~/lib/strapi"; import type { Route } from "./+types/applications-and-opportunities.$slug"; import { getRouteCopy } from "./_events-opportunities/copy"; import { FIELD_ERROR_KEYS, HONEYPOT_FIELD, isHoneypotFilled, validateApplication, type ApplicationValues, type FieldErrors, } from "./_events-opportunities/forms"; import { formatAttachmentMeta, formatOpportunityDate, opportunityStatusKey, } from "./_events-opportunities/opportunities"; import { fetchOpportunity } from "./_events-opportunities/opportunities.server"; import { readRootSeo } from "./_events-opportunities/root-seo"; import { submitApplication } from "./_events-opportunities/submissions.server"; import { BadgeRow, DetailList, Notice, StatusBadge, type DetailItem, } from "./_events-opportunities/ui"; import { detailAlternateDescriptors, SELF_ALTERNATES_HANDLE, } from "./_shared/alternates"; import { requireLocale } from "./_shared/listing"; import { BackLink, PageShell } from "./_shared/listing-ui"; import { RichText } from "./_shared/RichText"; /** This route emits its own canonical + hreflang - see ./_shared/alternates.ts. */ export const handle = SELF_ALTERNATES_HANDLE; export async function loader({ params }: Route.LoaderArgs) { const locale = requireLocale(params.lang); const slug = params.slug ?? ""; const { opportunity, alternates } = requireEntity( await fetchOpportunity(slug, locale), ); return { locale, opportunity, alternates }; } /* --------------------------------------------------------------------------- APPLICATION ------------------------------------------------------------------------ */ type ActionResult = | { readonly ok: true } | { readonly ok: false; readonly errors: FieldErrors; readonly values: ApplicationValues; /** 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 slug = params.slug ?? ""; const { opportunity } = requireEntity(await fetchOpportunity(slug, locale)); const form = await request.formData(); /* A closed call, or one that is handled elsewhere, has no form on the page and must not accept a POST either. */ if (opportunity.status !== "open" || opportunity.externalApplicationUrl) { throw data("This call is not accepting applications", { status: 409 }); } if (isHoneypotFilled(form)) { return { ok: true } satisfies ActionResult; } const { values, errors, valid } = validateApplication(form); if (!valid) { return data( { ok: false, errors, values, failed: false } satisfies ActionResult, { status: 400 }, ); } try { await submitApplication({ values, opportunityId: opportunity.documentId }); } catch (error) { if (!isStrapiError(error)) throw error; console.error( `[opportunities] application for "${slug}" was rejected: ${error.status} ${error.message}`, ); return data( { ok: false, errors: {}, values, failed: true } satisfies ActionResult, { status: 502 }, ); } return { ok: true } satisfies ActionResult; } /* --------------------------------------------------------------------------- META ------------------------------------------------------------------------ */ export function meta({ matches, loaderData }: Route.MetaArgs) { const root = readRootSeo(matches); const result = loaderData as Awaited> | undefined; const copy = getRouteCopy(root.locale); if (!result) { return buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: copy.opportunities.heading, description: copy.opportunities.metaDescription, }); } const { opportunity } = result; const deadline = opportunity.deadline ? translate(root.locale, "opportunities.deadline", { date: formatOpportunityDate(opportunity.deadline, root.locale), }) : undefined; const descriptors = buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: opportunity.title, description: opportunity.summary ?? deadline ?? copy.opportunities.metaDescription, ...(opportunity.image ? { image: { url: opportunity.image.src, alt: opportunity.image.alt } } : {}), // A closed call stays online for reference, but it should not keep // attracting search traffic for an application nobody can make. noIndex: opportunity.status === "closed", 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), }, { name: opportunity.title, pathname: root.pathname }, ]), }); return [ ...descriptors, ...detailAlternateDescriptors({ siteUrl: root.siteUrl, section: "applications", locale: root.locale, slugs: result.alternates, }), ]; } /* --------------------------------------------------------------------------- PAGE ------------------------------------------------------------------------ */ export default function OpportunityDetail() { const { opportunity } = useLoaderData(); const result = useActionData() as ActionResult | undefined; const navigation = useNavigation(); const { locale, t } = useTranslation(); const copy = getRouteCopy(locale); const listPath = sectionPath("applications", locale); const detailPath = `${listPath}/${encodeURIComponent(opportunity.slug)}`; const isSubmitting = navigation.state === "submitting"; const isOpen = opportunity.status === "open"; const details: DetailItem[] = [ { label: copy.opportunityDetail.statusLabel, value: t(opportunityStatusKey(opportunity.status)), }, ...(opportunity.opportunityType ? [ { label: copy.opportunityDetail.typeLabel, value: copy.opportunityTypes[opportunity.opportunityType], }, ] : []), ...(opportunity.openDate ? [ { label: copy.opportunityDetail.opensLabel, value: ( ), }, ] : []), ...(opportunity.deadline ? [ { label: copy.opportunityDetail.deadlineLabel, value: ( ), }, ] : []), ]; return (
{t(opportunityStatusKey(opportunity.status))} {opportunity.opportunityType ? ( {copy.opportunityTypes[opportunity.opportunityType]} ) : null}

{opportunity.title}

{opportunity.summary ? (

{opportunity.summary}

) : null}
{opportunity.description ? (

{copy.opportunityDetail.aboutHeading}

) : null} {opportunity.eligibility ? (

{t("opportunities.eligibility")}

) : null} {opportunity.howToApply ? (

{copy.opportunityDetail.howToApplyHeading}

) : null}
); } /* --------------------------------------------------------------------------- APPLICATION SECTION Closed, not open yet, handled elsewhere, by email, or the form itself. ------------------------------------------------------------------------ */ function ApplicationSection({ detailPath, opportunity, result, isSubmitting, }: { detailPath: string; opportunity: Awaited>["opportunity"]; result: ActionResult | undefined; isSubmitting: boolean; }) { const { locale, t } = useTranslation(); const copy = getRouteCopy(locale); const errors = result && !result.ok ? result.errors : {}; const values = result && !result.ok ? result.values : undefined; /* 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 = (name: string) => { const code = errors[name]; if (!code || code === "consent") return undefined; return t(FIELD_ERROR_KEYS[code]); }; if (result?.ok) { /* The draft's "[X weeks]" is TBD and the CMS models no response time, so the acknowledgement never names one. */ return ( {copy.opportunityDetail.applicationReceivedBody} ); } if (opportunity.status === "upcoming") { return ( ); } if (opportunity.status === "closed") { return ; } if (opportunity.externalApplicationUrl) { return ( {/* `safeHref` gates the CMS URL; without a usable one the notice still tells the reader that applications are handled elsewhere. */} {safeHref(opportunity.externalApplicationUrl) ? ( ) : null} ); } return (

{copy.opportunityDetail.applyHeading}

{copy.opportunityDetail.formIntro}

{result && !result.ok && result.failed ? ( {copy.form.submissionFailed} ) : null}