Files

380 lines
14 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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:
2026-09-11 02:46:56 +02:00
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.
2026-10-07 11:33:10 +02:00
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.
2026-09-11 02:46:56 +02:00
========================================================================= */
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";
2026-09-11 02:46:56 +02:00
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 ?? "",
2026-09-11 02:46:56 +02:00
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;
2026-10-07 11:33:10 +02:00
/** The role label - `consortium.role` in the CMS, or the bundled copy's. */
role: string | null;
description: string;
website: string | null;
}
2026-09-11 02:46:56 +02:00
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[] =
2026-09-11 02:46:56 +02:00
loaderData.consortium.length > 0
? loaderData.consortium.map((member) => ({
id: member.id,
name: member.name,
role: member.role,
description: member.description,
website: member.website,
}))
2026-09-11 02:46:56 +02:00
: copy.consortium.map((member) => ({
id: member.name,
name: member.name,
role: member.role,
description: member.description,
2026-09-11 02:46:56 +02:00
website: null,
}));
return (
<>
<PageHero
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(3rem,6vw,4.5rem)]"
2026-09-11 02:46:56 +02:00
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? copy.heroEyebrow}
2026-09-11 02:46:56 +02:00
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -260, from: "start", offset: "6%", width: 760, height: 600, durationSeconds: 20 }]}
reveal
2026-09-11 02:46:56 +02:00
{...(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>
2026-09-11 02:46:56 +02:00
))}
</dl>
2026-09-11 02:46:56 +02:00
</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} />
2026-09-11 02:46:56 +02:00
{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} />
2026-09-11 02:46:56 +02:00
))}
</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>
2026-09-11 02:46:56 +02:00
</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)]"
>
2026-09-11 02:46:56 +02:00
<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>
2026-09-11 02:46:56 +02:00
2026-10-07 11:33:10 +02:00
<ul className="list-none divide-y divide-[var(--border-subtle)] border-y border-[var(--border-subtle)]">
2026-09-11 02:46:56 +02:00
{consortium.map((member) => (
2026-10-07 11:33:10 +02:00
<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>
2026-09-11 02:46:56 +02:00
) : null}
2026-10-07 11:33:10 +02:00
<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>
2026-09-11 02:46:56 +02:00
</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}
/>
2026-09-11 02:46:56 +02:00
{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>
2026-09-11 02:46:56 +02:00
</PageSection>
</>
);
}