2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
HOME - /en and /ar
|
|
|
|
|
----------------------------------------------------------------------------
|
2026-09-20 14:16:53 +02:00
|
|
|
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:
|
2026-09-16 20:32:56 +02:00
|
|
|
|
2026-10-05 17:32:06 +02:00
|
|
|
Hero EU badge, the headline, the lead,
|
2026-09-20 14:16:53 +02:00
|
|
|
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.
|
2026-09-16 20:32:56 +02:00
|
|
|
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.
|
2026-10-07 11:33:10 +02:00
|
|
|
Consortium the three members, then the civil society network logos.
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
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
|
2026-09-20 14:16:53 +02:00
|
|
|
(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
|
2026-09-16 20:32:56 +02:00
|
|
|
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
|
2026-09-20 14:16:53 +02:00
|
|
|
_page-kit/motion.css - the headline wipe, the hero picture's scroll-driven
|
2026-09-24 00:22:40 +02:00
|
|
|
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.
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
========================================================================= */
|
|
|
|
|
|
2026-09-24 00:22:40 +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";
|
2026-09-16 20:32:56 +02:00
|
|
|
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";
|
2026-09-16 20:32:56 +02:00
|
|
|
import { Accordion } from "./_page-kit/accordion";
|
2026-09-24 00:22:40 +02:00
|
|
|
import { enableReveals } from "./_page-kit/reveal";
|
2026-09-11 02:46:56 +02:00
|
|
|
import {
|
|
|
|
|
isImageGalleryBlock,
|
2026-09-16 20:32:56 +02:00
|
|
|
type CmsFaq,
|
2026-09-11 02:46:56 +02:00
|
|
|
type CmsHomePage,
|
2026-10-05 17:32:06 +02:00
|
|
|
type CmsLegacyPage,
|
2026-09-16 20:32:56 +02:00
|
|
|
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,
|
2026-09-20 14:16:53 +02:00
|
|
|
type ResolvedPartner,
|
2026-09-11 02:46:56 +02:00
|
|
|
} from "./_page-kit/cms.server";
|
|
|
|
|
import { pageCopy } from "./_page-kit/copy";
|
2026-10-05 17:32:06 +02:00
|
|
|
import { resolveLegacyStage } from "./_page-kit/legacy-cms.server";
|
2026-09-11 02:46:56 +02:00
|
|
|
import { routeLocale } from "./_page-kit/locale";
|
|
|
|
|
import {
|
2026-09-16 20:32:56 +02:00
|
|
|
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";
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/** The network logo wall - a wide but finite grid. */
|
2026-09-11 02:46:56 +02:00
|
|
|
const PARTNER_PAGE_SIZE = 36;
|
2026-09-20 14:16:53 +02:00
|
|
|
/**
|
|
|
|
|
* Below this many partners the wall stays a grid.
|
|
|
|
|
*
|
2026-10-07 11:33:10 +02:00
|
|
|
* The loop slides exactly half the track; fewer than eight wider tiles would
|
|
|
|
|
* leave a short drift instead of filling a desktop frame, so the grid keeps
|
|
|
|
|
* them still.
|
2026-09-20 14:16:53 +02:00
|
|
|
*/
|
|
|
|
|
const PARTNER_MARQUEE_MIN = 8;
|
2026-09-16 20:32:56 +02:00
|
|
|
/** 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;
|
2026-09-20 14:16:53 +02:00
|
|
|
/** The draft's approach band shows three pictures. */
|
|
|
|
|
const GALLERY_SHOTS = 3;
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
/** 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
|
|
|
|
2026-09-16 20:32: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);
|
2026-10-05 17:32:06 +02:00
|
|
|
const legacyPagePromise = loadSingle<CmsLegacyPage>("legacy-page", {
|
|
|
|
|
locale,
|
|
|
|
|
populate: {
|
|
|
|
|
stageOne: { populate: { metrics: true, focusAreas: true } },
|
|
|
|
|
stageTwo: { populate: { metrics: true, focusAreas: true } },
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
const page = await loadSingle<CmsHomePage>("home-page", {
|
|
|
|
|
locale,
|
|
|
|
|
populate: {
|
|
|
|
|
hero: { populate: { media: true, ctas: true } },
|
2026-09-20 14:16:53 +02:00
|
|
|
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 } } } },
|
2026-09-16 20:32:56 +02:00
|
|
|
featuredNews: { populate: { coverImage: true, authorOrganisation: true } },
|
2026-09-11 02:46:56 +02:00
|
|
|
partners: { populate: { logo: true, logoDark: true } },
|
|
|
|
|
seo: { populate: { ogImage: true } },
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-10-05 17:32:06 +02:00
|
|
|
const legacyPage = await legacyPagePromise;
|
|
|
|
|
const legacyFallback = pageCopy(locale).legacy;
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
/* The curated relation wins; otherwise show every partner the CMS knows, so
|
2026-09-16 20:32:56 +02:00
|
|
|
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. */
|
2026-09-16 20:32:56 +02:00
|
|
|
const curatedPartners = list(page?.partners);
|
2026-09-11 02:46:56 +02:00
|
|
|
const partners =
|
2026-09-16 20:32:56 +02:00
|
|
|
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 },
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/* 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 },
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 14:16:53 +02:00
|
|
|
/* 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,
|
2026-10-05 17:32:06 +02:00
|
|
|
legacy: {
|
|
|
|
|
homeEyebrow: legacyPage?.homeEyebrow ?? legacyFallback.homeEyebrow,
|
|
|
|
|
homeHeading: legacyPage?.homeHeading ?? legacyFallback.homeHeading,
|
|
|
|
|
homeLead: legacyPage?.homeLead ?? legacyFallback.homeLead,
|
|
|
|
|
homeLinkLabel: legacyFallback.homeLinkLabel,
|
|
|
|
|
stageOne: resolveLegacyStage(legacyPage?.stageOne, legacyFallback.stageOne),
|
|
|
|
|
stageTwo: resolveLegacyStage(legacyPage?.stageTwo, legacyFallback.stageTwo),
|
|
|
|
|
},
|
2026-09-11 02:46:56 +02:00
|
|
|
hero: {
|
|
|
|
|
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",
|
|
|
|
|
})),
|
|
|
|
|
},
|
2026-09-20 14:16:53 +02:00
|
|
|
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),
|
2026-09-16 20:32:56 +02:00
|
|
|
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 }),
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
SMALL PIECES
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
interface HeroCtaProps {
|
|
|
|
|
href: string;
|
|
|
|
|
label: string;
|
|
|
|
|
variant: "primary" | "secondary";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-16 20:32:56 +02:00
|
|
|
* 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/**
|
2026-09-23 23:36:36 +02:00
|
|
|
* The approach band's pictures. The CMS alt text doubles as the visible
|
2026-09-16 20:32:56 +02:00
|
|
|
* 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-20 14:16:53 +02:00
|
|
|
*
|
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.
|
2026-09-16 20:32:56 +02:00
|
|
|
*/
|
2026-09-24 00:22:40 +02:00
|
|
|
/* 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 "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 14:16:53 +02:00
|
|
|
function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
|
2026-09-16 20:32:56 +02:00
|
|
|
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) => (
|
2026-09-24 00:22:40 +02:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
return (
|
|
|
|
|
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
2026-09-24 00:22:40 +02:00
|
|
|
{images.map((image, index) => (
|
|
|
|
|
<li
|
|
|
|
|
key={image.src}
|
|
|
|
|
className={cx("sh-reveal", revealEdge(index, images.length), revealStep(index))}
|
|
|
|
|
>
|
2026-09-16 20:32:56 +02:00
|
|
|
<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)]">
|
2026-09-20 14:16:53 +02:00
|
|
|
<img
|
|
|
|
|
src={image.src}
|
|
|
|
|
alt=""
|
|
|
|
|
loading="lazy"
|
|
|
|
|
className="h-[15rem] w-full object-cover"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-09-16 20:32:56 +02:00
|
|
|
{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-16 20:32:56 +02:00
|
|
|
))}
|
2026-09-14 12:38:05 +02:00
|
|
|
</ul>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 14:16:53 +02:00
|
|
|
/**
|
|
|
|
|
* 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"
|
2026-10-07 11:33:10 +02:00
|
|
|
className={cx(
|
|
|
|
|
"w-auto max-w-full object-contain",
|
|
|
|
|
fixedWidth
|
|
|
|
|
? "h-[6.75rem] transition-transform duration-[var(--motion-duration-base)] group-hover:scale-105"
|
|
|
|
|
: "max-h-[3.5rem]",
|
|
|
|
|
)}
|
2026-09-20 14:16:53 +02:00
|
|
|
/>
|
|
|
|
|
) : (
|
2026-10-07 11:33:10 +02:00
|
|
|
<span
|
|
|
|
|
className={cx(
|
|
|
|
|
"body-sm-bold text-center text-[var(--text-secondary)]",
|
|
|
|
|
fixedWidth &&
|
|
|
|
|
"transition-colors duration-[var(--motion-duration-base)] group-hover:text-[var(--text-accent)]",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-09-20 14:16:53 +02:00
|
|
|
{partner.name}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<li
|
|
|
|
|
{...(decorative ? { "aria-hidden": true } : {})}
|
|
|
|
|
className={cx(
|
2026-10-07 11:33:10 +02:00
|
|
|
"flex items-center justify-center",
|
|
|
|
|
fixedWidth
|
|
|
|
|
? "group me-lg h-[7.5rem] w-[12.5rem] shrink-0 ps-lg pe-lg"
|
|
|
|
|
: "h-[5.5rem] 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)] sh-reveal sh-reveal--in",
|
2026-09-20 14:16:53 +02:00
|
|
|
/* 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. */
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/** 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) {
|
2026-09-20 14:16:53 +02:00
|
|
|
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-10-05 17:32:06 +02:00
|
|
|
const legacyCopy = loaderData.legacy;
|
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;
|
2026-09-24 00:22:40 +02:00
|
|
|
/* 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();
|
|
|
|
|
}, []);
|
2026-09-16 20:32:56 +02:00
|
|
|
/* 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;
|
2026-09-24 00:22:40 +02:00
|
|
|
// const lead = hero.subheading ?? copy.heroLead;
|
2026-09-16 20:32:56 +02:00
|
|
|
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",
|
|
|
|
|
}))
|
|
|
|
|
: [
|
|
|
|
|
{
|
2026-09-16 20:32:56 +02:00
|
|
|
href: sectionPath("applications", locale),
|
|
|
|
|
label: copy.heroCtaPrimary,
|
2026-09-14 12:38:05 +02:00
|
|
|
variant: "primary",
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-09-16 20:32:56 +02:00
|
|
|
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 (
|
|
|
|
|
<>
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* 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}
|
2026-09-24 00:22:40 +02:00
|
|
|
lead={""}
|
2026-09-16 20:32:56 +02:00
|
|
|
leadWithActions
|
|
|
|
|
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]}
|
2026-09-16 20:32:56 +02:00
|
|
|
/* 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. */
|
2026-09-16 20:32:56 +02:00
|
|
|
funderBadge={
|
|
|
|
|
<EuBadge
|
|
|
|
|
size="sm"
|
|
|
|
|
statement={t("site.euStatement")}
|
|
|
|
|
locale={locale}
|
2026-09-23 22:33:58 +02:00
|
|
|
surface={theme}
|
2026-09-16 20:32:56 +02:00
|
|
|
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"
|
2026-09-16 20:32:56 +02:00
|
|
|
actions={heroCtas.map((cta) => (
|
|
|
|
|
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
|
|
|
|
))}
|
2026-09-11 02:46:56 +02:00
|
|
|
/>
|
|
|
|
|
|
2026-10-05 17:32:06 +02:00
|
|
|
{/* THE TWO EARLIER SHARP PHASES */}
|
|
|
|
|
<PageSection
|
|
|
|
|
aria-labelledby="home-legacy-heading"
|
|
|
|
|
className="bg-[var(--surface-sunken)]"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-col gap-2xl">
|
|
|
|
|
<div className="sh-reveal flex flex-col gap-md border-b border-[var(--border-default)] pb-lg md:max-w-[52rem]">
|
|
|
|
|
<p className="label-md uppercase text-[var(--text-accent)]">
|
|
|
|
|
{legacyCopy.homeEyebrow}
|
|
|
|
|
</p>
|
|
|
|
|
<SectionHeader
|
|
|
|
|
id="home-legacy-heading"
|
|
|
|
|
title={legacyCopy.homeHeading}
|
|
|
|
|
lead={legacyCopy.homeLead}
|
|
|
|
|
size="display"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-lg lg:grid-cols-2">
|
|
|
|
|
{[
|
|
|
|
|
{
|
|
|
|
|
stage: legacyCopy.stageOne,
|
|
|
|
|
anchor: "sharp-i",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
stage: legacyCopy.stageTwo,
|
|
|
|
|
anchor: "sharp-ii",
|
|
|
|
|
},
|
|
|
|
|
].map(({ stage, anchor }) => (
|
|
|
|
|
<article
|
|
|
|
|
key={stage.name}
|
|
|
|
|
className="sh-lift flex h-full flex-col overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)]"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-1 flex-col gap-lg p-lg md:p-xl">
|
|
|
|
|
<div className="flex flex-wrap items-baseline justify-between gap-sm">
|
|
|
|
|
<h3 className="heading-h2 text-[var(--text-primary)]">
|
|
|
|
|
{stage.name}
|
|
|
|
|
</h3>
|
|
|
|
|
<span className="label-md text-[var(--text-accent)]">
|
|
|
|
|
{stage.years}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="body-md text-[var(--text-secondary)]">
|
|
|
|
|
{stage.summary}
|
|
|
|
|
</p>
|
|
|
|
|
<dl className="grid grid-cols-3 gap-md border-y border-[var(--border-subtle)] py-md">
|
|
|
|
|
{stage.metrics.slice(0, 3).map((metric) => (
|
|
|
|
|
<div key={metric.label} className="flex flex-col gap-2xs">
|
|
|
|
|
<dt className="label-md text-[var(--text-secondary)]">
|
|
|
|
|
{metric.label}
|
|
|
|
|
</dt>
|
|
|
|
|
<dd className="heading-h2 text-[var(--text-accent)]">
|
|
|
|
|
{metric.value}
|
|
|
|
|
</dd>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</dl>
|
|
|
|
|
<Button
|
|
|
|
|
as={Link}
|
|
|
|
|
to={`${sectionPath("legacy", locale)}#${anchor}`}
|
|
|
|
|
prefetch="intent"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className="self-start"
|
|
|
|
|
iconEnd={<ArrowIcon />}
|
|
|
|
|
>
|
|
|
|
|
{legacyCopy.homeLinkLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PageSection>
|
|
|
|
|
|
2026-09-20 14:16:53 +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. */}
|
2026-09-16 20:32:56 +02:00
|
|
|
<PageSection
|
2026-09-20 14:16:53 +02:00
|
|
|
aria-labelledby="home-approach-heading"
|
2026-09-16 20:32:56 +02:00
|
|
|
band
|
2026-09-23 23:36:36 +02:00
|
|
|
bleed
|
2026-09-16 20:32:56 +02:00
|
|
|
>
|
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)]">
|
2026-09-16 20:32:56 +02:00
|
|
|
<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
|
2026-09-20 14:16:53 +02:00
|
|
|
id="home-approach-heading"
|
|
|
|
|
eyebrow={approach.eyebrow ?? copy.approachEyebrow}
|
|
|
|
|
title={approach.heading ?? copy.approachHeading}
|
2026-09-14 12:38:05 +02:00
|
|
|
size="display"
|
2026-09-16 20:32:56 +02:00
|
|
|
align="center"
|
2026-09-14 12:38:05 +02:00
|
|
|
/>
|
2026-09-16 20:32:56 +02:00
|
|
|
|
2026-09-20 14:16:53 +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
|
|
|
|
2026-09-20 14:16:53 +02:00
|
|
|
<GalleryShots images={gallery} />
|
2026-09-16 20:32:56 +02:00
|
|
|
</div>
|
|
|
|
|
</PageSection>
|
|
|
|
|
|
|
|
|
|
{/* THREE AREAS OF CHANGE */}
|
2026-09-14 12:38:05 +02:00
|
|
|
<PageSection
|
2026-09-16 20:32:56 +02:00
|
|
|
aria-labelledby="home-work-heading"
|
|
|
|
|
className="bg-[var(--surface-sunken)]"
|
2026-09-14 12:38:05 +02:00
|
|
|
>
|
2026-09-16 20:32: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="home-work-heading"
|
|
|
|
|
title={copy.workHeading}
|
|
|
|
|
size="display"
|
|
|
|
|
/>
|
2026-09-14 12:38:05 +02:00
|
|
|
|
2026-09-16 20:32:56 +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">
|
2026-09-24 00:22:40 +02:00
|
|
|
{pillars.map((pillar, index) => (
|
2026-09-16 20:32:56 +02:00
|
|
|
<li
|
|
|
|
|
key={pillar.title}
|
2026-09-24 00:22:40 +02:00
|
|
|
/* `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),
|
|
|
|
|
)}
|
2026-09-16 20:32:56 +02:00
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* 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
|
|
|
|
2026-09-16 20:32: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">
|
2026-09-24 00:22:40 +02:00
|
|
|
{news.map((item, index) => (
|
2026-09-16 20:32:56 +02:00
|
|
|
<Card.Content
|
|
|
|
|
key={item.id}
|
|
|
|
|
as="li"
|
2026-09-24 00:22:40 +02:00
|
|
|
className={cx(
|
|
|
|
|
"sh-reveal sh-reveal--in",
|
|
|
|
|
revealEdge(index, news.length),
|
|
|
|
|
revealStep(index),
|
|
|
|
|
)}
|
2026-09-16 20:32:56 +02:00
|
|
|
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>
|
|
|
|
|
) : (
|
2026-09-16 20:32:56 +02:00
|
|
|
<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 ? (
|
2026-09-24 00:22:40 +02:00
|
|
|
<Accordion items={faqs} itemClassName="sh-reveal" />
|
2026-09-16 20:32:56 +02:00
|
|
|
) : (
|
|
|
|
|
<EmptyState message={copy.faqEmpty} />
|
2026-09-11 02:46:56 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</PageSection>
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
{/* 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">
|
2026-09-24 00:22:40 +02:00
|
|
|
{copy.consortium.map((member, index) => (
|
2026-09-16 20:32:56 +02:00
|
|
|
<li
|
|
|
|
|
key={member.name}
|
2026-09-24 00:22:40 +02:00
|
|
|
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),
|
|
|
|
|
)}
|
2026-09-16 20:32:56 +02:00
|
|
|
>
|
2026-09-20 11:14:15 +02:00
|
|
|
<span className="rounded-chip bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
|
2026-09-16 20:32:56 +02:00
|
|
|
{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>
|
|
|
|
|
|
2026-09-20 14:16:53 +02:00
|
|
|
{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>
|
|
|
|
|
) : (
|
2026-09-16 20:32:56 +02:00
|
|
|
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
|
2026-09-20 14:16:53 +02:00
|
|
|
{partners.map((partner) => (
|
|
|
|
|
<PartnerTile key={partner.id} partner={partner} />
|
|
|
|
|
))}
|
2026-09-16 20:32:56 +02:00
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</PageSection>
|
2026-09-11 02:46:56 +02:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|