An audit of all twelve draft files against the build turned up a set of mismatches, most of them in the tier-2 token layer rather than in any one component. Tier 1 is untouched throughout - only the meanings moved. SHAPE. The drafts draw three corners, not five: 2px for a tag, thumbnail, flag or checkbox, 4px for anything with a box, and fully round for a filter chip or status pill. Nothing in any draft is 8px or 12px. --radius-card and --radius-surface therefore come down to radius/sm, a --radius-chip is added for the 2px tier, and --radius-control-lg - a quirk of the Figma Button frame's ghost cells - is retired along with its four consumers. ELEVATION. A shadow is a fraction of the ground it falls on, and the ramp was tuned for a white page: on the drafts' near-black ground a 12-28% black is invisible. Tier 1 gains the two deep steps the drafts actually draw (--shadow-dark-lg/xl), and --elevation-card/-popover/-modal move into the per-surface blocks so the dark surface can take them while the light one keeps the soft ramp. A resting card now has no shadow at all - the drafts separate with a hairline - and the black is spent on what floats. CARDS. The hover was a white-24% hairline and a faint glow; the drafts turn the hairline accent green, lift the card 6px and land a deep black under it. The lift is pointer-only and is dropped under prefers-reduced-motion. GHOST IS A TEXT LINK. The drafts never fill a ghost control - every "All updates ->" is plain text that turns green while the label-to-arrow gap opens. The tokens carry no fill in any state and the variant opts out of the 3px lift, which also retires the WCAG workaround the old Figma hover forced (green label on a green fill, 1.40:1). NAVIGATION. A nav label rests in text/secondary and brightens to primary; the current section stays primary and is marked by the accent rule, not by green text. HERO STRIPE. The middle block was painted near-black against a comment claiming the redesign had dropped white; the drafts paint it #F8F9FA. Back to background/inverse, and to the drafts' 390 : 495 : 363 split. styles/README.md is updated to match, including a contrast table that still described --surface-raised as the retired #2e2e2c. Not changed, because each is a decision rather than a defect: the drafts set every eyebrow, tag and meta line in IBM Plex Mono at 10-11px with 1.2-1.4px tracking (a third typeface, and no Arabic counterpart); draft headings are weight 800, which needs a font file we do not have; and the EU badge statement stays at --text-primary, since the visibility lockup is contractual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
743 lines
26 KiB
TypeScript
743 lines
26 KiB
TypeScript
/* ============================================================================
|
|
HOME - /en and /ar
|
|
----------------------------------------------------------------------------
|
|
The redesigned home page (Claude Design "Home", second drop), carried onto
|
|
the project's own design system. Six bands, alternating the two dark
|
|
surfaces the way the draft alternates #0B0B0A and #1D1D1B:
|
|
|
|
Hero EU badge, an optional status pill, the headline, the lead,
|
|
the CTA pair, the wide picture, the brand stripe and the
|
|
figures row. Two slow washes drift behind it.
|
|
Launch "An important start for the project": the launch lead and
|
|
three pictures from the CMS gallery.
|
|
Work "Three areas of change" - the three approved pillars, with
|
|
a link through to What We Do.
|
|
Newsroom three cards from the newsroom, newest first.
|
|
FAQs a sticky heading column beside the accordion.
|
|
Consortium the three members, then the civil society network logos.
|
|
|
|
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
|
--------------------------------------------
|
|
* The draft's FAQ column promises a reply "within five working days". That
|
|
commitment appears in no approved document, so it is not published
|
|
(CLAUDE.md rule 5). Everything else in the draft is verifiable: the four
|
|
figures, the ministries at the launch and the three pillars are all in
|
|
docs/ or in the bundled draft copy.
|
|
* Every list - news, questions, partner logos, launch pictures - is CMS
|
|
data, never the draft's sample entries. Each band shows its localised
|
|
empty state instead of inventing content.
|
|
* The draft's own navigation and footer are ignored: the site ships
|
|
<Navigation /> and <Footer /> from the app shell.
|
|
|
|
MOTION: every animation in the draft is here, as classes from
|
|
_page-kit/motion.css, plus the figures counting up on arrival. The one
|
|
effect deliberately dropped is the coloured glow behind a hovered button or
|
|
card.
|
|
|
|
There is no Arabic frame, so /ar is this same DOM under `dir="rtl"`, which
|
|
is why every inline edge below is logical (ps-/pe-/start-/end-).
|
|
========================================================================= */
|
|
|
|
import { useEffect, useLayoutEffect, useState } from "react";
|
|
import { Link } from "react-router";
|
|
|
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
|
import { Card } from "~/components/ui/Card";
|
|
import { EuBadge } from "~/components/ui/EuBadge";
|
|
import {
|
|
createTranslator,
|
|
formatDate,
|
|
getMessages,
|
|
sectionPath,
|
|
type Locale,
|
|
} from "~/i18n";
|
|
import {
|
|
buildMeta,
|
|
buildOrganizationJsonLd,
|
|
buildWebSiteJsonLd,
|
|
} from "~/lib/seo";
|
|
import { safeHref } from "~/lib/safe-href";
|
|
|
|
import type { Route } from "./+types/home";
|
|
import { Accordion } from "./_page-kit/accordion";
|
|
import {
|
|
isImageGalleryBlock,
|
|
type CmsFaq,
|
|
type CmsHomePage,
|
|
type CmsNewsArticle,
|
|
type CmsPartner,
|
|
} from "./_page-kit/cms-types";
|
|
import {
|
|
list,
|
|
loadMany,
|
|
loadSingle,
|
|
toImage,
|
|
toPartner,
|
|
type ResolvedImage,
|
|
} from "./_page-kit/cms.server";
|
|
import { pageCopy } from "./_page-kit/copy";
|
|
import { routeLocale } from "./_page-kit/locale";
|
|
import {
|
|
Container,
|
|
cx,
|
|
EmptyState,
|
|
PageHero,
|
|
PageSection,
|
|
SectionHeader,
|
|
} from "./_page-kit/sections";
|
|
|
|
import "./_page-kit/motion.css";
|
|
|
|
/** The network logo wall - a wide but finite grid. */
|
|
const PARTNER_PAGE_SIZE = 36;
|
|
/** The draft's newsroom band is a straight three-up row. */
|
|
const NEWS_PAGE_SIZE = 3;
|
|
/** More than a dozen questions is a page of its own, not a home page band. */
|
|
const FAQ_PAGE_SIZE = 12;
|
|
/** The draft's launch band shows three pictures. */
|
|
const LAUNCH_SHOTS = 3;
|
|
|
|
/** A newsroom card, already localised and safe to serialise to the client. */
|
|
interface NewsCard {
|
|
id: string;
|
|
title: string;
|
|
href: string;
|
|
excerpt: string | null;
|
|
dateLabel: string | null;
|
|
organisation: string | null;
|
|
image: { src: string; alt: string } | null;
|
|
}
|
|
|
|
function toNewsCard(article: CmsNewsArticle, locale: Locale): NewsCard {
|
|
const image = toImage(article.coverImage, "small");
|
|
|
|
return {
|
|
id: article.documentId,
|
|
title: article.title,
|
|
href: `${sectionPath("news", locale)}/${article.slug}`,
|
|
excerpt: article.excerpt ?? null,
|
|
/* The CMS holds one date for both languages; the reader's locale decides
|
|
how it is written out. */
|
|
dateLabel: article.publishDate ? formatDate(article.publishDate, locale) : null,
|
|
organisation: article.authorOrganisation?.name ?? null,
|
|
image: image ? { src: image.src, alt: image.alt } : null,
|
|
};
|
|
}
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const locale = routeLocale(params);
|
|
|
|
const page = await loadSingle<CmsHomePage>("home-page", {
|
|
locale,
|
|
populate: {
|
|
hero: { populate: { media: true, ctas: true } },
|
|
stats: true,
|
|
// Strapi 5 populates a dynamic zone per component with `on`.
|
|
blocks: { on: { "blocks.image-gallery": { populate: { images: true } } } },
|
|
featuredNews: { populate: { coverImage: true, authorOrganisation: true } },
|
|
partners: { populate: { logo: true, logoDark: true } },
|
|
seo: { populate: { ogImage: true } },
|
|
},
|
|
});
|
|
|
|
/* The curated relation wins; otherwise show every partner the CMS knows, so
|
|
the wall fills in as soon as partners exist - before anyone remembers to
|
|
curate the home page itself. */
|
|
const curatedPartners = list(page?.partners);
|
|
const partners =
|
|
curatedPartners.length > 0
|
|
? curatedPartners
|
|
: await loadMany<CmsPartner>("partners", {
|
|
locale,
|
|
sort: ["name:asc"],
|
|
filters: { partnerRole: { $in: ["cso-partner", "institution"] } },
|
|
populate: { logo: true, logoDark: true },
|
|
pagination: { pageSize: PARTNER_PAGE_SIZE },
|
|
});
|
|
|
|
/* Same rule for the newsroom: an editor's picks, else simply the newest
|
|
articles, so the band is never empty while the newsroom has entries. */
|
|
const curatedNews = list(page?.featuredNews);
|
|
const news =
|
|
curatedNews.length > 0
|
|
? curatedNews
|
|
: await loadMany<CmsNewsArticle>("news-articles", {
|
|
locale,
|
|
sort: ["publishDate:desc", "title:asc"],
|
|
populate: { coverImage: true, authorOrganisation: true },
|
|
pagination: { pageSize: NEWS_PAGE_SIZE },
|
|
});
|
|
|
|
const faqs = await loadMany<CmsFaq>("faqs", {
|
|
locale,
|
|
sort: ["order:asc", "createdAt:asc"],
|
|
pagination: { pageSize: FAQ_PAGE_SIZE },
|
|
});
|
|
|
|
const gallery = list(page?.blocks)
|
|
.filter(isImageGalleryBlock)
|
|
.flatMap((block) => list(block.images))
|
|
.map((image) => toImage(image, "medium"))
|
|
.filter((image): image is ResolvedImage => image !== undefined);
|
|
|
|
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,
|
|
ctas: list(page?.hero?.ctas).map((cta) => ({
|
|
label: cta.label,
|
|
href: cta.href,
|
|
variant: cta.variant ?? "primary",
|
|
})),
|
|
},
|
|
/* Editor-owned figures win; the bundled four stand in until then. */
|
|
stats: list(page?.stats).map((stat) => ({
|
|
value: stat.value,
|
|
label: stat.label,
|
|
})),
|
|
gallery: gallery.slice(0, LAUNCH_SHOTS),
|
|
partnersHeadline: page?.partnersHeadline ?? null,
|
|
partners: partners.map(toPartner),
|
|
news: news.slice(0, NEWS_PAGE_SIZE).map((article) => toNewsCard(article, locale)),
|
|
faqs: faqs.map((faq) => ({
|
|
id: faq.documentId,
|
|
question: faq.question,
|
|
answer: faq.answer,
|
|
})),
|
|
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 messages = getMessages(locale);
|
|
|
|
return buildMeta({
|
|
locale,
|
|
pathname: root.pathname,
|
|
siteUrl: root.siteUrl,
|
|
title: loaderData?.seo.title ?? messages.site.metaTitle,
|
|
description: loaderData?.seo.description ?? messages.site.description,
|
|
...(loaderData?.seo.image
|
|
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
|
|
: {}),
|
|
noIndex: loaderData?.seo.noIndex ?? false,
|
|
// Emitted once for the whole site, from its home page.
|
|
jsonLd: [
|
|
buildOrganizationJsonLd({ siteUrl: root.siteUrl, locale }),
|
|
buildWebSiteJsonLd({ siteUrl: root.siteUrl, locale }),
|
|
],
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
SMALL PIECES
|
|
------------------------------------------------------------------------ */
|
|
|
|
interface HeroCtaProps {
|
|
href: string;
|
|
label: string;
|
|
variant: "primary" | "secondary";
|
|
}
|
|
|
|
/**
|
|
* A hero call to action.
|
|
*
|
|
* Both controls are the project's Button, which now carries the Claude Design
|
|
* drafts' own treatment: the accent fill for the primary, the outline ring for
|
|
* the secondary, and a 3px lift under the pointer. The secondary is no longer
|
|
* flipped onto the light surface - the drafts draw it as an outline on the
|
|
* dark band, not as a white fill.
|
|
*/
|
|
function HeroCta({ href, label, variant }: HeroCtaProps) {
|
|
return (
|
|
<Button
|
|
as={Link}
|
|
to={href}
|
|
prefetch="intent"
|
|
size="lg"
|
|
variant={variant}
|
|
iconEnd={variant === "primary" ? <ArrowIcon /> : undefined}
|
|
>
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The hero's status line. The draft hard-codes "Now live · Expression of
|
|
* Interest open"; here the words are the CMS hero eyebrow, so the pill only
|
|
* ever announces a state an editor has actually written - and the hero simply
|
|
* has no pill until then. The dot pulses; the text does not move.
|
|
*/
|
|
function StatusPill({ label }: { label: string }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-xs rounded-pill border border-[var(--border-subtle)] bg-[var(--surface-raised)] py-2xs ps-sm pe-md">
|
|
<span
|
|
aria-hidden="true"
|
|
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
|
/>
|
|
<span className="label-md uppercase text-[var(--text-secondary)]">{label}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/* `useLayoutEffect` would warn during SSR, and the count only exists in a
|
|
browser, so the effect is picked per environment. */
|
|
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
|
|
/**
|
|
* A figure that counts up to its value on arrival, as the draft does.
|
|
*
|
|
* The server renders the FINAL value, so a reader without JavaScript - and a
|
|
* search engine - sees the real figure. In a browser the count is reset before
|
|
* the first paint (layout effect, not a plain effect), so the number never
|
|
* flashes its end state first. A value the CMS wrote as words rather than
|
|
* digits is simply printed.
|
|
*/
|
|
function CountUpValue({ value }: { value: string }) {
|
|
const parsed = /^(\d[\d,]*)([\s\S]*)$/.exec(value);
|
|
const target = parsed ? Number(parsed[1]!.replace(/,/g, "")) : null;
|
|
const suffix = parsed ? parsed[2]! : "";
|
|
const [shown, setShown] = useState<number | null>(target);
|
|
|
|
useBrowserLayoutEffect(() => {
|
|
if (target === null) return;
|
|
/* app.css collapses animation, but this one is script-driven, so the
|
|
preference has to be honoured here as well. */
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
|
|
let frame = 0;
|
|
const started = performance.now();
|
|
const duration = 1500;
|
|
|
|
setShown(0);
|
|
|
|
const tick = (now: number) => {
|
|
const progress = Math.min(1, (now - started) / duration);
|
|
/* Ease out cubic - the draft's own curve. */
|
|
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))));
|
|
if (progress < 1) frame = requestAnimationFrame(tick);
|
|
};
|
|
|
|
frame = requestAnimationFrame(tick);
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [target]);
|
|
|
|
if (target === null) return <>{value}</>;
|
|
|
|
return (
|
|
<>
|
|
{shown}
|
|
{suffix}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The figures row that closes the hero: four cells divided by hairlines. The
|
|
* grid's own background paints the 1px gaps, which is how the draft draws the
|
|
* dividers without a border on each cell.
|
|
*/
|
|
function StatsRow({ stats }: { stats: readonly { value: string; label: string }[] }) {
|
|
if (stats.length === 0) return null;
|
|
|
|
return (
|
|
<dl className="grid grid-cols-1 gap-px border-y border-[var(--border-subtle)] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
|
|
{stats.map((stat) => (
|
|
/* Source order is term-then-description, as the spec requires; the
|
|
reversed column paints the figure above its label without repeating
|
|
the label for a screen reader. */
|
|
<div
|
|
key={`${stat.value}-${stat.label}`}
|
|
className="flex flex-col-reverse gap-2xs bg-[var(--surface-sunken)] px-lg py-xl text-start transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
|
|
>
|
|
<dt className="label-md uppercase text-[var(--text-tertiary)]">{stat.label}</dt>
|
|
<dd className="heading-h1 tabular-nums text-[var(--text-primary)]">
|
|
<CountUpValue value={stat.value} />
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The launch band's three pictures. The CMS alt text doubles as the visible
|
|
* caption - so the picture is described once, in the caption, and the <img>
|
|
* itself is marked decorative rather than having a screen reader read the same
|
|
* sentence twice.
|
|
*/
|
|
function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
|
if (images.length === 0) return null;
|
|
|
|
return (
|
|
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
|
{images.map((image) => (
|
|
<li key={image.src} className="sh-reveal">
|
|
<figure className="flex flex-col gap-xs">
|
|
<img
|
|
src={image.src}
|
|
alt=""
|
|
loading="lazy"
|
|
className="sh-lift h-[15rem] w-full rounded-control border border-[var(--border-subtle)] object-cover hover:border-[var(--border-accent)]"
|
|
/>
|
|
{image.alt ? (
|
|
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
|
|
{image.alt}
|
|
</figcaption>
|
|
) : null}
|
|
</figure>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
/** The "see the whole section" link the draft puts opposite a band heading. */
|
|
function BandLink({ to, label }: { to: string; label: string }) {
|
|
return (
|
|
<Button as={Link} to={to} prefetch="intent" variant="ghost" iconEnd={<ArrowIcon />}>
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
THE PAGE
|
|
------------------------------------------------------------------------ */
|
|
|
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
|
const { locale, hero, stats, gallery, partners, news, faqs } = loaderData;
|
|
const t = createTranslator(locale);
|
|
const copy = pageCopy(locale).home;
|
|
/* The three pillars are the About page's approved copy - one source, so the
|
|
two pages can never drift apart. */
|
|
const pillars = pageCopy(locale).about.pillars;
|
|
|
|
const headline = hero.headline ?? copy.heroHeadline;
|
|
const lead = hero.subheading ?? copy.heroLead;
|
|
const figures = stats.length > 0 ? stats : copy.stats;
|
|
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
|
|
|
|
/* The frame draws one accent CTA and one white one. Editor-supplied CTAs go
|
|
through the same component as the fallback pair, so a `secondary` CTA
|
|
authored in Strapi gets the white treatment too. */
|
|
const heroCtas: readonly HeroCtaProps[] =
|
|
hero.ctas.length > 0
|
|
? hero.ctas.map((cta) => ({
|
|
href: cta.href,
|
|
label: cta.label,
|
|
variant: cta.variant === "primary" ? "primary" : "secondary",
|
|
}))
|
|
: [
|
|
{
|
|
href: sectionPath("applications", locale),
|
|
label: copy.heroCtaPrimary,
|
|
variant: "primary",
|
|
},
|
|
{
|
|
href: sectionPath("about", locale),
|
|
label: copy.heroCtaSecondary,
|
|
variant: "secondary",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
{/* The reading-progress rule. Decorative, pointer-transparent, and
|
|
painted only where scroll timelines exist (motion.css). */}
|
|
<div
|
|
aria-hidden="true"
|
|
className="sh-progress pointer-events-none fixed inset-x-0 top-0 z-[var(--layer-dropdown)] h-[3px]"
|
|
/>
|
|
|
|
<PageHero
|
|
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-2xl)]"
|
|
title={headline}
|
|
lead={lead}
|
|
leadWithActions
|
|
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
|
|
ambient={[
|
|
{ top: -220, from: "start", offset: "8%", width: 780, height: 620, durationSeconds: 18 },
|
|
{
|
|
top: 120,
|
|
from: "end",
|
|
offset: "-160px",
|
|
width: 620,
|
|
height: 520,
|
|
tone: "danger",
|
|
durationSeconds: 24,
|
|
reverse: true,
|
|
},
|
|
]}
|
|
reveal
|
|
/* EU visibility sits at the top of the hero (the draft's badge). The
|
|
emblem is the dark-surface export because the hero sits on the
|
|
page's own dark surface. `emblemAlt` is left at its empty default:
|
|
the statement is real text beside the emblem, so naming the image
|
|
would read the attribution out twice. */
|
|
funderBadge={
|
|
<EuBadge
|
|
size="sm"
|
|
statement={t("site.euStatement")}
|
|
locale={locale}
|
|
surface="dark"
|
|
className="self-start"
|
|
/>
|
|
}
|
|
{...(hero.media ? { media: hero.media } : {})}
|
|
mediaTreatment="banner"
|
|
actions={heroCtas.map((cta) => (
|
|
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
|
))}
|
|
below={<StatsRow stats={figures} />}
|
|
/>
|
|
|
|
{/* THE LAUNCH */}
|
|
<PageSection
|
|
aria-labelledby="home-launch-heading"
|
|
band
|
|
>
|
|
<div className="flex flex-col gap-3xl">
|
|
<div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center">
|
|
<SectionHeader
|
|
id="home-launch-heading"
|
|
eyebrow={copy.launchEyebrow}
|
|
title={copy.launchHeading}
|
|
size="display"
|
|
align="center"
|
|
/>
|
|
|
|
<p className="body-lg text-[var(--text-secondary)]">{copy.launchLead}</p>
|
|
</div>
|
|
|
|
<LaunchShots images={gallery} />
|
|
</div>
|
|
</PageSection>
|
|
|
|
{/* THREE AREAS OF CHANGE */}
|
|
<PageSection
|
|
aria-labelledby="home-work-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="home-work-heading"
|
|
title={copy.workHeading}
|
|
size="display"
|
|
/>
|
|
|
|
<BandLink to={sectionPath("whatWeDo", locale)} label={copy.workLinkLabel} />
|
|
</div>
|
|
|
|
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
|
{pillars.map((pillar) => (
|
|
<li
|
|
key={pillar.title}
|
|
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
|
|
>
|
|
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
|
|
<p className="body-md text-[var(--text-secondary)]">
|
|
{pillar.description}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</PageSection>
|
|
|
|
{/* NEWSROOM */}
|
|
<PageSection
|
|
aria-labelledby="home-news-heading"
|
|
band
|
|
>
|
|
<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="home-news-heading"
|
|
eyebrow={copy.newsEyebrow}
|
|
title={copy.newsHeading}
|
|
size="display"
|
|
/>
|
|
|
|
<BandLink to={sectionPath("news", locale)} label={t("actions.viewAll")} />
|
|
</div>
|
|
|
|
{news.length > 0 ? (
|
|
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
|
{news.map((item) => (
|
|
<Card.Content
|
|
key={item.id}
|
|
as="li"
|
|
className="sh-reveal sh-reveal--in"
|
|
href={item.href}
|
|
title={item.title}
|
|
actionLabel={t("actions.readMore")}
|
|
{...(item.organisation
|
|
? { eyebrow: t("news.postedBy", { organisation: item.organisation }) }
|
|
: item.dateLabel
|
|
? { eyebrow: item.dateLabel }
|
|
: {})}
|
|
{...(item.organisation && item.dateLabel
|
|
? { badge: item.dateLabel }
|
|
: {})}
|
|
{...(item.excerpt ? { excerpt: item.excerpt } : {})}
|
|
{...(item.image ? { image: item.image } : {})}
|
|
titleAs="h3"
|
|
/>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<EmptyState message={copy.newsEmpty} />
|
|
)}
|
|
</div>
|
|
</PageSection>
|
|
|
|
{/* FAQs - the heading column stays with the reader while the answers
|
|
scroll beside it, as the draft has it. */}
|
|
<PageSection
|
|
aria-labelledby="home-faq-heading"
|
|
className="bg-[var(--surface-sunken)]"
|
|
>
|
|
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-2 lg:gap-4xl">
|
|
<div className="sh-reveal flex flex-col gap-lg lg:sticky lg:top-[var(--layout-sticky-top)] lg:self-start">
|
|
<SectionHeader
|
|
id="home-faq-heading"
|
|
eyebrow={copy.faqEyebrow}
|
|
title={copy.faqHeading}
|
|
lead={copy.faqLead}
|
|
size="display"
|
|
/>
|
|
|
|
<Button
|
|
as={Link}
|
|
to={sectionPath("knowledgeHub", locale)}
|
|
prefetch="intent"
|
|
size="lg"
|
|
className="self-start"
|
|
iconEnd={<ArrowIcon />}
|
|
>
|
|
{copy.faqCtaLabel}
|
|
</Button>
|
|
</div>
|
|
|
|
{faqs.length > 0 ? (
|
|
<Accordion items={faqs} className="sh-reveal" />
|
|
) : (
|
|
<EmptyState message={copy.faqEmpty} />
|
|
)}
|
|
</div>
|
|
</PageSection>
|
|
|
|
{/* THE CONSORTIUM, then the network it works with */}
|
|
<PageSection
|
|
aria-labelledby="home-consortium-heading"
|
|
band
|
|
>
|
|
<div className="flex flex-col gap-2xl">
|
|
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg">
|
|
<SectionHeader
|
|
id="home-consortium-heading"
|
|
title={consortiumHeading}
|
|
size="display"
|
|
/>
|
|
|
|
<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">
|
|
{copy.consortium.map((member) => (
|
|
<li
|
|
key={member.name}
|
|
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
|
|
>
|
|
<span className="rounded-chip bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
|
|
{member.role}
|
|
</span>
|
|
<h3 className="heading-h3 text-[var(--text-primary)]">{member.name}</h3>
|
|
<p className="body-md text-[var(--text-secondary)]">
|
|
{member.description}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<div className="flex flex-col gap-md">
|
|
<h3 className="label-md uppercase text-[var(--text-tertiary)]">
|
|
{copy.networkLabel}
|
|
</h3>
|
|
|
|
{partners.length > 0 ? (
|
|
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
|
|
{partners.map((partner) => {
|
|
const href = safeHref(partner.website);
|
|
|
|
/* The logo wall is a grid of equal tiles; the name is the
|
|
tile's text when there is no logo to draw, and always its
|
|
accessible name. */
|
|
const inner = (
|
|
<>
|
|
{partner.logo ? (
|
|
<img
|
|
src={partner.logo.src}
|
|
alt={partner.name}
|
|
loading="lazy"
|
|
className="max-h-[3.5rem] w-auto max-w-full object-contain"
|
|
/>
|
|
) : (
|
|
<span className="body-sm-bold text-center text-[var(--text-secondary)]">
|
|
{partner.name}
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<li
|
|
key={partner.id}
|
|
className={cx(
|
|
"sh-reveal sh-reveal--in flex h-[5.5rem] items-center justify-center",
|
|
"rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md",
|
|
"transition-colors duration-[var(--motion-duration-base)]",
|
|
"hover:border-[var(--border-accent)]",
|
|
)}
|
|
>
|
|
{href ? (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex h-full w-full items-center justify-center"
|
|
>
|
|
{inner}
|
|
</a>
|
|
) : (
|
|
inner
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : (
|
|
<EmptyState message={copy.partnersEmpty} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PageSection>
|
|
</>
|
|
);
|
|
}
|