Files
sharper.ly/apps/web/app/routes/events-calendar.tsx
T

254 lines
8.0 KiB
TypeScript
Raw Normal View History

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