/* ============================================================================ 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, buildEventJsonLd, 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, loaderData }: Route.MetaArgs) { const root = readRootSeo(matches); const copy = getRouteCopy(root.locale); const search = new URLSearchParams(location.search); const eventJsonLd = (loaderData?.events ?? []).map((event) => buildEventJsonLd({ siteUrl: root.siteUrl, locale: root.locale, name: event.title, pathname: `${sectionPath("events", root.locale)}/${encodeURIComponent(event.slug)}`, startDate: toIsoDate(event.start) ?? event.start, ...(event.end ? { endDate: toIsoDate(event.end) ?? event.end } : {}), ...(event.summary ? { description: event.summary } : {}), ...(event.image ? { image: event.image.src } : {}), ...(event.place ? { locationName: event.place } : {}), ...(event.region ? { addressLocality: event.region } : {}), isOnline: event.format === "online" || event.format === "hybrid", }), ); 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), }, ]), ...eventJsonLd, ], }); } 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 ( <> {available ? null : ( {copy.events.unavailable} )}
{regions.length > 0 ? ( ({ value, label: value }))} allLabel={t("filters.all")} applyLabel={t("filters.apply")} action={pathname} keep={keep} /> ) : null} {/* The draft draws the List / Month switch as a pair of square controls with no caption, beside the rounded filter pills. */}
{view === "list" ? : null}
{view === "month" ? ( href("month", month)} /> ) : events.length > 0 ? ( <> {/* The list is the page's content, so it gets a heading in the outline even though the hero already names the page. */}

{copy.events.listLabel}

    {events.map((event) => ( ))}
) : hasEvents && isFiltered ? ( ) : ( )}
); } /* --------------------------------------------------------------------------- ONE EVENT, AS THE DRAFT DRAWS IT ------------------------------------------------------------------------ */ /** "10:00–14:00" in Libya time, or nothing for an all-day event. */ function formatClock(event: EventCardData, locale: Locale): string | undefined { if (event.allDay) return undefined; const options: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", timeZone: EVENT_TIME_ZONE, }; const formatter = new Intl.DateTimeFormat(intlLocale(locale), options); const start = new Date(event.start); if (Number.isNaN(start.getTime())) return undefined; const end = event.end ? new Date(event.end) : null; if (end && !Number.isNaN(end.getTime()) && end.getTime() !== start.getTime()) { return formatter.formatRange(start, end); } return formatter.format(start); } /** The date block on the inline-start edge: month over the day number. */ function DateChip({ event, locale }: { event: EventCardData; locale: Locale }) { const timeZone = event.allDay ? "UTC" : EVENT_TIME_ZONE; const date = new Date(event.start); if (Number.isNaN(date.getTime())) return null; const part = (options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(intlLocale(locale), { ...options, timeZone }).format( date, ); return ( /* The draft draws a 76x82 block holding the month over the day and nothing else - the full date, year included, is on the line under the title, so repeating it here only crowds the block. */
{part({ month: "short" })} {part({ day: "numeric" })}
); } function EventRow({ event, href }: { event: EventCardData; href: string }) { const { locale, t } = useTranslation(); const copy = getRouteCopy(locale); const isPast = event.timeStatus === "completed"; const isLive = !isPast && event.registrationStatus === "open"; const formatLabel = event.format === "online" ? t("events.online") : event.format === "hybrid" ? copy.events.formatHybrid : event.format === "in-person" ? t("events.inPerson") : undefined; const clock = formatClock(event, locale); /* The draft's kicker above the title. Empty for an event the CMS left unclassified ("other"), in which case only the time status shows. */ const typeLabel = event.eventType ? copy.events.eventTypes[event.eventType] : undefined; return (

{typeLabel ? ( <> {typeLabel} ) : null} {t(eventTimeStatusKey(event.timeStatus))}

{event.title}

Boolean(item), )} />
{/* A finished event's registration state is history, not news. */} {isPast ? null : ( {isLive ? ( )} {/* The draft ends every row with the filled accent action - Register, Apply, Join waitlist, Read recap - not a text hint. `sh-card-above` lifts it over the stretched title link so it stays its own target, and the label comes from the event's own registration state. */}
); } /* --------------------------------------------------------------------------- THE MONTH GRID ------------------------------------------------------------------------ */ function MonthView({ calendar, locale, label, emptyMessage, previousLabel, nextLabel, hrefFor, }: { calendar: CalendarMonth; locale: Locale; label: string; emptyMessage: string; previousLabel: string; nextLabel: string; hrefFor: (month: string) => string; }) { const monthName = new Intl.DateTimeFormat(intlLocale(locale), { month: "long", year: "numeric", timeZone: "UTC", }).format(new Date(`${calendar.firstDay}T00:00:00Z`)); return (

{monthName}

{/* The hairlines are the grid's own gap showing through, which is how the draft draws it - one border round the whole block, one pixel between the cells. */}
{calendar.weekdays.map((weekday) => (
{weekdayLabel(weekday, intlLocale(locale), "long")}
))} {calendar.days.map((day) => ( ))}
{calendar.entryCount === 0 ? (

{emptyMessage}

) : null}
); } function MonthCell({ day, locale }: { day: CalendarDay; locale: Locale }) { const dayNumber = new Intl.NumberFormat(intlLocale(locale), { useGrouping: false, }).format(day.dayNumber); return (

{day.entries.map((entry) => ( {entry.title} ))}
); }