initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -0,0 +1,247 @@
/* ============================================================================
ROUTE COPY - Events Calendar / Applications and Opportunities
----------------------------------------------------------------------------
The message catalogue in ~/i18n holds UI chrome that every page shares
(actions, filters, form labels, statuses). Page prose belongs to the page,
and lives here.
Every string below is taken from the approved draft,
docs/SHARPER_Website_Draft_Section_EN_AR.docx - section 4 "Events Calendar"
and section 8 "Applications and Opportunities" - in both languages. Where
the draft marks a value "TBD" (network eligibility criteria, the response
time in weeks, the project email, activity figures) NOTHING is invented: the
sentence either omits it or the value comes from the CMS.
Parity is enforced by the type: `RouteCopy` is derived from the English
object, so a key added on one side and forgotten on the other is a compile
error rather than a blank paragraph in production.
========================================================================= */
import type { Locale } from "~/i18n";
const EN = {
events: {
/** Draft 4, EN headline. */
heading: "Upcoming Events",
/** Draft 4, EN standfirst. */
intro:
"Register for a SUQ dialogue, join a training, or track what’s coming next across Libya.",
metaDescription:
"Dialogues, trainings and Civil Society Law Sessions across Libya. Register for a SUQ dialogue, join a training, or track what’s coming next.",
listLabel: "Events",
statusFilterLabel: "Status",
emptyHint:
"Dialogues, trainings and sessions are published here as soon as they are scheduled.",
unavailable:
"The events calendar cannot be loaded right now. Please try again in a moment.",
formatHybrid: "In person and online",
},
eventDetail: {
backToList: "Back to the events calendar",
aboutHeading: "About this event",
detailsHeading: "Event details",
registrationDeadlineLabel: "Registration deadline",
registerHeading: "Register for this event",
waitlistHeading: "Join the waitlist",
formIntro:
"We use these details only to manage your place and to send joining information.",
registrationClosed: "Registration for this event is closed.",
/** `event.registrationStatus === "not-required"`. */
registrationNotRequired: "No registration required",
registrationNotRequiredNote:
"This event is open to all - no registration is needed.",
registrationFull:
"This event is fully booked. Join the waitlist and we will contact you if a place becomes available.",
eventCompleted: "This event has already taken place.",
joinOnlineLabel: "Joining link",
addToCalendarHint:
"Downloads an .ics file you can open in any calendar application.",
timeZoneNote: "All times are shown in Libya time (UTC+2).",
/**
* `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader
* has to be asked in plain words rather than opted in silently.
*/
consentLabel:
"I agree that SHARP-ER may store the details above to manage my registration for this event.",
consentHint:
"We keep them only for as long as the event needs, and never share them with anyone outside the project. You can ask us to delete them at any time.",
/** Draft 4, EN confirmation screen, reused for the waitlist variant. */
waitlistConfirmationTitle: "You’re on the waitlist.",
waitlistConfirmationBody:
"Thank you — {event} on {date} is fully booked, so we have added you to the waitlist. We will email you if a place becomes available.",
},
opportunities: {
/** Draft 8, EN headline. */
heading: "Get Involved",
/** Draft 8, EN standfirst. */
intro:
"Whether you’re a civil society organisation or an individual, here’s how to take part in SHARP-ER.",
metaDescription:
"Calls for expression of interest, SHARP network membership and upcoming sessions — how civil society organisations and individuals take part in SHARP-ER.",
pathwaysHeading: "Ways to take part",
pathwayCallsTitle: "Apply for a Call for Expression of Interest",
pathwayCallsBody:
"Open calls for CSO sub-projects and Policy-to-Rights (P2R) initiatives.",
pathwayNetworkTitle: "Join the SHARP Network",
pathwayNetworkBody:
"Become part of the civil society network behind SHARP-ER: regional meetings, peer learning, and joint events.",
pathwaySessionsTitle: "Register for a SUQ Dialogue or Training",
pathwaySessionsBody:
"Take part in an upcoming Search, Understand, Question dialogue or a dialogue-facilitation training.",
listHeading: "Current calls",
listLabel: "Calls and opportunities",
statusFilterLabel: "Status",
empty: "There are no calls or opportunities to show yet.",
emptyHint:
"Calls for expression of interest are published here as soon as they open.",
unavailable:
"Calls and opportunities cannot be loaded right now. Please try again in a moment.",
},
/** `opportunity.opportunityType`, verbatim, with the draft's own wording. */
opportunityTypes: {
"call-eoi": "Call for Expression of Interest",
"network-membership": "Network membership",
training: "Training",
grant: "Grant",
other: "Opportunity",
},
opportunityDetail: {
backToList: "Back to applications and opportunities",
aboutHeading: "About this opportunity",
detailsHeading: "Call details",
howToApplyHeading: "How to apply",
attachmentsHeading: "Documents",
applyHeading: "Apply",
statusLabel: "Status",
typeLabel: "Type",
opensLabel: "Opens",
deadlineLabel: "Deadline",
formIntro:
"Tell us briefly why you are applying. Our team reviews every application.",
messageHint:
"Briefly describe your organisation and why you are applying.",
closedNote: "This call is closed. It stays published for reference.",
upcomingNote: "This call opens on {date}.",
externalApplication:
"Applications for this call are handled on an external site.",
/**
* Draft 8 marks the response time "[X weeks] - TBD" and the CMS has no
* field for it, so no number is ever named.
*/
applicationReceivedBody:
"Thank you for your application. Our team will review it and follow up by email.",
},
form: {
/** Honeypot label - never seen by a human, read by a bot. */
honeypotLabel: "Leave this field empty",
submissionFailed:
"Your details could not be submitted. Please try again in a moment.",
},
};
export type RouteCopy = typeof EN;
const AR: RouteCopy = {
events: {
heading: "الفعاليات القادمة",
intro:
"سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم في مختلف أنحاء ليبيا.",
metaDescription:
"حوارات وتدريبات وجلسات قانون المجتمع المدني في مختلف أنحاء ليبيا. سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم.",
listLabel: "الفعاليات",
statusFilterLabel: "الحالة",
emptyHint: "تُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.",
unavailable:
"تعذّر تحميل تقويم الفعاليات في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
formatHybrid: "حضوري وعبر الإنترنت",
},
eventDetail: {
backToList: "العودة إلى تقويم الفعاليات",
aboutHeading: "عن الفعالية",
detailsHeading: "تفاصيل الفعالية",
registrationDeadlineLabel: "الموعد النهائي للتسجيل",
registerHeading: "سجّل في هذه الفعالية",
waitlistHeading: "انضم إلى قائمة الانتظار",
formIntro:
"نستخدم هذه البيانات فقط لإدارة مكانك وإرسال تفاصيل الانضمام.",
registrationClosed: "أُغلق التسجيل في هذه الفعالية.",
registrationNotRequired: "لا يلزم التسجيل",
registrationNotRequiredNote:
"هذه الفعالية مفتوحة للجميع، ولا حاجة إلى التسجيل.",
registrationFull:
"اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.",
eventCompleted: "انتهت هذه الفعالية.",
joinOnlineLabel: "رابط الانضمام",
addToCalendarHint: "يُنزّل ملف .ics يمكن فتحه في أي تطبيق تقويم.",
timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).",
consentLabel:
"أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.",
consentHint:
"نحتفظ بها فقط للمدة التي تتطلبها الفعالية، ولا نشاركها مع أي جهة خارج المشروع. ويمكنك أن تطلب حذفها في أي وقت.",
waitlistConfirmationTitle: "تمت إضافتك إلى قائمة الانتظار.",
waitlistConfirmationBody:
"شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.",
},
opportunities: {
heading: "شارك معنا",
intro:
"سواء كنتم منظمة مجتمع مدني أو فردًا، إليكم كيفية المشاركة في SHARP-ER.",
metaDescription:
"دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.",
pathwaysHeading: "طرق المشاركة",
pathwayCallsTitle: "التقديم على دعوة للتعبير عن الاهتمام",
pathwayCallsBody:
"دعوات مفتوحة للمشاريع الفرعية لمنظمات المجتمع المدني ومبادرات «من السياسة إلى الحقوق» (P2R).",
pathwayNetworkTitle: "انضم إلى شبكة SHARP",
pathwayNetworkBody:
"كن جزءًا من شبكة المجتمع المدني وراء SHARP-ER: اجتماعات إقليمية، وتعلّم بين الأقران، وفعاليات مشتركة.",
pathwaySessionsTitle: "سجّل في حوار SUQ أو تدريب",
pathwaySessionsBody:
"شارك في حوار قادم من نوع «ابحث، افهم، اسأل» أو في تدريب على تيسير الحوار.",
listHeading: "الدعوات الحالية",
listLabel: "الدعوات والفرص",
statusFilterLabel: "الحالة",
empty: "لا توجد دعوات أو فرص لعرضها حتى الآن.",
emptyHint: "تُنشر هنا دعوات التعبير عن الاهتمام فور فتحها.",
unavailable:
"تعذّر تحميل الدعوات والفرص في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
},
opportunityTypes: {
"call-eoi": "دعوة للتعبير عن الاهتمام",
"network-membership": "عضوية الشبكة",
training: "تدريب",
grant: "منحة",
other: "فرصة",
},
opportunityDetail: {
backToList: "العودة إلى طلبات وفرص المشاركة",
aboutHeading: "عن هذه الفرصة",
detailsHeading: "تفاصيل الدعوة",
howToApplyHeading: "كيفية التقديم",
attachmentsHeading: "المستندات",
applyHeading: "قدّم طلبك",
statusLabel: "الحالة",
typeLabel: "النوع",
opensLabel: "تاريخ الفتح",
deadlineLabel: "الموعد النهائي",
formIntro: "أخبرنا باختصار عن سبب تقديمك. يراجع فريقنا كل طلب.",
messageHint: "صف باختصار منظمتك وسبب تقديمك.",
closedNote: "أُغلقت هذه الدعوة، وتبقى منشورة للاطّلاع.",
upcomingNote: "تُفتح هذه الدعوة بتاريخ {date}.",
externalApplication: "يتم التقديم على هذه الدعوة عبر موقع خارجي.",
applicationReceivedBody:
"شكرًا لتقديم طلبك. سيراجعه فريقنا ويتواصل معك عبر البريد الإلكتروني.",
},
form: {
honeypotLabel: "اترك هذا الحقل فارغًا",
submissionFailed:
"تعذّر إرسال بياناتك. يرجى المحاولة مرة أخرى بعد قليل.",
},
};
const COPY: Readonly<Record<Locale, RouteCopy>> = { en: EN, ar: AR };
/** The route copy for a locale. Falls back to English, never to "". */
export function getRouteCopy(locale: Locale): RouteCopy {
return COPY[locale] ?? EN;
}
@@ -0,0 +1,231 @@
/* ============================================================================
EVENTS - Strapi access. SERVER ONLY.
----------------------------------------------------------------------------
Maps the CMS entity onto the plain view model in ./events.ts, so the route
component never sees a Strapi shape and the loader payload stays small and
serialisable. The fault-tolerant fetch helpers come from ../_shared/cms, so
this section degrades exactly like Resources, Knowledge Hub and News do.
CONTENT TYPE api::event.event (collection, i18n enabled, draft & publish)
The field names below are copied from
apps/cms/src/api/event/content-types/event/schema.json - they are the
contract, and getting one wrong is a 400 from Strapi, not a blank field.
slug uid, required, targets `title`
title string, required
description text (max 500) - the card excerpt
body richtext - the detail page body
eventType enum suq-dialogue | training | law-session |
network-meeting | launch | other
startDateTime datetime, required
endDateTime datetime
timezone string, default "Africa/Tripoli"
format enum in-person | online | hybrid
venue string - "Bab Al-Bahr Hotel". There is NO
`city` field.
onlineUrl string - joining link for online / hybrid
region RELATION -> api::region.region (populate it; a
region is an entry, not a string)
registrationStatus enum open | closed | full | waitlist | not-required
capacity integer
waitlistEnabled boolean
registrationDeadline datetime
recapUrl string - "Read recap" for a past event
coverImage media (single image)
There is no external "registrationUrl" on this type: registration is either
handled by the on-site form or it is not open.
WHY THE WHOLE SET IS FETCHED
Upcoming / Ongoing / Completed are derived from the dates (see ./events.ts)
and cannot be expressed as a Strapi filter without duplicating the rule in
two places, where it would drift. The calendar of a two-year project is
small, so one request brings it back and the filtering, sorting and paging
happen in memory. EVENTS_FETCH_LIMIT is the ceiling; raise it or move to
date-range queries if the project ever outgrows it.
========================================================================= */
import type { Locale } from "~/i18n";
import type { StrapiEntity, StrapiMedia } from "~/lib/strapi";
import {
collectLocalizedSlugs,
LOCALIZATION_POPULATE,
type CmsLocalizable,
type LocalizedSlugs,
} from "../_shared/alternates";
import {
fetchBySlug,
fetchCollection,
toImage,
type CmsRichText,
type CmsTaxonomy,
} from "../_shared/cms";
import {
deriveTimeStatus,
EVENT_REGISTRATION_STATUSES,
isDateOnly,
type EventCardData,
type EventDetailData,
type EventFormat,
type EventRegistrationStatus,
} from "./events";
export const EVENTS_RESOURCE = "events";
/** Hard ceiling on one listing request. */
export const EVENTS_FETCH_LIMIT = 200;
interface StrapiEvent extends StrapiEntity, CmsLocalizable {
readonly slug: string;
readonly title: string;
readonly description?: string | null;
readonly body?: CmsRichText;
readonly startDateTime: string;
readonly endDateTime?: string | null;
readonly format?: EventFormat | null;
readonly venue?: string | null;
readonly region?: CmsTaxonomy | null;
readonly onlineUrl?: string | null;
readonly registrationStatus?: EventRegistrationStatus | null;
readonly registrationDeadline?: string | null;
readonly waitlistEnabled?: boolean | null;
readonly recapUrl?: string | null;
readonly coverImage?: StrapiMedia | null;
}
const FORMATS: readonly EventFormat[] = ["in-person", "online", "hybrid"];
/** What one listing request populates. Named so the two queries cannot drift. */
const LIST_POPULATE = {
coverImage: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
region: { fields: ["name", "slug"] },
} as const;
/* ---------------------------------------------------------------------------
MAPPING
------------------------------------------------------------------------ */
/** Trimmed, or undefined - so an empty CMS string never renders as a gap. */
function text(value: string | null | undefined): string | undefined {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed.length > 0 ? trimmed : undefined;
}
function oneOf<T extends string>(
value: unknown,
allowed: readonly T[],
): T | undefined {
return typeof value === "string" && (allowed as readonly string[]).includes(value)
? (value as T)
: undefined;
}
function toCard(entry: StrapiEvent, now: Date): EventCardData {
const start = entry.startDateTime;
const end = text(entry.endDateTime);
const allDay = isDateOnly(start);
return {
slug: entry.slug,
title: entry.title,
summary: text(entry.description),
start,
end,
allDay,
format: oneOf(entry.format, FORMATS),
place: text(entry.venue),
region: text(entry.region?.name),
/* The CMS default is "not-required", not "open": an event nobody has
opened registration for must not invite one. */
registrationStatus:
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
"not-required",
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
recapUrl: text(entry.recapUrl),
image: toImage(entry.coverImage, "medium"),
};
}
function toDetail(entry: StrapiEvent, now: Date): EventDetailData {
return {
...toCard(entry, now),
documentId: entry.documentId,
description: entry.body ?? undefined,
onlineUrl: text(entry.onlineUrl),
registrationDeadline: text(entry.registrationDeadline),
venue: text(entry.venue),
waitlistEnabled: entry.waitlistEnabled === true,
};
}
/* ---------------------------------------------------------------------------
QUERIES
------------------------------------------------------------------------ */
export interface EventListResult {
readonly events: EventCardData[];
/**
* False when the collection could not be read at all. The listing then
* renders its empty state with a notice rather than a 500 - an unreachable
* calendar is not a broken page, and the rest of the site still works.
*/
readonly available: boolean;
}
/** Every published event for a locale, mapped against a single "now". */
export async function fetchEvents(
locale: Locale,
now: Date = new Date(),
): Promise<EventListResult> {
const result = await fetchCollection<StrapiEvent>(EVENTS_RESOURCE, {
locale,
sort: ["startDateTime:asc"],
populate: LIST_POPULATE,
pagination: { page: 1, pageSize: EVENTS_FETCH_LIMIT },
});
return {
events: result.entries
.filter((entry) => Boolean(entry?.slug && entry.startDateTime))
.map((entry) => toCard(entry, now)),
available: result.available,
};
}
/**
* One event by slug, or null when the CMS has no such entry.
*
* A detail page does not degrade the way the listing does: a missing entry is
* a genuine 404, and a CMS outage propagates as a 500 rather than telling a
* crawler that a real event does not exist.
*/
export interface EventDetailResult {
readonly event: EventDetailData;
/** The slug of this event in every language the CMS holds it in. */
readonly alternates: LocalizedSlugs;
}
export async function fetchEvent(
slug: string,
locale: Locale,
now: Date = new Date(),
): Promise<EventDetailResult | null> {
const entry = await fetchBySlug<StrapiEvent>(EVENTS_RESOURCE, slug, {
locale,
populate: {
...LIST_POPULATE,
// The Arabic translation has its own slug - see ../_shared/alternates.ts.
localizations: LOCALIZATION_POPULATE,
},
});
if (!entry) return null;
return {
event: toDetail(entry, now),
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
};
}
@@ -0,0 +1,408 @@
/* ============================================================================
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";
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;
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);
}
@@ -0,0 +1,152 @@
/* ============================================================================
FORM PARSING AND VALIDATION - pure
----------------------------------------------------------------------------
Actions validate, components render. The action returns error CODES, never
sentences, so the same result is correct on /en and /ar and the message
catalogue stays the only place a wording lives.
Both forms work without JavaScript: they are plain <Form method="post">
submissions, so validation has to be complete on the server. There is no
client mirror of these rules to drift out of sync.
========================================================================= */
import type { TranslationKey } from "~/i18n";
/**
* The failures a reader can act on. Codes, not sentences.
*
* `consent` is its own code because "This field is required." is the wrong
* sentence for an unticked checkbox: an unticked box is a decision, not an
* omission, so it gets its own wording.
*/
export type FieldErrorCode = "required" | "email" | "consent";
export type FieldErrors = Readonly<Record<string, FieldErrorCode>>;
/**
* Message catalogue key for each code - TOTAL over `FieldErrorCode`, so a new
* code cannot be added without giving it a sentence in both languages. The
* catalogue is the only place a wording lives (see the header), which is why
* `consent` resolves here and not out of this section's own copy.
*/
export const FIELD_ERROR_KEYS: Readonly<
Record<FieldErrorCode, TranslationKey>
> = {
required: "form.errorRequired",
email: "form.errorEmail",
consent: "form.errorConsent",
};
/**
* A field no human fills in. A bot that fills every input gets a 200 and an
* acknowledgement, and nothing is written to the CMS.
*/
export const HONEYPOT_FIELD = "website";
/* Deliberately permissive: one @, no spaces, a dot in the domain. Anything
stricter rejects valid addresses, and only a delivered email proves one. */
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
/** Upper bounds, applied by truncation rather than by an error message. */
const LIMITS = {
name: 120,
organisation: 160,
email: 254,
phone: 40,
message: 4000,
} as const;
function field(form: FormData, name: string, limit: number): string {
const value = form.get(name);
return typeof value === "string" ? value.trim().slice(0, limit) : "";
}
export function isHoneypotFilled(form: FormData): boolean {
const value = form.get(HONEYPOT_FIELD);
return typeof value === "string" && value.trim().length > 0;
}
/* ---------------------------------------------------------------------------
EVENT REGISTRATION - draft 4
------------------------------------------------------------------------ */
export interface RegistrationValues {
readonly fullName: string;
readonly email: string;
readonly organisation: string;
readonly phone: string;
/**
* `event-registration.consentGdpr` is REQUIRED in the CMS: a submission
* without it is rejected with a 400, so the checkbox is a real, labelled
* control on the form and a real validation rule here - never a hidden
* input that consents on the reader's behalf.
*/
readonly consentGdpr: boolean;
}
/** The name of the consent checkbox, shared by the form and the validator. */
export const CONSENT_FIELD = "consentGdpr";
/** A checkbox is either absent or carries its value ("on" by default). */
function checkbox(form: FormData, name: string): boolean {
const value = form.get(name);
return typeof value === "string" && value.length > 0 && value !== "false";
}
export interface Validated<T> {
readonly values: T;
readonly errors: FieldErrors;
readonly valid: boolean;
}
export function validateRegistration(
form: FormData,
): Validated<RegistrationValues> {
const values: RegistrationValues = {
fullName: field(form, "fullName", LIMITS.name),
email: field(form, "email", LIMITS.email),
organisation: field(form, "organisation", LIMITS.organisation),
phone: field(form, "phone", LIMITS.phone),
consentGdpr: checkbox(form, CONSENT_FIELD),
};
const errors: Record<string, FieldErrorCode> = {};
if (!values.fullName) errors.fullName = "required";
if (!values.email) errors.email = "required";
else if (!EMAIL.test(values.email)) errors.email = "email";
if (!values.consentGdpr) errors[CONSENT_FIELD] = "consent";
return { values, errors, valid: Object.keys(errors).length === 0 };
}
/* ---------------------------------------------------------------------------
OPPORTUNITY APPLICATION - draft 8
------------------------------------------------------------------------ */
export interface ApplicationValues {
readonly fullName: string;
readonly email: string;
readonly organisation: string;
readonly message: string;
}
export function validateApplication(
form: FormData,
): Validated<ApplicationValues> {
const values: ApplicationValues = {
fullName: field(form, "fullName", LIMITS.name),
email: field(form, "email", LIMITS.email),
organisation: field(form, "organisation", LIMITS.organisation),
message: field(form, "message", LIMITS.message),
};
const errors: Record<string, FieldErrorCode> = {};
if (!values.fullName) errors.fullName = "required";
if (!values.email) errors.email = "required";
else if (!EMAIL.test(values.email)) errors.email = "email";
if (!values.message) errors.message = "required";
return { values, errors, valid: Object.keys(errors).length === 0 };
}
@@ -0,0 +1,219 @@
/* ============================================================================
iCALENDAR (RFC 5545)
----------------------------------------------------------------------------
Builds the file behind "Add to calendar". Pure, dependency-free and written
against the spec rather than against one client's tolerance:
* CRLF line endings, and content lines folded at 75 OCTETS - folded on
character boundaries, so an Arabic title (2 bytes per letter) is never
cut in half;
* TEXT values escaped for \ ; , and newlines;
* timed events written as UTC (`...Z`), all-day events as VALUE=DATE with
a DTEND on the day AFTER the last day, which is what "exclusive end"
means in the spec and what every calendar client expects;
* a stable UID, so re-importing an updated file replaces the entry
instead of duplicating it.
No ORGANIZER property: it requires a real mailto: address, and the project
inbox is still marked TBD in the draft copy. An invented one would end up in
readers' calendars.
========================================================================= */
const CRLF = "\r\n";
/** RFC 5545 3.1: a content line is at most 75 octets, excluding the CRLF. */
const MAX_OCTETS = 75;
const encoder = new TextEncoder();
export type IcsStatus = "CONFIRMED" | "TENTATIVE" | "CANCELLED";
export interface IcsEvent {
/** Globally unique and STABLE for this event. */
readonly uid: string;
/** ISO 8601. */
readonly start: string;
readonly end?: string | undefined;
/** Write the all-day form (VALUE=DATE) instead of a timed one. */
readonly allDay?: boolean;
readonly summary: string;
readonly description?: string | undefined;
readonly location?: string | undefined;
readonly url?: string | undefined;
readonly status?: IcsStatus;
/** DTSTAMP. Defaults to now; pass a fixed value in a test. */
readonly stamp?: Date;
}
export interface IcsCalendarOptions {
/** PRODID. Identifies the software that wrote the file. */
readonly prodId?: string;
/** X-WR-CALNAME, shown by some clients when a file is imported. */
readonly name?: string;
}
const DEFAULT_PROD_ID = "-//SHARP-ER//Events Calendar//EN";
/* ---------------------------------------------------------------------------
VALUE ENCODING
------------------------------------------------------------------------ */
/**
* Control characters a TEXT value may not carry. TAB is legal and the two
* newline characters are escaped before this runs, so neither is in the class.
*/
const CONTROL_CHARS =
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
/** RFC 5545 3.3.11 TEXT escaping, plus control characters dropped. */
export function escapeIcsText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r\n|\r|\n/g, "\\n")
.replace(CONTROL_CHARS, "");
}
/** "20260312T140000Z" */
function toUtcStamp(date: Date): string {
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
/** "20260312" - the calendar day of a date-only value, read in UTC. */
function toDateValue(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, "");
}
function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 86_400_000);
}
/**
* Folds one content line to 75 octets. Continuation lines begin with a single
* space, which the parser strips again.
*/
export function foldIcsLine(line: string): string {
if (encoder.encode(line).length <= MAX_OCTETS) return line;
const parts: string[] = [];
let current = "";
let octets = 0;
// Iterating the string yields whole code points, so a multi-byte character
// is never split across two lines.
for (const char of line) {
const size = encoder.encode(char).length;
if (octets + size > MAX_OCTETS) {
parts.push(current);
current = " ";
octets = 1;
}
current += char;
octets += size;
}
parts.push(current);
return parts.join(CRLF);
}
/* ---------------------------------------------------------------------------
BUILDER
------------------------------------------------------------------------ */
function property(name: string, value: string): string {
return foldIcsLine(`${name}:${value}`);
}
function eventLines(event: IcsEvent): string[] {
const start = new Date(event.start);
if (Number.isNaN(start.getTime())) return [];
const parsedEnd = event.end ? new Date(event.end) : undefined;
const end =
parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : undefined;
const lines: string[] = ["BEGIN:VEVENT"];
lines.push(property("UID", escapeIcsText(event.uid)));
lines.push(property("DTSTAMP", toUtcStamp(event.stamp ?? new Date())));
if (event.allDay) {
lines.push(`DTSTART;VALUE=DATE:${toDateValue(start)}`);
// DTEND is exclusive: the day after the last day of the event.
lines.push(`DTEND;VALUE=DATE:${toDateValue(addDays(end ?? start, 1))}`);
} else {
lines.push(property("DTSTART", toUtcStamp(start)));
if (end && end.getTime() > start.getTime()) {
lines.push(property("DTEND", toUtcStamp(end)));
}
}
lines.push(property("SUMMARY", escapeIcsText(event.summary)));
if (event.description) {
lines.push(property("DESCRIPTION", escapeIcsText(event.description)));
}
if (event.location) {
lines.push(property("LOCATION", escapeIcsText(event.location)));
}
if (event.url) {
// A URI value is not TEXT and must not be escaped.
lines.push(property("URL", event.url));
}
lines.push(property("STATUS", event.status ?? "CONFIRMED"));
lines.push("END:VEVENT");
return lines;
}
/** A complete VCALENDAR document, CRLF-terminated as the spec requires. */
export function buildIcsCalendar(
events: readonly IcsEvent[],
options: IcsCalendarOptions = {},
): string {
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
property("PRODID", options.prodId ?? DEFAULT_PROD_ID),
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
];
if (options.name) {
lines.push(property("X-WR-CALNAME", escapeIcsText(options.name)));
}
for (const event of events) lines.push(...eventLines(event));
lines.push("END:VCALENDAR");
return `${lines.join(CRLF)}${CRLF}`;
}
/* ---------------------------------------------------------------------------
RESPONSE
------------------------------------------------------------------------ */
/** ASCII-safe file name, with the UTF-8 original kept in `filename*`. */
function contentDisposition(filename: string): string {
const ascii = filename.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
const fallback = ascii.length > 4 ? ascii : "event.ics";
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(
filename,
)}`;
}
/** The whole HTTP response for a .ics download. */
export function icsResponse(body: string, filename: string): Response {
return new Response(body, {
status: 200,
headers: {
"content-type": "text/calendar; charset=utf-8",
"content-disposition": contentDisposition(filename),
// The file changes whenever the event does, and it is cheap to rebuild.
"cache-control": "public, max-age=0, must-revalidate",
},
});
}
@@ -0,0 +1,212 @@
/* ============================================================================
OPPORTUNITIES - Strapi access. SERVER ONLY.
----------------------------------------------------------------------------
CONTENT TYPE api::opportunity.opportunity
(collection, i18n enabled, draft & publish)
Field names copied from
apps/cms/src/api/opportunity/content-types/opportunity/schema.json.
slug uid, required, targets `title`
title string, required
summary text (max 500) - the card excerpt
body richtext
eligibility richtext - draft §8 marks the network
criteria "TBD"; the page shows
this block only when it is filled
howToApply richtext
opportunityType enum call-eoi | network-membership | training |
grant | other
opportunityStatus enum upcoming | open | closed (required, default
"upcoming" - see deriveOpportunityStatus)
openDate date - before it, the call is "Upcoming"
deadline datetime - inclusive; after it, "Closed"
externalApplicationUrl string - an EXTERNAL portal; when set,
the on-site form is not rendered
attachments media (multiple) - ToR, guidelines, annexes
coverImage media (single image)
There is NO `applicationEmail` and NO `responseWeeks` on this type: the
draft marks the response time TBD and the CMS never grew a field for it, so
the acknowledgement never names a number.
Where the applications collection is written: ./applications.server.ts.
========================================================================= */
import type { Locale } from "~/i18n";
import type { StrapiEntity, StrapiMedia } from "~/lib/strapi";
import {
collectLocalizedSlugs,
LOCALIZATION_POPULATE,
type CmsLocalizable,
type LocalizedSlugs,
} from "../_shared/alternates";
import {
fetchBySlug,
fetchCollection,
toFile,
toImage,
type CmsFile,
type CmsRichText,
} from "../_shared/cms";
import {
deriveOpportunityStatus,
OPPORTUNITY_TYPES,
type OpportunityCardData,
type OpportunityDetailData,
type OpportunityStatus,
type OpportunityType,
} from "./opportunities";
export const OPPORTUNITIES_RESOURCE = "opportunities";
/** Hard ceiling on one listing request. */
export const OPPORTUNITIES_FETCH_LIMIT = 100;
interface StrapiOpportunity extends StrapiEntity, CmsLocalizable {
readonly slug: string;
readonly title: string;
readonly summary?: string | null;
readonly body?: CmsRichText;
readonly eligibility?: CmsRichText;
readonly howToApply?: CmsRichText;
readonly opportunityType?: OpportunityType | null;
readonly opportunityStatus?: OpportunityStatus | null;
readonly openDate?: string | null;
readonly deadline?: string | null;
readonly externalApplicationUrl?: string | null;
readonly attachments?: readonly StrapiMedia[] | null;
readonly coverImage?: StrapiMedia | null;
}
const STATUSES: readonly OpportunityStatus[] = ["open", "closed", "upcoming"];
const LIST_POPULATE = {
coverImage: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
} as const;
/* ---------------------------------------------------------------------------
MAPPING
------------------------------------------------------------------------ */
function text(value: string | null | undefined): string | undefined {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed.length > 0 ? trimmed : undefined;
}
function oneOf<T extends string>(
value: unknown,
allowed: readonly T[],
): T | undefined {
return typeof value === "string" && (allowed as readonly string[]).includes(value)
? (value as T)
: undefined;
}
function toCard(entry: StrapiOpportunity, now: Date): OpportunityCardData {
return {
slug: entry.slug,
title: entry.title,
summary: text(entry.summary),
status: deriveOpportunityStatus(
{
openDate: text(entry.openDate),
deadline: text(entry.deadline),
status: oneOf(entry.opportunityStatus, STATUSES),
},
now,
),
openDate: text(entry.openDate),
deadline: text(entry.deadline),
opportunityType: oneOf(entry.opportunityType, OPPORTUNITY_TYPES),
image: toImage(entry.coverImage, "medium"),
};
}
function toDetail(entry: StrapiOpportunity, now: Date): OpportunityDetailData {
const attachments = (entry.attachments ?? [])
.map((media) => toFile(media))
.filter((file): file is CmsFile => file !== undefined);
return {
...toCard(entry, now),
documentId: entry.documentId,
description: entry.body ?? undefined,
eligibility: entry.eligibility ?? undefined,
howToApply: entry.howToApply ?? undefined,
externalApplicationUrl: text(entry.externalApplicationUrl),
attachments,
};
}
/* ---------------------------------------------------------------------------
QUERIES
------------------------------------------------------------------------ */
export interface OpportunityListResult {
readonly opportunities: OpportunityCardData[];
/** False when the collection could not be read at all. */
readonly available: boolean;
}
/**
* Every published opportunity for a locale. Open / Upcoming / Closed is
* derived from the dates (see ./opportunities.ts), so - as with the events
* calendar - the set is fetched once and filtered in memory rather than
* duplicating that rule in a Strapi query.
*/
export async function fetchOpportunities(
locale: Locale,
now: Date = new Date(),
): Promise<OpportunityListResult> {
const result = await fetchCollection<StrapiOpportunity>(
OPPORTUNITIES_RESOURCE,
{
locale,
sort: ["deadline:asc", "title:asc"],
populate: LIST_POPULATE,
pagination: { page: 1, pageSize: OPPORTUNITIES_FETCH_LIMIT },
},
);
return {
opportunities: result.entries
.filter((entry) => Boolean(entry?.slug && entry.title))
.map((entry) => toCard(entry, now)),
available: result.available,
};
}
export interface OpportunityDetailResult {
readonly opportunity: OpportunityDetailData;
readonly alternates: LocalizedSlugs;
}
/** One opportunity by slug, or null when the CMS has no such entry. */
export async function fetchOpportunity(
slug: string,
locale: Locale,
now: Date = new Date(),
): Promise<OpportunityDetailResult | null> {
const entry = await fetchBySlug<StrapiOpportunity>(
OPPORTUNITIES_RESOURCE,
slug,
{
locale,
populate: {
...LIST_POPULATE,
attachments: { fields: ["url", "name", "ext", "mime", "size"] },
localizations: LOCALIZATION_POPULATE,
},
},
);
if (!entry) return null;
return {
opportunity: toDetail(entry, now),
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
};
}
@@ -0,0 +1,227 @@
/* ============================================================================
OPPORTUNITIES - view model, status derivation
----------------------------------------------------------------------------
PURE. Strapi access lives in ./opportunities.server.ts.
Draft section 8 asks for a status tag of Open / Closed, and the listing
additionally distinguishes a call that has been announced but has not opened
yet. The status is derived from `openDate` / `deadline` so it cannot go
stale; `opportunityStatus` in the CMS is required and defaults to
"upcoming", so it is used as an editorial CLOSE and as the fallback when a
call carries no dates at all - never as a blanket override, which would let
a forgotten default hide an open call.
========================================================================= */
import {
formatDate,
formatFileSize,
type Locale,
type TranslationKey,
} from "~/i18n";
import type { CmsFile, CmsImage, CmsRichText } from "../_shared/cms";
/* ---------------------------------------------------------------------------
TYPES
------------------------------------------------------------------------ */
export type OpportunityStatus = "open" | "closed" | "upcoming";
/** The filter tabs: All - Open - Upcoming - Closed. */
export type OpportunityStatusFilter = "all" | OpportunityStatus;
export const OPPORTUNITY_STATUS_FILTERS: readonly OpportunityStatusFilter[] = [
"all",
"open",
"upcoming",
"closed",
];
export function isOpportunityStatusFilter(
value: unknown,
): value is OpportunityStatusFilter {
return (
typeof value === "string" &&
(OPPORTUNITY_STATUS_FILTERS as readonly string[]).includes(value)
);
}
/**
* `opportunity.opportunityType` in the CMS, verbatim. Only
* `network-membership` carries behaviour - it is what the "Join the SHARP
* Network" card links to when the CMS holds such an entry.
*/
export type OpportunityType =
| "call-eoi"
| "network-membership"
| "training"
| "grant"
| "other";
export const OPPORTUNITY_TYPES = [
"call-eoi",
"network-membership",
"training",
"grant",
"other",
] as const satisfies readonly OpportunityType[];
export interface OpportunityCardData {
readonly slug: string;
readonly title: string;
readonly summary?: string;
readonly status: OpportunityStatus;
/** ISO date the call opens - `openDate` in the CMS. */
readonly openDate?: string;
/** ISO date applications close. */
readonly deadline?: string;
readonly opportunityType?: OpportunityType;
readonly image?: CmsImage;
}
export interface OpportunityDetailData extends OpportunityCardData {
readonly documentId: string;
readonly description?: CmsRichText;
readonly eligibility?: CmsRichText;
readonly howToApply?: CmsRichText;
/** An external application portal. When set, the on-site form is not shown. */
readonly externalApplicationUrl?: string;
/** ToR, guidelines, annexes - `attachments` in the CMS. */
readonly attachments: readonly CmsFile[];
}
/* ---------------------------------------------------------------------------
STATUS
------------------------------------------------------------------------ */
export interface OpportunityWindow {
readonly openDate?: string | null;
readonly deadline?: string | null;
/** `opportunity.opportunityStatus`. Required in the CMS, default "upcoming". */
readonly status?: OpportunityStatus | null;
}
/** A deadline is inclusive: a call closes at the end of its closing day. */
function endOfDay(value: string): number {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return Number.NaN;
date.setUTCHours(23, 59, 59, 999);
return date.getTime();
}
/**
* Precedence, in order:
* 1. an editorial "closed" - a call can be suspended or closed early, and
* that decision must win over a deadline that has not passed yet;
* 2. the dates, when the entry carries any - they cannot go stale;
* 3. whatever `opportunityStatus` says, for a call with no dates at all.
*/
export function deriveOpportunityStatus(
entry: OpportunityWindow,
now: Date = new Date(),
): OpportunityStatus {
if (entry.status === "closed") return "closed";
const at = now.getTime();
let dated = false;
if (entry.openDate) {
const opens = new Date(entry.openDate).getTime();
if (!Number.isNaN(opens)) {
dated = true;
if (at < opens) return "upcoming";
}
}
if (entry.deadline) {
const closes = endOfDay(entry.deadline);
if (!Number.isNaN(closes)) {
dated = true;
if (at > closes) return "closed";
}
}
return dated ? "open" : (entry.status ?? "open");
}
/** Catalogue key for the Open / Upcoming / Closed badge. */
export function opportunityStatusKey(
status: OpportunityStatus,
): TranslationKey {
return status === "open"
? "opportunities.statusOpen"
: status === "upcoming"
? "filters.upcoming"
: "opportunities.statusClosed";
}
/** Label of the card's call to action - only an open call invites an apply. */
export function opportunityActionKey(
status: OpportunityStatus,
): TranslationKey {
return status === "open" ? "actions.apply" : "actions.viewDetails";
}
/* ---------------------------------------------------------------------------
LIST OPERATIONS
------------------------------------------------------------------------ */
function deadlineTime(item: OpportunityCardData): number {
const value = item.deadline ? new Date(item.deadline).getTime() : Number.NaN;
return Number.isNaN(value) ? Number.POSITIVE_INFINITY : value;
}
const STATUS_ORDER: Readonly<Record<OpportunityStatus, number>> = {
open: 0,
upcoming: 1,
closed: 2,
};
/** Open calls first with the nearest deadline at the top, closed ones last. */
export function sortOpportunities(
items: readonly OpportunityCardData[],
): OpportunityCardData[] {
return [...items].sort((a, b) => {
const byStatus = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
if (byStatus !== 0) return byStatus;
return a.status === "closed"
? deadlineTime(b) - deadlineTime(a)
: deadlineTime(a) - deadlineTime(b);
});
}
export function filterOpportunities(
items: readonly OpportunityCardData[],
status: OpportunityStatusFilter,
): OpportunityCardData[] {
if (status === "all") return [...items];
return items.filter((item) => item.status === status);
}
/** The CMS entry the "Join the SHARP Network" card should link to, if any. */
export function findNetworkOpportunity(
items: readonly OpportunityCardData[],
): OpportunityCardData | undefined {
return items.find((item) => item.opportunityType === "network-membership");
}
/* ---------------------------------------------------------------------------
FORMATTING
------------------------------------------------------------------------ */
/** "12 March 2026" - a deadline is a calendar day, never a time of day. */
export function formatOpportunityDate(
value: string | null | undefined,
locale: Locale,
): string {
return formatDate(value, locale, { dateStyle: "long", timeZone: "UTC" });
}
/** "PDF · 1.2 MB", or just the type when the upload records no size. */
export function formatAttachmentMeta(file: CmsFile, locale: Locale): string {
const size =
file.sizeBytes > 0 ? formatFileSize(file.sizeBytes, locale) : undefined;
return [file.kind || undefined, size].filter(Boolean).join(" · ");
}
@@ -0,0 +1,71 @@
/* ============================================================================
ROOT LOADER DATA, READ FROM `meta`
----------------------------------------------------------------------------
`buildMeta` needs the locale, the pathname and the site origin, and all
three are resolved once per request by the root loader. A `meta` function
reaches them through its `matches`.
React Router 8 names that property `loaderData` on every match (see
`CreateMetaArgs` in react-router/lib/types/route-module-annotations.d.ts and
the `Meta` component that builds the array). Version 7 called it `data`, and
plenty of examples still do, so both are read here.
Defensive rather than indexed: `matches[0]` has no data at all while the
root loader is in flight or on an error render, and a `meta` that throws
takes the whole document down with it.
========================================================================= */
import { DEFAULT_LOCALE, isLocale, localizedPath, type Locale } from "~/i18n";
export interface RootSeo {
readonly locale: Locale;
/** Pathname INCLUDING the locale prefix. */
readonly pathname: string;
/** Site origin without a trailing slash. */
readonly siteUrl: string;
}
/** Matches the default in ~/lib/env.server, for the case root data is absent. */
const FALLBACK_SITE_URL = "https://sharper.ly";
function readMatchData(match: unknown): Record<string, unknown> | undefined {
if (!match || typeof match !== "object") return undefined;
const { id, loaderData, data } = match as {
id?: unknown;
loaderData?: unknown;
data?: unknown;
};
if (id !== "root") return undefined;
const payload = loaderData ?? data;
return payload && typeof payload === "object"
? (payload as Record<string, unknown>)
: undefined;
}
/** The root loader's `{ locale, pathname, siteUrl }`, with safe fallbacks. */
export function readRootSeo(matches: readonly unknown[]): RootSeo {
for (const match of matches) {
const record = readMatchData(match);
if (!record) continue;
const locale = isLocale(record.locale) ? record.locale : DEFAULT_LOCALE;
return {
locale,
pathname:
typeof record.pathname === "string"
? record.pathname
: localizedPath("/", locale),
siteUrl:
typeof record.siteUrl === "string" ? record.siteUrl : FALLBACK_SITE_URL,
};
}
return {
locale: DEFAULT_LOCALE,
pathname: localizedPath("/", DEFAULT_LOCALE),
siteUrl: FALLBACK_SITE_URL,
};
}
@@ -0,0 +1,112 @@
/* ============================================================================
STRAPI WRITE - SERVER ONLY
----------------------------------------------------------------------------
~/lib/strapi.ts is a read client: the site renders from the CMS and never
mutates it. The two public forms in this section are the exception - an
event registration and an application to a call - so the POST lives here,
next to the routes that need it, and reuses that module's error type so a
caller handles one failure shape.
The `.server` suffix keeps it out of the browser bundle; it is imported by
`action` only.
========================================================================= */
import { getServerEnv } from "~/lib/env.server";
import { StrapiError } from "~/lib/strapi";
const DEFAULT_TIMEOUT_MS = 10_000;
export interface StrapiWriteInit {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
}
interface StrapiErrorEnvelope {
readonly error?: {
readonly status?: number;
readonly name?: string;
readonly message?: string;
readonly details?: unknown;
};
}
/**
* `POST /api/{resource}` with Strapi's `{ data }` envelope, returning the
* created entity. Every failure - transport, timeout, 4xx, 5xx - arrives as a
* StrapiError, so the action can answer with one localised message instead of
* leaking the CMS's own wording to the reader.
*/
export async function createStrapiEntry<T>(
resource: string,
data: Readonly<Record<string, unknown>>,
init: StrapiWriteInit = {},
): Promise<T> {
const env = getServerEnv();
const url = `${env.STRAPI_URL}/api/${resource.replace(/^\/+/, "")}`;
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "application/json",
};
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const signal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal;
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ data }),
signal,
});
} catch (cause) {
const timedOut = cause instanceof Error && cause.name === "TimeoutError";
throw new StrapiError({
message: timedOut
? `Strapi write timed out after ${timeoutMs}ms: ${url}`
: `Strapi write failed: ${url}`,
status: 0,
url,
cause,
});
}
if (!response.ok) {
let body: StrapiErrorEnvelope["error"];
try {
body = ((await response.json()) as StrapiErrorEnvelope).error;
} catch {
body = undefined;
}
throw new StrapiError({
message:
body?.message ??
`Strapi responded ${response.status} ${response.statusText} for ${url}`,
status: response.status,
statusText: response.statusText,
url,
body,
});
}
if (response.status === 204) return undefined as T;
try {
const payload = (await response.json()) as { data?: T };
return payload.data as T;
} catch (cause) {
throw new StrapiError({
message: `Strapi returned a non-JSON body for ${url}`,
status: response.status,
statusText: response.statusText,
url,
cause,
});
}
}
@@ -0,0 +1,106 @@
/* ============================================================================
FORM SUBMISSIONS -> STRAPI. SERVER ONLY.
----------------------------------------------------------------------------
The two public forms of this section write into their own collections. Both
are ordinary <Form method="post"> actions, so they work with scripting off.
THE PAYLOAD IS THE WHITELIST, NOTHING MORE
Each collection has a `sanitize-submission` middleware in the CMS that
copies out a fixed set of fields and DROPS everything else, then sets the
workflow state and the timestamps server-side. Sending a field that is not
on the list is not merely ignored - a required field that is missing is a
400 and the reader loses what they typed. So the two payloads below are
exactly the whitelists, read from:
apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts
event · fullName · email · phone · organisation · role ·
dietaryOrAccess · consentGdpr
apps/cms/src/api/application/middlewares/sanitize-submission.ts
opportunity · applicantName · email · phone · organisation ·
organisationType · answers
`attachments` exists on the `application` content type but is NOT on that
whitelist and must never be sent: it is a media relation, so its value is a
list of ids of files already in the Media Library, and an unauthenticated
caller who could set it would bind arbitrary uploads onto a row reviewers
open. A public upload flow needs its own authenticated endpoint first.
There is no `language` field on either type ("locale" is reserved by the
i18n plugin and `language` was never modelled), and no `waitlist` boolean.
THE ENDPOINTS
The URL is the content type's PLURAL name:
api::event-registration.event-registration -> /api/event-registrations
api::application.application -> /api/applications
(not "opportunity-applications", which does not exist and 404s).
WAITLIST IS DERIVED SERVER-SIDE, NEVER SENT
`registrationState` is not on the registration whitelist, and it is not an
omission to work around: the middleware works it out from the event itself -
places taken vs `capacity`, plus `registrationStatus` / `waitlistEnabled` -
and answers `confirmed`, `waitlisted`, or a 400 when the event is full with
no waitlist to offer. That decision needs a count no client can be trusted
to report, so this module sends only what the registrant actually supplied
and lets the CMS decide what it is worth.
PERMISSIONS
`create` on both collections is granted to the Public role; `find` /
`findOne` are NOT, or anyone could read everyone else's contact details.
========================================================================= */
import type { ApplicationValues, RegistrationValues } from "./forms";
import { createStrapiEntry } from "./strapi-write.server";
export const EVENT_REGISTRATIONS_RESOURCE = "event-registrations";
export const APPLICATIONS_RESOURCE = "applications";
/** Strapi rejects "" on an email field; absent is the honest value. */
function optional(value: string): string | undefined {
return value.length > 0 ? value : undefined;
}
export interface RegistrationSubmission {
readonly values: RegistrationValues;
/** documentId of the event being registered for. */
readonly eventId: string;
}
export async function submitRegistration(
input: RegistrationSubmission,
): Promise<void> {
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, {
event: input.eventId,
fullName: input.values.fullName,
email: input.values.email,
phone: optional(input.values.phone),
organisation: optional(input.values.organisation),
// Required in the schema: without it every POST is a 400.
consentGdpr: input.values.consentGdpr,
});
}
export interface ApplicationSubmission {
readonly values: ApplicationValues;
/** documentId of the opportunity being applied to. */
readonly opportunityId: string;
}
/** The key the free-text answer is filed under inside `answers` (json). */
export const APPLICATION_MESSAGE_KEY = "message";
export async function submitApplication(
input: ApplicationSubmission,
): Promise<void> {
await createStrapiEntry(APPLICATIONS_RESOURCE, {
opportunity: input.opportunityId,
// `fullName` is the registration's field name; an application's is
// `applicantName`.
applicantName: input.values.fullName,
email: input.values.email,
organisation: optional(input.values.organisation),
// `answers` is a json field - the form's one free-text question goes in
// it, keyed, so more questions can be added without another migration.
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
});
}
@@ -0,0 +1,432 @@
/* ============================================================================
EVENTS AND OPPORTUNITIES - the pieces these two sections add
----------------------------------------------------------------------------
Everything generic already exists and is imported, not rebuilt:
~/components/ui/Button Button, ArrowIcon
~/components/ui/Card CardShell, CardMedia, CardLink, CardActionHint
- the primitives the card family exports "for
composing new card types"
../_shared/listing-ui PageShell, PageHeader, EmptyState, TabLinks,
Pagination, MetaList, MetaLine, BackLink
What is genuinely new here is the status badge the draft asks for on every
event and every call ("Open / Closed / Full", "Upcoming / Ongoing /
Completed"), the two cards that carry the field set of draft sections 4 and
8, and the notice panel a detail page uses to explain a closed registration.
RULES: tier-2 semantic tokens only, logical properties only, type from the
published roles. Nothing here knows about a hex value or a direction.
========================================================================= */
import type { ReactNode } from "react";
import {
CardActionHint,
CardLink,
CardMedia,
CardShell,
} from "~/components/ui/Card";
import type { CardElement, CardTitleElement } from "~/components/ui/Card";
import { toIsoDate, useTranslation } from "~/i18n";
import { MetaLine } from "../_shared/listing-ui";
import {
eventActionKey,
eventRegistrationLabel,
eventTimeStatusKey,
formatEventWhen,
type EventCardData,
} from "./events";
import { getRouteCopy } from "./copy";
import {
formatOpportunityDate,
opportunityActionKey,
opportunityStatusKey,
type OpportunityCardData,
} from "./opportunities";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ---------------------------------------------------------------------------
STATUS BADGE
------------------------------------------------------------------------ */
/** Accent = live and joinable, neutral = informational, danger = full. */
export type BadgeTone = "accent" | "neutral" | "danger";
const BADGE_TONE: Readonly<Record<BadgeTone, string>> = {
accent: "border-border-accent bg-surface-accent-subtle text-text-accent",
neutral: "border-border-subtle bg-surface-sunken text-text-tertiary",
danger: "border-border-danger bg-surface-danger-subtle text-text-danger",
};
/**
* Colour is never the only carrier: the badge always spells the state out, so
* it survives a monochrome print and a colour-blind reader.
*/
export function StatusBadge({
tone = "neutral",
children,
}: {
tone?: BadgeTone;
children: ReactNode;
}) {
return (
<span
className={cx(
"inline-flex items-center gap-2xs rounded-pill border px-xs py-2xs label-md",
BADGE_TONE[tone],
)}
>
{children}
</span>
);
}
/** A row of badges above a card title or a detail heading. */
export function BadgeRow({ children }: { children: ReactNode }) {
return <div className="flex flex-wrap items-center gap-xs">{children}</div>;
}
/* ---------------------------------------------------------------------------
NOTICE
------------------------------------------------------------------------ */
export type NoticeTone = "info" | "accent" | "danger";
const NOTICE_TONE: Readonly<Record<NoticeTone, string>> = {
info: "border-border-default bg-surface-sunken text-text-secondary",
accent: "border-border-accent bg-surface-accent-subtle text-text-primary",
danger: "border-border-danger bg-surface-danger-subtle text-text-primary",
};
/**
* The panel that explains a state: registration closed, the call is not open
* yet, the CMS could not be reached. `role` is left to the caller - a notice
* that appears in response to a submission needs "status", one that is part of
* the page does not.
*/
export function Notice({
tone = "info",
title,
children,
role,
}: {
tone?: NoticeTone;
title?: string;
children?: ReactNode;
role?: "status" | "alert";
}) {
return (
<div
role={role}
className={cx(
"flex flex-col gap-xs rounded-card border p-lg",
NOTICE_TONE[tone],
)}
>
{title ? <p className="body-md-bold text-text-primary">{title}</p> : null}
{children ? <div className="body-md">{children}</div> : null}
</div>
);
}
/* ---------------------------------------------------------------------------
EVENT CARD - draft section 4
title - date and time - format/location - registration status - CTA
------------------------------------------------------------------------ */
export function EventCard({
event,
href,
as = "article",
titleAs = "h3",
className,
}: {
event: EventCardData;
href: string;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}) {
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const Title = titleAs;
const when = formatEventWhen(event, locale);
const formatLabel =
event.format === "online"
? t("events.online")
: event.format === "hybrid"
? copy.events.formatHybrid
: event.format === "in-person"
? t("events.inPerson")
: undefined;
const isPast = event.timeStatus === "completed";
return (
<CardShell
as={as}
linked
className={cx("flex-col overflow-hidden", className)}
>
{event.image ? (
<CardMedia
image={{ src: event.image.src, alt: event.image.alt }}
className="aspect-[9/5] w-full"
/>
) : null}
<div className="flex flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
<BadgeRow>
<StatusBadge tone={isPast ? "neutral" : "accent"}>
{t(eventTimeStatusKey(event.timeStatus))}
</StatusBadge>
{/* A finished event's registration state is history, not news. */}
{isPast ? null : (
<StatusBadge
tone={
event.registrationStatus === "full"
? "danger"
: event.registrationStatus === "closed"
? "neutral"
: "accent"
}
>
{eventRegistrationLabel(event.registrationStatus, locale)}
</StatusBadge>
)}
</BadgeRow>
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{event.title}
</CardLink>
</Title>
<p className="body-sm-bold text-text-secondary">
<time dateTime={toIsoDate(event.start)}>{when}</time>
</p>
<MetaLine items={[formatLabel, event.place, event.region].filter(
(item): item is string => Boolean(item),
)} />
{event.summary ? (
<p className="body-sm text-text-secondary line-clamp-3">
{event.summary}
</p>
) : null}
</div>
<CardActionHint label={t(eventActionKey(event))} />
</div>
</CardShell>
);
}
/* ---------------------------------------------------------------------------
OPPORTUNITY CARD - draft section 8
title - status tag - deadline - CTA
------------------------------------------------------------------------ */
export function OpportunityCard({
opportunity,
href,
as = "article",
titleAs = "h3",
className,
}: {
opportunity: OpportunityCardData;
href: string;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}) {
const { locale, t } = useTranslation();
const Title = titleAs;
const isOpen = opportunity.status === "open";
/* An open or closed call is defined by its deadline; a call that has not
opened yet is defined by its opening date. */
const dateLine =
opportunity.status === "upcoming" && opportunity.openDate
? formatOpportunityDate(opportunity.openDate, locale)
: opportunity.deadline
? t("opportunities.deadline", {
date: formatOpportunityDate(opportunity.deadline, locale),
})
: undefined;
const dateValue =
opportunity.status === "upcoming" && opportunity.openDate
? opportunity.openDate
: opportunity.deadline;
return (
<CardShell
as={as}
linked
className={cx("flex-col overflow-hidden", className)}
>
{opportunity.image ? (
<CardMedia
image={{ src: opportunity.image.src, alt: opportunity.image.alt }}
className="aspect-[9/5] w-full"
/>
) : null}
<div className="flex flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
<BadgeRow>
<StatusBadge tone={isOpen ? "accent" : "neutral"}>
{t(opportunityStatusKey(opportunity.status))}
</StatusBadge>
{opportunity.opportunityType ? (
<StatusBadge>
{getRouteCopy(locale).opportunityTypes[
opportunity.opportunityType
]}
</StatusBadge>
) : null}
</BadgeRow>
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{opportunity.title}
</CardLink>
</Title>
{dateLine ? (
<p className="body-sm-bold text-text-secondary">
<time dateTime={toIsoDate(dateValue)}>{dateLine}</time>
</p>
) : null}
{opportunity.summary ? (
<p className="body-sm text-text-secondary line-clamp-3">
{opportunity.summary}
</p>
) : null}
</div>
<CardActionHint label={t(opportunityActionKey(opportunity.status))} />
</div>
</CardShell>
);
}
/* ---------------------------------------------------------------------------
DEFINITION GRID - the "Event details" block
------------------------------------------------------------------------ */
export interface DetailItem {
readonly label: string;
readonly value: ReactNode;
}
/**
* A description list rather than a table: the pairs are a key/value set, they
* reflow to one column on a phone, and a screen reader announces the label
* with the value.
*/
export function DetailList({
heading,
items,
}: {
heading: string;
items: readonly DetailItem[];
}) {
const present = items.filter((item) => item.value !== undefined && item.value !== null);
if (present.length === 0) return null;
return (
<section
aria-label={heading}
className="flex flex-col gap-md rounded-card border border-border-subtle bg-surface-raised p-lg"
>
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
<dl className="grid gap-md sm:grid-cols-2">
{present.map((item) => (
<div key={item.label} className="flex flex-col gap-2xs">
<dt className="body-sm text-text-tertiary">{item.label}</dt>
<dd className="body-md-bold text-text-primary">{item.value}</dd>
</div>
))}
</dl>
</section>
);
}
/* ---------------------------------------------------------------------------
CONSENT CHECKBOX
----------------------------------------------------------------------------
`event-registration.consentGdpr` is a REQUIRED boolean in the CMS, so the
reader has to be asked - out loud, in a real <label>, with the reason next
to it. ~/components/ui has no Checkbox (this is the only one in the site),
so the control is composed here from the same tier-2 tokens the rest of the
section uses; promote it to ~/components/ui when a second form needs one.
Accessibility: the label wraps nothing - it is associated by `htmlFor`, so
the hint and the error can sit outside it and still be announced, via
`aria-describedby`. `aria-invalid` marks the failure for a screen reader,
and the message is text, never colour alone.
------------------------------------------------------------------------ */
export function ConsentCheckbox({
name,
label,
hint,
error,
defaultChecked = false,
}: {
name: string;
label: string;
hint?: string;
error?: string;
defaultChecked?: boolean;
}) {
const hintId = hint ? `${name}-hint` : undefined;
const errorId = error ? `${name}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="flex flex-col gap-2xs">
<div className="flex items-start gap-sm">
<input
id={name}
name={name}
type="checkbox"
value="true"
defaultChecked={defaultChecked}
required
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className="mt-2xs size-md shrink-0 rounded-[var(--radius-control)] border border-border-strong accent-[var(--interactive-primary-bg)]"
/>
<label htmlFor={name} className="body-sm text-text-primary">
{label}
</label>
</div>
{hint ? (
<p id={hintId} className="body-sm text-text-tertiary ps-lg">
{hint}
</p>
) : null}
{error ? (
<p id={errorId} className="body-sm text-text-danger ps-lg">
{error}
</p>
) : null}
</div>
);
}