initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -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";
+116
View File
@@ -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;
}