232 lines
8.1 KiB
TypeScript
232 lines
8.1 KiB
TypeScript
/* ============================================================================
|
|||
|
|
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),
|
||
|
|
};
|
||
|
|
}
|