Files

301 lines
11 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
WHAT WE DO - /en/what-we-do and /ar/what-we-do
----------------------------------------------------------------------------
The redesigned What We Do page ("What-We-Do", Claude Design draft), carried
onto the project's own design system. Four bands:
Hero eyebrow, headline and the one-line lead, on the sunken
surface with the two drifting washes behind it.
Areas the four connected areas of work as a seam grid, each cell
led by its numeral.
Guidance the five values as a centred chip row, then the four
principles as bar-topped tiles.
Activities the closing panel with the accent start-edge.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
--------------------------------------------
* The draft gives each principle an explanatory line ("Local organisations
lead the design and delivery of activities", "Solutions are built to
outlast the project's own timeline", "Institutional change is supported
step by step, not imposed"). None of those sentences is in an approved
document, so the tiles carry only the clause the approved "How we work"
sentence states, which is what the draft's own titles carry too.
* The draft's coloured hover glow is deliberately dropped.
2026-09-11 02:46:56 +02:00
Overridden field by field by api::what-we-do-page.what-we-do-page; the draft
copy below is what renders until an editor fills that in.
Arabic is this same DOM under `dir="rtl"`, so every inline edge is logical.
2026-09-11 02:46:56 +02:00
========================================================================= */
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
2026-09-11 02:46:56 +02:00
import { createTranslator, formatNumber, sectionPath } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/what-we-do";
import { isRichTextBlock, type CmsWhatWeDoPage } from "./_page-kit/cms-types";
import {
list,
loadSingle,
toImage,
toStringList,
} from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
PageHero,
PageSection,
Prose,
SectionHeader,
ValueChips,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
2026-09-11 02:46:56 +02:00
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const page = await loadSingle<CmsWhatWeDoPage>("what-we-do-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
workstreams: { populate: { icon: true } },
principles: true,
blocks: { on: { "blocks.rich-text": { populate: "*" } } },
seo: { populate: { ogImage: true } },
},
});
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
intro: page?.intro ?? null,
richTextBlocks: list(page?.blocks)
.filter(isRichTextBlock)
.map((block) => ({ id: block.id, body: block.body })),
workstreams: list(page?.workstreams).map((workstream) => ({
title: workstream.title,
description: workstream.description ?? "",
icon: toImage(workstream.icon, "thumbnail") ?? null,
})),
principles: list(page?.principles).map((principle) => ({
title: principle.title,
description: principle.description ?? "",
})),
guidingValues: toStringList(page?.guidingValues),
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).whatWeDo;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: loaderData?.seo.title ?? t("nav.sections.whatWeDo"),
description: loaderData?.seo.description ?? copy.lead,
...(loaderData?.seo.image
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
: {}),
noIndex: loaderData?.seo.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{
name: t("nav.sections.whatWeDo"),
pathname: sectionPath("whatWeDo", locale),
},
]),
});
}
/** See the note on the same constant in about.tsx. */
const ACCENT_EDGE =
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
2026-09-11 02:46:56 +02:00
export default function WhatWeDo({ loaderData }: Route.ComponentProps) {
const { locale, hero, intro, richTextBlocks } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).whatWeDo;
const workstreams =
loaderData.workstreams.length > 0
? loaderData.workstreams
: copy.workstreams.map((workstream) => ({ ...workstream, icon: null }));
const values =
loaderData.guidingValues.length > 0 ? loaderData.guidingValues : copy.values;
/* An editor's principles carry a line of their own; the bundled fallback is
the four clauses of the approved sentence and nothing more. */
const principles =
loaderData.principles.length > 0
? loaderData.principles
: copy.principleTitles.map((title) => ({ title, description: "" }));
2026-09-11 02:46:56 +02:00
return (
<>
<PageHero
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(2.75rem,6vw,4.25rem)]"
2026-09-11 02:46:56 +02:00
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.whatWeDo")}
2026-09-11 02:46:56 +02:00
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -240, from: "end", offset: "-120px", width: 700, height: 560, durationSeconds: 22 }]}
leadTone="primary"
reveal
2026-09-11 02:46:56 +02:00
{...(hero.media ? { media: hero.media } : {})}
/>
{/* THE FOUR AREAS OF WORK - one seam grid, the cells raised out of the
band, each led by its numeral (localised, so the Arabic page numbers
its cells in its own conventions). */}
2026-09-11 02:46:56 +02:00
<PageSection
aria-labelledby="wwd-workstreams-heading"
className="bg-[var(--surface-sunken)]"
2026-09-11 02:46:56 +02:00
>
<div className="flex flex-col gap-2xl">
{/* The draft prints the heading alone - the approved intro sentence
IS this heading - so no lead is drawn under it. */}
2026-09-11 02:46:56 +02:00
<SectionHeader
className="sh-reveal max-w-[var(--layout-prose-max)]"
2026-09-11 02:46:56 +02:00
id="wwd-workstreams-heading"
title={copy.workstreamsHeading}
/>
{intro ? (
<Prose>
<RichText markdown={intro} />
</Prose>
) : null}
<ul className="grid list-none grid-cols-1 gap-[1px] overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
2026-09-11 02:46:56 +02:00
{workstreams.map((workstream, index) => (
<li
key={workstream.title}
className="sh-reveal flex min-h-[16.25rem] flex-col gap-sm bg-[var(--surface-card)] p-lg transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-muted)]"
>
<p
aria-hidden="true"
className="heading-h2 text-[var(--text-accent)]"
2026-09-11 02:46:56 +02:00
>
{formatNumber(index + 1, locale, { minimumIntegerDigits: 2 })}
</p>
{/* The draft sets an area title a step above body - the same
size it gives the consortium names on About. */}
<h3 className="heading-h3 text-[var(--text-primary)]">
{workstream.title}
</h3>
<p className="body-md text-[var(--text-secondary)]">
2026-09-11 02:46:56 +02:00
{workstream.description}
</p>
2026-09-11 02:46:56 +02:00
</li>
))}
</ul>
</div>
</PageSection>
{/* WHAT GUIDES US, AND HOW WE WORK - the one band the draft raises off
the near-black ground, so it names its surface explicitly. */}
<PageSection
aria-labelledby="wwd-principles-heading"
band
>
<div className="flex flex-col gap-2xl">
{/* The draft's chips rest as a plain outline and only take the accent
fill under the pointer; the shared component rests filled, so the
resting and hover paints are restated here until it carries the
draft's own treatment. */}
<ValueChips
className="sh-reveal"
align="center"
values={values}
label={copy.valuesLabel}
2026-09-11 02:46:56 +02:00
/>
<div className="flex flex-col gap-2xl border-t border-[var(--border-default)] pt-2xl">
{/* The draft draws the four principles straight under the rule,
with no heading over them; the heading stays in the outline. */}
<h2 id="wwd-principles-heading" className="sr-only">
{copy.principlesHeading}
</h2>
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-4">
{principles.map((principle) => (
<li
key={principle.title}
className="sh-reveal flex flex-col gap-sm"
>
{/* The short accent rule the draft draws over each tile,
drawn on from the inline start. Decorative. */}
<span
aria-hidden="true"
className="sh-bar-grow block h-[3px] w-[44px] bg-[var(--surface-accent)]"
/>
<h3 className="body-md-bold text-[var(--text-primary)]">
{principle.title}
</h3>
{principle.description ? (
<p className="body-sm text-[var(--text-secondary)]">
{principle.description}
</p>
) : null}
2026-09-11 02:46:56 +02:00
</li>
))}
</ul>
{richTextBlocks.map((block) => (
<Prose key={block.id}>
<RichText markdown={block.body} />
</Prose>
))}
</div>
2026-09-11 02:46:56 +02:00
</div>
</PageSection>
{/* THE WAY ON TO THE ACTIVITIES */}
<PageSection
aria-labelledby="wwd-activities-heading"
className="bg-[var(--surface-sunken)]"
>
<div
className={`sh-reveal flex flex-wrap items-center justify-between gap-xl rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg md:p-xl ${ACCENT_EDGE}`}
>
<h2
id="wwd-activities-heading"
className="heading-h2 max-w-[39rem] text-[var(--text-primary)]"
>
{copy.activitiesCtaHeading}
</h2>
<Button
as={Link}
to={sectionPath("activities", locale)}
prefetch="intent"
size="lg"
iconEnd={<ArrowIcon />}
>
{t("actions.seeActivities")}
</Button>
</div>
2026-09-11 02:46:56 +02:00
</PageSection>
</>
);
}