Files
sharper.ly/apps/web/app/routes/events-calendar.tsx
T
IllusionandClaude Opus 5 19fab4a783 Match the shipped styling to the Claude Design drafts
An audit of all twelve draft files against the build turned up a set of
mismatches, most of them in the tier-2 token layer rather than in any one
component. Tier 1 is untouched throughout - only the meanings moved.

SHAPE. The drafts draw three corners, not five: 2px for a tag, thumbnail,
flag or checkbox, 4px for anything with a box, and fully round for a filter
chip or status pill. Nothing in any draft is 8px or 12px. --radius-card and
--radius-surface therefore come down to radius/sm, a --radius-chip is added
for the 2px tier, and --radius-control-lg - a quirk of the Figma Button
frame's ghost cells - is retired along with its four consumers.

ELEVATION. A shadow is a fraction of the ground it falls on, and the ramp
was tuned for a white page: on the drafts' near-black ground a 12-28% black
is invisible. Tier 1 gains the two deep steps the drafts actually draw
(--shadow-dark-lg/xl), and --elevation-card/-popover/-modal move into the
per-surface blocks so the dark surface can take them while the light one
keeps the soft ramp. A resting card now has no shadow at all - the drafts
separate with a hairline - and the black is spent on what floats.

CARDS. The hover was a white-24% hairline and a faint glow; the drafts turn
the hairline accent green, lift the card 6px and land a deep black under it.
The lift is pointer-only and is dropped under prefers-reduced-motion.

GHOST IS A TEXT LINK. The drafts never fill a ghost control - every
"All updates ->" is plain text that turns green while the label-to-arrow gap
opens. The tokens carry no fill in any state and the variant opts out of the
3px lift, which also retires the WCAG workaround the old Figma hover forced
(green label on a green fill, 1.40:1).

NAVIGATION. A nav label rests in text/secondary and brightens to primary;
the current section stays primary and is marked by the accent rule, not by
green text.

HERO STRIPE. The middle block was painted near-black against a comment
claiming the redesign had dropped white; the drafts paint it #F8F9FA. Back
to background/inverse, and to the drafts' 390 : 495 : 363 split.

styles/README.md is updated to match, including a contrast table that still
described --surface-raised as the retired #2e2e2c.

Not changed, because each is a decision rather than a defect: the drafts set
every eyebrow, tag and meta line in IBM Plex Mono at 10-11px with 1.2-1.4px
tracking (a third typeface, and no Arabic counterpart); draft headings are
weight 800, which needs a font file we do not have; and the EU badge
statement stays at --text-primary, since the visibility lockup is
contractual.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:14:15 +02:00

653 lines
21 KiB
TypeScript
Raw 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, 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 (
<>
<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>
);
}