Files
sharper.ly/apps/web/app/routes/activities-and-initiatives.tsx
IllusionandClaude Opus 5 aedef6daeb Rebuild the site to the Claude Design drafts
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>
2026-09-16 20:32:56 +02:00

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>
);
}