/* ============================================================================ 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, EVENT_TYPES, type EventType, } 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 eventType?: EventType | 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( 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, eventType: oneOf(entry.eventType, EVENT_TYPES), 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 { const result = await fetchCollection(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 { const entry = await fetchBySlug(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), }; }