initial commit
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user