/* ============================================================================ ACTIVITIES AND INITIATIVES - the section index ---------------------------------------------------------------------------- URL /en/activities-and-initiatives · /ar/activities-and-initiatives Route id "activities" (see app/routes.ts) Laid out to the 2026 redesign draft ("Activities-and-Initiatives"): an ambient hero, a "filter by partner" pill row, and the five activity streams as a numbered disclosure list - each row carrying its implementing partner and opening onto the stream's description and the activities filed under it. The five streams are the approved draft copy (docs/SHARPER_Website_Draft_Section_EN_AR.docx, section 3): their headings, descriptions and implementing partners are project structure and live in ./activities.content, while the activities themselves come from Strapi. THE DISCLOSURE IS A NATIVE
, NOT THE PAGE KIT'S . That component renders a markdown answer and nothing else; these panels carry a grid of links to the activities in the stream, and those links have to stay in the served HTML whether the panel is open or shut, or the only route a crawler has to an activity page disappears. `
` keeps them there and needs no JavaScript to work. THE FIGURES BAND IS NOT BUILT. The draft draws three counters reading "TBD" over a note saying the numbers must be confirmed before publication; the approved copy says the same ("[XX+] activities completed · [XX+] CSOs engaged · [X] regions covered - placeholder only"). Neither the figures nor the placeholder is rendered (CLAUDE.md rule 5), and there is no `activities-page` single type in the CMS to read a confirmed figure from. 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. ========================================================================= */ import { useSearchParams } from "react-router"; 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_STREAMS, ACTIVITY_STREAM_KEYS, activitiesIndexPath, activityPath, getIndexCopy, getStreamCopy, groupActivitiesByStream, isActivityStatus, readRootMeta, streamAnchorId, toActivityStreamKey, type ActivityStreamKey, type ActivitySummary, } from "./activities.content"; import { getRouteCopy } from "./_events-opportunities/copy"; import { FilterPills, ListingHero, PageShell } from "./_shared/listing-ui"; import { getListingChrome } from "./_shared/page-copy"; import type { Route } from "./+types/activities-and-initiatives"; import "./_page-kit/motion.css"; /* --------------------------------------------------------------------------- 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; /* --------------------------------------------------------------------------- THE PARTNER FILTER ---------------------------------------------------------------------------- The draft's pill row. The three consortium members are proper names, not translatable copy - they are written the same way in the English and the Arabic draft - and which stream each one implements is read off the approved stream copy rather than restated here, so the filter cannot drift from the line the panel shows. ------------------------------------------------------------------------ */ const PARTNER_FILTERS = ["acted", "uec", "sbe"] as const; type PartnerFilter = (typeof PARTNER_FILTERS)[number]; const PARTNER_LABELS: Readonly> = { acted: "Acted", uec: "UEC", sbe: "SBE", }; function isPartnerFilter(value: unknown): value is PartnerFilter { return ( typeof value === "string" && (PARTNER_FILTERS as readonly string[]).includes(value) ); } /** True when the stream's approved partner line names this organisation. */ function streamHasPartner( stream: ActivityStreamKey, partner: PartnerFilter, ): boolean { return ACTIVITY_STREAMS[DEFAULT_LOCALE][stream].partners .toLowerCase() .includes(partner); } /* --------------------------------------------------------------------------- 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, }; } 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 [searchParams] = useSearchParams(); const copy = getIndexCopy(locale); const chrome = getListingChrome(locale).activities; const routeCopy = getRouteCopy(locale); const grouped = groupActivitiesByStream(activities); const hasActivities = activities.length > 0; const pathname = activitiesIndexPath(locale); const requested = searchParams.get("partner"); const partner = isPartnerFilter(requested) ? requested : null; const streams = ACTIVITY_STREAM_KEYS.filter( (stream) => partner === null || streamHasPartner(stream, partner), ); const pills = [ { id: "all", label: chrome.allPartners, href: pathname, isCurrent: partner === null, }, ...PARTNER_FILTERS.map((value) => ({ id: value, label: PARTNER_LABELS[value], href: `${pathname}?partner=${value}`, isCurrent: partner === value, })), ]; return ( <> {/* The draft reveals the filter row as it enters, like every other band on the page. */}
{hasActivities ? null : (

{copy.emptyTitle}

{copy.emptyBody}

)}

{copy.streamsNavLabel}

{streams.map((stream, index) => ( ))}
{/* Where to go next - the draft's two closing cards. Both blurbs are the approved standfirsts of sections 4 and 8, not new copy. */}
); } /* --------------------------------------------------------------------------- ONE STREAM ------------------------------------------------------------------------ */ /** The +/- mark, drawn the way the page kit's accordion draws it. */ function ToggleMark() { return ( ); } function StreamDisclosure({ stream, ordinal, open, activities, hasActivities, locale, viewDetailsLabel, streamEmptyLabel, streamListLabel, }: { stream: ActivityStreamKey; ordinal: number; open: boolean; activities: readonly ActivitySummary[]; hasActivities: boolean; locale: Locale; viewDetailsLabel: string; streamEmptyLabel: string; streamListLabel: string; }) { const copy = getStreamCopy(stream, locale); const anchor = streamAnchorId(stream); const number = new Intl.NumberFormat(`${locale}-u-nu-latn`, { minimumIntegerDigits: 2, useGrouping: false, }).format(ordinal); return (

{copy.title}

{copy.partners}
{/* The animation is attached by the `open` state, so it plays when the reader opens the row rather than once on first paint. */}

{copy.description}

{copy.partnerLabel}: {" "} {copy.partners}

{activities.length > 0 ? ( <>

{streamListLabel}

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

{streamEmptyLabel}

) : null}
); }