Files
Illusion 5310272715 feat(events): add calendar email support
Help registrants add events to their calendars and keep waitlisted places distinct from confirmed attendance.
2026-09-27 15:57:43 +02:00

671 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
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=&region=&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 (
<>
<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)}`}
/>
))}
</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>
</>
);
}
/* ---------------------------------------------------------------------------
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>
</div>
<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)]"
/>
) : 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"
>
{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}
</Button>
<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={[
"rounded-chip border-s-2 border-s-[var(--border-accent)]",
"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>
);
}