2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
HOME - /en and /ar
|
|
|
|
|
----------------------------------------------------------------------------
|
2026-09-16 20:32:56 +02:00
|
|
|
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-).
|
2026-09-11 02:46:56 +02:00
|
|
|
========================================================================= */
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
import { useEffect, useLayoutEffect, useState } from "react";
|
2026-09-11 02:46:56 +02:00
|
|
|
import { Link } from "react-router";
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
import type { Route } from "./+types/home";
|
2026-09-16 20:32:56 +02:00
|
|
|
import { Accordion } from "./_page-kit/accordion";
|
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-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,
|
|
|
|
|
} from "./_page-kit/cms.server";
|
|
|
|
|
import { pageCopy } from "./_page-kit/copy";
|
|
|
|
|
import { routeLocale } from "./_page-kit/locale";
|
|
|
|
|
import {
|
2026-09-14 12:38:05 +02:00
|
|
|
Container,
|
2026-09-16 20:32:56 +02:00
|
|
|
cx,
|
2026-09-11 02:46:56 +02:00
|
|
|
EmptyState,
|
|
|
|
|
PageHero,
|
|
|
|
|
PageSection,
|
|
|
|
|
SectionHeader,
|
|
|
|
|
} from "./_page-kit/sections";
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
import "./_page-kit/motion.css";
|
|
|
|
|
|
|
|
|
|
/** The network logo wall - a wide but finite grid. */
|
2026-09-11 02:46:56 +02:00
|
|
|
const PARTNER_PAGE_SIZE = 36;
|
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;
|
|
|
|
|
/** 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;
|
|
|
|
|
}
|
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);
|
|
|
|
|
|
|
|
|
|
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 } } } },
|
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 } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* 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-11 02:46:56 +02:00
|
|
|
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",
|
|
|
|
|
})),
|
|
|
|
|
},
|
2026-09-16 20:32:56 +02:00
|
|
|
/* Editor-owned figures win; the bundled four stand in until then. */
|
2026-09-11 02:46:56 +02:00
|
|
|
stats: list(page?.stats).map((stat) => ({
|
|
|
|
|
value: stat.value,
|
|
|
|
|
label: stat.label,
|
|
|
|
|
})),
|
2026-09-16 20:32:56 +02:00
|
|
|
gallery: gallery.slice(0, LAUNCH_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
|
|
|
* 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.
|
2026-09-14 12:38:05 +02:00
|
|
|
*/
|
2026-09-16 20:32:56 +02:00
|
|
|
function StatsRow({ stats }: { stats: readonly { value: string; label: string }[] }) {
|
|
|
|
|
if (stats.length === 0) return null;
|
2026-09-14 12:38:05 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-09-16 20:32:56 +02:00
|
|
|
<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>
|
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-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-16 20:32:56 +02:00
|
|
|
const { locale, hero, stats, gallery, partners, news, faqs } = loaderData;
|
2026-09-11 02:46:56 +02:00
|
|
|
const t = createTranslator(locale);
|
|
|
|
|
const copy = pageCopy(locale).home;
|
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-16 20:32:56 +02:00
|
|
|
const lead = hero.subheading ?? copy.heroLead;
|
|
|
|
|
const figures = stats.length > 0 ? stats : copy.stats;
|
|
|
|
|
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-16 20:32:56 +02:00
|
|
|
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-2xl)]"
|
2026-09-11 02:46:56 +02:00
|
|
|
title={headline}
|
2026-09-16 20:32:56 +02:00
|
|
|
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"
|
|
|
|
|
/>
|
|
|
|
|
}
|
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} />
|
|
|
|
|
))}
|
|
|
|
|
below={<StatsRow stats={figures} />}
|
2026-09-11 02:46:56 +02:00
|
|
|
/>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* 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">
|
2026-09-14 12:38:05 +02:00
|
|
|
<SectionHeader
|
2026-09-16 20:32:56 +02:00
|
|
|
id="home-launch-heading"
|
|
|
|
|
eyebrow={copy.launchEyebrow}
|
|
|
|
|
title={copy.launchHeading}
|
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
|
|
|
|
|
|
|
|
<p className="body-lg text-[var(--text-secondary)]">{copy.launchLead}</p>
|
2026-09-14 12:38:05 +02:00
|
|
|
</div>
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
<LaunchShots images={gallery} />
|
|
|
|
|
</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">
|
|
|
|
|
{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>
|
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">
|
|
|
|
|
{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"
|
|
|
|
|
/>
|
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 ? (
|
|
|
|
|
<Accordion items={faqs} className="sh-reveal" />
|
|
|
|
|
) : (
|
|
|
|
|
<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">
|
|
|
|
|
{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-control 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>
|
2026-09-11 02:46:56 +02:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|