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>
600 lines
19 KiB
TypeScript
600 lines
19 KiB
TypeScript
/* ============================================================================
|
|
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 { Turnstile } from "~/components/ui/Turnstile";
|
|
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 { verifyTurnstile } from "~/lib/turnstile.server";
|
|
|
|
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;
|
|
/** 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 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,
|
|
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 submitApplication({
|
|
values,
|
|
opportunityId: opportunity.documentId,
|
|
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
|
});
|
|
} 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,
|
|
verificationFailed: false,
|
|
} 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<ReturnType<typeof loader>> | 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<typeof loader>();
|
|
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: (
|
|
<time dateTime={toIsoDate(opportunity.openDate)}>
|
|
{formatOpportunityDate(opportunity.openDate, locale)}
|
|
</time>
|
|
),
|
|
},
|
|
]
|
|
: []),
|
|
...(opportunity.deadline
|
|
? [
|
|
{
|
|
label: copy.opportunityDetail.deadlineLabel,
|
|
value: (
|
|
<time dateTime={toIsoDate(opportunity.deadline)}>
|
|
{formatOpportunityDate(opportunity.deadline, locale)}
|
|
</time>
|
|
),
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
|
|
return (
|
|
<PageShell>
|
|
<BackLink to={listPath} label={copy.opportunityDetail.backToList} />
|
|
|
|
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
|
|
<BadgeRow>
|
|
<StatusBadge tone={isOpen ? "accent" : "neutral"}>
|
|
{t(opportunityStatusKey(opportunity.status))}
|
|
</StatusBadge>
|
|
{opportunity.opportunityType ? (
|
|
<StatusBadge>
|
|
{copy.opportunityTypes[opportunity.opportunityType]}
|
|
</StatusBadge>
|
|
) : null}
|
|
</BadgeRow>
|
|
|
|
<h1 className="display-sm text-text-primary">{opportunity.title}</h1>
|
|
|
|
{opportunity.summary ? (
|
|
<p className="body-lg text-text-secondary">{opportunity.summary}</p>
|
|
) : null}
|
|
</header>
|
|
|
|
<div className="flex flex-col gap-2xl lg:flex-row lg:items-start">
|
|
<div className="flex min-w-0 flex-1 flex-col gap-lg">
|
|
{opportunity.description ? (
|
|
<section
|
|
aria-labelledby="opportunity-about"
|
|
className="flex flex-col gap-md"
|
|
>
|
|
<h2
|
|
id="opportunity-about"
|
|
className="heading-h2 text-text-primary"
|
|
>
|
|
{copy.opportunityDetail.aboutHeading}
|
|
</h2>
|
|
<RichText value={opportunity.description} />
|
|
</section>
|
|
) : null}
|
|
|
|
{opportunity.eligibility ? (
|
|
<section
|
|
aria-labelledby="opportunity-eligibility"
|
|
className="flex flex-col gap-md"
|
|
>
|
|
<h2
|
|
id="opportunity-eligibility"
|
|
className="heading-h3 text-text-primary"
|
|
>
|
|
{t("opportunities.eligibility")}
|
|
</h2>
|
|
<RichText value={opportunity.eligibility} />
|
|
</section>
|
|
) : null}
|
|
|
|
{opportunity.howToApply ? (
|
|
<section
|
|
aria-labelledby="opportunity-how"
|
|
className="flex flex-col gap-md"
|
|
>
|
|
<h2 id="opportunity-how" className="heading-h3 text-text-primary">
|
|
{copy.opportunityDetail.howToApplyHeading}
|
|
</h2>
|
|
<RichText value={opportunity.howToApply} />
|
|
</section>
|
|
) : null}
|
|
|
|
<ApplicationSection
|
|
detailPath={detailPath}
|
|
opportunity={opportunity}
|
|
result={result}
|
|
isSubmitting={isSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<aside className="flex w-full flex-col gap-lg lg:w-[22rem] lg:shrink-0">
|
|
<DetailList
|
|
heading={copy.opportunityDetail.detailsHeading}
|
|
items={details}
|
|
/>
|
|
|
|
{opportunity.attachments.length > 0 ? (
|
|
<section
|
|
aria-labelledby="opportunity-documents"
|
|
className="flex flex-col gap-md rounded-card border border-border-subtle bg-surface-raised p-lg"
|
|
>
|
|
<h2
|
|
id="opportunity-documents"
|
|
className="label-md uppercase text-text-tertiary"
|
|
>
|
|
{copy.opportunityDetail.attachmentsHeading}
|
|
</h2>
|
|
<ul className="flex flex-col gap-md">
|
|
{opportunity.attachments.map((file) => (
|
|
<li key={file.url} className="flex flex-col items-start gap-2xs">
|
|
{/* Media URLs come from the CMS too; an unusable one keeps
|
|
the file's name on the page as plain text. */}
|
|
{safeHref(file.url) ? (
|
|
<Button
|
|
as="a"
|
|
href={safeHref(file.url)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
{file.name}
|
|
</Button>
|
|
) : (
|
|
<span className="body-sm-bold text-text-primary">
|
|
{file.name}
|
|
</span>
|
|
)}
|
|
<p className="body-sm text-text-tertiary">
|
|
{formatAttachmentMeta(file, locale)}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
</aside>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
APPLICATION SECTION
|
|
Closed, not open yet, handled elsewhere, by email, or the form itself.
|
|
------------------------------------------------------------------------ */
|
|
|
|
function ApplicationSection({
|
|
detailPath,
|
|
opportunity,
|
|
result,
|
|
isSubmitting,
|
|
}: {
|
|
detailPath: string;
|
|
opportunity: Awaited<ReturnType<typeof loader>>["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 (
|
|
<Notice tone="accent" role="status" title={t("form.successTitle")}>
|
|
{copy.opportunityDetail.applicationReceivedBody}
|
|
</Notice>
|
|
);
|
|
}
|
|
|
|
if (opportunity.status === "upcoming") {
|
|
return (
|
|
<Notice
|
|
tone="info"
|
|
title={
|
|
opportunity.openDate
|
|
? interpolate(copy.opportunityDetail.upcomingNote, {
|
|
date: formatOpportunityDate(opportunity.openDate, locale),
|
|
})
|
|
: t("filters.upcoming")
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (opportunity.status === "closed") {
|
|
return <Notice tone="info" title={copy.opportunityDetail.closedNote} />;
|
|
}
|
|
|
|
if (opportunity.externalApplicationUrl) {
|
|
return (
|
|
<Notice tone="info" title={copy.opportunityDetail.externalApplication}>
|
|
{/* `safeHref` gates the CMS URL; without a usable one the notice still
|
|
tells the reader that applications are handled elsewhere. */}
|
|
{safeHref(opportunity.externalApplicationUrl) ? (
|
|
<Button
|
|
as="a"
|
|
href={safeHref(opportunity.externalApplicationUrl)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
variant="primary"
|
|
iconEnd={<ArrowIcon />}
|
|
>
|
|
{t("actions.apply")}
|
|
</Button>
|
|
) : null}
|
|
</Notice>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section
|
|
id="apply"
|
|
aria-labelledby="apply-heading"
|
|
className="flex flex-col gap-lg rounded-card border border-border-subtle bg-surface-raised p-lg"
|
|
>
|
|
<div className="flex flex-col gap-xs">
|
|
<h2 id="apply-heading" className="heading-h3 text-text-primary">
|
|
{copy.opportunityDetail.applyHeading}
|
|
</h2>
|
|
<p className="body-sm text-text-secondary">
|
|
{copy.opportunityDetail.formIntro}
|
|
</p>
|
|
</div>
|
|
|
|
{result && !result.ok && result.failed ? (
|
|
<Notice tone="danger" role="alert" title={t("form.errorGeneric")}>
|
|
{copy.form.submissionFailed}
|
|
</Notice>
|
|
) : null}
|
|
|
|
{result && !result.ok && result.verificationFailed ? (
|
|
<Notice tone="danger" role="alert" title={t("form.errorVerification")} />
|
|
) : null}
|
|
|
|
<Form method="post" action={detailPath} className="flex flex-col gap-md">
|
|
<div className="grid gap-md sm:grid-cols-2">
|
|
<Input
|
|
name="fullName"
|
|
label={t("form.name")}
|
|
required
|
|
autoComplete="name"
|
|
defaultValue={values?.fullName}
|
|
error={fieldError("fullName")}
|
|
/>
|
|
<Input
|
|
name="email"
|
|
type="email"
|
|
label={t("form.email")}
|
|
required
|
|
autoComplete="email"
|
|
inputMode="email"
|
|
defaultValue={values?.email}
|
|
error={fieldError("email")}
|
|
/>
|
|
<Input
|
|
name="organisation"
|
|
label={t("form.organisation")}
|
|
hint={t("form.optional")}
|
|
autoComplete="organization"
|
|
defaultValue={values?.organisation}
|
|
className="sm:col-span-2"
|
|
/>
|
|
</div>
|
|
|
|
<Textarea
|
|
name="message"
|
|
label={t("form.message")}
|
|
hint={copy.opportunityDetail.messageHint}
|
|
required
|
|
rows={6}
|
|
defaultValue={values?.message}
|
|
error={fieldError("message")}
|
|
/>
|
|
|
|
{/* Honeypot. Hidden from sight and from assistive technology alike. */}
|
|
<div aria-hidden="true" className="hidden">
|
|
<label htmlFor={HONEYPOT_FIELD}>{copy.form.honeypotLabel}</label>
|
|
<input
|
|
id={HONEYPOT_FIELD}
|
|
name={HONEYPOT_FIELD}
|
|
type="text"
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
|
|
<Turnstile
|
|
action="opportunity-application"
|
|
locale={locale}
|
|
resetKey={result}
|
|
/>
|
|
|
|
<div className="flex flex-wrap items-center gap-sm">
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
isLoading={isSubmitting}
|
|
iconEnd={<ArrowIcon />}
|
|
>
|
|
{t("actions.apply")}
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</section>
|
|
);
|
|
}
|