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)
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
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.
|
|
|
|
|
========================================================================= */
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
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 {
|
2026-09-16 20:32:56 +02:00
|
|
|
ACTIVITY_STREAMS,
|
2026-09-11 02:46:56 +02:00
|
|
|
ACTIVITY_STREAM_KEYS,
|
|
|
|
|
activitiesIndexPath,
|
|
|
|
|
activityPath,
|
|
|
|
|
getIndexCopy,
|
|
|
|
|
getStreamCopy,
|
|
|
|
|
groupActivitiesByStream,
|
|
|
|
|
isActivityStatus,
|
|
|
|
|
readRootMeta,
|
|
|
|
|
streamAnchorId,
|
|
|
|
|
toActivityStreamKey,
|
2026-09-16 20:32:56 +02:00
|
|
|
type ActivityStreamKey,
|
2026-09-11 02:46:56 +02:00
|
|
|
type ActivitySummary,
|
|
|
|
|
} from "./activities.content";
|
2026-09-16 20:32:56 +02:00
|
|
|
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";
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
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;
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
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();
|
2026-09-16 20:32:56 +02:00
|
|
|
const [searchParams] = useSearchParams();
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
const copy = getIndexCopy(locale);
|
2026-09-16 20:32:56 +02:00
|
|
|
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;
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
const pathname = activitiesIndexPath(locale);
|
|
|
|
|
const requested = searchParams.get("partner");
|
|
|
|
|
const partner = isPartnerFilter(requested) ? requested : null;
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
const streams = ACTIVITY_STREAM_KEYS.filter(
|
|
|
|
|
(stream) => partner === null || streamHasPartner(stream, partner),
|
|
|
|
|
);
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-16 20:32: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
|
2026-09-16 20:32:56 +02:00
|
|
|
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
|
|
|
>
|
2026-09-16 20:32: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>
|
2026-09-16 20:32:56 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<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
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:32: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-[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>
|
|
|
|
|
);
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|