Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the project's own design system: tier-2 tokens, the published type roles, logical properties throughout, and both locales. Surfaces - The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts do; the body previously painted the band colour, so every band was a step too light. - A card is drawn one step off whatever it stands on (#1D1D1B on the ground, #141413 inside a band). Components paint --surface-card and a band re-points it for its subtree, so a card can never come out the same colour as its band. Shell - Nav: translucent ground with a blur, chevrons on group triggers, an accent rule under the current section, the drafts' compact dropdown card, and an EN | AR toggle in place of the globe menu. - Footer: the drafts' four-column grid, one-line brand text, Facebook as a bordered button, the EU lockup, the implemented-by row and the funding disclaimer. Pages - Home: hero with counting figures, launch band, three areas of change, newsroom, FAQ accordion and the consortium band. - The nine interior pages rebuilt and then audited section by section against their drafts. - Applications: the three-step application form now posts real submissions to the Strapi `application` type, validated server-side and working without JavaScript. Motion - Every animation in the drafts, as classes in _page-kit/motion.css: staggered entrances, scroll-driven reveals, the drifting washes (each page carries its own geometry), the sheen, the counting figures and the accordion. The drafts' coloured hover glow is deliberately not shipped. CMS - New `faq` collection type behind the home page's accordion. - `role` added to the consortium member component so an editor can supply the chip and the deliverable separately. Content - No invented figure, date, name or commitment ships: the five-working-day reply, the placeholder email and Facebook handle, the sample news, events, publications and FAQs, the invented application rules and the TBD counters are all withheld pending real content. Figures that survived (3 consortium partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec 2027) are verified against docs/ and the bundled draft copy. Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20 page/locale URLs render 200 through the built SSR handler, including with the CMS unreachable; no raw hex or physical-direction utility in components or routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
519 lines
18 KiB
TypeScript
519 lines
18 KiB
TypeScript
/* ============================================================================
|
|
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.
|
|
|
|
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<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);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
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();
|
|
|
|
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 (
|
|
<>
|
|
<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 : (
|
|
<section
|
|
aria-labelledby="activities-empty"
|
|
className="flex flex-col items-start gap-md rounded-card border border-dashed border-border-default 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>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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-[var(--radius-control)] 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>
|
|
);
|
|
}
|