Files

667 lines
20 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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<ActivityEntity>("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<ReturnType<typeof loader>>;
/**
* 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<ActivitySummary[]> {
if (!stream) return [];
try {
const response = await getMany<ActivityEntity>("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<typeof loader>();
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 (
<article className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-[var(--layout-section-gap)] px-[var(--layout-gutter)] py-4xl">
<header className="flex flex-col gap-lg">
<Button
as={Link}
to={activitiesIndexPath(locale)}
variant="ghost"
size="sm"
iconStart={<ArrowIcon direction="backward" />}
className="self-start"
>
{copy.backToIndex}
</Button>
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
{stream ? (
<p className="label-md text-text-accent">{stream.title}</p>
) : null}
<h1 className="display-sm text-text-primary">{activity.title}</h1>
{activity.summary ? (
<p className="body-lg text-text-secondary">{activity.summary}</p>
) : null}
</div>
<dl className="flex flex-wrap gap-x-2xl gap-y-md">
{activity.location ? (
<MetaPair label={copy.locationLabel} value={activity.location} />
) : null}
{partner ? (
<MetaPair label={copy.partnerLabel} value={partner} />
) : null}
</dl>
{activity.imageUrl ? (
<img
src={activity.imageUrl}
alt={activity.imageAlt}
width={activity.imageWidth ?? undefined}
height={activity.imageHeight ?? undefined}
className="w-full rounded-card object-cover"
loading="eager"
decoding="async"
/>
) : null}
</header>
<section
aria-labelledby="activity-details"
className="flex max-w-[var(--layout-prose-max)] flex-col gap-md"
>
<h2 id="activity-details" className="sr-only">
{copy.detailsHeading}
</h2>
<RichText value={activity.body} fallback={copy.bodyMissing} />
</section>
{related.length > 0 ? (
<section aria-labelledby="activity-related" className="flex flex-col gap-lg">
<h2 id="activity-related" className="heading-h2 text-text-primary">
{copy.relatedHeading}
</h2>
<ul className="flex list-none flex-col gap-lg">
{related.map((item) => (
<Card.Horizontal
as="li"
key={item.id}
href={activityPath(item.slug, locale)}
title={item.title}
eyebrow={item.location ?? undefined}
excerpt={item.summary ?? undefined}
actionLabel={t("actions.viewDetails")}
image={
item.imageUrl
? { src: item.imageUrl, alt: item.imageAlt }
: undefined
}
/>
))}
</ul>
</section>
) : null}
</article>
);
}
/** One `<dt>`/`<dd>` pair in the activity's fact list. */
function MetaPair({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-2xs">
<dt className="label-md text-text-tertiary">{label}</dt>
<dd className="body-md-bold text-text-primary">{value}</dd>
</div>
);
}
/* ---------------------------------------------------------------------------
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 <Prose>{fallback}</Prose>;
return (
<>
{paragraphs.map((paragraph, index) => (
<Prose key={index}>{paragraph}</Prose>
))}
</>
);
}
if (!value || value.length === 0) return <Prose>{fallback}</Prose>;
return (
<>
{value.map((node, index) => (
<RichTextBlock key={index} node={node} />
))}
</>
);
}
function Prose({ children }: { children: ReactNode }) {
return <p className="body-md text-text-secondary">{children}</p>;
}
function RichTextBlock({ node }: { node: RichTextNode }) {
switch (node.type) {
case "heading": {
const level = node.level ?? 2;
if (level <= 2) {
return (
<h3 className="heading-h3 mt-lg text-text-primary">
<RichTextChildren nodes={node.children} />
</h3>
);
}
return (
<h4 className="body-lg-bold mt-md text-text-primary">
<RichTextChildren nodes={node.children} />
</h4>
);
}
case "list": {
const List = node.format === "ordered" ? "ol" : "ul";
return (
<List
className={`ps-lg body-md text-text-secondary ${
node.format === "ordered" ? "list-decimal" : "list-disc"
}`}
>
{node.children?.map((item, index) => (
<li key={index} className="mb-2xs">
<RichTextChildren nodes={item.children} />
</li>
))}
</List>
);
}
case "quote":
return (
<blockquote className="body-lg border-s-2 border-border-accent ps-md text-text-primary">
<RichTextChildren nodes={node.children} />
</blockquote>
);
case "code":
return (
<pre className="body-sm overflow-x-auto rounded-card bg-surface-raised p-md text-text-secondary">
<code>
<RichTextChildren nodes={node.children} />
</code>
</pre>
);
case "image":
return node.image?.url ? (
<img
src={node.image.url}
alt={node.image.alternativeText ?? ""}
width={node.image.width ?? undefined}
height={node.image.height ?? undefined}
className="w-full rounded-card"
loading="lazy"
decoding="async"
/>
) : null;
case "paragraph":
return (
<Prose>
<RichTextChildren nodes={node.children} />
</Prose>
);
// A block kind the editor gains later renders as prose rather than as a
// gap in the page.
default:
return node.children?.length ? (
<Prose>
<RichTextChildren nodes={node.children} />
</Prose>
) : null;
}
}
function RichTextChildren({ nodes }: { nodes?: readonly RichTextNode[] }) {
if (!nodes) return null;
return (
<>
{nodes.map((node, index) => (
<RichTextLeaf key={index} node={node} />
))}
</>
);
}
const EXTERNAL_HREF = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i;
function RichTextLeaf({ node }: { node: RichTextNode }) {
if (node.type === "link" && node.url) {
const children = <RichTextChildren nodes={node.children} />;
const className =
"text-text-link underline underline-offset-2 hover:text-text-link-hover";
return EXTERNAL_HREF.test(node.url) ? (
<a
href={node.url}
className={className}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
) : (
<Link to={node.url} className={className} prefetch="intent">
{children}
</Link>
);
}
if (node.children?.length) {
return <RichTextChildren nodes={node.children} />;
}
const text = node.text;
if (!text) return null;
let content: ReactNode = text;
if (node.code) content = <code className="body-sm">{content}</code>;
if (node.bold) content = <strong className="body-md-bold">{content}</strong>;
if (node.italic) content = <em>{content}</em>;
if (node.underline) content = <u>{content}</u>;
if (node.strikethrough) content = <s>{content}</s>;
return <Fragment>{content}</Fragment>;
}