/* ============================================================================
EVENTS CALENDAR - /{locale}/events-calendar
----------------------------------------------------------------------------
Draft section 4, laid out to the 2026 redesign draft ("Events-Calendar"):
filter row All · Upcoming · Ongoing · Completed as pills, a region
select beside them, and a List / Month view switch
list view one wide row per event: a date chip, the title, where and
when, the registration state, and the call to action
month view a seven-column grid with the events of one month in it
Every control is a real URL (?status=®ion=&view=&month=&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.
NOT ONE DATE ON THIS PAGE IS WRITTEN HERE. The draft's five sample events -
the Tripoli SUQ dialogue on 4 March, the Benghazi network meeting, "March
2026" - were mockup content. The grid and the list are built from Strapi
entries; with an empty CMS the localised empty state renders instead, and if
Strapi cannot be reached at all the page still renders, with a notice,
rather than a 500.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
* the "You're registered." confirmation panel - registration happens on
the event's own page, which already renders that approved confirmation;
putting it on the listing would mean faking a submission;
* "Add to calendar · iCal / Google" on the month header - the .ics route
is per event (events-calendar.$slug.ics.ts) and there is no whole-
calendar feed to offer, so the action lives on each event instead;
* the event-type kicker ("SUQ Dialogue", "Training"): `event.eventType`
exists in Strapi but is not carried on the card view model, which lives
in a module this change does not own.
========================================================================= */
import { Link, useSearchParams } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { CardLink, CardShell } from "~/components/ui/Card";
import {
intlLocale,
localizedPath,
sectionPath,
toIsoDate,
translate,
useTranslation,
type Locale,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/events-calendar";
import {
collectRegions,
eventActionKey,
eventRegistrationLabel,
eventTimeStatusKey,
EVENT_STATUS_FILTERS,
EVENT_TIME_ZONE,
filterEvents,
formatEventWhen,
sortEvents,
type EventCardData,
} 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 { Notice } from "./_events-opportunities/ui";
import {
buildCalendarMonth,
calendarDayIn,
monthKeyOf,
parseMonthKey,
weekdayLabel,
weekStartFor,
type CalendarDay,
type CalendarMonth,
} from "./_shared/calendar";
import {
buildPagination,
PAGE_SIZE,
parseEnum,
parsePage,
requireLocale,
withParam,
} from "./_shared/listing";
import {
EmptyState,
FilterPills,
ListingHero,
MetaLine,
PageShell,
Pagination,
PillSelect,
ResultSummary,
} from "./_shared/listing-ui";
import { getListingChrome } from "./_shared/page-copy";
import "./_page-kit/motion.css";
/** Longest region value we will echo back into a filter. */
const MAX_REGION_LENGTH = 80;
/** The two ways the calendar can be read. */
const VIEWS = ["list", "month"] as const;
type CalendarView = (typeof VIEWS)[number];
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 view = parseEnum(url.searchParams, "view", VIEWS, "list");
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;
/* Which month the grid opens on: the one the reader asked for, else the
month of the next event in the filtered set, else the current month.
Derived, never a literal - the draft's "March 2026" was sample content. */
const requestedMonth = parseMonthKey(url.searchParams.get("month"));
const firstDay = matching[0]
? calendarDayIn(matching[0].start, EVENT_TIME_ZONE)
: undefined;
const today = calendarDayIn(now, EVENT_TIME_ZONE);
const month =
requestedMonth ??
(firstDay ? monthKeyOf(firstDay) : monthKeyOf(today ?? "1970-01-01"));
const calendar = buildCalendarMonth({
month,
entries: matching.map((event) => ({
id: event.slug,
title: event.title,
href: `${sectionPath("events", locale)}/${encodeURIComponent(event.slug)}`,
start: event.start,
...(event.end ? { end: event.end } : {}),
allDay: event.allDay,
})),
timeZone: EVENT_TIME_ZONE,
weekStartsOn: weekStartFor(locale),
now,
});
return {
locale,
events: matching.slice(start, start + PAGE_SIZE),
pagination,
status,
region,
regions,
view,
calendar,
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, paginated or re-viewed calendar is the same content in
// another order.
noIndex:
search.has("status") ||
search.has("region") ||
search.has("page") ||
search.has("view") ||
search.has("month"),
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,
view,
calendar,
available,
hasEvents,
} = loaderData;
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const chrome = getListingChrome(locale).events;
const [searchParams] = useSearchParams();
const pathname = sectionPath("events", locale);
const href = (key: string, value: string | undefined) =>
`${pathname}${withParam(searchParams, key, value)}`;
const statusPills = 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,
}));
/* Paging and the month on show are properties of one view, so switching
view keeps the filters and starts the new view at its own beginning. */
const viewParams = new URLSearchParams();
if (status !== "all") viewParams.set("status", status);
if (region) viewParams.set("region", region);
const viewPills = VIEWS.map((value) => ({
id: value,
label: value === "list" ? chrome.viewList : chrome.viewMonth,
href: `${pathname}${withParam(
viewParams,
"view",
value === "list" ? undefined : value,
)}`,
isCurrent: view === value,
}));
/* What the region form has to carry across a GET submit - the query string
is replaced wholesale, so anything not in the form is lost. */
const keep: (readonly [string, string])[] = [
...(status === "all" ? [] : [["status", status] as const]),
...(view === "list" ? [] : [["view", view] as const]),
];
const isFiltered = status !== "all" || region !== null;
return (
<>
{copy.events.listLabel}
{events.map((event) => (
{typeLabel ? ( <> {typeLabel} > ) : null} {t(eventTimeStatusKey(event.timeStatus))}
{emptyMessage}
) : null}{day.entries.map((entry) => ( {entry.title} ))}