Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the project's own design system: tier-2 tokens, the published type roles, logical properties throughout, and both locales. Surfaces - The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts do; the body previously painted the band colour, so every band was a step too light. - A card is drawn one step off whatever it stands on (#1D1D1B on the ground, #141413 inside a band). Components paint --surface-card and a band re-points it for its subtree, so a card can never come out the same colour as its band. Shell - Nav: translucent ground with a blur, chevrons on group triggers, an accent rule under the current section, the drafts' compact dropdown card, and an EN | AR toggle in place of the globe menu. - Footer: the drafts' four-column grid, one-line brand text, Facebook as a bordered button, the EU lockup, the implemented-by row and the funding disclaimer. Pages - Home: hero with counting figures, launch band, three areas of change, newsroom, FAQ accordion and the consortium band. - The nine interior pages rebuilt and then audited section by section against their drafts. - Applications: the three-step application form now posts real submissions to the Strapi `application` type, validated server-side and working without JavaScript. Motion - Every animation in the drafts, as classes in _page-kit/motion.css: staggered entrances, scroll-driven reveals, the drifting washes (each page carries its own geometry), the sheen, the counting figures and the accordion. The drafts' coloured hover glow is deliberately not shipped. CMS - New `faq` collection type behind the home page's accordion. - `role` added to the consortium member component so an editor can supply the chip and the deliverable separately. Content - No invented figure, date, name or commitment ships: the five-working-day reply, the placeholder email and Facebook handle, the sample news, events, publications and FAQs, the invented application rules and the TBD counters are all withheld pending real content. Figures that survived (3 consortium partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec 2027) are verified against docs/ and the bundled draft copy. Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20 page/locale URLs render 200 through the built SSR handler, including with the CMS unreachable; no raw hex or physical-direction utility in components or routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
407 lines
16 KiB
TypeScript
407 lines
16 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("whatWeDo", locale)}
|
|
prefetch="intent"
|
|
size="lg"
|
|
iconEnd={<ArrowIcon />}
|
|
>
|
|
{t("actions.learnWhatWeDo")}
|
|
</Button>
|
|
|
|
{/* The draft marks the legacy page as "[link: …]" with no target
|
|
yet, so this second button appears only once the CMS gives it
|
|
a real one. */}
|
|
{legacy.link ? (
|
|
legacy.link.isExternal ? (
|
|
<Button
|
|
as="a"
|
|
href={legacy.link.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
variant="secondary"
|
|
size="lg"
|
|
iconEnd={<ArrowIcon />}
|
|
>
|
|
{legacy.link.label}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
as={Link}
|
|
to={legacy.link.href}
|
|
prefetch="intent"
|
|
variant="secondary"
|
|
size="lg"
|
|
iconEnd={<ArrowIcon />}
|
|
>
|
|
{legacy.link.label}
|
|
</Button>
|
|
)
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</PageSection>
|
|
</>
|
|
);
|
|
}
|