342 lines
12 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|