initial commit
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
/* ============================================================================
|
||||
EVENTS CALENDAR - /{locale}/events-calendar
|
||||
----------------------------------------------------------------------------
|
||||
Draft section 4. The listing the draft describes:
|
||||
|
||||
filter tabs All - Upcoming - Ongoing - Completed, plus a region filter
|
||||
card fields title - date and time - format/location - registration
|
||||
status - call to action (Register / Apply / Read recap)
|
||||
|
||||
Every filter is a real URL (?status=®ion=&page=), so a filtered calendar
|
||||
is shareable, survives the back button and is reachable by a crawler that
|
||||
only follows links. There is no client-side filtering to fall out of step
|
||||
with the server, and the page works with scripting off.
|
||||
|
||||
Upcoming / Ongoing / Completed are DERIVED from the dates rather than stored
|
||||
(see ./_events-opportunities/events.ts), so nothing goes stale the morning
|
||||
after an event.
|
||||
|
||||
EMPTY BY DESIGN: the CMS is empty today, so the state that actually renders
|
||||
is the localised "no events yet" panel - and if Strapi cannot be reached at
|
||||
all, the page still renders, with a notice, instead of a 500.
|
||||
========================================================================= */
|
||||
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import {
|
||||
localizedPath,
|
||||
sectionPath,
|
||||
translate,
|
||||
useTranslation,
|
||||
} from "~/i18n";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
import type { Route } from "./+types/events-calendar";
|
||||
import {
|
||||
collectRegions,
|
||||
EVENT_STATUS_FILTERS,
|
||||
eventTimeStatusKey,
|
||||
filterEvents,
|
||||
sortEvents,
|
||||
} from "./_events-opportunities/events";
|
||||
import { fetchEvents } from "./_events-opportunities/events.server";
|
||||
import { getRouteCopy } from "./_events-opportunities/copy";
|
||||
import { readRootSeo } from "./_events-opportunities/root-seo";
|
||||
import { EventCard, Notice } 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";
|
||||
|
||||
/** Longest region value we will echo back into a filter. */
|
||||
const MAX_REGION_LENGTH = 80;
|
||||
|
||||
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",
|
||||
EVENT_STATUS_FILTERS,
|
||||
"all",
|
||||
);
|
||||
|
||||
const { events, available } = await fetchEvents(locale, now);
|
||||
|
||||
/* Region options come from the events themselves, never from a hard-coded
|
||||
list: an empty CMS offers no regions, and a region only appears once
|
||||
something is actually happening in it. A requested region that no event
|
||||
carries is dropped rather than rendering an empty page. */
|
||||
const regions = collectRegions(events, locale);
|
||||
const requestedRegion = url.searchParams
|
||||
.get("region")
|
||||
?.trim()
|
||||
.slice(0, MAX_REGION_LENGTH);
|
||||
const region =
|
||||
requestedRegion && regions.includes(requestedRegion) ? requestedRegion : null;
|
||||
|
||||
const matching = sortEvents(filterEvents(events, { status, region }));
|
||||
const pagination = buildPagination(
|
||||
parsePage(url.searchParams),
|
||||
PAGE_SIZE,
|
||||
matching.length,
|
||||
);
|
||||
const start = (pagination.page - 1) * PAGE_SIZE;
|
||||
|
||||
return {
|
||||
locale,
|
||||
events: matching.slice(start, start + PAGE_SIZE),
|
||||
pagination,
|
||||
status,
|
||||
region,
|
||||
regions,
|
||||
available,
|
||||
/** Distinguishes "the CMS is empty" from "these filters match nothing". */
|
||||
hasEvents: events.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
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.events"),
|
||||
description: copy.events.metaDescription,
|
||||
// A filtered or paginated view is the same content in another order.
|
||||
noIndex:
|
||||
search.has("status") || search.has("region") || search.has("page"),
|
||||
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
||||
{
|
||||
name: translate(root.locale, "nav.sections.home"),
|
||||
pathname: localizedPath("/", root.locale),
|
||||
},
|
||||
{
|
||||
name: translate(root.locale, "nav.sections.events"),
|
||||
pathname: sectionPath("events", root.locale),
|
||||
},
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
export default function EventsCalendar({ loaderData }: Route.ComponentProps) {
|
||||
const { events, pagination, status, region, regions, available, hasEvents } =
|
||||
loaderData;
|
||||
const { locale, t } = useTranslation();
|
||||
const copy = getRouteCopy(locale);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const pathname = sectionPath("events", locale);
|
||||
const href = (key: string, value: string | undefined) =>
|
||||
`${pathname}${withParam(searchParams, key, value)}`;
|
||||
|
||||
const tabs = EVENT_STATUS_FILTERS.map((value) => ({
|
||||
id: value,
|
||||
label: value === "all" ? t("filters.all") : t(eventTimeStatusKey(value)),
|
||||
href: href("status", value === "all" ? undefined : value),
|
||||
isCurrent: status === value,
|
||||
}));
|
||||
|
||||
const isFiltered = status !== "all" || region !== null;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader heading={copy.events.heading} intro={copy.events.intro} />
|
||||
|
||||
{available ? null : (
|
||||
<Notice tone="info" role="status">
|
||||
{copy.events.unavailable}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-lg">
|
||||
<TabLinks label={copy.events.statusFilterLabel} tabs={tabs} />
|
||||
|
||||
{regions.length > 0 ? (
|
||||
<nav aria-label={t("filters.region")} className="flex flex-col gap-xs">
|
||||
<p className="label-md uppercase text-text-tertiary">
|
||||
{t("filters.region")}
|
||||
</p>
|
||||
<ul className="flex flex-wrap items-center gap-xs">
|
||||
<li>
|
||||
<Button
|
||||
as={Link}
|
||||
to={href("region", undefined)}
|
||||
prefetch="intent"
|
||||
variant={region === null ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-current={region === null ? "true" : undefined}
|
||||
>
|
||||
{t("filters.all")}
|
||||
</Button>
|
||||
</li>
|
||||
{regions.map((value) => (
|
||||
<li key={value}>
|
||||
<Button
|
||||
as={Link}
|
||||
to={href("region", value)}
|
||||
prefetch="intent"
|
||||
variant={region === value ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-current={region === value ? "true" : undefined}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
</div>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<>
|
||||
<ul
|
||||
aria-label={copy.events.listLabel}
|
||||
className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{events.map((event) => (
|
||||
<EventCard
|
||||
key={event.slug}
|
||||
as="li"
|
||||
event={event}
|
||||
href={`${pathname}/${encodeURIComponent(event.slug)}`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
model={pagination}
|
||||
pathname={pathname}
|
||||
params={searchParams}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
) : hasEvents && isFiltered ? (
|
||||
<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={t("events.noEvents")}
|
||||
body={copy.events.emptyHint}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user