Files
sharper.ly/apps/web/app/routes/home.tsx
T

881 lines
32 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
HOME - /en and /ar
----------------------------------------------------------------------------
The redesigned home page (Claude Design "Home page redesign with news and
FAQs", third 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 and the brand stripe. Two
slow washes drift behind it.
Approach "A nationwide project, delivered locally": what the project
does, and up to three pictures. Heading, lead and pictures
are all `home-page.approach`, so the band is edited in one
place rather than half in a field and half in Blocks.
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 against
docs/ or the bundled draft copy.
* The figures band the second drop carried is gone, as it is from the third
draft, and so is the `home-page.stats` component behind it: no approved
document carries a number, so there was never anything to put in it.
* Every list - news, questions, partner logos, band 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 - the headline wipe, the hero picture's scroll-driven
drift, each item tasting in as it scrolls into view (the edge tiles of a row
sliding in from their own side, the middles and stacked lists rising), the
gallery and card hovers, the logo marquee. 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-).
2026-09-11 02:46:56 +02:00
========================================================================= */
import { useEffect } from "react";
2026-09-23 22:33:58 +02:00
import { Link, useRouteLoaderData } from "react-router";
2026-09-11 02:46:56 +02:00
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";
2026-09-11 02:46:56 +02:00
import {
buildMeta,
buildOrganizationJsonLd,
buildWebSiteJsonLd,
} from "~/lib/seo";
import { safeHref } from "~/lib/safe-href";
2026-09-23 22:33:58 +02:00
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
2026-09-11 02:46:56 +02:00
import type { Route } from "./+types/home";
import { Accordion } from "./_page-kit/accordion";
import { enableReveals } from "./_page-kit/reveal";
2026-09-11 02:46:56 +02:00
import {
isImageGalleryBlock,
type CmsFaq,
2026-09-11 02:46:56 +02:00
type CmsHomePage,
type CmsNewsArticle,
2026-09-11 02:46:56 +02:00
type CmsPartner,
} from "./_page-kit/cms-types";
import {
list,
loadMany,
loadSingle,
toImage,
toPartner,
type ResolvedImage,
type ResolvedPartner,
2026-09-11 02:46:56 +02:00
} from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import {
cx,
2026-09-11 02:46:56 +02:00
EmptyState,
PageHero,
PageSection,
SectionHeader,
2026-09-23 23:36:36 +02:00
type PageHeroWordmark,
2026-09-11 02:46:56 +02:00
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
2026-09-23 23:36:36 +02:00
/**
* The hero lockup above the headline: the full SHARP-ER pair at the SVGs' own
* box (218 + 112 wide), picked per surface the way the navigation's brand
* picks it (Brand.tsx) - white "inverse" exports on the dark hero, the ink
* pair on a light one. `alt` is empty: the sticky bar's brand link already
* carries the accessible project name.
*/
const HERO_WORDMARK: Record<AppTheme, PageHeroWordmark> = {
dark: {
src: "/assets/sharp-wordmark-inverse.svg",
alt: "",
width: 218,
height: 46,
drawnWidth: 218,
suffix: {
src: "/assets/sharp-er-suffix-inverse.svg",
width: 112,
height: 46,
drawnWidth: 112,
},
},
light: {
src: "/assets/sharp-wordmark.svg",
alt: "",
width: 218,
height: 46,
drawnWidth: 218,
suffix: {
src: "/assets/sharp-er-suffix.svg",
width: 112,
height: 46,
drawnWidth: 112,
},
},
};
/** The network logo wall - a wide but finite grid. */
2026-09-11 02:46:56 +02:00
const PARTNER_PAGE_SIZE = 36;
/**
* Below this many partners the wall stays a grid.
*
* The marquee loops by carrying the tiles twice and sliding exactly half the
* track, which only reads as a loop while the half is wider than the frame it
* slides through. Eight 178px tiles clear a desktop container; fewer would
* drift a short row across an empty band, so the grid keeps them still.
*/
const PARTNER_MARQUEE_MIN = 8;
/** 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 approach band shows three pictures. */
const GALLERY_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;
}
2026-09-11 02:46:56 +02:00
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,
};
}
2026-09-14 12:38:05 +02:00
2026-09-11 02:46:56 +02:00
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 } },
approach: { populate: { images: true } },
2026-09-11 02:46:56 +02:00
// Strapi 5 populates a dynamic zone per component with `on`.
blocks: { on: { "blocks.image-gallery": { populate: { images: true } } } },
featuredNews: { populate: { coverImage: true, authorOrganisation: true } },
2026-09-11 02:46:56 +02:00
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
2026-09-11 02:46:56 +02:00
curate the home page itself. */
const curatedPartners = list(page?.partners);
2026-09-11 02:46:56 +02:00
const partners =
curatedPartners.length > 0
? curatedPartners
2026-09-11 02:46:56 +02:00
: 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 },
});
/* The band's own pictures come first. An `image-gallery` block is still
read after them, so a gallery an editor built in the Blocks zone before
the band had a media field of its own keeps rendering. */
const gallery = [
...list(page?.approach?.images),
...list(page?.blocks)
.filter(isImageGalleryBlock)
.flatMap((block) => list(block.images)),
]
2026-09-11 02:46:56 +02:00
.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",
})),
},
approach: {
eyebrow: page?.approach?.eyebrow ?? null,
heading: page?.approach?.heading ?? null,
lead: page?.approach?.lead ?? null,
},
gallery: gallery.slice(0, GALLERY_SHOTS),
2026-09-11 02:46:56 +02:00
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,
})),
2026-09-11 02:46:56 +02:00
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
------------------------------------------------------------------------ */
2026-09-14 12:38:05 +02:00
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.
2026-09-14 12:38:05 +02:00
*/
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>
);
}
/**
2026-09-23 23:36:36 +02:00
* The approach band's 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.
*
2026-09-23 23:36:36 +02:00
* The frame and the picture move as one: the frame lifts and takes the accent
* hairline (`sh-lift`) and the whole tile grows under the pointer
* (`sh-lift--grow`) - the draft grows the frame, it does not push the picture
* inside it.
*
* THREE PICTURES RENDER AS THE DRAFT'S MOSAIC (figma 559:4089): the middle
* tile dominates - 605px wide, full height, full opacity - and the two flanks
* sit shorter beside it (427/426px wide, 0.72 of its height) at 50% opacity,
* vertically centred on the middle. One or two pictures fall back to the
* uniform grid, where the mosaic's "faded flank" reading has no partner.
*/
/* The edge items of a row land in from their own side of the line - the start
or the end - while the middle ones rise, so a row reads as arriving edge-first
instead of lifting as one. A lone item simply rises. */
function revealEdge(index: number, count: number): string {
if (count < 2) return "";
if (index === 0) return "sh-reveal--from-start";
if (index === count - 1) return "sh-reveal--from-end";
return "";
}
/* A beat between the tiles of a row: each item after the first holds at its
hidden state a moment longer, so a row cascades start-to-end. Rows here cap
at three tiles, so two steps are enough. */
function revealStep(index: number): string {
if (index === 1) return "sh-reveal--step-1";
if (index >= 2) return "sh-reveal--step-2";
return "";
}
function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
if (images.length === 0) return null;
2026-09-23 23:36:36 +02:00
const mosaic = images.length === 3;
if (mosaic) {
return (
/* The track widths are the draft's own drawing (605 centre, ~427/426
flanks); the aspect ratios keep each tile at its drawn proportions, so
the row only reads as a mosaic from lg up and stacks full-width, full
opacity below it. `lg:items-center` sits the shorter flanks level with
the middle one, as the draft has them. gap-3xl is the closest step to
the draft's 60px. */
<ul className="grid list-none grid-cols-1 gap-3xl lg:grid-cols-[427fr_605fr_426fr] lg:items-center">
{images.map((image, index) => (
<li
key={image.src}
className={cx("sh-reveal", revealEdge(index, images.length), revealStep(index))}
>
2026-09-23 23:36:36 +02:00
<figure className="flex flex-col gap-xs">
<div
className={cx(
"sh-lift sh-lift--grow rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]",
index === 1 ? "aspect-[605/310]" : "aspect-[427/228] lg:opacity-50",
)}
>
<img
src={image.src}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
</div>
{image.alt ? (
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
{image.alt}
</figcaption>
) : null}
</figure>
</li>
))}
</ul>
);
}
return (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{images.map((image, index) => (
<li
key={image.src}
className={cx("sh-reveal", revealEdge(index, images.length), revealStep(index))}
>
<figure className="flex flex-col gap-xs">
2026-09-23 23:36:36 +02:00
<div className="sh-lift sh-lift--grow rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]">
<img
src={image.src}
alt=""
loading="lazy"
className="h-[15rem] w-full object-cover"
/>
</div>
{image.alt ? (
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
{image.alt}
</figcaption>
) : null}
</figure>
2026-09-14 12:38:05 +02:00
</li>
))}
2026-09-14 12:38:05 +02:00
</ul>
);
}
/**
* One tile on the civil society logo wall.
*
* `decorative` renders the second half of the marquee track: the same tile,
* hidden from assistive technology, with no link and no alt text, so the loop
* repeats visually without repeating the wall for a screen reader or adding a
* second tab stop per partner.
*/
function PartnerTile({
partner,
fixedWidth = false,
decorative = false,
}: {
partner: ResolvedPartner;
fixedWidth?: boolean;
decorative?: boolean;
}) {
const href = decorative ? undefined : safeHref(partner.website);
/* The logo wall is a wall 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={decorative ? "" : 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
{...(decorative ? { "aria-hidden": true } : {})}
className={cx(
"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)]",
/* A still grid reveals tile by tile; a travelling track must not, or
each tile would ramp its own opacity while it moves.
The track's spacing is a margin on the tile rather than a `gap` on
the row: the loop slides exactly half the track, so each half has to
carry its own trailing space or the seam lands half a gap short. */
fixedWidth ? "me-md w-[11.125rem] shrink-0" : "sh-reveal sh-reveal--in",
decorative && "sh-marquee__clone",
)}
>
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex h-full w-full items-center justify-center"
>
{inner}
</a>
) : (
inner
)}
</li>
);
}
/** 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
------------------------------------------------------------------------ */
2026-09-11 02:46:56 +02:00
export default function Home({ loaderData }: Route.ComponentProps) {
const { locale, hero, approach, gallery, partners, news, faqs } = loaderData;
2026-09-11 02:46:56 +02:00
const t = createTranslator(locale);
const copy = pageCopy(locale).home;
2026-09-23 22:33:58 +02:00
/* The hero badge sits on the page's own surface, so it follows the theme
exactly as the footer's badge does: same root cookie, same emblem. */
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
/* Chromium plays every item reveal natively via scroll-driven timelines
(motion.css). For non-Chromium browsers `_page-kit/reveal.ts` flips each
`.sh-reveal` item to `.sh-reveal-js--in` as it enters the viewport, so the
fade-and-drift lands everywhere - while reduced-motion visitors and users
without JS keep every item fully visible. */
useEffect(() => {
enableReveals();
}, []);
/* 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;
2026-09-11 02:46:56 +02:00
const headline = hero.headline ?? copy.heroHeadline;
// const lead = hero.subheading ?? copy.heroLead;
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
2026-09-11 02:46:56 +02:00
2026-09-14 12:38:05 +02:00
/* 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,
2026-09-14 12:38:05 +02:00
variant: "primary",
},
{
href: sectionPath("about", locale),
label: copy.heroCtaSecondary,
2026-09-14 12:38:05 +02:00
variant: "secondary",
},
];
2026-09-11 02:46:56 +02:00
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]"
/>
2026-09-11 02:46:56 +02:00
<PageHero
2026-09-23 23:22:57 +02:00
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)]"
2026-09-11 02:46:56 +02:00
title={headline}
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,
},
]}
2026-09-23 23:36:36 +02:00
reveal
wordmark={HERO_WORDMARK[theme]}
/* EU visibility sits at the top of the hero (the draft's badge). The
2026-09-23 22:33:58 +02:00
emblem matches the page surface the hero stands on, which the theme
cookie decides - dark emblem in dark mode, light emblem in light
mode, like the footer's badge. `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}
2026-09-23 22:33:58 +02:00
surface={theme}
className="self-start"
/>
}
2026-09-11 02:46:56 +02:00
{...(hero.media ? { media: hero.media } : {})}
2026-09-14 12:38:05 +02:00
mediaTreatment="banner"
actions={heroCtas.map((cta) => (
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
))}
2026-09-11 02:46:56 +02:00
/>
{/* WHAT THE PROJECT IS, AND WHO DELIVERS IT */}
2026-09-23 23:36:36 +02:00
{/* `bleed` drops the 80rem Container so the band's content - the three
pictures especially - runs the full width of the viewport, the way
the draft draws the mosaic. The gutter the Container used to add is
re-applied here, and the header column still constrains itself to its
own measure, so only the gallery actually strays to the edges. */}
<PageSection
aria-labelledby="home-approach-heading"
band
2026-09-23 23:36:36 +02:00
bleed
>
2026-09-23 23:36:36 +02:00
<div className="flex w-full flex-col gap-3xl px-[var(--layout-gutter)] lg:px-[var(--layout-gutter-lg)]">
<div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center">
2026-09-14 12:38:05 +02:00
<SectionHeader
id="home-approach-heading"
eyebrow={approach.eyebrow ?? copy.approachEyebrow}
title={approach.heading ?? copy.approachHeading}
2026-09-14 12:38:05 +02:00
size="display"
align="center"
2026-09-14 12:38:05 +02:00
/>
<p className="body-lg text-[var(--text-secondary)]">
{approach.lead ?? copy.approachLead}
</p>
2026-09-14 12:38:05 +02:00
</div>
2026-09-11 02:46:56 +02:00
<GalleryShots images={gallery} />
</div>
</PageSection>
{/* THREE AREAS OF CHANGE */}
2026-09-14 12:38:05 +02:00
<PageSection
aria-labelledby="home-work-heading"
className="bg-[var(--surface-sunken)]"
2026-09-14 12:38:05 +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="home-work-heading"
title={copy.workHeading}
size="display"
/>
2026-09-14 12:38:05 +02:00
<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, index) => (
<li
key={pillar.title}
/* `sh-reveal--in` adds the draft's rise-and-scale; the edge
tiles of the row slide in from their own side instead.
`sh-rule-top` draws the accent rule across the top of the
tile as the pointer arrives. */
className={cx(
"sh-lift sh-reveal sh-reveal--in sh-rule-top 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)]",
revealEdge(index, pillars.length),
revealStep(index),
)}
>
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
<p className="body-md text-[var(--text-secondary)]">
{pillar.description}
</p>
</li>
))}
</ul>
2026-09-11 02:46:56 +02:00
</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"
/>
2026-09-11 02:46:56 +02:00
<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, index) => (
<Card.Content
key={item.id}
as="li"
className={cx(
"sh-reveal sh-reveal--in",
revealEdge(index, news.length),
revealStep(index),
)}
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"
/>
2026-09-11 02:46:56 +02:00
))}
</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} itemClassName="sh-reveal" />
) : (
<EmptyState message={copy.faqEmpty} />
2026-09-11 02:46:56 +02:00
)}
</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, index) => (
<li
key={member.name}
className={cx(
"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)]",
revealEdge(index, copy.consortium.length),
revealStep(index),
)}
>
<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 ? (
<EmptyState message={copy.partnersEmpty} />
) : partners.length >= PARTNER_MARQUEE_MIN ? (
/* The wall drifts, as the third draft draws it. The track holds
the tiles twice so the loop is seamless; the mask fades it out
at both ends instead of cutting a tile in half. Both halves
come from one list, so the wall can never show a partner the
CMS does not hold. */
<div className="sh-marquee-mask relative overflow-hidden">
<ul className="sh-marquee flex list-none">
{partners.map((partner) => (
<PartnerTile key={partner.id} partner={partner} fixedWidth />
))}
{partners.map((partner) => (
<PartnerTile
key={`repeat-${partner.id}`}
partner={partner}
fixedWidth
decorative
/>
))}
</ul>
</div>
) : (
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
{partners.map((partner) => (
<PartnerTile key={partner.id} partner={partner} />
))}
</ul>
)}
</div>
</div>
</PageSection>
2026-09-11 02:46:56 +02:00
</>
);
}