2026-09-11 02:46:56 +02:00
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
EVENTS CALENDAR - /{locale}/events-calendar
|
|
|
|
|
|
----------------------------------------------------------------------------
|
2026-09-16 20:32:56 +02:00
|
|
|
|
Draft section 4, laid out to the 2026 redesign draft ("Events-Calendar"):
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
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
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
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.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
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.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
|
|
import { Link, useSearchParams } from "react-router";
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
|
|
|
|
|
import { CardLink, CardShell } from "~/components/ui/Card";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
import {
|
2026-09-16 20:32:56 +02:00
|
|
|
|
intlLocale,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
localizedPath,
|
|
|
|
|
|
sectionPath,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
toIsoDate,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
translate,
|
|
|
|
|
|
useTranslation,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
type Locale,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
} from "~/i18n";
|
|
|
|
|
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
|
|
|
|
|
|
|
|
|
|
|
import type { Route } from "./+types/events-calendar";
|
|
|
|
|
|
import {
|
|
|
|
|
|
collectRegions,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
eventActionKey,
|
|
|
|
|
|
eventRegistrationLabel,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
eventTimeStatusKey,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
EVENT_STATUS_FILTERS,
|
|
|
|
|
|
EVENT_TIME_ZONE,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
filterEvents,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
formatEventWhen,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
sortEvents,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
type EventCardData,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
} from "./_events-opportunities/events";
|
|
|
|
|
|
import { fetchEvents } from "./_events-opportunities/events.server";
|
|
|
|
|
|
import { getRouteCopy } from "./_events-opportunities/copy";
|
|
|
|
|
|
import { readRootSeo } from "./_events-opportunities/root-seo";
|
2026-09-16 20:32:56 +02:00
|
|
|
|
import { Notice } from "./_events-opportunities/ui";
|
|
|
|
|
|
import {
|
|
|
|
|
|
buildCalendarMonth,
|
|
|
|
|
|
calendarDayIn,
|
|
|
|
|
|
monthKeyOf,
|
|
|
|
|
|
parseMonthKey,
|
|
|
|
|
|
weekdayLabel,
|
|
|
|
|
|
weekStartFor,
|
|
|
|
|
|
type CalendarDay,
|
|
|
|
|
|
type CalendarMonth,
|
|
|
|
|
|
} from "./_shared/calendar";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
import {
|
|
|
|
|
|
buildPagination,
|
|
|
|
|
|
PAGE_SIZE,
|
|
|
|
|
|
parseEnum,
|
|
|
|
|
|
parsePage,
|
|
|
|
|
|
requireLocale,
|
|
|
|
|
|
withParam,
|
|
|
|
|
|
} from "./_shared/listing";
|
|
|
|
|
|
import {
|
|
|
|
|
|
EmptyState,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
FilterPills,
|
|
|
|
|
|
ListingHero,
|
|
|
|
|
|
MetaLine,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
PageShell,
|
|
|
|
|
|
Pagination,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
PillSelect,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
ResultSummary,
|
|
|
|
|
|
} from "./_shared/listing-ui";
|
2026-09-16 20:32:56 +02:00
|
|
|
|
import { getListingChrome } from "./_shared/page-copy";
|
|
|
|
|
|
|
|
|
|
|
|
import "./_page-kit/motion.css";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
|
|
/** Longest region value we will echo back into a filter. */
|
|
|
|
|
|
const MAX_REGION_LENGTH = 80;
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
/** The two ways the calendar can be read. */
|
|
|
|
|
|
const VIEWS = ["list", "month"] as const;
|
|
|
|
|
|
type CalendarView = (typeof VIEWS)[number];
|
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
|
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",
|
|
|
|
|
|
);
|
2026-09-16 20:32:56 +02:00
|
|
|
|
const view = parseEnum(url.searchParams, "view", VIEWS, "list");
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
/* 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
|
return {
|
|
|
|
|
|
locale,
|
|
|
|
|
|
events: matching.slice(start, start + PAGE_SIZE),
|
|
|
|
|
|
pagination,
|
|
|
|
|
|
status,
|
|
|
|
|
|
region,
|
|
|
|
|
|
regions,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
view,
|
|
|
|
|
|
calendar,
|
2026-09-11 02:46:56 +02:00
|
|
|
|
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,
|
2026-09-16 20:32:56 +02:00
|
|
|
|
// A filtered, paginated or re-viewed calendar is the same content in
|
|
|
|
|
|
// another order.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
noIndex:
|
2026-09-16 20:32:56 +02:00
|
|
|
|
search.has("status") ||
|
|
|
|
|
|
search.has("region") ||
|
|
|
|
|
|
search.has("page") ||
|
|
|
|
|
|
search.has("view") ||
|
|
|
|
|
|
search.has("month"),
|
2026-09-11 02:46:56 +02:00
|
|
|
|
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) {
|
2026-09-16 20:32:56 +02:00
|
|
|
|
const {
|
|
|
|
|
|
events,
|
|
|
|
|
|
pagination,
|
|
|
|
|
|
status,
|
|
|
|
|
|
region,
|
|
|
|
|
|
regions,
|
|
|
|
|
|
view,
|
|
|
|
|
|
calendar,
|
|
|
|
|
|
available,
|
|
|
|
|
|
hasEvents,
|
|
|
|
|
|
} = loaderData;
|
2026-09-11 02:46:56 +02:00
|
|
|
|
const { locale, t } = useTranslation();
|
|
|
|
|
|
const copy = getRouteCopy(locale);
|
2026-09-16 20:32:56 +02:00
|
|
|
|
const chrome = getListingChrome(locale).events;
|
2026-09-11 02:46:56 +02:00
|
|
|
|
const [searchParams] = useSearchParams();
|
|
|
|
|
|
|
|
|
|
|
|
const pathname = sectionPath("events", locale);
|
|
|
|
|
|
const href = (key: string, value: string | undefined) =>
|
|
|
|
|
|
`${pathname}${withParam(searchParams, key, value)}`;
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
const statusPills = EVENT_STATUS_FILTERS.map((value) => ({
|
2026-09-11 02:46:56 +02:00
|
|
|
|
id: value,
|
|
|
|
|
|
label: value === "all" ? t("filters.all") : t(eventTimeStatusKey(value)),
|
|
|
|
|
|
href: href("status", value === "all" ? undefined : value),
|
|
|
|
|
|
isCurrent: status === value,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
/* 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]),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
|
const isFiltered = status !== "all" || region !== null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-16 20:32:56 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<ListingHero
|
|
|
|
|
|
eyebrow={chrome.eyebrow}
|
|
|
|
|
|
heading={copy.events.heading}
|
|
|
|
|
|
intro={copy.events.intro}
|
|
|
|
|
|
ambient={[{ top: -250, from: "end", offset: "4%", width: 720, height: 560, durationSeconds: 20 }]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<PageShell>
|
|
|
|
|
|
{available ? null : (
|
|
|
|
|
|
<Notice tone="info" role="status">
|
|
|
|
|
|
{copy.events.unavailable}
|
|
|
|
|
|
</Notice>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-lg">
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-lg border-b border-border-subtle pb-lg">
|
|
|
|
|
|
<FilterPills
|
|
|
|
|
|
label={copy.events.statusFilterLabel}
|
|
|
|
|
|
pills={statusPills}
|
|
|
|
|
|
>
|
|
|
|
|
|
{regions.length > 0 ? (
|
|
|
|
|
|
<PillSelect
|
|
|
|
|
|
name="region"
|
|
|
|
|
|
label={t("filters.region")}
|
|
|
|
|
|
value={region ?? ""}
|
|
|
|
|
|
options={regions.map((value) => ({ value, label: value }))}
|
|
|
|
|
|
allLabel={t("filters.all")}
|
|
|
|
|
|
applyLabel={t("filters.apply")}
|
|
|
|
|
|
action={pathname}
|
|
|
|
|
|
keep={keep}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</FilterPills>
|
|
|
|
|
|
|
|
|
|
|
|
{/* The draft draws the List / Month switch as a pair of square
|
|
|
|
|
|
controls with no caption, beside the rounded filter pills. */}
|
|
|
|
|
|
<FilterPills
|
|
|
|
|
|
label={chrome.viewLabel}
|
|
|
|
|
|
pills={viewPills}
|
|
|
|
|
|
shape="control"
|
|
|
|
|
|
hideLabel
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{view === "list" ? <ResultSummary pagination={pagination} t={t} /> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{view === "month" ? (
|
|
|
|
|
|
<MonthView
|
|
|
|
|
|
calendar={calendar}
|
|
|
|
|
|
locale={locale}
|
|
|
|
|
|
emptyMessage={chrome.monthEmpty}
|
|
|
|
|
|
label={chrome.calendarLabel}
|
|
|
|
|
|
previousLabel={chrome.previousMonth}
|
|
|
|
|
|
nextLabel={chrome.nextMonth}
|
|
|
|
|
|
hrefFor={(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. */}
|
|
|
|
|
|
<h2 className="sr-only">{copy.events.listLabel}</h2>
|
|
|
|
|
|
<ul
|
|
|
|
|
|
aria-label={copy.events.listLabel}
|
|
|
|
|
|
className="flex flex-col gap-md"
|
|
|
|
|
|
>
|
|
|
|
|
|
{events.map((event) => (
|
|
|
|
|
|
<EventRow
|
|
|
|
|
|
key={event.slug}
|
|
|
|
|
|
event={event}
|
|
|
|
|
|
href={`${pathname}/${encodeURIComponent(event.slug)}`}
|
|
|
|
|
|
/>
|
2026-09-11 02:46:56 +02:00
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
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. */
|
|
|
|
|
|
<div className="flex h-[5.125rem] w-[4.75rem] shrink-0 flex-col items-center justify-center gap-2xs rounded-card border border-border-subtle bg-surface-sunken">
|
|
|
|
|
|
<span className="label-md uppercase text-text-accent">
|
|
|
|
|
|
{part({ month: "short" })}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="heading-h2 text-text-primary">
|
|
|
|
|
|
{part({ day: "numeric" })}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<CardShell
|
|
|
|
|
|
as="li"
|
|
|
|
|
|
linked
|
|
|
|
|
|
className="sh-reveal sh-reveal--in flex-col gap-lg p-lg md:flex-row md:items-center"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex min-w-0 flex-1 items-center gap-lg">
|
|
|
|
|
|
<DateChip event={event} locale={locale} />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex min-w-0 flex-col gap-xs">
|
|
|
|
|
|
<p className="label-md uppercase text-text-tertiary">
|
|
|
|
|
|
{typeLabel ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="text-text-accent">{typeLabel}</span>
|
|
|
|
|
|
<span aria-hidden="true"> · </span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{t(eventTimeStatusKey(event.timeStatus))}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<h3 className="sh-card-title body-lg-bold text-text-primary">
|
|
|
|
|
|
<CardLink href={href} className="sh-card-link">
|
|
|
|
|
|
{event.title}
|
|
|
|
|
|
</CardLink>
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="body-sm-bold text-text-secondary">
|
|
|
|
|
|
<time dateTime={toIsoDate(event.start)}>
|
|
|
|
|
|
{formatEventWhen(event, locale)}
|
|
|
|
|
|
</time>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<MetaLine
|
|
|
|
|
|
items={[formatLabel, event.place, event.region, clock].filter(
|
|
|
|
|
|
(item): item is string => Boolean(item),
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-09-11 02:46:56 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
|
<div className="flex flex-wrap items-center gap-md md:justify-end">
|
|
|
|
|
|
{/* A finished event's registration state is history, not news. */}
|
|
|
|
|
|
{isPast ? null : (
|
|
|
|
|
|
<span className="inline-flex items-center gap-xs rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
|
|
|
|
|
|
{isLive ? (
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
2026-09-11 02:46:56 +02:00
|
|
|
|
/>
|
2026-09-16 20:32:56 +02:00
|
|
|
|
) : null}
|
|
|
|
|
|
{eventRegistrationLabel(event.registrationStatus, locale)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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. */}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
as={Link}
|
|
|
|
|
|
to={href}
|
|
|
|
|
|
prefetch="intent"
|
|
|
|
|
|
variant="primary"
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
iconEnd={<ArrowIcon />}
|
|
|
|
|
|
className="sh-card-above shrink-0"
|
2026-09-11 02:46:56 +02:00
|
|
|
|
>
|
2026-09-16 20:32:56 +02:00
|
|
|
|
{t(eventActionKey(event))}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<section aria-label={label} className="flex flex-col gap-lg">
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-md">
|
|
|
|
|
|
<h2 className="heading-h3 text-text-primary">{monthName}</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-xs">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
as={Link}
|
|
|
|
|
|
to={hrefFor(calendar.previous)}
|
|
|
|
|
|
prefetch="intent"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
iconStart={<ArrowIcon direction="backward" />}
|
|
|
|
|
|
>
|
|
|
|
|
|
{previousLabel}
|
2026-09-11 02:46:56 +02:00
|
|
|
|
</Button>
|
2026-09-16 20:32:56 +02:00
|
|
|
|
<Button
|
|
|
|
|
|
as={Link}
|
|
|
|
|
|
to={hrefFor(calendar.next)}
|
|
|
|
|
|
prefetch="intent"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
iconEnd={<ArrowIcon />}
|
|
|
|
|
|
>
|
|
|
|
|
|
{nextLabel}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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. */}
|
|
|
|
|
|
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-card border border-border-subtle bg-[var(--border-subtle)]">
|
|
|
|
|
|
{calendar.weekdays.map((weekday) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={weekday}
|
|
|
|
|
|
className="bg-surface-raised p-sm text-center label-md uppercase text-text-tertiary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span aria-hidden="true">
|
|
|
|
|
|
{weekdayLabel(weekday, intlLocale(locale), "narrow")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="sr-only">
|
|
|
|
|
|
{weekdayLabel(weekday, intlLocale(locale), "long")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{calendar.days.map((day) => (
|
|
|
|
|
|
<MonthCell key={day.key} day={day} locale={locale} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{calendar.entryCount === 0 ? (
|
|
|
|
|
|
<p className="body-md text-text-secondary" role="status">
|
|
|
|
|
|
{emptyMessage}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MonthCell({ day, locale }: { day: CalendarDay; locale: Locale }) {
|
|
|
|
|
|
const dayNumber = new Intl.NumberFormat(intlLocale(locale), {
|
|
|
|
|
|
useGrouping: false,
|
|
|
|
|
|
}).format(day.dayNumber);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={[
|
|
|
|
|
|
"sh-lift flex min-h-[6rem] flex-col gap-2xs p-xs",
|
|
|
|
|
|
day.inMonth ? "bg-[var(--surface-page)]" : "bg-surface-sunken",
|
|
|
|
|
|
"hover:bg-surface-muted",
|
|
|
|
|
|
].join(" ")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p
|
|
|
|
|
|
className={[
|
|
|
|
|
|
"label-md",
|
|
|
|
|
|
day.isToday ? "text-text-accent" : "text-text-tertiary",
|
|
|
|
|
|
day.inMonth ? "" : "opacity-60",
|
|
|
|
|
|
].join(" ")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<time dateTime={day.key}>{dayNumber}</time>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{day.entries.map((entry) => (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
key={`${day.key}-${entry.id}`}
|
|
|
|
|
|
to={entry.href}
|
|
|
|
|
|
prefetch="intent"
|
|
|
|
|
|
className={[
|
2026-09-20 11:14:15 +02:00
|
|
|
|
"rounded-chip border-s-2 border-s-[var(--border-accent)]",
|
2026-09-16 20:32:56 +02:00
|
|
|
|
"bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary",
|
|
|
|
|
|
"transition-colors duration-[var(--motion-duration-fast)]",
|
|
|
|
|
|
"ease-[var(--motion-ease-standard)] hover:text-text-accent",
|
|
|
|
|
|
].join(" ")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{entry.title}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-09-11 02:46:56 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|