377 lines
14 KiB
TypeScript
377 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 the three members as role / name / deliverable panels, under
|
|
a header row that 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 chip - `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="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
|
{consortium.map((member) => (
|
|
<li key={member.id}>
|
|
<InfoPanel
|
|
/* On the sunken band the draft lifts these cards to the
|
|
raised dark it uses for a page band (#1d1d1b), one step
|
|
lighter than the panel shade InfoPanel defaults to. The
|
|
`!` is what makes the override beat the component's own
|
|
`bg-*` utility, which sorts after this one. */
|
|
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
|
|
title={member.name}
|
|
{...(member.role
|
|
? { eyebrow: member.role, eyebrowVariant: "chip" as const }
|
|
: {})}
|
|
>
|
|
<span className="block">{member.description}</span>
|
|
{member.website ? (
|
|
<a
|
|
href={member.website}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-xs inline-block body-sm-bold text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
|
>
|
|
{member.name}
|
|
</a>
|
|
) : null}
|
|
</InfoPanel>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|