Files
sharper.ly/apps/web/app/routes/activities-and-initiatives.tsx
T

519 lines
18 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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 <details>, NOT THE PAGE KIT'S <Accordion>.
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. `<details>` 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.
2026-09-11 02:46:56 +02:00
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";
2026-09-11 02:46:56 +02:00
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,
2026-09-11 02:46:56 +02:00
ACTIVITY_STREAM_KEYS,
activitiesIndexPath,
activityPath,
getIndexCopy,
getStreamCopy,
groupActivitiesByStream,
isActivityStatus,
readRootMeta,
streamAnchorId,
toActivityStreamKey,
type ActivityStreamKey,
2026-09-11 02:46:56 +02:00
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";
2026-09-11 02:46:56 +02:00
import type { Route } from "./+types/activities-and-initiatives";
import "./_page-kit/motion.css";
2026-09-11 02:46:56 +02:00
/* ---------------------------------------------------------------------------
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<Record<PartnerFilter, string>> = {
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);
}
2026-09-11 02:46:56 +02:00
/* ---------------------------------------------------------------------------
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,
};
}
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 [searchParams] = useSearchParams();
2026-09-11 02:46:56 +02:00
const copy = getIndexCopy(locale);
const chrome = getListingChrome(locale).activities;
const routeCopy = getRouteCopy(locale);
2026-09-11 02:46:56 +02:00
const grouped = groupActivitiesByStream(activities);
const hasActivities = activities.length > 0;
const pathname = activitiesIndexPath(locale);
const requested = searchParams.get("partner");
const partner = isPartnerFilter(requested) ? requested : null;
2026-09-11 02:46:56 +02:00
const streams = ACTIVITY_STREAM_KEYS.filter(
(stream) => partner === null || streamHasPartner(stream, partner),
);
2026-09-11 02:46:56 +02:00
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 (
<>
<ListingHero
eyebrow={copy.eyebrow}
heading={copy.headline}
intro={copy.lede}
ambient={[{ top: -250, from: "start", offset: "12%", width: 740, height: 580, durationSeconds: 21 }]}
/>
<PageShell>
{/* The draft reveals the filter row as it enters, like every other
band on the page. */}
<div className="sh-reveal">
<FilterPills label={chrome.partnerFilterLabel} pills={pills} />
</div>
{hasActivities ? null : (
2026-09-11 02:46:56 +02:00
<section
aria-labelledby="activities-empty"
className="flex flex-col items-start gap-md rounded-card border border-dashed border-border-default p-xl"
2026-09-11 02:46:56 +02:00
>
<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>
2026-09-11 02:46:56 +02:00
</section>
)}
<section aria-label={copy.streamsNavLabel} className="flex flex-col">
<h2 className="sr-only">{copy.streamsNavLabel}</h2>
<div className="border-t border-border-default">
{streams.map((stream, index) => (
<StreamDisclosure
key={stream}
stream={stream}
ordinal={index + 1}
open={index === 0}
activities={grouped[stream]}
hasActivities={hasActivities}
locale={locale}
viewDetailsLabel={t("actions.viewDetails")}
streamEmptyLabel={copy.streamEmpty}
streamListLabel={copy.streamListLabel}
/>
))}
</div>
</section>
{/* Where to go next - the draft's two closing cards. Both blurbs are
the approved standfirsts of sections 4 and 8, not new copy. */}
<nav aria-label={chrome.nextStepsLabel}>
<h2 className="sr-only">{chrome.nextStepsLabel}</h2>
<ul className="grid gap-lg sm:grid-cols-2">
<Card.Content
as="li"
titleAs="h3"
className="sh-reveal sh-reveal--in"
href={sectionPath("events", locale)}
eyebrow={chrome.nextEventsEyebrow}
title={routeCopy.events.heading}
excerpt={routeCopy.events.intro}
actionLabel={t("actions.viewUpcomingSessions")}
/>
<Card.Content
as="li"
titleAs="h3"
className="sh-reveal sh-reveal--in"
href={sectionPath("applications", locale)}
eyebrow={chrome.nextApplicationsEyebrow}
title={routeCopy.opportunities.heading}
excerpt={routeCopy.opportunities.intro}
actionLabel={t("actions.seeCurrentCalls")}
/>
</ul>
</nav>
</PageShell>
</>
);
}
/* ---------------------------------------------------------------------------
ONE STREAM
------------------------------------------------------------------------ */
/** The +/- mark, drawn the way the page kit's accordion draws it. */
function ToggleMark() {
return (
<span
aria-hidden="true"
className={[
"flex size-8 shrink-0 items-center justify-center rounded-pill border",
"border-border-default text-text-accent transition-colors",
"duration-[var(--motion-duration-base)]",
"group-open:border-border-accent group-open:bg-surface-accent-subtle",
].join(" ")}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M1 7h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path
d="M7 1v12"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
className="origin-center transition-transform duration-[var(--motion-duration-base)] group-open:scale-y-0"
/>
</svg>
</span>
2026-09-11 02:46:56 +02:00
);
}
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 (
<details
id={anchor}
open={open}
className="sh-reveal sh-lift group scroll-mt-2xl border-b border-border-default hover:bg-surface-raised"
>
<summary
className={[
"flex cursor-pointer list-none items-center gap-lg py-lg",
"text-start text-text-primary transition-colors",
"duration-[var(--motion-duration-base)] hover:text-text-accent",
"[&::-webkit-details-marker]:hidden",
].join(" ")}
>
<span aria-hidden="true" className="label-md min-w-8 text-text-tertiary">
{number}
</span>
<h3 className="flex-1 heading-h3">{copy.title}</h3>
<span className="hidden shrink-0 rounded-chip bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block">
{copy.partners}
</span>
<ToggleMark />
</summary>
{/* The animation is attached by the `open` state, so it plays when the
reader opens the row rather than once on first paint. */}
<div className="flex flex-col gap-lg pb-xl ps-2xl group-open:[animation:sh-answer_var(--motion-duration-base)_var(--motion-ease-entrance)_both]">
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
<p className="body-md text-text-secondary">{copy.description}</p>
<p className="body-sm text-text-tertiary">
<span className="body-sm-bold text-text-secondary">
{copy.partnerLabel}:
</span>{" "}
{copy.partners}
</p>
</div>
{activities.length > 0 ? (
<>
<h4 className="sr-only">{streamListLabel}</h4>
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{activities.map((activity) => (
<Card.Content
as="li"
key={activity.id}
titleAs="h4"
href={activityPath(activity.slug, locale)}
title={activity.title}
eyebrow={activity.location ?? undefined}
excerpt={activity.summary ?? undefined}
actionLabel={viewDetailsLabel}
image={
activity.imageUrl
? { src: activity.imageUrl, alt: activity.imageAlt }
: undefined
}
/>
))}
</ul>
</>
) : hasActivities ? (
<p className="body-sm text-text-tertiary">{streamEmptyLabel}</p>
) : null}
</div>
</details>
);
2026-09-11 02:46:56 +02:00
}