initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user