Files
sharper.ly/apps/web/app/routes/applications-and-opportunities.$slug.tsx
T
IllusionandClaude Opus 5.5 709a978da5 feat(web): add Cloudflare Turnstile to the public forms
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>
2026-10-24 12:05:51 +02:00

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>
);
}