/* ============================================================================ 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 ( {available ? null : ( {copy.events.unavailable} )}
{regions.length > 0 ? ( ) : null}
{events.length > 0 ? ( <> ) : hasEvents && isFiltered ? ( ) : ( )}
); }