2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
HOME - /en and /ar
|
|
|
|
|
----------------------------------------------------------------------------
|
2026-09-14 12:38:05 +02:00
|
|
|
Figma frame 253:4799 ("Home-en"). The frame is four bands:
|
|
|
|
|
|
|
|
|
|
Hero header (253:4800) the SHARP-ER lockup, the headline, the CTA
|
|
|
|
|
pair and the wide washed picture. The frame's
|
|
|
|
|
subheading node is hidden, so the hero carries
|
|
|
|
|
no lead unless the CMS supplies one.
|
|
|
|
|
About the Programme 253:4835, hidden="true" in the frame. It is
|
|
|
|
|
the only place figures appear, so the band
|
|
|
|
|
renders ONLY once the CMS has real numbers -
|
|
|
|
|
there is no placeholder standing in for it.
|
|
|
|
|
Offical launch (361:10853) "The Journey of SHARP-ER Launch": one centred
|
|
|
|
|
lead with four bold runs, a three-up photo
|
|
|
|
|
strip that bleeds past the page and is
|
|
|
|
|
clipped, and three plain centred captions.
|
|
|
|
|
Partners (393:14754) "Meet Our Partners", start-aligned, over a
|
|
|
|
|
grid of logo-and-name tiles.
|
|
|
|
|
Footer owned by the app shell, not by this module
|
|
|
|
|
|
|
|
|
|
There is no Arabic frame in the file - only the navigation and the footer
|
|
|
|
|
are drawn in Arabic - so /ar is this same DOM under `dir="rtl"`, which is
|
|
|
|
|
why every inline edge below is logical (ps-/pe-/start-/end-) and never
|
|
|
|
|
left/right.
|
|
|
|
|
|
|
|
|
|
Copy is the approved draft in both languages; the pictures, the figures and
|
|
|
|
|
the partner list come from Strapi and degrade to a localised empty state
|
|
|
|
|
while it is empty.
|
2026-09-11 02:46:56 +02:00
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
import { Link } from "react-router";
|
|
|
|
|
|
|
|
|
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
|
|
|
|
import { createTranslator, getMessages, sectionPath } from "~/i18n";
|
|
|
|
|
import {
|
|
|
|
|
buildMeta,
|
|
|
|
|
buildOrganizationJsonLd,
|
|
|
|
|
buildWebSiteJsonLd,
|
|
|
|
|
} from "~/lib/seo";
|
|
|
|
|
import { safeHref } from "~/lib/safe-href";
|
|
|
|
|
|
|
|
|
|
import type { Route } from "./+types/home";
|
|
|
|
|
import {
|
|
|
|
|
isImageGalleryBlock,
|
|
|
|
|
type CmsHomePage,
|
|
|
|
|
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-11 02:46:56 +02:00
|
|
|
EmptyState,
|
|
|
|
|
PageHero,
|
|
|
|
|
PageSection,
|
|
|
|
|
SectionHeader,
|
|
|
|
|
StatStrip,
|
|
|
|
|
} from "./_page-kit/sections";
|
|
|
|
|
|
|
|
|
|
/** Partner logos and CSO tiles - a wide but finite grid. */
|
|
|
|
|
const PARTNER_PAGE_SIZE = 36;
|
|
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
/**
|
|
|
|
|
* The mark the hero draws above the headline (figma 253:4800). The frame paints
|
|
|
|
|
* "SHARP" only - 272.58 x 57.65 of ink - not the full SHARP-ER lockup the
|
|
|
|
|
* navigation carries, so this is the SHARP wordmark on its own, as vector so it
|
|
|
|
|
* stays crisp at any size. `alt` is empty because the navigation's brand link
|
|
|
|
|
* already carries the project's accessible name.
|
|
|
|
|
*/
|
|
|
|
|
const HERO_WORDMARK = {
|
|
|
|
|
src: "/assets/sharp-wordmark-inverse.svg",
|
|
|
|
|
alt: "",
|
|
|
|
|
width: 272,
|
|
|
|
|
height: 57,
|
|
|
|
|
drawnWidth: 273,
|
|
|
|
|
} as const;
|
|
|
|
|
|
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 } } } },
|
|
|
|
|
partners: { populate: { logo: true, logoDark: true } },
|
|
|
|
|
seo: { populate: { ogImage: true } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* The curated relation wins; otherwise show every partner the CMS knows, so
|
|
|
|
|
the grid fills in as soon as partners exist - before anyone remembers to
|
|
|
|
|
curate the home page itself. */
|
|
|
|
|
const curated = list(page?.partners);
|
|
|
|
|
const partners =
|
|
|
|
|
curated.length > 0
|
|
|
|
|
? curated
|
|
|
|
|
: await loadMany<CmsPartner>("partners", {
|
|
|
|
|
locale,
|
|
|
|
|
sort: ["name:asc"],
|
|
|
|
|
filters: { partnerRole: { $in: ["cso-partner", "institution"] } },
|
|
|
|
|
populate: { logo: true, logoDark: true },
|
|
|
|
|
pagination: { pageSize: PARTNER_PAGE_SIZE },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const gallery = list(page?.blocks)
|
|
|
|
|
.filter(isImageGalleryBlock)
|
|
|
|
|
.flatMap((block) => list(block.images))
|
|
|
|
|
.map((image) => toImage(image, "medium"))
|
|
|
|
|
.filter((image): image is ResolvedImage => image !== undefined);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
locale,
|
|
|
|
|
hero: {
|
|
|
|
|
eyebrow: page?.hero?.eyebrow ?? null,
|
|
|
|
|
headline: page?.hero?.headline ?? null,
|
|
|
|
|
subheading: page?.hero?.subheading ?? null,
|
|
|
|
|
media: toImage(page?.hero?.media, "large") ?? null,
|
|
|
|
|
ctas: list(page?.hero?.ctas).map((cta) => ({
|
|
|
|
|
label: cta.label,
|
|
|
|
|
href: cta.href,
|
|
|
|
|
variant: cta.variant ?? "primary",
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
stats: list(page?.stats).map((stat) => ({
|
|
|
|
|
value: stat.value,
|
|
|
|
|
label: stat.label,
|
|
|
|
|
})),
|
|
|
|
|
gallery,
|
|
|
|
|
partnersHeadline: page?.partnersHeadline ?? null,
|
|
|
|
|
partners: partners.map(toPartner),
|
|
|
|
|
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-14 12:38:05 +02:00
|
|
|
interface HeroCtaProps {
|
|
|
|
|
href: string;
|
|
|
|
|
label: string;
|
|
|
|
|
variant: "primary" | "secondary";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A hero call to action. Figma paints the secondary one as a white fill with a
|
|
|
|
|
* black stroke and a dark label, which is the secondary variant on the LIGHT
|
|
|
|
|
* surface - so the surface is flipped on the control itself. It has to be the
|
|
|
|
|
* control and not a wrapper: `[data-surface]` paints a background in the base
|
|
|
|
|
* layer, and a wrapping element would draw a square white box behind the
|
|
|
|
|
* button's rounded corners.
|
|
|
|
|
*/
|
|
|
|
|
function HeroCta({ href, label, variant }: HeroCtaProps) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
as={Link}
|
|
|
|
|
to={href}
|
|
|
|
|
prefetch="intent"
|
|
|
|
|
size="lg"
|
|
|
|
|
variant={variant}
|
|
|
|
|
{...(variant === "secondary" ? { "data-surface": "light" } : {})}
|
|
|
|
|
iconEnd={variant === "primary" ? <ArrowIcon /> : undefined}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The three-up strip under the launch lead (figma 361:11115). The row is drawn
|
|
|
|
|
* 1849px wide against a 1440px page, so it deliberately overflows the page and
|
|
|
|
|
* is clipped by the band - the widths below are the designed proportions of the
|
|
|
|
|
* 1440 artboard (500 / 729 / 500) carried over as viewport fractions, which
|
|
|
|
|
* reproduces the overhang at any width. The centre picture is the emphasised
|
|
|
|
|
* one; the flanks sit at half strength and are dropped on small screens, where
|
|
|
|
|
* three clipped pictures would be unreadable.
|
|
|
|
|
*/
|
|
|
|
|
function LaunchStrip({ images }: { images: readonly ResolvedImage[] }) {
|
|
|
|
|
const first = images[0];
|
|
|
|
|
if (!first) return null;
|
|
|
|
|
|
|
|
|
|
/* The flanks only exist as a trio. With one or two pictures the emphasised
|
|
|
|
|
one stays centred and the row simply has no wings, rather than throwing
|
|
|
|
|
the centre off-axis or hiding an uploaded picture below `lg`. */
|
|
|
|
|
const complete = images.length >= 3;
|
|
|
|
|
const centre = complete ? images[1]! : first;
|
|
|
|
|
const flanks = complete
|
|
|
|
|
? { start: images[0]!, end: images[2]! }
|
|
|
|
|
: { start: undefined, end: undefined };
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ul className="flex list-none items-center justify-center gap-3xl">
|
|
|
|
|
{flanks.start ? (
|
|
|
|
|
<li className="hidden shrink-0 lg:block">
|
|
|
|
|
<img
|
|
|
|
|
src={flanks.start.src}
|
|
|
|
|
alt={flanks.start.alt}
|
|
|
|
|
loading="lazy"
|
|
|
|
|
className="aspect-[500/268] w-[34.7vw] object-cover opacity-50"
|
|
|
|
|
/>
|
|
|
|
|
</li>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<li className="shrink-0">
|
|
|
|
|
<img
|
|
|
|
|
src={centre.src}
|
|
|
|
|
alt={centre.alt}
|
|
|
|
|
loading="lazy"
|
|
|
|
|
className="aspect-[729/374] w-[86vw] object-cover lg:w-[50.6vw]"
|
|
|
|
|
/>
|
|
|
|
|
</li>
|
|
|
|
|
|
|
|
|
|
{flanks.end ? (
|
|
|
|
|
<li className="hidden shrink-0 lg:block">
|
|
|
|
|
<img
|
|
|
|
|
src={flanks.end.src}
|
|
|
|
|
alt={flanks.end.alt}
|
|
|
|
|
loading="lazy"
|
|
|
|
|
className="aspect-[500/267] w-[34.7vw] object-cover opacity-50"
|
|
|
|
|
/>
|
|
|
|
|
</li>
|
|
|
|
|
) : null}
|
|
|
|
|
</ul>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
|
|
|
|
const { locale, hero, stats, gallery, partners } = loaderData;
|
|
|
|
|
const t = createTranslator(locale);
|
|
|
|
|
const copy = pageCopy(locale).home;
|
|
|
|
|
|
|
|
|
|
const headline = hero.headline ?? copy.heroHeadline;
|
|
|
|
|
const partnersHeadline = loaderData.partnersHeadline ?? copy.partnersHeading;
|
|
|
|
|
|
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("about", locale),
|
|
|
|
|
label: t("actions.discoverSharper"),
|
|
|
|
|
variant: "primary",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
href: sectionPath("whatWeDo", locale),
|
|
|
|
|
label: t("actions.exploreOurWork"),
|
|
|
|
|
variant: "secondary",
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
2026-09-14 12:38:05 +02:00
|
|
|
{/* `lead` is passed only when the CMS supplies a subheading: the frame's
|
|
|
|
|
own subheading node is hidden, so nothing stands under the headline
|
|
|
|
|
unless an editor deliberately writes one. */}
|
2026-09-11 02:46:56 +02:00
|
|
|
<PageHero
|
|
|
|
|
title={headline}
|
|
|
|
|
{...(hero.eyebrow ? { eyebrow: hero.eyebrow } : {})}
|
2026-09-14 12:38:05 +02:00
|
|
|
{...(hero.subheading ? { lead: hero.subheading } : {})}
|
|
|
|
|
wordmark={HERO_WORDMARK}
|
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-11 02:46:56 +02:00
|
|
|
actions={
|
2026-09-14 12:38:05 +02:00
|
|
|
heroCtas.map((cta) => (
|
|
|
|
|
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
|
|
|
|
))
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
{/* FIGURES - 253:4835 is hidden in the frame and every figure in the
|
|
|
|
|
draft is marked TBD, so the band exists only once the CMS has real
|
|
|
|
|
numbers. Nothing is rendered in its place. */}
|
|
|
|
|
{stats.length > 0 ? (
|
|
|
|
|
<PageSection aria-labelledby="home-stats-heading">
|
|
|
|
|
<div className="flex flex-col gap-3xl">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
id="home-stats-heading"
|
|
|
|
|
title={copy.statsHeading}
|
|
|
|
|
size="display"
|
|
|
|
|
/>
|
2026-09-11 02:46:56 +02:00
|
|
|
<StatStrip stats={stats} />
|
2026-09-14 12:38:05 +02:00
|
|
|
</div>
|
|
|
|
|
</PageSection>
|
|
|
|
|
) : null}
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
{/* THE LAUNCH STORY - the band is `bleed` so the photo strip can run past
|
|
|
|
|
the content column, and it clips what runs past the page. */}
|
|
|
|
|
<PageSection
|
|
|
|
|
aria-labelledby="home-launch-heading"
|
|
|
|
|
bleed
|
|
|
|
|
className="overflow-hidden"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-col gap-3xl">
|
|
|
|
|
<Container>
|
|
|
|
|
<div className="flex flex-col items-center gap-lg text-center">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
id="home-launch-heading"
|
|
|
|
|
title={copy.launchHeading}
|
|
|
|
|
size="display"
|
|
|
|
|
align="center"
|
|
|
|
|
/>
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
{/* One paragraph with four bold runs, as the frame sets it. The
|
|
|
|
|
designed 20px has no published type role, so it takes the
|
|
|
|
|
nearest one, Body/Large - whose 28px leading is the frame's. */}
|
|
|
|
|
<p className="body-lg text-[var(--text-primary)]">
|
|
|
|
|
{copy.launchLead.map((run, index) =>
|
|
|
|
|
typeof run === "string" ? (
|
|
|
|
|
<span key={index}>{run}</span>
|
|
|
|
|
) : (
|
|
|
|
|
<strong key={index} className="body-lg-bold">
|
|
|
|
|
{run.em}
|
|
|
|
|
</strong>
|
|
|
|
|
),
|
|
|
|
|
)}
|
2026-09-11 02:46:56 +02:00
|
|
|
</p>
|
2026-09-14 12:38:05 +02:00
|
|
|
</div>
|
|
|
|
|
</Container>
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
{gallery.length > 0 ? <LaunchStrip images={gallery} /> : null}
|
|
|
|
|
|
|
|
|
|
<Container>
|
|
|
|
|
{/* Figma prints these three captions with no heading of their own;
|
|
|
|
|
the heading exists for the outline and for screen readers. */}
|
|
|
|
|
<h3 id="home-mechanisms-heading" className="sr-only">
|
|
|
|
|
{copy.mechanismsHeading}
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
<ul
|
|
|
|
|
aria-labelledby="home-mechanisms-heading"
|
|
|
|
|
className="grid list-none grid-cols-1 gap-lg text-center sm:grid-cols-2 lg:grid-cols-3"
|
|
|
|
|
>
|
|
|
|
|
{copy.mechanisms.map((mechanism) => (
|
|
|
|
|
<li
|
|
|
|
|
key={mechanism}
|
|
|
|
|
className="mx-auto max-w-[21.875rem] heading-h2 text-[var(--text-primary)]"
|
|
|
|
|
>
|
|
|
|
|
{mechanism}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</Container>
|
2026-09-11 02:46:56 +02:00
|
|
|
</div>
|
|
|
|
|
</PageSection>
|
|
|
|
|
|
|
|
|
|
{/* PARTNERS */}
|
|
|
|
|
<PageSection aria-labelledby="home-partners-heading">
|
2026-09-14 12:38:05 +02:00
|
|
|
<div className="flex flex-col gap-3xl">
|
2026-09-11 02:46:56 +02:00
|
|
|
<SectionHeader
|
|
|
|
|
id="home-partners-heading"
|
|
|
|
|
title={partnersHeadline}
|
2026-09-14 12:38:05 +02:00
|
|
|
size="display"
|
2026-09-11 02:46:56 +02:00
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{partners.length > 0 ? (
|
2026-09-14 12:38:05 +02:00
|
|
|
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3 lg:gap-x-4xl">
|
2026-09-11 02:46:56 +02:00
|
|
|
{partners.map((partner) => (
|
2026-09-14 12:38:05 +02:00
|
|
|
/* The tile pads its inline start only, as the frame draws it,
|
|
|
|
|
and sits on the sunken surface - darker than the page, which
|
|
|
|
|
is the way round the design has it. */
|
2026-09-11 02:46:56 +02:00
|
|
|
<li
|
|
|
|
|
key={partner.id}
|
2026-09-14 12:38:05 +02:00
|
|
|
className="flex min-h-[7.125rem] items-center gap-md rounded-control bg-[var(--surface-sunken)] ps-md py-sm"
|
2026-09-11 02:46:56 +02:00
|
|
|
>
|
|
|
|
|
{partner.logo ? (
|
|
|
|
|
<img
|
|
|
|
|
src={partner.logo.src}
|
|
|
|
|
alt=""
|
|
|
|
|
loading="lazy"
|
2026-09-14 12:38:05 +02:00
|
|
|
className="size-[4.375rem] shrink-0 object-contain"
|
2026-09-11 02:46:56 +02:00
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{/* An editor-supplied URL: `safeHref` refuses anything
|
|
|
|
|
that is not http/https/mailto/tel, and the plain-text
|
|
|
|
|
branch below already renders the partner's name. */}
|
|
|
|
|
{safeHref(partner.website) ? (
|
|
|
|
|
<a
|
|
|
|
|
href={safeHref(partner.website)}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
2026-09-14 12:38:05 +02:00
|
|
|
className="body-lg-bold text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
2026-09-11 02:46:56 +02:00
|
|
|
>
|
|
|
|
|
{partner.name}
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
2026-09-14 12:38:05 +02:00
|
|
|
<span className="body-lg-bold text-[var(--text-primary)]">
|
2026-09-11 02:46:56 +02:00
|
|
|
{partner.name}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
) : (
|
|
|
|
|
<EmptyState message={copy.partnersEmpty} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</PageSection>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|