Files
IllusionandClaude Opus 5 aedef6daeb 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>
2026-09-16 20:32:56 +02:00

427 lines
14 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 - view model, status derivation, formatting
----------------------------------------------------------------------------
PURE. No server import, so the same helpers run in the loader and in the
component. Everything that talks to Strapi lives in ./events.server.ts.
The three calendar states the draft asks for (Upcoming / Ongoing /
Completed, draft section 4) are DERIVED from the dates rather than stored,
so an editor cannot forget to move an event to "Completed" the morning
after it happened. The registration state (Open / Closed / Full) is a real
editorial decision and does come from the CMS.
========================================================================= */
import {
formatDate,
formatDateRange,
translate,
type Locale,
type TranslationKey,
} from "~/i18n";
import type { CmsImage, CmsRichText } from "../_shared/cms";
import { getRouteCopy } from "./copy";
/* ---------------------------------------------------------------------------
TIME ZONE
The project is nationwide Libya, which sits on UTC+2 all year (no DST since
2013). Pinning the zone keeps the server-rendered string and the hydrated
one identical whatever the reader's device is set to, and shows the local
time of the room the event is held in.
------------------------------------------------------------------------ */
export const EVENT_TIME_ZONE = "Africa/Tripoli";
const EVENT_UTC_OFFSET = "+02:00";
/* ---------------------------------------------------------------------------
TYPES
------------------------------------------------------------------------ */
/** How an event is attended. Mirrors the CMS enumeration. */
export type EventFormat = "in-person" | "online" | "hybrid";
/**
* Editorial registration state. The five values are the CMS enumeration
* `event.registrationStatus`, verbatim - draft 4 names only the first three,
* but an editor can also mark a full event as taking a waitlist, or say that
* an event needs no registration at all.
*/
export type EventRegistrationStatus =
| "open"
| "closed"
| "full"
| "waitlist"
| "not-required";
/**
* api::event.eventType - what kind of session this is. The redesign prints it
* as a kicker above the title ("SUQ Dialogue", "Training"), so the label map
* lives with the rest of the route copy, in both locales.
*/
export const EVENT_TYPES = [
"suq-dialogue",
"training",
"law-session",
"network-meeting",
"launch",
"other",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
export const EVENT_REGISTRATION_STATUSES = [
"open",
"closed",
"full",
"waitlist",
"not-required",
] as const satisfies readonly EventRegistrationStatus[];
/** Derived from the dates, never stored. */
export type EventTimeStatus = "upcoming" | "ongoing" | "completed";
/** The filter tabs: All - Upcoming - Ongoing - Completed (draft 4). */
export type EventStatusFilter = "all" | EventTimeStatus;
export const EVENT_STATUS_FILTERS: readonly EventStatusFilter[] = [
"all",
"upcoming",
"ongoing",
"completed",
];
export function isEventStatusFilter(value: unknown): value is EventStatusFilter {
return (
typeof value === "string" &&
(EVENT_STATUS_FILTERS as readonly string[]).includes(value)
);
}
/** What a card needs. Plain and serialisable - it crosses the loader boundary. */
export interface EventCardData {
readonly slug: string;
readonly title: string;
/** Kicker above the title. Absent when the CMS leaves the type unset. */
readonly eventType?: EventType;
readonly summary?: string;
/** ISO 8601. Date-only when `allDay`. */
readonly start: string;
readonly end?: string;
readonly allDay: boolean;
readonly format?: EventFormat;
/** The venue. Absent for an online-only event - the CMS has no `city`. */
readonly place?: string;
/** Name of the related region entry, not a free-text string. */
readonly region?: string;
readonly registrationStatus: EventRegistrationStatus;
readonly timeStatus: EventTimeStatus;
/** Set on a past event that has a write-up - drives the "Read recap" label. */
readonly recapUrl?: string;
readonly image?: CmsImage;
}
/** Everything the detail page renders. */
export interface EventDetailData extends EventCardData {
readonly documentId: string;
/** The long body. There is no external registration portal field in the CMS. */
readonly description?: CmsRichText;
readonly onlineUrl?: string;
readonly registrationDeadline?: string;
readonly venue?: string;
/** Editorial: the CMS offers a waitlist once the event is full. */
readonly waitlistEnabled: boolean;
}
/* ---------------------------------------------------------------------------
TIME
------------------------------------------------------------------------ */
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
/** True for a CMS `date` field ("2026-03-12"), false for a `datetime`. */
export function isDateOnly(value: string | null | undefined): boolean {
return typeof value === "string" && DATE_ONLY.test(value.trim());
}
/** "2026-03-12" for an instant, read in Libyan local time. */
function libyanCalendarDay(value: Date): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: EVENT_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(value);
}
/** The last instant of the Libyan calendar day that contains `value`. */
function endOfLibyanDay(value: Date): Date {
return new Date(
`${libyanCalendarDay(value)}T23:59:59.999${EVENT_UTC_OFFSET}`,
);
}
interface EventInterval {
readonly start: string;
readonly end?: string;
readonly allDay: boolean;
}
/**
* When an event is over.
* - explicit `end` on a timed event -> that instant
* - all-day (with or without `end`) -> the end of the last day
* - timed event with no `end` -> the end of its own day, so an
* evening dialogue reads "Ongoing" for the rest of that day and
* "Completed" from midnight, rather than the instant it starts.
*/
export function eventEndsAt(event: EventInterval): Date | null {
const start = new Date(event.start);
if (Number.isNaN(start.getTime())) return null;
if (event.allDay) {
const last = event.end ? new Date(event.end) : start;
return endOfLibyanDay(Number.isNaN(last.getTime()) ? start : last);
}
if (event.end) {
const end = new Date(event.end);
if (!Number.isNaN(end.getTime())) return end;
}
return endOfLibyanDay(start);
}
/** Upcoming / Ongoing / Completed for an instant in time. */
export function deriveTimeStatus(
event: EventInterval,
now: Date = new Date(),
): EventTimeStatus {
const start = new Date(event.start);
if (Number.isNaN(start.getTime())) return "upcoming";
const end = eventEndsAt(event) ?? start;
const at = now.getTime();
if (at < start.getTime()) return "upcoming";
return at <= end.getTime() ? "ongoing" : "completed";
}
/* ---------------------------------------------------------------------------
FORMATTING
------------------------------------------------------------------------ */
/**
* "12 March 2026, 14:00" / "12–14 March 2026" for an all-day range.
* `withTimeZone` adds the zone abbreviation - use it on the detail page,
* where the reader is deciding whether they can attend.
*/
export function formatEventWhen(
event: EventInterval,
locale: Locale,
options: { readonly withTimeZone?: boolean } = {},
): string {
if (event.allDay) {
// A date-only value parses as UTC midnight, so it is read back in UTC to
// land on the day the editor typed.
return formatDateRange(event.start, event.end ?? null, locale, {
dateStyle: "long",
timeZone: "UTC",
});
}
// `dateStyle`/`timeStyle` cannot be combined with `timeZoneName`, so the
// components are spelled out.
const parts: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: EVENT_TIME_ZONE,
...(options.withTimeZone ? { timeZoneName: "short" } : {}),
};
if (event.end && !isDateOnly(event.end)) {
return formatDateRange(event.start, event.end, locale, parts);
}
return formatDate(event.start, locale, parts);
}
/** The short date used in a confirmation sentence ("… on 12 March 2026"). */
export function formatEventDay(
event: EventInterval,
locale: Locale,
): string {
return formatDate(event.start, locale, {
dateStyle: "long",
timeZone: event.allDay ? "UTC" : EVENT_TIME_ZONE,
});
}
/* ---------------------------------------------------------------------------
LIST OPERATIONS
The calendar of a two-year project is small enough to sort, filter and
paginate in memory, which keeps the derived Upcoming/Ongoing/Completed
states honest - Strapi cannot express them without duplicating the rule in
a query. `EVENTS_FETCH_LIMIT` in ./events.server.ts is the ceiling.
------------------------------------------------------------------------ */
const MS_PER_DAY = 86_400_000;
function startTime(event: EventCardData): number {
const value = new Date(event.start).getTime();
return Number.isNaN(value) ? 0 : value;
}
/**
* Upcoming and ongoing events first, soonest at the top; completed ones after
* them, most recent first. Filtering to a single tab keeps that tab's order.
*/
export function sortEvents(
events: readonly EventCardData[],
): EventCardData[] {
return [...events].sort((a, b) => {
const aPast = a.timeStatus === "completed";
const bPast = b.timeStatus === "completed";
if (aPast !== bPast) return aPast ? 1 : -1;
return aPast
? startTime(b) - startTime(a)
: startTime(a) - startTime(b);
});
}
export interface EventFilters {
readonly status: EventStatusFilter;
readonly region: string | null;
}
export function filterEvents(
events: readonly EventCardData[],
filters: EventFilters,
): EventCardData[] {
return events.filter((event) => {
if (filters.status !== "all" && event.timeStatus !== filters.status) {
return false;
}
if (filters.region && event.region !== filters.region) return false;
return true;
});
}
/** Every region that actually has an event, in the reader's collation order. */
export function collectRegions(
events: readonly EventCardData[],
locale: Locale,
): string[] {
const regions = new Set<string>();
for (const event of events) {
if (event.region) regions.add(event.region);
}
return [...regions].sort((a, b) => a.localeCompare(b, locale));
}
/* ---------------------------------------------------------------------------
REGISTRATION
------------------------------------------------------------------------ */
/**
* True when the on-site form should be shown at all. "not-required" means the
* reader simply turns up, so there is nothing to submit; "closed" speaks for
* itself; "full" and "waitlist" still take names for the waiting list.
*/
export function isRegistrationOpen(event: EventCardData): boolean {
return (
event.timeStatus !== "completed" &&
(event.registrationStatus === "open" ||
event.registrationStatus === "full" ||
event.registrationStatus === "waitlist")
);
}
/**
* The label of the card's call to action. Draft section 4 names all four:
* "Register", "Apply", "Read recap" and, for an event whose registration has
* not opened or has closed, plain "View details".
*/
export function eventActionKey(event: EventCardData): TranslationKey {
if (event.timeStatus === "completed") {
return event.recapUrl ? "actions.readRecap" : "actions.viewDetails";
}
if (
event.registrationStatus === "full" ||
event.registrationStatus === "waitlist"
) {
return "events.joinWaitlist";
}
if (
event.registrationStatus === "closed" ||
event.registrationStatus === "not-required"
) {
return "actions.viewDetails";
}
return "actions.register";
}
/** Catalogue key for the Upcoming / Ongoing / Completed badge. */
export function eventTimeStatusKey(status: EventTimeStatus): TranslationKey {
return status === "upcoming"
? "filters.upcoming"
: status === "ongoing"
? "filters.ongoing"
: "filters.completed";
}
/**
* The registration badge label.
*
* Not a catalogue KEY on its own: the shared catalogue names Open / Closed /
* Full, and `event.registrationStatus` has two states beyond those, so
* "not-required" comes from this section's copy in ./copy.ts and "waitlist"
* reads "Full" - which is what a reader needs to know about the places left.
*/
export function eventRegistrationLabel(
status: EventRegistrationStatus,
locale: Locale,
): string {
if (status === "not-required") {
return getRouteCopy(locale).eventDetail.registrationNotRequired;
}
const key: TranslationKey =
status === "open"
? "events.statusOpen"
: status === "closed"
? "events.statusClosed"
: "events.statusFull";
return translate(locale, key);
}
/**
* True when a submission joins the waitlist rather than taking a place.
*
* KNOWN SERVER-SIDE LIMITATION: the CMS middleware
* (apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts)
* hard-sets `registrationState: "confirmed"` on every create and the field is
* not in the client whitelist, so this intent CANNOT be transmitted. The page
* tells the reader the truth about the EVENT (it is full, this is a waitlist
* request); the stored row still says "confirmed" until that middleware is
* fixed, and staff triage it. Nothing here fakes a waitlist flag the API
* would reject.
*/
export function isWaitlisted(event: EventCardData): boolean {
return (
event.registrationStatus === "full" ||
event.registrationStatus === "waitlist"
);
}
/** Days until an event starts - used only to decide "registers soon" copy. */
export function daysUntil(event: EventInterval, now: Date = new Date()): number {
const start = new Date(event.start).getTime();
if (Number.isNaN(start)) return 0;
return Math.ceil((start - now.getTime()) / MS_PER_DAY);
}