2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
Footer - figma frame 140:7813
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
ONE component for all four published variants:
|
|
|
|
|
light EN 140:7812 | dark EN 140:7814 | light AR 140:8713 | dark AR 140:8685
|
|
|
|
|
`surface` picks the palette and the logo cut; direction comes from `<html dir>`
|
|
|
|
|
and every offset is a logical property, so the Arabic variants are the same
|
|
|
|
|
markup mirrored - there is no RTL branch anywhere below.
|
|
|
|
|
|
|
|
|
|
STYLING: Tailwind utilities for layout and spacing, tier-2 semantic tokens for
|
|
|
|
|
everything that carries meaning (`text-[var(--text-primary)]`). No raw hex, no
|
|
|
|
|
tier-1 primitive, no component stylesheet.
|
|
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
ONE DELIBERATE DEVIATION FROM THE PUBLISHED FRAMES:
|
|
|
|
|
the two EN frames disagree on the sitemap. 140:7812 carries the real IA but
|
|
|
|
|
no column headings; 140:7814 carries headings but labels for pages that do
|
|
|
|
|
not exist (Partners, Blog, Photo Gallery, SUQs, Pilot Initiatives,
|
|
|
|
|
Research). This takes the newer IA - built from ROUTE_SLUGS, the same
|
|
|
|
|
source the router is built from - and the headings. Linking the frame's
|
|
|
|
|
labels verbatim is what previously shipped five dead links.
|
|
|
|
|
|
|
|
|
|
Column headings take `--text-tertiary`, as the frames do: that token is
|
|
|
|
|
already re-pointed to a readable step on the light surface (semantic.css),
|
|
|
|
|
so it needs no a11y override here.
|
2026-09-11 02:46:56 +02:00
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
import { Link } from "react-router";
|
|
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
FooterCopy,
|
|
|
|
|
FooterLink,
|
|
|
|
|
FooterPartner,
|
|
|
|
|
FooterProps,
|
|
|
|
|
FooterSocialLink,
|
|
|
|
|
} from "./types";
|
|
|
|
|
import {
|
|
|
|
|
DEFAULT_FOOTER_COPY,
|
|
|
|
|
DEFAULT_FOOTER_PARTNERS,
|
2026-09-16 20:32:56 +02:00
|
|
|
defaultFooterSocialLinks,
|
2026-09-11 02:46:56 +02:00
|
|
|
FOOTER_ASSETS,
|
|
|
|
|
FOOTER_EU_EMBLEM_SIZE,
|
|
|
|
|
FOOTER_WORDMARK_SIZE,
|
|
|
|
|
defaultFooterLinkGroups,
|
|
|
|
|
} from "./content";
|
2026-09-16 20:32:56 +02:00
|
|
|
import "./Footer.css";
|
|
|
|
|
import { EuBadge } from "../EuBadge";
|
2026-09-11 02:46:56 +02:00
|
|
|
import { FooterSocialIcon } from "./FooterSocialIcon";
|
|
|
|
|
|
|
|
|
|
const cx = (...parts: readonly (string | false | undefined)[]): string =>
|
|
|
|
|
parts.filter(Boolean).join(" ");
|
|
|
|
|
|
|
|
|
|
function definedOnly<T extends object>(source: Partial<T> | undefined): Partial<T> {
|
|
|
|
|
if (!source) return {};
|
|
|
|
|
return Object.fromEntries(
|
|
|
|
|
Object.entries(source).filter(([, value]) => value !== undefined),
|
|
|
|
|
) as Partial<T>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Visible focus ring on every interactive element, straight off the focus tokens. */
|
|
|
|
|
const FOCUS_RING = cx(
|
|
|
|
|
"rounded-[var(--radius-control)]",
|
|
|
|
|
"focus-visible:[outline:var(--focus-ring-width)_solid_var(--focus-ring-color)]",
|
|
|
|
|
"focus-visible:[outline-offset:var(--focus-ring-offset)]",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/** Only `color` - `transition-colors` would also fade `outline-color`, which
|
|
|
|
|
makes the focus ring arrive late. */
|
|
|
|
|
const TRANSITION =
|
|
|
|
|
"transition-[color] duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
|
|
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
/** Body/Large Bold links - 18/28, as the footer frame sets them. */
|
2026-09-11 02:46:56 +02:00
|
|
|
const LINK_CLASS = cx(
|
|
|
|
|
FOCUS_RING,
|
2026-09-16 20:32:56 +02:00
|
|
|
/* Colour AND the 4px nudge, both eased, both in ./Footer.css - see the note
|
|
|
|
|
there on why this is not a `translate-x` utility. */
|
|
|
|
|
"sh-footer-link",
|
|
|
|
|
"body-md-bold inline-block no-underline text-[var(--text-primary)]",
|
|
|
|
|
"hover:text-[var(--text-accent)]",
|
2026-09-11 02:46:56 +02:00
|
|
|
);
|
|
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
/** Label/Medium - the "implemented by" label (figma 228:3035). */
|
2026-09-11 02:46:56 +02:00
|
|
|
const LABEL_CLASS = "label-md uppercase";
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/** The drafts set the column headings as a small uppercase label.
|
2026-09-14 12:38:05 +02:00
|
|
|
`--text-tertiary` is already remapped to a readable step on the light
|
|
|
|
|
surface, so the same token is correct on both. */
|
2026-09-16 20:32:56 +02:00
|
|
|
const GROUP_HEADING_CLASS = "label-md uppercase text-[var(--text-tertiary)]";
|
2026-09-14 12:38:05 +02:00
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
/**
|
|
|
|
|
* Internal links go through the router so the footer navigates client-side;
|
|
|
|
|
* anything flagged `external` stays a plain anchor and opens in a new tab.
|
|
|
|
|
*/
|
|
|
|
|
function FooterNavLink({ link, className }: { link: FooterLink; className: string }) {
|
|
|
|
|
if (link.external) {
|
|
|
|
|
return (
|
|
|
|
|
<a href={link.href} className={className} target="_blank" rel="noopener noreferrer">
|
|
|
|
|
{link.label}
|
|
|
|
|
</a>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<Link to={link.href} className={className} prefetch="intent">
|
|
|
|
|
{link.label}
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Footer({
|
|
|
|
|
surface = "dark",
|
|
|
|
|
locale = "en",
|
|
|
|
|
dir,
|
|
|
|
|
linkGroups,
|
2026-09-16 20:32:56 +02:00
|
|
|
socialLinks,
|
2026-09-11 02:46:56 +02:00
|
|
|
partners = DEFAULT_FOOTER_PARTNERS,
|
|
|
|
|
copy: copyOverrides,
|
|
|
|
|
homeHref,
|
|
|
|
|
className,
|
|
|
|
|
id,
|
|
|
|
|
}: FooterProps) {
|
|
|
|
|
const isDark = surface === "dark";
|
|
|
|
|
// A CMS field that has not been filled in arrives as undefined; spreading it
|
|
|
|
|
// would blank the default, so only defined overrides are applied.
|
|
|
|
|
const copy: FooterCopy = { ...DEFAULT_FOOTER_COPY[locale], ...definedOnly(copyOverrides) };
|
|
|
|
|
const groups = linkGroups ?? defaultFooterLinkGroups(locale);
|
2026-09-16 20:32:56 +02:00
|
|
|
/* `[]` from a caller hides the row deliberately; `undefined` means "nobody
|
|
|
|
|
said", so the project's own confirmed channels stand in. */
|
|
|
|
|
const social = socialLinks ?? defaultFooterSocialLinks(locale);
|
2026-09-11 02:46:56 +02:00
|
|
|
const home = homeHref ?? `/${locale}`;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<footer
|
|
|
|
|
id={id}
|
|
|
|
|
dir={dir}
|
|
|
|
|
data-surface={surface}
|
|
|
|
|
className={cx(
|
2026-09-16 20:32:56 +02:00
|
|
|
/* The drafts stand the footer on its own ground (#141413) under a
|
|
|
|
|
hairline, rather than letting it inherit the page surface. */
|
|
|
|
|
"w-full border-t border-[var(--border-subtle)] bg-[var(--surface-footer)]",
|
2026-09-14 12:38:05 +02:00
|
|
|
"px-lg pb-2xl pt-[var(--layout-section-gap)] md:px-xl",
|
2026-09-11 02:46:56 +02:00
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-2xl">
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* ---- brand column + sitemap --------------------------------- */}
|
|
|
|
|
<div className="grid gap-xl lg:grid-cols-[minmax(11.875rem,1fr)_2fr]">
|
|
|
|
|
<div className="flex flex-col items-start gap-md">
|
2026-09-11 02:46:56 +02:00
|
|
|
<Link to={home} aria-label={copy.homeLabel} className={cx(FOCUS_RING, "w-fit")}>
|
|
|
|
|
<img
|
|
|
|
|
src={isDark ? FOOTER_ASSETS.wordmarkInverse : FOOTER_ASSETS.wordmark}
|
|
|
|
|
alt="SHARP-ER"
|
|
|
|
|
width={FOOTER_WORDMARK_SIZE.width}
|
|
|
|
|
height={FOOTER_WORDMARK_SIZE.height}
|
|
|
|
|
className="block h-[27px] w-[196px]"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
decoding="async"
|
|
|
|
|
/>
|
|
|
|
|
</Link>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* One line: the full name and the dates the project runs. */}
|
|
|
|
|
<p className="body-sm max-w-[13.75rem] text-[var(--text-secondary)]">
|
|
|
|
|
{copy.brandLine}
|
2026-09-11 02:46:56 +02:00
|
|
|
</p>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* The draft draws each channel as a bordered button with its
|
|
|
|
|
glyph and a written label, not as a bare icon. */}
|
|
|
|
|
{social.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<h2 className="sr-only">{copy.socialHeading}</h2>
|
|
|
|
|
<ul className="flex flex-wrap gap-xs">
|
|
|
|
|
{social.map((link) => (
|
|
|
|
|
<li key={link.platform}>
|
|
|
|
|
<SocialButton link={link} />
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* EU visibility, through the shared lockup the hero uses. */}
|
|
|
|
|
<EuBadge
|
|
|
|
|
statement={copy.euStatement}
|
|
|
|
|
locale={locale}
|
|
|
|
|
surface={surface}
|
|
|
|
|
size="sm"
|
|
|
|
|
/>
|
2026-09-11 02:46:56 +02:00
|
|
|
</div>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
<nav
|
|
|
|
|
aria-label={copy.navLabel}
|
|
|
|
|
className="grid gap-xl sm:grid-cols-2 lg:grid-cols-3"
|
|
|
|
|
>
|
|
|
|
|
{groups.map((group) => (
|
|
|
|
|
<div key={group.id} className="flex min-w-0 flex-col gap-sm">
|
2026-09-14 12:38:05 +02:00
|
|
|
<h2 className={GROUP_HEADING_CLASS}>{group.heading}</h2>
|
2026-09-16 20:32:56 +02:00
|
|
|
<ul className="flex flex-col gap-sm">
|
2026-09-11 02:46:56 +02:00
|
|
|
{group.links.map((link: FooterLink, linkIndex: number) => (
|
|
|
|
|
<li key={`${link.href}-${linkIndex}`}>
|
|
|
|
|
<FooterNavLink link={link} className={LINK_CLASS} />
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* ---- implemented by ------------------------------------------ */}
|
2026-09-11 02:46:56 +02:00
|
|
|
{partners.length > 0 && (
|
2026-09-16 20:32:56 +02:00
|
|
|
<div className="flex flex-wrap items-center justify-between gap-lg border-t border-[var(--border-subtle)] pt-lg">
|
|
|
|
|
<p className={cx(LABEL_CLASS, "text-[var(--text-secondary)]")}>
|
2026-09-11 02:46:56 +02:00
|
|
|
{copy.implementedByLabel}
|
|
|
|
|
</p>
|
2026-09-16 20:32:56 +02:00
|
|
|
{/* The draft prints the three names in bordered chips because it
|
|
|
|
|
cannot embed artwork. The real marks are shown instead - they
|
|
|
|
|
are contractual visibility and must not be substituted. */}
|
2026-09-11 02:46:56 +02:00
|
|
|
<ul className="flex flex-wrap items-center gap-lg">
|
|
|
|
|
{partners.map((partner) => (
|
|
|
|
|
<li key={partner.id} className="flex items-center">
|
|
|
|
|
<PartnerMark isDark={isDark} partner={partner} />
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
{/* ---- the funding disclaimer closes the footer ----------------- */}
|
|
|
|
|
<p className="body-sm max-w-[48.75rem] text-[var(--text-tertiary)]">
|
|
|
|
|
{copy.disclaimer}
|
|
|
|
|
</p>
|
2026-09-11 02:46:56 +02:00
|
|
|
</div>
|
|
|
|
|
</footer>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
Consortium logo. The three marks are shown together and never on their own
|
|
|
|
|
(communication guideline), so this renders one mark of that row - each keeps
|
|
|
|
|
its own designed box rather than a shared height rule.
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
function PartnerMark({ isDark, partner }: { isDark: boolean; partner: FooterPartner }) {
|
|
|
|
|
const image = (
|
|
|
|
|
<img
|
|
|
|
|
src={isDark ? partner.srcInverse : partner.src}
|
|
|
|
|
alt={partner.name}
|
|
|
|
|
width={partner.width}
|
|
|
|
|
height={partner.height}
|
|
|
|
|
style={{ width: `${partner.width}px`, height: `${partner.height}px` }}
|
|
|
|
|
className="block"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
decoding="async"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!partner.href) return image;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<a
|
|
|
|
|
href={partner.href}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className={cx(FOCUS_RING, "inline-flex")}
|
|
|
|
|
>
|
|
|
|
|
{image}
|
|
|
|
|
</a>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
2026-09-16 20:32:56 +02:00
|
|
|
One social channel, drawn as the draft's bordered button: the glyph, then
|
|
|
|
|
the written label ("Follow us on Facebook"), so the control names itself
|
|
|
|
|
instead of relying on an icon alone.
|
2026-09-11 02:46:56 +02:00
|
|
|
------------------------------------------------------------------------ */
|
2026-09-16 20:32:56 +02:00
|
|
|
function SocialButton({ link }: { link: FooterSocialLink }) {
|
2026-09-11 02:46:56 +02:00
|
|
|
return (
|
2026-09-16 20:32:56 +02:00
|
|
|
<a
|
|
|
|
|
href={link.href}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className={cx(
|
|
|
|
|
FOCUS_RING,
|
|
|
|
|
TRANSITION,
|
|
|
|
|
"inline-flex items-center gap-xs rounded-control no-underline",
|
|
|
|
|
"border border-[var(--border-subtle)] py-xs ps-sm pe-md",
|
|
|
|
|
"text-[var(--text-primary)]",
|
|
|
|
|
"hover:border-[var(--border-accent)] hover:bg-[var(--surface-page)]",
|
|
|
|
|
"hover:text-[var(--text-accent)]",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<FooterSocialIcon platform={link.platform} className="h-[17px] w-[17px]" />
|
2026-09-20 14:16:53 +02:00
|
|
|
{/* The label never wraps under its own icon: "Follow us on Facebook" is
|
|
|
|
|
one line at every width, as the draft has it. */}
|
|
|
|
|
<span className="body-sm-bold whitespace-nowrap">{link.label}</span>
|
2026-09-16 20:32:56 +02:00
|
|
|
</a>
|
2026-09-11 02:46:56 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default Footer;
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
Footer.stories-ish - usage, as a comment only (no Storybook in this project)
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// 1. Dark English footer - the default, matches figma 140:7814.
|
|
|
|
|
// <html lang="en" dir="ltr"> already carries the direction.
|
|
|
|
|
<Footer locale="en" />
|
|
|
|
|
|
|
|
|
|
// 2. Light English footer - figma 140:7812.
|
|
|
|
|
<Footer surface="light" locale="en" />
|
|
|
|
|
|
|
|
|
|
// 3. Arabic, dark. Same markup; the RTL mirroring comes from <html dir="rtl">,
|
|
|
|
|
// so nothing is passed for direction inside the real app.
|
|
|
|
|
<Footer locale="ar" />
|
|
|
|
|
|
|
|
|
|
// 4. Light Arabic - figma 140:8713. `dir` is only needed when the footer is
|
|
|
|
|
// previewed inside a document that is not in that locale.
|
|
|
|
|
<Footer surface="light" locale="ar" dir="rtl" />
|
|
|
|
|
|
|
|
|
|
// 5. Strapi-driven: every list is a prop, so the CMS payload maps 1:1.
|
|
|
|
|
<Footer
|
|
|
|
|
surface="dark"
|
|
|
|
|
locale="en"
|
|
|
|
|
linkGroups={cms.footer.groups}
|
|
|
|
|
socialLinks={cms.footer.social}
|
|
|
|
|
partners={cms.footer.partners}
|
|
|
|
|
copy={{ description: cms.footer.blurb }}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
// 6. Sitemap-only footer, no consortium row and no social row.
|
|
|
|
|
<Footer partners={[]} socialLinks={[]} />
|
|
|
|
|
|
|
|
|
|
========================================================================= */
|