Files
sharper.ly/apps/web/app/routes/events-calendar.tsx
T

653 lines
21 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
EVENTS CALENDAR - /{locale}/events-calendar
----------------------------------------------------------------------------
Draft section 4, laid out to the 2026 redesign draft ("Events-Calendar"):
2026-09-11 02:46: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
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.
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.
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";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { CardLink, CardShell } from "~/components/ui/Card";
2026-09-11 02:46:56 +02:00
import {
intlLocale,
2026-09-11 02:46:56 +02:00
localizedPath,
sectionPath,
toIsoDate,
2026-09-11 02:46:56 +02:00
translate,
useTranslation,
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,
eventActionKey,
eventRegistrationLabel,
2026-09-11 02:46:56 +02:00
eventTimeStatusKey,
EVENT_STATUS_FILTERS,
EVENT_TIME_ZONE,
2026-09-11 02:46:56 +02:00
filterEvents,
formatEventWhen,
2026-09-11 02:46:56 +02:00
sortEvents,
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";
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,
FilterPills,
ListingHero,
MetaLine,
2026-09-11 02:46:56 +02:00
PageShell,
Pagination,
PillSelect,
2026-09-11 02:46:56 +02:00
ResultSummary,
} from "./_shared/listing-ui";
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;
/** 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",
);
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;
/* 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,
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,
// A filtered, paginated or re-viewed calendar is the same content in
// another order.
2026-09-11 02:46:56 +02:00
noIndex:
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) {
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);
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)}`;
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,
}));
/* 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 (
<>
<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>
<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>
<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
/>
) : 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
>
{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>
<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-[var(--radius-control)] 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>
2026-09-11 02:46:56 +02:00
);
}