Files

557 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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<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}
<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>
<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>
);
}