Files

380 lines
14 KiB
TypeScript

/* ============================================================================
ABOUT - /en/about and /ar/about
----------------------------------------------------------------------------
The redesigned About page ("About", Claude Design draft), carried onto the
project's own design system. Five bands, alternating the two dark surfaces
the way the draft alternates #0B0B0A and #1D1D1B:
Hero eyebrow, headline and lead on the sunken surface, with the
two drifting washes behind it.
Facts a four-cell seam grid: the run, the coverage, the lead
partner and the funder.
Nationwide the approved body prose beside the three focus areas.
Consortium unboxed rows for the three members.
The header carries the EU funding line.
Legacy the SHARP I and II panel with the accent start-edge, and the
call to action into What We Do.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
--------------------------------------------
* Nothing was cut for sourcing: every fact, role and sentence the draft
prints is in section 1 of docs/SHARPER_Website_Draft_Section_EN_AR.docx or
in the project brief. What the draft does NOT print - a figure of any kind
- stays absent, because the draft marks every statistic "TBD".
* The draft's coloured hover glow (a green box-shadow under a hovered card)
is deliberately dropped; the tiles only rise.
Every block is the FALLBACK: api::about-page.about-page overrides it field
by field as soon as an editor fills it in.
MOTION: classes from _page-kit/motion.css - the staggered hero entrance, the
drifting washes, the scroll-driven band reveals and the hover lift.
There is no Arabic frame in the draft, so /ar is this same DOM under
`dir="rtl"`: every inline edge below is logical (ps-/pe-/start-/end-, or an
explicit `border-inline-start-*`), never left/right.
========================================================================= */
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { createTranslator, sectionPath } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/about";
import {
isRichTextBlock,
type CmsAboutPage,
} from "./_page-kit/cms-types";
import { list, loadSingle, toImage } 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 {
InfoPanel,
PageHero,
PageSection,
SectionHeader,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const page = await loadSingle<CmsAboutPage>("about-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
consortium: { populate: { partner: { populate: { logo: true } } } },
pillars: true,
legacyLink: 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,
},
body: page?.body ?? null,
richTextBlocks: list(page?.blocks)
.filter(isRichTextBlock)
.map((block) => ({ id: block.id, body: block.body })),
pillars: list(page?.pillars).map((pillar) => ({
title: pillar.title,
description: pillar.description ?? "",
})),
consortium: list(page?.consortium)
.filter((member) => member.partner !== null && member.partner !== undefined)
.map((member) => ({
id: member.partner?.documentId ?? String(member.id ?? member.partner?.id),
name: member.partner?.name ?? "",
/* The chip and the line under the name are separate fields, so a
CMS-authored member reads exactly like the bundled copy. */
role: member.role ?? null,
description: member.roleDescription ?? member.partner?.description ?? "",
website: member.partner?.website ?? null,
})),
legacy: {
headline: page?.legacyHeadline ?? null,
body: page?.legacyBody ?? null,
link: page?.legacyLink
? {
label: page.legacyLink.label,
href: page.legacyLink.href,
isExternal: page.legacyLink.isExternal ?? false,
}
: null,
},
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).about;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: loaderData?.seo.title ?? t("nav.sections.about"),
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.about"), pathname: sectionPath("about", locale) },
]),
});
}
/**
* The accent start-edge the draft paints down the left of its closing panel.
* Written as arbitrary properties rather than `border-s-4`: the edge needs its
* own colour as well as its own width, and `border-inline-start-*` is the only
* spelling that mirrors in Arabic without an `rtl:` override.
*/
const ACCENT_EDGE =
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
/** One member of the consortium, however it reached the page. */
interface ConsortiumMember {
id: string;
name: string;
/** The role label - `consortium.role` in the CMS, or the bundled copy's. */
role: string | null;
description: string;
website: string | null;
}
export default function About({ loaderData }: Route.ComponentProps) {
const { locale, hero, body, richTextBlocks, legacy } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).about;
const pillars = loaderData.pillars.length > 0 ? loaderData.pillars : copy.pillars;
const consortium: readonly ConsortiumMember[] =
loaderData.consortium.length > 0
? loaderData.consortium.map((member) => ({
id: member.id,
name: member.name,
role: member.role,
description: member.description,
website: member.website,
}))
: copy.consortium.map((member) => ({
id: member.name,
name: member.name,
role: member.role,
description: member.description,
website: null,
}));
return (
<>
<PageHero
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(3rem,6vw,4.5rem)]"
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? copy.heroEyebrow}
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -260, from: "start", offset: "6%", width: 760, height: 600, durationSeconds: 20 }]}
reveal
{...(hero.media ? { media: hero.media } : {})}
/>
{/* THE FACTS BAND - four cells divided by a one-pixel seam, which is the
band's own background showing between them. Facts, not figures: the
run, the coverage, the lead partner and the funder, each written out
in the approved draft. */}
<PageSection className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] pt-0 pb-0 md:pt-0 md:pb-0">
<dl className="grid grid-cols-1 gap-[1px] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
{copy.facts.map((fact) => (
<div
key={fact.label}
className="sh-reveal flex flex-col gap-2xs bg-[var(--surface-sunken)] p-lg transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
>
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{fact.label}
</dt>
<dd className="body-lg-bold text-[var(--text-primary)]">
{fact.value}
</dd>
</div>
))}
</dl>
</PageSection>
{/* A NATIONWIDE PROJECT, DELIVERED LOCALLY - the approved body prose on
the inline-start side, the three focus areas stacked beside it. The
band is the raised dark the draft paints (#1d1d1b) and has to say so:
the document ground is now the sunken near-black. */}
<PageSection
aria-labelledby="about-body-heading"
band
>
<div className="grid grid-cols-1 items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
<div className="sh-reveal flex flex-col gap-md">
<SectionHeader id="about-body-heading" title={copy.bodyHeading} />
{body ? (
<RichText markdown={body} />
) : (
/* Two paragraphs, as the draft sets them - the run and the
consortium first, then what the project does. */
copy.body.map((paragraph) => (
<p
key={paragraph}
className="body-md text-[var(--text-secondary)]"
>
{paragraph}
</p>
))
)}
{richTextBlocks.map((block) => (
<RichText key={block.id} markdown={block.body} />
))}
</div>
<div className="flex flex-col gap-md">
{/* The draft prints no heading over this column; the heading exists
for the document outline and for screen readers. */}
<h3 id="about-pillars-heading" className="sr-only">
{copy.pillarsHeading}
</h3>
<ul
aria-labelledby="about-pillars-heading"
className="flex list-none flex-col gap-md"
>
{pillars.map((pillar) => (
<li key={pillar.title}>
<InfoPanel
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
title={pillar.title}
titleAs="h4"
>
{pillar.description}
</InfoPanel>
</li>
))}
</ul>
</div>
</div>
</PageSection>
{/* THE CONSORTIUM */}
{/* The draft draws no rule between the raised band above and this one -
the change of surface is the seam - so this band carries none. */}
<PageSection
aria-labelledby="about-consortium-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="flex flex-col gap-2xl">
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
<SectionHeader
id="about-consortium-heading"
title={copy.consortiumHeading}
/>
{/* EU visibility, as the draft draws it beside this heading. The
emblem itself is carried by the navigation and the footer. */}
<p className="label-md uppercase text-[var(--text-tertiary)]">
{t("site.euStatement")}
</p>
</div>
<ul className="list-none divide-y divide-[var(--border-subtle)] border-y border-[var(--border-subtle)]">
{consortium.map((member) => (
<li
key={member.id}
className="sh-reveal sh-reveal--in grid grid-cols-1 gap-md py-lg md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-xl"
>
<div className="flex flex-col gap-xs">
{member.role ? (
<p className="label-md uppercase text-[var(--text-accent)]">
{member.role}
</p>
) : null}
<h3 className="heading-h3 text-[var(--text-primary)]">
{member.website ? (
<a
href={member.website}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{member.name}
</a>
) : (
member.name
)}
</h3>
</div>
<p className="body-md text-[var(--text-secondary)]">
{member.description}
</p>
</li>
))}
</ul>
</div>
</PageSection>
{/* SHARP I AND II, AND THE WAY ON TO WHAT WE DO */}
<PageSection
aria-labelledby="about-legacy-heading"
className="bg-[var(--surface-sunken)] pt-0 md:pt-0"
>
<div
className={`sh-reveal flex flex-wrap items-center justify-between gap-xl rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg md:p-xl ${ACCENT_EDGE}`}
>
<div className="flex max-w-[44rem] flex-col gap-sm">
<SectionHeader
id="about-legacy-heading"
eyebrow={copy.legacyEyebrow}
title={legacy.headline ?? copy.legacyHeading}
/>
{legacy.body ? (
<RichText markdown={legacy.body} />
) : (
<p className="body-md text-[var(--text-secondary)]">
{copy.legacyBody}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-md">
<Button
as={Link}
to={sectionPath("legacy", locale)}
prefetch="intent"
size="lg"
iconEnd={<ArrowIcon />}
>
{copy.legacyLinkLabel}
</Button>
</div>
</div>
</PageSection>
</>
);
}