Files
sharper.ly/apps/web/app/routes/applications-and-opportunities.tsx
T
2026-09-11 02:46:56 +02:00

272 lines
8.3 KiB
TypeScript

/* ============================================================================
APPLICATIONS AND OPPORTUNITIES - /{locale}/applications-and-opportunities
----------------------------------------------------------------------------
Draft section 8 ("Get Involved"). Two halves:
1. 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;
2. the current calls, which come from the CMS and carry a status tag of
Open / Upcoming / Closed.
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. Nothing here
invents a criterion, a deadline or a figure.
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 calls list shows its localised
"nothing open yet" panel.
========================================================================= */
import { Link, useSearchParams } from "react-router";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import {
localizedPath,
sectionPath,
translate,
useTranslation,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/applications-and-opportunities";
import { getRouteCopy } from "./_events-opportunities/copy";
import {
filterOpportunities,
findNetworkOpportunity,
OPPORTUNITY_STATUS_FILTERS,
opportunityStatusKey,
sortOpportunities,
} from "./_events-opportunities/opportunities";
import { fetchOpportunities } from "./_events-opportunities/opportunities.server";
import { readRootSeo } from "./_events-opportunities/root-seo";
import { Notice, OpportunityCard } from "./_events-opportunities/ui";
import {
buildPagination,
PAGE_SIZE,
parseEnum,
parsePage,
requireLocale,
withParam,
} from "./_shared/listing";
import {
EmptyState,
PageHeader,
PageShell,
Pagination,
ResultSummary,
TabLinks,
} from "./_shared/listing-ui";
/** Anchor of the calls list - the "See current calls" card jumps to it. */
const LIST_ANCHOR = "current-calls";
export async function loader({ params, request }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const url = new URL(request.url);
const now = new Date();
const status = parseEnum(
url.searchParams,
"status",
OPPORTUNITY_STATUS_FILTERS,
"all",
);
const { opportunities, available } = await fetchOpportunities(locale, now);
const matching = sortOpportunities(
filterOpportunities(opportunities, status),
);
const pagination = buildPagination(
parsePage(url.searchParams),
PAGE_SIZE,
matching.length,
);
const start = (pagination.page - 1) * PAGE_SIZE;
/* "Join the SHARP Network" points at the network entry when the CMS has one
and at Contact when it does not - never at a URL that does not exist. */
const network = findNetworkOpportunity(opportunities);
return {
locale,
opportunities: matching.slice(start, start + PAGE_SIZE),
pagination,
status,
available,
hasOpportunities: opportunities.length > 0,
networkSlug: network?.slug ?? null,
};
}
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,
noIndex: search.has("status") || search.has("page"),
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),
},
]),
});
}
export default function ApplicationsAndOpportunities({
loaderData,
}: Route.ComponentProps) {
const {
opportunities,
pagination,
status,
available,
hasOpportunities,
networkSlug,
} = loaderData;
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const [searchParams] = useSearchParams();
const pathname = sectionPath("applications", locale);
const href = (value: string | undefined) =>
`${pathname}${withParam(searchParams, "status", value)}`;
const tabs = OPPORTUNITY_STATUS_FILTERS.map((value) => ({
id: value,
label: value === "all" ? t("filters.all") : t(opportunityStatusKey(value)),
href: href(value === "all" ? undefined : value),
isCurrent: status === value,
}));
const pathways = [
{
id: "calls",
title: copy.opportunities.pathwayCallsTitle,
excerpt: copy.opportunities.pathwayCallsBody,
href: `#${LIST_ANCHOR}`,
actionLabel: t("actions.seeCurrentCalls"),
},
{
id: "network",
title: copy.opportunities.pathwayNetworkTitle,
excerpt: copy.opportunities.pathwayNetworkBody,
href: networkSlug
? `${pathname}/${encodeURIComponent(networkSlug)}`
: sectionPath("contact", locale),
actionLabel: t("actions.applyToJoin"),
},
{
id: "sessions",
title: copy.opportunities.pathwaySessionsTitle,
excerpt: copy.opportunities.pathwaySessionsBody,
href: sectionPath("events", locale),
actionLabel: t("actions.viewUpcomingSessions"),
},
];
return (
<PageShell>
<PageHeader
heading={copy.opportunities.heading}
intro={copy.opportunities.intro}
/>
<section aria-labelledby="pathways" className="flex flex-col gap-lg">
<h2 id="pathways" className="heading-h2 text-text-primary">
{copy.opportunities.pathwaysHeading}
</h2>
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{pathways.map((pathway) => (
<Card.Content
key={pathway.id}
as="li"
href={pathway.href}
title={pathway.title}
excerpt={pathway.excerpt}
actionLabel={pathway.actionLabel}
/>
))}
</ul>
</section>
<section
id={LIST_ANCHOR}
aria-labelledby="current-calls-heading"
className="flex flex-col gap-lg scroll-mt-2xl"
>
<h2 id="current-calls-heading" className="heading-h2 text-text-primary">
{copy.opportunities.listHeading}
</h2>
{available ? null : (
<Notice tone="info" role="status">
{copy.opportunities.unavailable}
</Notice>
)}
<TabLinks label={copy.opportunities.statusFilterLabel} tabs={tabs} />
<ResultSummary pagination={pagination} t={t} />
{opportunities.length > 0 ? (
<>
<ul
aria-label={copy.opportunities.listLabel}
className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3"
>
{opportunities.map((opportunity) => (
<OpportunityCard
key={opportunity.slug}
as="li"
opportunity={opportunity}
href={`${pathname}/${encodeURIComponent(opportunity.slug)}`}
/>
))}
</ul>
<Pagination
model={pagination}
pathname={pathname}
params={searchParams}
t={t}
/>
</>
) : hasOpportunities && status !== "all" ? (
<EmptyState
title={t("filters.noResults")}
body={t("filters.noResultsHint")}
>
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
</EmptyState>
) : (
<EmptyState
title={copy.opportunities.empty}
body={copy.opportunities.emptyHint}
/>
)}
</section>
</PageShell>
);
}