Files
sharper.ly/apps/web/app/routes/home.tsx
T
2026-09-11 02:46:56 +02:00

342 lines
12 KiB
TypeScript

/* ============================================================================
HOME - /en and /ar
----------------------------------------------------------------------------
Figma frame 253:4799 ("Home-en"), which is laid out as:
Hero header (253:4800) headline + CTA group + wide picture
Stats hidden in the frame, and every figure in the
draft copy is marked TBD - so the band renders a
localised "not yet confirmed" note and only shows
numbers the CMS supplies
Offical launch (361:10853) "The Journey of the SHARP-ER Launch", the
launch story, the photo strip and the three
captions under it
Partners (393:14754) "Meet Our Partners" logo grid
Footer owned by the app shell, not by this module
The launch story is the approved copy from docs/SHARPER Home Page .docx 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.
========================================================================= */
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
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 {
EmptyState,
PageHero,
PageSection,
SectionHeader,
StatStrip,
} from "./_page-kit/sections";
/** Partner logos and CSO tiles - a wide but finite grid. */
const PARTNER_PAGE_SIZE = 36;
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 }),
],
});
}
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 lead = hero.subheading ?? t("site.description");
const partnersHeadline = loaderData.partnersHeadline ?? copy.partnersHeading;
/* The three captions under the photo strip in Figma. Each one points at the
section of the site that carries that strand of work forward, and every
label is a catalogue string so /ar never inherits an English one. */
const mechanisms = [
{
title: copy.mechanisms[0]!,
href: sectionPath("whatWeDo", locale),
actionLabel: t("actions.learnWhatWeDo"),
},
{
title: copy.mechanisms[1]!,
href: sectionPath("activities", locale),
actionLabel: t("actions.seeActivities"),
},
{
title: copy.mechanisms[2]!,
href: sectionPath("applications", locale),
actionLabel: t("actions.seeCurrentCalls"),
},
];
return (
<>
<PageHero
title={headline}
{...(hero.eyebrow ? { eyebrow: hero.eyebrow } : {})}
lead={lead}
{...(hero.media ? { media: hero.media } : {})}
actions={
hero.ctas.length > 0 ? (
hero.ctas.map((cta) => (
<Button
key={`${cta.href}-${cta.label}`}
as={Link}
to={cta.href}
prefetch="intent"
size="lg"
variant={cta.variant}
iconEnd={cta.variant === "primary" ? <ArrowIcon /> : undefined}
>
{cta.label}
</Button>
))
) : (
<>
<Button
as={Link}
to={sectionPath("about", locale)}
prefetch="intent"
size="lg"
variant="primary"
iconEnd={<ArrowIcon />}
>
{t("actions.learnMore")}
</Button>
<Button
as={Link}
to={sectionPath("whatWeDo", locale)}
prefetch="intent"
size="lg"
variant="secondary"
>
{t("actions.learnWhatWeDo")}
</Button>
</>
)
}
/>
{/* FIGURES - never invented here. The draft fact sheet marks them TBD. */}
<PageSection aria-labelledby="home-stats-heading" className="py-2xl md:py-3xl">
<div className="flex flex-col gap-lg">
<SectionHeader id="home-stats-heading" title={copy.statsHeading} />
{stats.length > 0 ? (
<StatStrip stats={stats} />
) : (
<EmptyState message={copy.statsPending} />
)}
</div>
</PageSection>
{/* THE LAUNCH STORY - approved copy, EN and AR. */}
<PageSection aria-labelledby="home-launch-heading">
<div className="flex flex-col gap-2xl">
<SectionHeader
id="home-launch-heading"
title={copy.launchHeading}
align="center"
/>
<div className="mx-auto flex w-full max-w-[var(--layout-prose-max)] flex-col gap-md">
{copy.launchParagraphs.map((paragraph) => (
<p key={paragraph.slice(0, 32)} className="body-md text-[var(--text-secondary)]">
{paragraph}
</p>
))}
<p className="body-sm-bold text-[var(--text-accent)]">{copy.fundingNote}</p>
</div>
{/* Figma prints these three captions under the photo strip with no
heading of their own; the heading exists for the outline only. */}
<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 sm:grid-cols-2 lg:grid-cols-3"
>
{mechanisms.map((mechanism, index) => (
<Card.Content
key={mechanism.title}
as="li"
titleAs="h4"
href={mechanism.href}
title={mechanism.title}
actionLabel={mechanism.actionLabel}
{...(gallery[index]
? {
image: {
src: gallery[index]!.src,
alt: gallery[index]!.alt,
},
}
: {})}
/>
))}
</ul>
</div>
</PageSection>
{/* PARTNERS */}
<PageSection aria-labelledby="home-partners-heading">
<div className="flex flex-col gap-2xl">
<SectionHeader
id="home-partners-heading"
title={partnersHeadline}
align="center"
/>
{partners.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{partners.map((partner) => (
<li
key={partner.id}
className="flex items-center gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-md"
>
{partner.logo ? (
<img
src={partner.logo.src}
alt=""
loading="lazy"
className="size-12 shrink-0 rounded-control object-contain"
/>
) : 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"
className="body-sm-bold text-[var(--text-primary)] underline-offset-2 hover:underline"
>
{partner.name}
</a>
) : (
<span className="body-sm-bold text-[var(--text-primary)]">
{partner.name}
</span>
)}
</li>
))}
</ul>
) : (
<EmptyState message={copy.partnersEmpty} />
)}
</div>
</PageSection>
</>
);
}