Rebuild the site to the Claude Design drafts
Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the project's own design system: tier-2 tokens, the published type roles, logical properties throughout, and both locales. Surfaces - The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts do; the body previously painted the band colour, so every band was a step too light. - A card is drawn one step off whatever it stands on (#1D1D1B on the ground, #141413 inside a band). Components paint --surface-card and a band re-points it for its subtree, so a card can never come out the same colour as its band. Shell - Nav: translucent ground with a blur, chevrons on group triggers, an accent rule under the current section, the drafts' compact dropdown card, and an EN | AR toggle in place of the globe menu. - Footer: the drafts' four-column grid, one-line brand text, Facebook as a bordered button, the EU lockup, the implemented-by row and the funding disclaimer. Pages - Home: hero with counting figures, launch band, three areas of change, newsroom, FAQ accordion and the consortium band. - The nine interior pages rebuilt and then audited section by section against their drafts. - Applications: the three-step application form now posts real submissions to the Strapi `application` type, validated server-side and working without JavaScript. Motion - Every animation in the drafts, as classes in _page-kit/motion.css: staggered entrances, scroll-driven reveals, the drifting washes (each page carries its own geometry), the sheen, the counting figures and the accordion. The drafts' coloured hover glow is deliberately not shipped. CMS - New `faq` collection type behind the home page's accordion. - `role` added to the consortium member component so an editor can supply the chip and the deliverable separately. Content - No invented figure, date, name or commitment ships: the five-working-day reply, the placeholder email and Facebook handle, the sample news, events, publications and FAQs, the invented application rules and the TBD counters are all withheld pending real content. Figures that survived (3 consortium partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec 2027) are verified against docs/ and the bundled draft copy. Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20 page/locale URLs render 200 through the built SSR handler, including with the CMS unreachable; no raw hex or physical-direction utility in components or routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,49 +1,84 @@
|
||||
/* ============================================================================
|
||||
EVENTS CALENDAR - /{locale}/events-calendar
|
||||
----------------------------------------------------------------------------
|
||||
Draft section 4. The listing the draft describes:
|
||||
Draft section 4, laid out to the 2026 redesign draft ("Events-Calendar"):
|
||||
|
||||
filter tabs All - Upcoming - Ongoing - Completed, plus a region filter
|
||||
card fields title - date and time - format/location - registration
|
||||
status - call to action (Register / Apply / Read recap)
|
||||
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 filter is a real URL (?status=®ion=&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.
|
||||
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.
|
||||
|
||||
EMPTY BY DESIGN: the CMS is empty today, so the state that actually renders
|
||||
is the localised "no events yet" panel - and if Strapi cannot be reached at
|
||||
all, the page still renders, with a notice, instead of a 500.
|
||||
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 { Button } from "~/components/ui/Button";
|
||||
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,
|
||||
EVENT_STATUS_FILTERS,
|
||||
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 { EventCard, Notice } from "./_events-opportunities/ui";
|
||||
import { Notice } from "./_events-opportunities/ui";
|
||||
import {
|
||||
buildCalendarMonth,
|
||||
calendarDayIn,
|
||||
monthKeyOf,
|
||||
parseMonthKey,
|
||||
weekdayLabel,
|
||||
weekStartFor,
|
||||
type CalendarDay,
|
||||
type CalendarMonth,
|
||||
} from "./_shared/calendar";
|
||||
import {
|
||||
buildPagination,
|
||||
PAGE_SIZE,
|
||||
@@ -54,16 +89,25 @@ import {
|
||||
} from "./_shared/listing";
|
||||
import {
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
FilterPills,
|
||||
ListingHero,
|
||||
MetaLine,
|
||||
PageShell,
|
||||
Pagination,
|
||||
PillSelect,
|
||||
ResultSummary,
|
||||
TabLinks,
|
||||
} 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);
|
||||
@@ -75,6 +119,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
EVENT_STATUS_FILTERS,
|
||||
"all",
|
||||
);
|
||||
const view = parseEnum(url.searchParams, "view", VIEWS, "list");
|
||||
|
||||
const { events, available } = await fetchEvents(locale, now);
|
||||
|
||||
@@ -98,6 +143,33 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
);
|
||||
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),
|
||||
@@ -105,6 +177,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
status,
|
||||
region,
|
||||
regions,
|
||||
view,
|
||||
calendar,
|
||||
available,
|
||||
/** Distinguishes "the CMS is empty" from "these filters match nothing". */
|
||||
hasEvents: events.length > 0,
|
||||
@@ -122,9 +196,14 @@ export function meta({ location, matches }: Route.MetaArgs) {
|
||||
siteUrl: root.siteUrl,
|
||||
title: translate(root.locale, "nav.sections.events"),
|
||||
description: copy.events.metaDescription,
|
||||
// A filtered or paginated view is the same content in another order.
|
||||
// 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("status") ||
|
||||
search.has("region") ||
|
||||
search.has("page") ||
|
||||
search.has("view") ||
|
||||
search.has("month"),
|
||||
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
||||
{
|
||||
name: translate(root.locale, "nav.sections.home"),
|
||||
@@ -139,115 +218,435 @@ export function meta({ location, matches }: Route.MetaArgs) {
|
||||
}
|
||||
|
||||
export default function EventsCalendar({ loaderData }: Route.ComponentProps) {
|
||||
const { events, pagination, status, region, regions, available, hasEvents } =
|
||||
loaderData;
|
||||
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 tabs = EVENT_STATUS_FILTERS.map((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 (
|
||||
<PageShell>
|
||||
<PageHeader heading={copy.events.heading} intro={copy.events.intro} />
|
||||
<>
|
||||
<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 }]}
|
||||
/>
|
||||
|
||||
{available ? null : (
|
||||
<Notice tone="info" role="status">
|
||||
{copy.events.unavailable}
|
||||
</Notice>
|
||||
)}
|
||||
<PageShell>
|
||||
{available ? null : (
|
||||
<Notice tone="info" role="status">
|
||||
{copy.events.unavailable}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-lg">
|
||||
<TabLinks label={copy.events.statusFilterLabel} tabs={tabs} />
|
||||
<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>
|
||||
|
||||
{regions.length > 0 ? (
|
||||
<nav aria-label={t("filters.region")} className="flex flex-col gap-xs">
|
||||
<p className="label-md uppercase text-text-tertiary">
|
||||
{t("filters.region")}
|
||||
</p>
|
||||
<ul className="flex flex-wrap items-center gap-xs">
|
||||
<li>
|
||||
<Button
|
||||
as={Link}
|
||||
to={href("region", undefined)}
|
||||
prefetch="intent"
|
||||
variant={region === null ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-current={region === null ? "true" : undefined}
|
||||
>
|
||||
{t("filters.all")}
|
||||
</Button>
|
||||
</li>
|
||||
{regions.map((value) => (
|
||||
<li key={value}>
|
||||
<Button
|
||||
as={Link}
|
||||
to={href("region", value)}
|
||||
prefetch="intent"
|
||||
variant={region === value ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-current={region === value ? "true" : undefined}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
</li>
|
||||
{/* 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>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
</div>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<>
|
||||
<ul
|
||||
aria-label={copy.events.listLabel}
|
||||
className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3"
|
||||
<Pagination
|
||||
model={pagination}
|
||||
pathname={pathname}
|
||||
params={searchParams}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
) : hasEvents && isFiltered ? (
|
||||
<EmptyState
|
||||
title={t("filters.noResults")}
|
||||
body={t("filters.noResultsHint")}
|
||||
>
|
||||
{events.map((event) => (
|
||||
<EventCard
|
||||
key={event.slug}
|
||||
as="li"
|
||||
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>
|
||||
<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-[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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user