initial commit
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/* ============================================================================
|
||||
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.
|
||||
|
||||
TWO DELIBERATE DEVIATIONS FROM THE PUBLISHED FRAMES, both noted inline:
|
||||
1. the two EN frames disagree on the sitemap (140:7812 carries the real IA
|
||||
but no column headings, 140:7814 carries headings but stale labels) -
|
||||
this takes the newer IA and the headings;
|
||||
2. `text/tertiary` (#949492) on white is ~2.9:1, so the small labels use
|
||||
`--text-secondary` instead. Revert the two lines marked (a11y) for
|
||||
pixel-exact parity.
|
||||
========================================================================= */
|
||||
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type {
|
||||
FooterCopy,
|
||||
FooterLink,
|
||||
FooterPartner,
|
||||
FooterProps,
|
||||
FooterSocialLink,
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_FOOTER_COPY,
|
||||
DEFAULT_FOOTER_PARTNERS,
|
||||
DEFAULT_FOOTER_SOCIAL_LINKS,
|
||||
FOOTER_ASSETS,
|
||||
FOOTER_EU_EMBLEM_SIZE,
|
||||
FOOTER_WORDMARK_SIZE,
|
||||
defaultFooterLinkGroups,
|
||||
} from "./content";
|
||||
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)]";
|
||||
|
||||
/** Body/Medium Bold links, as in figma 380:12385. */
|
||||
const LINK_CLASS = cx(
|
||||
FOCUS_RING,
|
||||
TRANSITION,
|
||||
"body-md-bold inline-block text-[var(--text-primary)]",
|
||||
"hover:text-[var(--text-link)] hover:underline underline-offset-4",
|
||||
);
|
||||
|
||||
/** Label/Medium, used for the column headings and the "implemented by" label. */
|
||||
const LABEL_CLASS = "label-md uppercase";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
socialLinks = DEFAULT_FOOTER_SOCIAL_LINKS,
|
||||
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);
|
||||
const home = homeHref ?? `/${locale}`;
|
||||
|
||||
return (
|
||||
<footer
|
||||
id={id}
|
||||
dir={dir}
|
||||
data-surface={surface}
|
||||
className={cx(
|
||||
// data-surface paints background-color and color itself (app.css), so
|
||||
// the footer needs no colour utility of its own.
|
||||
"w-full px-lg pb-2xl pt-[var(--layout-section-gap)] md:px-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-2xl">
|
||||
{/* ---- brand + sitemap (figma 140:7684 / 140:8686) ---------------- */}
|
||||
<div className="flex flex-col gap-2xl lg:flex-row lg:gap-3xl">
|
||||
{/* --- brand column (figma 140:7685 / 140:8687) ----------------- */}
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-col gap-md",
|
||||
// the divider sits on the inline-end of the brand block, which is
|
||||
// the left edge once <html dir="rtl"> mirrors the row.
|
||||
"lg:w-[345px] lg:shrink-0 lg:border-e lg:border-[var(--border-subtle)] lg:pe-2xl",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<p className="body-sm text-[var(--text-secondary)]">{copy.tagline}</p>
|
||||
|
||||
<p className="body-sm max-w-[46ch] text-[var(--text-primary)]">
|
||||
{copy.description}
|
||||
</p>
|
||||
|
||||
{/* --- EU visibility (figma 140:7688 / 238:2325) -------------- */}
|
||||
<div className="flex items-center gap-2xs pt-2xs">
|
||||
<img
|
||||
src={isDark ? FOOTER_ASSETS.euEmblemInverse : FOOTER_ASSETS.euEmblem}
|
||||
alt={copy.euEmblemAlt}
|
||||
width={FOOTER_EU_EMBLEM_SIZE.width}
|
||||
height={FOOTER_EU_EMBLEM_SIZE.height}
|
||||
className="block h-[32px] w-[48px] shrink-0"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<p className="body-sm-bold max-w-[24ch] text-[var(--text-primary)]">
|
||||
{copy.euStatement}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{socialLinks.length > 0 && <SocialRow copy={copy} links={socialLinks} />}
|
||||
</div>
|
||||
|
||||
{/* --- sitemap columns (figma 380:12402) ------------------------ */}
|
||||
<nav aria-label={copy.navLabel} className="flex flex-1 flex-col gap-2xl sm:flex-row sm:gap-0">
|
||||
{groups.map((group, index) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={cx(
|
||||
"flex min-w-0 flex-col gap-md sm:flex-1",
|
||||
// the rule sits on the inline-start of every column but the
|
||||
// first, so it mirrors with the row under dir="rtl".
|
||||
index > 0 && "sm:border-s sm:border-[var(--border-subtle)] sm:ps-xl",
|
||||
index < groups.length - 1 && "sm:pe-xl",
|
||||
)}
|
||||
>
|
||||
{/* (a11y) figma uses text/tertiary; see the header note. */}
|
||||
<h2 className={cx(LABEL_CLASS, "text-[var(--text-secondary)]")}>
|
||||
{group.heading}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-sm">
|
||||
{group.links.map((link: FooterLink, linkIndex: number) => (
|
||||
<li key={`${link.href}-${linkIndex}`}>
|
||||
<FooterNavLink link={link} className={LINK_CLASS} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* ---- implemented-by bar (figma 228:3062 / 140:8710) ------------- */}
|
||||
{partners.length > 0 && (
|
||||
<div className="flex flex-col gap-lg border-t border-[var(--border-default)] pt-lg sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Figma paints this label background/accent on dark and
|
||||
text/secondary on light; --text-accent is the readable green. */}
|
||||
<p
|
||||
className={cx(
|
||||
LABEL_CLASS,
|
||||
"text-[var(--text-secondary)] surface-dark:text-[var(--text-accent)]",
|
||||
)}
|
||||
>
|
||||
{copy.implementedByLabel}
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Social row. Not in the published frames - the draft copy asks for it, so it
|
||||
closes the brand column. The negative inline-start margin cancels the icon
|
||||
padding so the glyphs optically align with the text above them.
|
||||
------------------------------------------------------------------------ */
|
||||
function SocialRow({
|
||||
copy,
|
||||
links,
|
||||
}: {
|
||||
copy: FooterCopy;
|
||||
links: readonly FooterSocialLink[];
|
||||
}) {
|
||||
return (
|
||||
<div className="pt-2xs">
|
||||
<h2 className="sr-only">{copy.socialHeading}</h2>
|
||||
<ul className="-ms-2xs flex flex-wrap items-center gap-2xs">
|
||||
{links.map((link) => (
|
||||
<li key={link.platform}>
|
||||
<a
|
||||
href={link.href}
|
||||
aria-label={link.label}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cx(
|
||||
FOCUS_RING,
|
||||
TRANSITION,
|
||||
"inline-flex items-center justify-center p-2xs",
|
||||
"text-[var(--text-secondary)] hover:text-[var(--text-link)]",
|
||||
)}
|
||||
>
|
||||
<FooterSocialIcon
|
||||
platform={link.platform}
|
||||
className="h-[var(--control-icon-size)] w-[var(--control-icon-size)]"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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={[]} />
|
||||
|
||||
========================================================================= */
|
||||
@@ -0,0 +1,43 @@
|
||||
/* ============================================================================
|
||||
Footer - social glyphs
|
||||
----------------------------------------------------------------------------
|
||||
The Figma icon set (frame 84:4485) only ships the arrow pair, so the brand
|
||||
marks below are inlined rather than exported. Inline is also what the surface
|
||||
model needs: the glyph paints with `currentColor`, so one file works on both
|
||||
the light and the dark footer without a second asset.
|
||||
========================================================================= */
|
||||
|
||||
import type { FooterSocialPlatform } from "./types";
|
||||
|
||||
/** Single-path 24x24 brand marks, viewBox `0 0 24 24`, filled with currentColor. */
|
||||
const GLYPHS: Readonly<Record<FooterSocialPlatform, string>> = {
|
||||
facebook:
|
||||
"M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
|
||||
linkedin:
|
||||
"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 1 1 0-4.124 2.062 2.062 0 0 1 0 4.124Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z",
|
||||
youtube:
|
||||
"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814ZM9.545 15.568V8.432L15.818 12l-6.273 3.568Z",
|
||||
instagram:
|
||||
"M12 2.163c3.204 0 3.584.012 4.85.07 1.17.053 1.805.249 2.227.413.56.218.96.478 1.382.9.421.42.681.82.898 1.38.164.423.36 1.058.413 2.228.058 1.266.07 1.646.07 4.85 0 3.204-.012 3.584-.07 4.85-.053 1.17-.249 1.805-.413 2.227a3.72 3.72 0 0 1-.898 1.382c-.422.421-.822.681-1.382.898-.422.164-1.057.36-2.227.413-1.265.058-1.645.07-4.85.07-3.204 0-3.584-.012-4.85-.07-1.17-.053-1.805-.249-2.227-.413a3.72 3.72 0 0 1-1.382-.898 3.72 3.72 0 0 1-.898-1.382c-.164-.422-.36-1.057-.413-2.227-.058-1.266-.07-1.646-.07-4.85 0-3.204.012-3.584.07-4.85.053-1.17.249-1.805.413-2.227a3.72 3.72 0 0 1 .898-1.382 3.72 3.72 0 0 1 1.382-.898c.422-.164 1.057-.36 2.227-.413 1.266-.058 1.646-.07 4.85-.07ZM12 0C8.741 0 8.332.014 7.052.072 5.775.13 4.902.333 4.14.63a5.88 5.88 0 0 0-2.126 1.384A5.88 5.88 0 0 0 .63 4.14C.333 4.902.13 5.775.072 7.052.014 8.332 0 8.741 0 12s.014 3.668.072 4.948c.058 1.277.261 2.15.558 2.912a5.88 5.88 0 0 0 1.384 2.126A5.88 5.88 0 0 0 4.14 23.37c.762.297 1.635.5 2.912.558C8.332 23.986 8.741 24 12 24s3.668-.014 4.948-.072c1.277-.058 2.15-.261 2.912-.558a5.88 5.88 0 0 0 2.126-1.384 5.88 5.88 0 0 0 1.384-2.126c.297-.762.5-1.635.558-2.912.058-1.28.072-1.689.072-4.948s-.014-3.668-.072-4.948c-.058-1.277-.261-2.15-.558-2.912a5.88 5.88 0 0 0-1.384-2.126A5.88 5.88 0 0 0 19.86.63c-.762-.297-1.635-.5-2.912-.558C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm7.846-10.405a1.44 1.44 0 1 1-2.88 0 1.44 1.44 0 0 1 2.88 0Z",
|
||||
x: "M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
|
||||
};
|
||||
|
||||
export interface FooterSocialIconProps {
|
||||
readonly platform: FooterSocialPlatform;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
/** Decorative glyph - the accessible name lives on the wrapping link. */
|
||||
export function FooterSocialIcon({ platform, className }: FooterSocialIconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className={className}
|
||||
>
|
||||
<path d={GLYPHS[platform]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/* ============================================================================
|
||||
Footer - typed defaults
|
||||
----------------------------------------------------------------------------
|
||||
Draft copy is taken from docs/SHARPER_Website_Draft_Section_EN_AR.docx and the
|
||||
communication guideline; the section names are the ten-section IA. Everything
|
||||
here is a default only - Strapi will supply the same shapes later, so nothing
|
||||
below may be imported by anything except <Footer />'s own prop defaults.
|
||||
========================================================================= */
|
||||
|
||||
import { ROUTE_SLUGS } from "~/lib/route-slugs";
|
||||
|
||||
import type {
|
||||
FooterCopy,
|
||||
FooterLinkGroup,
|
||||
FooterLocale,
|
||||
FooterPartner,
|
||||
FooterSocialLink,
|
||||
} from "./types";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Assets - exported from Figma (frames 312:4012, 84:4483, 228:2984) and
|
||||
committed under apps/web/public/assets/. Two cuts of every piece of baked
|
||||
ink: the plain one for a light surface, `*-inverse` for a dark one.
|
||||
------------------------------------------------------------------------ */
|
||||
export const FOOTER_ASSETS = {
|
||||
/** SHARP-ER wordmark, designed 334x46 (figma 317:5571 / 317:7812). */
|
||||
wordmark: "/assets/sharp-er-wordmark.png",
|
||||
wordmarkInverse: "/assets/sharp-er-wordmark-inverse.png",
|
||||
/** EU emblem, designed 48x32 (figma 22:5358 / 238:2323). */
|
||||
euEmblem: "/assets/eu-emblem.png",
|
||||
euEmblemInverse: "/assets/eu-emblem-inverse.png",
|
||||
} as const;
|
||||
|
||||
/** Designed CSS pixels of the wordmark inside the footer brand column. */
|
||||
export const FOOTER_WORDMARK_SIZE = { width: 196, height: 27 } as const;
|
||||
|
||||
/** Designed CSS pixels of the EU emblem (figma "european union", 48x32). */
|
||||
export const FOOTER_EU_EMBLEM_SIZE = { width: 48, height: 32 } as const;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Route slugs
|
||||
The footer only ever builds `/{locale}/{slug}`, and the slugs come from the
|
||||
ONE source of truth the route config itself is built from - redeclaring them
|
||||
here is how the footer ended up linking at five pages that do not exist.
|
||||
Re-exported under the old name so the barrel keeps its public surface.
|
||||
------------------------------------------------------------------------ */
|
||||
export const FOOTER_ROUTE_SLUGS = ROUTE_SLUGS;
|
||||
|
||||
const path = (locale: FooterLocale, slug: string): string => `/${locale}/${slug}`;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Copy
|
||||
------------------------------------------------------------------------ */
|
||||
export const DEFAULT_FOOTER_COPY: Readonly<Record<FooterLocale, FooterCopy>> = {
|
||||
en: {
|
||||
tagline: "Shared Action on Rule of Law Progress – Exit Route",
|
||||
description:
|
||||
"A nationwide initiative supporting Libya’s civil society, local institutions and communities in advancing justice, accountability and good governance. Running from December 2025 to December 2027.",
|
||||
euStatement: "This project is funded by the European Union",
|
||||
euEmblemAlt: "Emblem of the European Union",
|
||||
implementedByLabel: "Implemented by",
|
||||
socialHeading: "Follow SHARP-ER",
|
||||
homeLabel: "SHARP-ER — home",
|
||||
navLabel: "Site sections",
|
||||
},
|
||||
ar: {
|
||||
tagline: "العمل المشترك لتعزيز سيادة القانون – مسار الخروج",
|
||||
description:
|
||||
"مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة. يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027.",
|
||||
euStatement: "هذا المشروع ممول من الاتحاد الأوروبي",
|
||||
euEmblemAlt: "شعار الاتحاد الأوروبي",
|
||||
implementedByLabel: "الجهات المنفّذة",
|
||||
socialHeading: "تابعوا SHARP-ER",
|
||||
homeLabel: "SHARP-ER — الصفحة الرئيسية",
|
||||
navLabel: "أقسام الموقع",
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Sitemap columns - the three IA groups. "Home" is not repeated as a link
|
||||
because the wordmark above the columns already points at it.
|
||||
------------------------------------------------------------------------ */
|
||||
const LINK_GROUPS: Readonly<
|
||||
Record<FooterLocale, readonly FooterLinkGroup[]>
|
||||
> = {
|
||||
en: [
|
||||
{
|
||||
id: "about-and-strategy",
|
||||
heading: "About and Strategy",
|
||||
links: [
|
||||
{ label: "About", href: path("en", FOOTER_ROUTE_SLUGS.about) },
|
||||
{ label: "What We Do", href: path("en", FOOTER_ROUTE_SLUGS.whatWeDo) },
|
||||
{
|
||||
label: "Activities and Initiatives",
|
||||
href: path("en", FOOTER_ROUTE_SLUGS.activities),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "engagement-and-opportunities",
|
||||
heading: "Engagement and Opportunities",
|
||||
links: [
|
||||
{ label: "Events Calendar", href: path("en", FOOTER_ROUTE_SLUGS.events) },
|
||||
{
|
||||
label: "Applications and Opportunities",
|
||||
href: path("en", FOOTER_ROUTE_SLUGS.applications),
|
||||
},
|
||||
{ label: "News and Updates", href: path("en", FOOTER_ROUTE_SLUGS.news) },
|
||||
{ label: "Contact", href: path("en", FOOTER_ROUTE_SLUGS.contact) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knowledge-and-impact",
|
||||
heading: "Knowledge and Impact",
|
||||
links: [
|
||||
{
|
||||
label: "Resources and Publications",
|
||||
href: path("en", FOOTER_ROUTE_SLUGS.resources),
|
||||
},
|
||||
{ label: "Knowledge Hub", href: path("en", FOOTER_ROUTE_SLUGS.knowledgeHub) },
|
||||
],
|
||||
},
|
||||
],
|
||||
ar: [
|
||||
{
|
||||
id: "about-and-strategy",
|
||||
heading: "عن المشروع والاستراتيجية",
|
||||
links: [
|
||||
{ label: "عن المشروع", href: path("ar", FOOTER_ROUTE_SLUGS.about) },
|
||||
{ label: "ماذا نفعل", href: path("ar", FOOTER_ROUTE_SLUGS.whatWeDo) },
|
||||
{ label: "الأنشطة والمبادرات", href: path("ar", FOOTER_ROUTE_SLUGS.activities) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "engagement-and-opportunities",
|
||||
heading: "المشاركة والفرص",
|
||||
links: [
|
||||
{ label: "التقويم الزمني للفعاليات", href: path("ar", FOOTER_ROUTE_SLUGS.events) },
|
||||
{ label: "طلبات وفرص المشاركة", href: path("ar", FOOTER_ROUTE_SLUGS.applications) },
|
||||
{ label: "الأخبار والمستجدات", href: path("ar", FOOTER_ROUTE_SLUGS.news) },
|
||||
{ label: "تواصل معنا", href: path("ar", FOOTER_ROUTE_SLUGS.contact) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knowledge-and-impact",
|
||||
heading: "المعرفة والأثر",
|
||||
links: [
|
||||
{ label: "الموارد والمنشورات", href: path("ar", FOOTER_ROUTE_SLUGS.resources) },
|
||||
{ label: "مركز المعرفة", href: path("ar", FOOTER_ROUTE_SLUGS.knowledgeHub) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** The three IA sitemap columns for a locale. */
|
||||
export function defaultFooterLinkGroups(
|
||||
locale: FooterLocale,
|
||||
): readonly FooterLinkGroup[] {
|
||||
return LINK_GROUPS[locale];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Consortium logos - shown together, never standalone (communication guideline).
|
||||
Sizes are the designed boxes from figma frame 228:2984.
|
||||
------------------------------------------------------------------------ */
|
||||
export const DEFAULT_FOOTER_PARTNERS: readonly FooterPartner[] = [
|
||||
{
|
||||
id: "acted",
|
||||
name: "Acted",
|
||||
src: "/assets/partner-acted.png",
|
||||
srcInverse: "/assets/partner-acted-inverse.png",
|
||||
width: 86,
|
||||
height: 24,
|
||||
},
|
||||
{
|
||||
id: "uec",
|
||||
name: "United Efforts Consultancy (UEC)",
|
||||
src: "/assets/partner-uec.png",
|
||||
srcInverse: "/assets/partner-uec-inverse.png",
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
{
|
||||
id: "sbe",
|
||||
name: "Stichting Bureau Ensemble (SBE)",
|
||||
src: "/assets/partner-sbe.png",
|
||||
srcInverse: "/assets/partner-sbe-inverse.png",
|
||||
width: 43,
|
||||
height: 24,
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Social profiles
|
||||
DELIBERATELY EMPTY. The draft copy names Facebook as the only confirmed
|
||||
channel and leaves its URL a placeholder, so there is no handle here that
|
||||
the project is known to own - and shipping a guessed one from an EU-funded
|
||||
site would point readers at an account somebody else may control.
|
||||
Real profiles arrive through the `socialLinks` prop (Strapi); the footer's
|
||||
`links.length > 0` guard drops the whole row until they do.
|
||||
------------------------------------------------------------------------ */
|
||||
export const DEFAULT_FOOTER_SOCIAL_LINKS: readonly FooterSocialLink[] = [];
|
||||
@@ -0,0 +1,31 @@
|
||||
/* ============================================================================
|
||||
Footer - public surface
|
||||
========================================================================= */
|
||||
|
||||
export { Footer, default } from "./Footer";
|
||||
export { FooterSocialIcon } from "./FooterSocialIcon";
|
||||
export type { FooterSocialIconProps } from "./FooterSocialIcon";
|
||||
|
||||
export {
|
||||
DEFAULT_FOOTER_COPY,
|
||||
DEFAULT_FOOTER_PARTNERS,
|
||||
DEFAULT_FOOTER_SOCIAL_LINKS,
|
||||
FOOTER_ASSETS,
|
||||
FOOTER_EU_EMBLEM_SIZE,
|
||||
FOOTER_ROUTE_SLUGS,
|
||||
FOOTER_WORDMARK_SIZE,
|
||||
defaultFooterLinkGroups,
|
||||
} from "./content";
|
||||
|
||||
export type {
|
||||
FooterCopy,
|
||||
FooterDirection,
|
||||
FooterLink,
|
||||
FooterLinkGroup,
|
||||
FooterLocale,
|
||||
FooterPartner,
|
||||
FooterProps,
|
||||
FooterSocialLink,
|
||||
FooterSocialPlatform,
|
||||
FooterSurface,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,116 @@
|
||||
/* ============================================================================
|
||||
Footer - public types
|
||||
----------------------------------------------------------------------------
|
||||
Everything the footer renders that is not chrome comes in through props, so
|
||||
the same component can later be driven straight from Strapi. The defaults in
|
||||
./content.ts are the fallback, not the contract.
|
||||
========================================================================= */
|
||||
|
||||
/** Which tier-2 surface the footer paints itself with. `:root` is the dark one. */
|
||||
export type FooterSurface = "light" | "dark";
|
||||
|
||||
/** The two locales the site is served in. URLs are prefixed on both. */
|
||||
export type FooterLocale = "en" | "ar";
|
||||
|
||||
/** Writing direction. Normally inherited from `<html dir>` - see FooterProps.dir. */
|
||||
export type FooterDirection = "ltr" | "rtl";
|
||||
|
||||
/** Social channels the footer can render a glyph for. */
|
||||
export type FooterSocialPlatform =
|
||||
| "facebook"
|
||||
| "linkedin"
|
||||
| "youtube"
|
||||
| "instagram"
|
||||
| "x";
|
||||
|
||||
/** A single sitemap or utility link. */
|
||||
export interface FooterLink {
|
||||
/** Visible label, already in the target locale. */
|
||||
readonly label: string;
|
||||
/** Absolute path including the locale prefix, e.g. `/en/about`. */
|
||||
readonly href: string;
|
||||
/** Opens in a new tab with `rel="noopener noreferrer"` when true. */
|
||||
readonly external?: boolean;
|
||||
}
|
||||
|
||||
/** One sitemap column: a heading plus its links. */
|
||||
export interface FooterLinkGroup {
|
||||
/** Stable key, locale independent (e.g. `about-and-strategy`). */
|
||||
readonly id: string;
|
||||
/** Visible column heading, already in the target locale. */
|
||||
readonly heading: string;
|
||||
readonly links: readonly FooterLink[];
|
||||
}
|
||||
|
||||
/** A social profile. `label` is the accessible name of the icon link. */
|
||||
export interface FooterSocialLink {
|
||||
readonly platform: FooterSocialPlatform;
|
||||
readonly label: string;
|
||||
readonly href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A consortium logo in the "implemented by" row. Two sources, because the
|
||||
* artwork is baked ink: `src` is the dark-ink cut for a light surface and
|
||||
* `srcInverse` the white cut for a dark surface. `width`/`height` are the
|
||||
* designed CSS pixels, kept explicit so the row never reflows on load.
|
||||
*/
|
||||
export interface FooterPartner {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly src: string;
|
||||
readonly srcInverse: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly href?: string;
|
||||
}
|
||||
|
||||
/** Every string the footer renders that is not a link label. */
|
||||
export interface FooterCopy {
|
||||
/** "Shared Action on Rule of Law Progress - Exit Route" */
|
||||
readonly tagline: string;
|
||||
/** Short programme description under the wordmark. */
|
||||
readonly description: string;
|
||||
/** EU funding statement rendered next to the emblem. */
|
||||
readonly euStatement: string;
|
||||
/** Alt text for the EU emblem image. */
|
||||
readonly euEmblemAlt: string;
|
||||
/** Label above the consortium logos. */
|
||||
readonly implementedByLabel: string;
|
||||
/** Visually hidden heading for the social row. */
|
||||
readonly socialHeading: string;
|
||||
/** Accessible name of the wordmark link. */
|
||||
readonly homeLabel: string;
|
||||
/** Accessible name of the sitemap `<nav>` and the `<footer>` landmark. */
|
||||
readonly navLabel: string;
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
/**
|
||||
* Which surface to paint. Sets `data-surface`, so the subtree re-resolves
|
||||
* every tier-2 token and the correct logo cut is chosen. Defaults to `dark`,
|
||||
* matching the home design.
|
||||
*/
|
||||
readonly surface?: FooterSurface;
|
||||
/** Selects the default copy and link set. Defaults to `en`. */
|
||||
readonly locale?: FooterLocale;
|
||||
/**
|
||||
* Writing direction. Leave unset in the real app - `<html dir>` owns it and
|
||||
* the footer lays out with logical properties, so it mirrors for free. Pass
|
||||
* it only when previewing the footer outside a document of that locale.
|
||||
*/
|
||||
readonly dir?: FooterDirection;
|
||||
/** Sitemap columns. Defaults to the ten-section IA for `locale`. */
|
||||
readonly linkGroups?: readonly FooterLinkGroup[];
|
||||
/** Social profiles. Pass `[]` to hide the row. */
|
||||
readonly socialLinks?: readonly FooterSocialLink[];
|
||||
/** Consortium logos. Pass `[]` to hide the row. */
|
||||
readonly partners?: readonly FooterPartner[];
|
||||
/** Overrides for individual strings; anything omitted falls back to `locale`. */
|
||||
readonly copy?: Partial<FooterCopy>;
|
||||
/** Target of the wordmark link. Defaults to `/{locale}`. */
|
||||
readonly homeHref?: string;
|
||||
/** Extra classes on the `<footer>` element. */
|
||||
readonly className?: string;
|
||||
readonly id?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user