/* ============================================================================ ACTIVITIES AND INITIATIVES - the section index ---------------------------------------------------------------------------- URL /en/activities-and-initiatives · /ar/activities-and-initiatives Route id "activities" (see app/routes.ts) The page is organised by the five activity streams of the approved draft copy (docs/SHARPER_Website_Draft_Section_EN_AR.docx, section 3): the stream headings, descriptions and implementing partners are project structure and live in ./activities.content, while the activities filed under each stream come from Strapi. THE FIELD NAMES BELOW ARE THE CMS'S OWN apps/cms/src/api/activity/content-types/activity/schema.json: `activityStream` (not `stream`), `activityStatus`, `coverImage` (not `cover`), `implementingPartners` and `regions` as RELATIONS, and NO dates or `location` string. Sorting or filtering on a field the content type does not have is a 400 from Strapi, so nothing here is guessed. The CMS is empty today, so the empty state is what actually renders: the five streams still describe the work, and one localised panel explains that individual activities are published as they are completed. ========================================================================= */ import { Link } from "react-router"; import { ArrowIcon, Button } from "~/components/ui/Button"; import { Card } from "~/components/ui/Card"; import { getMessages, sectionPath, useTranslation } from "~/i18n"; import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n/config"; import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import { getMany, isStrapiError, mediaAlt, mediaUrl, type StrapiEntity, type StrapiMedia, } from "~/lib/strapi"; import { ACTIVITY_STREAM_KEYS, activitiesIndexPath, activityPath, getIndexCopy, getStreamCopy, groupActivitiesByStream, isActivityStatus, readRootMeta, streamAnchorId, toActivityStreamKey, type ActivitySummary, } from "./activities.content"; import type { Route } from "./+types/activities-and-initiatives"; /* --------------------------------------------------------------------------- CMS SHAPES ------------------------------------------------------------------------ */ /** A related entry that only contributes its display name. */ interface NamedEntity { readonly name?: string | null; readonly slug?: string | null; } /** `api::activity.activity` - one activity or initiative. */ interface ActivityEntity extends StrapiEntity { readonly title: string; readonly slug: string; readonly activityStream?: string | null; readonly activityStatus?: string | null; readonly summary?: string | null; readonly regions?: readonly NamedEntity[] | null; readonly implementingPartners?: readonly NamedEntity[] | null; readonly coverImage?: StrapiMedia | null; } /** The fields one listing request asks for. All of them exist on the type. */ const ACTIVITY_FIELDS = [ "title", "slug", "activityStream", "activityStatus", "summary", ] as const; const ACTIVITY_POPULATE = { coverImage: { fields: ["url", "alternativeText"] }, regions: { fields: ["name", "slug"] }, implementingPartners: { fields: ["name", "slug"] }, } as const; /** * An activity carries no date in the CMS, so the newest CMS entry leads. * `publishedAt` is a system field and is always sortable. */ const ACTIVITY_SORT = ["publishedAt:desc", "title:asc"] as const; /** Names of a relation list, joined - or null when the relation is empty. */ function namesOf(entries: readonly NamedEntity[] | null | undefined): string | null { const names = (entries ?? []) .map((entry) => entry?.name?.trim()) .filter((name): name is string => Boolean(name)); return names.length > 0 ? names.join(", ") : null; } /** How many activities one request pulls. Well above the project's volume. */ const ACTIVITY_PAGE_SIZE = 100; /* --------------------------------------------------------------------------- LOADER ------------------------------------------------------------------------ */ /** * A CMS read that must never take the page down with it. An unreachable or * not-yet-modelled Strapi degrades to the empty state - the five stream * descriptions are still worth serving - and the reason is logged server-side. * Anything that is not a Strapi transport/HTTP error is rethrown untouched. */ async function readFromCms( label: string, read: () => Promise, fallback: T, ): Promise { try { return await read(); } catch (error) { if (isStrapiError(error)) { console.error(`[activities] ${label} unavailable: ${error.message}`); return fallback; } throw error; } } function toSummary(entity: ActivityEntity): ActivitySummary { return { id: entity.documentId, slug: entity.slug, title: entity.title, stream: toActivityStreamKey(entity.activityStream), summary: entity.summary ?? null, // Resolved here rather than in the component: mediaUrl() reads STRAPI_URL // from the server environment, which never reaches the browser bundle. imageUrl: mediaUrl(entity.coverImage?.url) ?? null, imageAlt: mediaAlt(entity.coverImage), location: namesOf(entity.regions), implementingPartner: namesOf(entity.implementingPartners), status: isActivityStatus(entity.activityStatus) ? entity.activityStatus : null, }; } /* DRAFT COPY NOTE - the source document ends section 3 with "[XX+] activities completed · [XX+] CSOs engaged · [X] regions covered - placeholder only: these figures are marked "TBD" in the official fact sheet and must not be published until confirmed." Nothing is hard-coded here and no placeholder is rendered. The figures strip has also been removed rather than left pointing at an `activities-page` single type that DOES NOT EXIST in the CMS (the single types are about-page, contact-page, home-page, site-settings and what-we-do-page): a request to /api/activities-page is a 404 on every page load. Give the section a home in Strapi first, then bring the strip back. */ export async function loader({ params }: Route.LoaderArgs) { // The ":lang" layout has already validated the segment; the guard is here so // the locale is a `Locale` by inference rather than by assertion. const locale: Locale = isLocale(params.lang) ? params.lang : DEFAULT_LOCALE; const activities = await readFromCms( "activities", async () => { const response = await getMany("activities", { locale, sort: [...ACTIVITY_SORT], fields: [...ACTIVITY_FIELDS], populate: ACTIVITY_POPULATE, pagination: { pageSize: ACTIVITY_PAGE_SIZE, withCount: false }, }); return response.data; }, [] as ActivityEntity[], ); return { locale, activities: activities .filter((entity) => Boolean(entity.slug && entity.title)) .map(toSummary), }; } /* --------------------------------------------------------------------------- META The canonical link and the en / ar / x-default hreflang alternates are emitted for every route by the shell (root.tsx `Layout`), because a leaf `meta` replaces its parents' wholesale - so `alternates` stays false here on purpose. Passing it would duplicate every one of those tags. ------------------------------------------------------------------------ */ export function meta({ matches }: Route.MetaArgs) { const root = readRootMeta(matches, activitiesIndexPath(DEFAULT_LOCALE)); const { locale, pathname, siteUrl } = root; const copy = getIndexCopy(locale); const { sections } = getMessages(locale).nav; return buildMeta({ locale, pathname, siteUrl, title: sections.activities, description: copy.metaDescription, jsonLd: buildBreadcrumbJsonLd(siteUrl, [ { name: sections.home, pathname: sectionPath("home", locale) }, { name: sections.activities, pathname: activitiesIndexPath(locale) }, ]), }); } /* --------------------------------------------------------------------------- VIEW ------------------------------------------------------------------------ */ export default function ActivitiesAndInitiatives({ loaderData, }: Route.ComponentProps) { const { activities } = loaderData; const { locale, t } = useTranslation(); const copy = getIndexCopy(locale); const grouped = groupActivitiesByStream(activities); const hasActivities = activities.length > 0; return (

{copy.eyebrow}

{copy.headline}

{copy.lede}

{hasActivities ? null : (

{copy.emptyTitle}

{copy.emptyBody}

)} {ACTIVITY_STREAM_KEYS.map((stream) => { const streamCopy = getStreamCopy(stream, locale); const items = grouped[stream]; const anchor = streamAnchorId(stream); return (

{streamCopy.title}

{streamCopy.description}

{streamCopy.partnerLabel}: {" "} {streamCopy.partners}

{items.length > 0 ? ( <>

{copy.streamListLabel}

    {items.map((activity) => ( ))}
) : hasActivities ? (

{copy.streamEmpty}

) : null}
); })}
); } /** The card kicker: where the activity runs. An activity carries no dates. */ function activityEyebrow(activity: ActivitySummary): string | undefined { return activity.location ?? undefined; }