/* ============================================================================ ONE ACTIVITY OR INITIATIVE ---------------------------------------------------------------------------- URL /en/activities-and-initiatives/:slug ยท /ar/activities-and-initiatives/:slug Registered in app/routes.ts as "activitiesDetail", a SIBLING of the listing route under the ":lang" layout, and typed against its generated `./+types/` module. A slug the CMS does not know still falls through to the localised 404, which is the correct answer for it. ========================================================================= */ import { Fragment, type ReactNode } from "react"; import { Link, useLoaderData, type MetaDescriptor, } from "react-router"; import { ArrowIcon, Button } from "~/components/ui/Button"; import { Card } from "~/components/ui/Card"; import { getMessages, sectionPath, toIsoDate, useTranslation } from "~/i18n"; import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n/config"; import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta, type JsonLd, } from "~/lib/seo"; import { getBySlug, getMany, isStrapiError, isStrapiNotFound, mediaAlt, mediaUrl, notFound, requireEntity, type StrapiEntity, type StrapiMedia, } from "~/lib/strapi"; import type { Route } from "./+types/activities-and-initiatives.$slug"; import { ACTIVITY_STREAM_CMS_VALUES, activitiesIndexPath, activityPath, getDetailCopy, getStreamCopy, isActivityStatus, readRootMeta, toActivityStreamKey, type ActivityStreamKey, type ActivitySummary, } from "./activities.content"; import { collectLocalizedSlugs, detailAlternateDescriptors, LOCALIZATION_POPULATE, SELF_ALTERNATES_HANDLE, type CmsLocalizationRef, type LocalizedSlugs, } from "./_shared/alternates"; /* --------------------------------------------------------------------------- CMS SHAPES ------------------------------------------------------------------------ */ /** * A Strapi rich-text value. The Blocks editor emits a node tree; a plain Text * or Markdown field emits a string. Both are rendered below, so the content * type can switch field kinds without touching this route. */ type RichTextValue = string | readonly RichTextNode[] | null; interface RichTextImage { readonly url?: string | null; readonly alternativeText?: string | null; readonly width?: number | null; readonly height?: number | null; } interface RichTextNode { readonly type?: string; readonly children?: readonly RichTextNode[]; readonly text?: string; readonly bold?: boolean; readonly italic?: boolean; readonly underline?: boolean; readonly strikethrough?: boolean; readonly code?: boolean; readonly url?: string; readonly level?: number; readonly format?: string; readonly image?: RichTextImage; } /** A related entry that only contributes its display name. */ interface NamedEntity { readonly name?: string | null; readonly slug?: string | null; } /** * `api::activity.activity`, field for field from * apps/cms/src/api/activity/content-types/activity/schema.json. There is no * `stream`, no `cover`, no `location` string and no date pair on this type. */ interface ActivityEntity extends StrapiEntity { readonly title: string; readonly slug: string; readonly activityStream?: string | null; readonly activityStatus?: string | null; readonly summary?: string | null; readonly body?: RichTextValue; readonly regions?: readonly NamedEntity[] | null; readonly implementingPartners?: readonly NamedEntity[] | null; readonly coverImage?: StrapiMedia | null; readonly localizations?: readonly CmsLocalizationRef[] | null; } const ACTIVITY_SUMMARY_FIELDS = [ "title", "slug", "activityStream", "activityStatus", "summary", ] as const; 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 other activities from the same stream are offered at the end. */ const RELATED_LIMIT = 3; /* --------------------------------------------------------------------------- RICH TEXT - server side ------------------------------------------------------------------------ */ /** * Absolutise every image URL inside a Blocks tree while still on the server: * `mediaUrl()` reads STRAPI_URL, so a relative "/uploads/x.jpg" cannot be * resolved once the tree has been serialised to the browser. */ function resolveRichTextMedia(value: RichTextValue): RichTextValue { if (value === null || typeof value === "string") return value; return value.map((node) => resolveNodeMedia(node)); } function resolveNodeMedia(node: RichTextNode): RichTextNode { const children = node.children?.map((child) => resolveNodeMedia(child)); const url = node.image?.url ? mediaUrl(node.image.url) : undefined; return { ...node, ...(children ? { children } : {}), ...(node.image ? { image: { ...node.image, url: url ?? null } } : {}), }; } /* --------------------------------------------------------------------------- LOADER ------------------------------------------------------------------------ */ /** This route emits its own canonical + hreflang - see ./_shared/alternates.ts. */ export const handle = SELF_ALTERNATES_HANDLE; export async function loader({ params }: Route.LoaderArgs) { const locale: Locale = isLocale(params.lang) ? params.lang : DEFAULT_LOCALE; const slug = params.slug; if (!slug) notFound(); let entity: ActivityEntity | null; try { entity = await getBySlug("activities", slug, { locale, populate: { coverImage: { fields: ["url", "alternativeText", "width", "height"], }, regions: { fields: ["name", "slug"] }, implementingPartners: { fields: ["name", "slug"] }, // The Arabic translation has its own slug - see ./_shared/alternates. localizations: LOCALIZATION_POPULATE, }, }); } catch (error) { // A 404 from Strapi means "no such activity"; anything else (the CMS is // down, the content type is missing, the token is wrong) is a real server // fault and must not masquerade as a missing page, so it is rethrown for // the root ErrorBoundary to render as an error rather than as a 404. if (isStrapiNotFound(error)) entity = null; else throw error; } // Throws the localised 404 that the root ErrorBoundary renders. const activity = requireEntity(entity); const stream = toActivityStreamKey(activity.activityStream); return { locale, alternates: collectLocalizedSlugs( locale, activity.slug, activity.localizations, ) satisfies LocalizedSlugs, activity: { id: activity.documentId, slug: activity.slug, title: activity.title, stream, status: isActivityStatus(activity.activityStatus) ? activity.activityStatus : null, summary: activity.summary ?? null, body: resolveRichTextMedia(activity.body ?? null), location: namesOf(activity.regions), implementingPartner: namesOf(activity.implementingPartners), imageUrl: mediaUrl(activity.coverImage?.url) ?? null, imageAlt: mediaAlt(activity.coverImage), imageWidth: activity.coverImage?.width ?? null, imageHeight: activity.coverImage?.height ?? null, publishedAt: activity.publishedAt, updatedAt: activity.updatedAt, }, related: await loadRelated(locale, stream, activity.documentId), }; } export type ActivityDetailLoaderData = Awaited>; /** * Other activities from the same stream. Purely additive, so a failure here * degrades to an empty list instead of taking down a page that already has * everything it needs. */ async function loadRelated( locale: Locale, stream: ActivityStreamKey | null, documentId: string, ): Promise { if (!stream) return []; try { const response = await getMany("activities", { locale, filters: { // The CMS enumeration value, which is not always the web key. activityStream: { $eq: ACTIVITY_STREAM_CMS_VALUES[stream] }, documentId: { $ne: documentId }, }, sort: [...ACTIVITY_SORT], fields: [...ACTIVITY_SUMMARY_FIELDS], populate: { coverImage: { fields: ["url", "alternativeText"] }, regions: { fields: ["name", "slug"] }, implementingPartners: { fields: ["name", "slug"] }, }, pagination: { pageSize: RELATED_LIMIT, withCount: false }, }); return response.data .filter((entity) => Boolean(entity.slug && entity.title)) .map((entity) => ({ id: entity.documentId, slug: entity.slug, title: entity.title, stream, summary: entity.summary ?? null, 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, })); } catch (error) { if (isStrapiError(error)) { console.error(`[activity:${documentId}] related unavailable: ${error.message}`); return []; } throw error; } } /* --------------------------------------------------------------------------- META The canonical link and the en / ar / x-default hreflang alternates come from the shell (root.tsx `Layout`) for every route, so `alternates` stays false here - passing it would emit each of those tags twice. ------------------------------------------------------------------------ */ export function meta({ loaderData, matches }: Route.MetaArgs): MetaDescriptor[] { const { locale, pathname, siteUrl } = readRootMeta( matches, activitiesIndexPath(DEFAULT_LOCALE), ); const { sections } = getMessages(locale).nav; const activity = loaderData?.activity; // No loader data means the boundary is rendering (a 404, most often); the // shell's own meta covers that case, so only the section title is claimed. if (!activity) { return buildMeta({ locale, pathname, siteUrl, title: sections.activities }); } const streamTitle = activity.stream ? getStreamCopy(activity.stream, locale).title : undefined; const description = activity.summary ?? undefined; const datePublished = toIsoDate(activity.publishedAt ?? activity.updatedAt); const jsonLd: JsonLd[] = [ buildBreadcrumbJsonLd(siteUrl, [ { name: sections.home, pathname: sectionPath("home", locale) }, { name: sections.activities, pathname: activitiesIndexPath(locale) }, { name: activity.title, pathname: activityPath(activity.slug, locale) }, ]), ]; if (datePublished) { jsonLd.push( buildArticleJsonLd({ siteUrl, locale, headline: activity.title, pathname: activityPath(activity.slug, locale), datePublished, dateModified: toIsoDate(activity.updatedAt) || undefined, description, image: activity.imageUrl ?? undefined, section: streamTitle, }), ); } const descriptors = buildMeta({ locale, pathname, siteUrl, title: activity.title, description, type: "article", image: activity.imageUrl ? { url: activity.imageUrl, alt: activity.imageAlt, width: activity.imageWidth ?? undefined, height: activity.imageHeight ?? undefined, } : undefined, jsonLd, }); return [ ...descriptors, ...detailAlternateDescriptors({ siteUrl, section: "activities", locale, slugs: loaderData?.alternates ?? {}, }), ]; } /* --------------------------------------------------------------------------- VIEW ------------------------------------------------------------------------ */ export default function ActivityDetail() { const { activity, related } = useLoaderData(); const { locale, t } = useTranslation(); const copy = getDetailCopy(locale); const stream = activity.stream ? getStreamCopy(activity.stream, locale) : null; const partner = activity.implementingPartner ?? stream?.partners ?? null; return (
{stream ? (

{stream.title}

) : null}

{activity.title}

{activity.summary ? (

{activity.summary}

) : null}
{activity.location ? ( ) : null} {partner ? ( ) : null}
{activity.imageUrl ? ( {activity.imageAlt} ) : null}

{copy.detailsHeading}

{related.length > 0 ? (
    {related.map((item) => ( ))}
) : null}
); } /** One `
`/`
` pair in the activity's fact list. */ function MetaPair({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } /* --------------------------------------------------------------------------- RICH TEXT - rendering Strapi Blocks and plain text both land here. Nothing is injected as HTML: every node is turned into a React element, so CMS content cannot script the page, and the styling comes from type roles and semantic tokens only. ------------------------------------------------------------------------ */ function RichText({ value, fallback, }: { value: RichTextValue; fallback: string; }) { if (typeof value === "string") { const paragraphs = value .split(/\n{2,}/) .map((paragraph) => paragraph.trim()) .filter(Boolean); if (paragraphs.length === 0) return {fallback}; return ( <> {paragraphs.map((paragraph, index) => ( {paragraph} ))} ); } if (!value || value.length === 0) return {fallback}; return ( <> {value.map((node, index) => ( ))} ); } function Prose({ children }: { children: ReactNode }) { return

{children}

; } function RichTextBlock({ node }: { node: RichTextNode }) { switch (node.type) { case "heading": { const level = node.level ?? 2; if (level <= 2) { return (

); } return (

); } case "list": { const List = node.format === "ordered" ? "ol" : "ul"; return ( {node.children?.map((item, index) => (
  • ))}
    ); } case "quote": return (
    ); case "code": return (
              
                
              
            
    ); case "image": return node.image?.url ? ( {node.image.alternativeText ) : null; case "paragraph": return ( ); // A block kind the editor gains later renders as prose rather than as a // gap in the page. default: return node.children?.length ? ( ) : null; } } function RichTextChildren({ nodes }: { nodes?: readonly RichTextNode[] }) { if (!nodes) return null; return ( <> {nodes.map((node, index) => ( ))} ); } const EXTERNAL_HREF = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i; function RichTextLeaf({ node }: { node: RichTextNode }) { if (node.type === "link" && node.url) { const children = ; const className = "text-text-link underline underline-offset-2 hover:text-text-link-hover"; return EXTERNAL_HREF.test(node.url) ? ( {children} ) : ( {children} ); } if (node.children?.length) { return ; } const text = node.text; if (!text) return null; let content: ReactNode = text; if (node.code) content = {content}; if (node.bold) content = {content}; if (node.italic) content = {content}; if (node.underline) content = {content}; if (node.strikethrough) content = {content}; return {content}; }