initial commit
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
/* ============================================================================
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user