initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -0,0 +1,347 @@
/* ============================================================================
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<T>(
label: string,
read: () => Promise<T>,
fallback: T,
): Promise<T> {
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<ActivityEntity>("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 (
<div 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 max-w-[var(--layout-prose-max)] flex-col gap-md">
<p className="label-md text-text-accent">{copy.eyebrow}</p>
<h1 className="display-sm text-text-primary">{copy.headline}</h1>
<p className="body-lg text-text-secondary">{copy.lede}</p>
</header>
<nav aria-label={copy.streamsNavLabel}>
<h2 className="sr-only">{copy.streamsNavLabel}</h2>
<ul className="flex flex-wrap gap-sm">
{ACTIVITY_STREAM_KEYS.map((stream) => (
<li key={stream}>
<a
href={`#${streamAnchorId(stream)}`}
className="body-sm-bold inline-flex rounded-pill border border-border-subtle px-md py-xs text-text-secondary transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)] hover:border-border-strong hover:text-text-primary"
>
{getStreamCopy(stream, locale).title}
</a>
</li>
))}
</ul>
</nav>
{hasActivities ? null : (
<section
aria-labelledby="activities-empty"
className="flex flex-col items-start gap-md rounded-card border border-border-subtle bg-surface-raised p-xl"
>
<h2 id="activities-empty" className="body-lg-bold text-text-primary">
{copy.emptyTitle}
</h2>
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
{copy.emptyBody}
</p>
<Button
as={Link}
to={sectionPath("events", locale)}
variant="secondary"
size="sm"
iconEnd={<ArrowIcon />}
>
{t("nav.sections.events")}
</Button>
</section>
)}
{ACTIVITY_STREAM_KEYS.map((stream) => {
const streamCopy = getStreamCopy(stream, locale);
const items = grouped[stream];
const anchor = streamAnchorId(stream);
return (
<section
key={stream}
id={anchor}
aria-labelledby={`${anchor}-title`}
className="flex scroll-mt-4xl flex-col gap-lg"
>
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
<h2 id={`${anchor}-title`} className="heading-h2 text-text-primary">
{streamCopy.title}
</h2>
<p className="body-md text-text-secondary">
{streamCopy.description}
</p>
<p className="body-sm text-text-tertiary">
<span className="body-sm-bold text-text-secondary">
{streamCopy.partnerLabel}:
</span>{" "}
{streamCopy.partners}
</p>
</div>
{items.length > 0 ? (
<>
<h3 className="sr-only">{copy.streamListLabel}</h3>
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{items.map((activity) => (
<Card.Content
as="li"
key={activity.id}
titleAs="h4"
href={activityPath(activity.slug, locale)}
title={activity.title}
eyebrow={activityEyebrow(activity)}
excerpt={activity.summary ?? undefined}
actionLabel={t("actions.viewDetails")}
image={
activity.imageUrl
? { src: activity.imageUrl, alt: activity.imageAlt }
: undefined
}
/>
))}
</ul>
</>
) : hasActivities ? (
<p className="body-sm text-text-tertiary">{copy.streamEmpty}</p>
) : null}
</section>
);
})}
</div>
);
}
/** The card kicker: where the activity runs. An activity carries no dates. */
function activityEyebrow(activity: ActivitySummary): string | undefined {
return activity.location ?? undefined;
}