initial commit
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/* ============================================================================
|
||||
CMS SHAPES
|
||||
----------------------------------------------------------------------------
|
||||
Hand-written mirrors of the Strapi 5 schemas in apps/cms/src/api/** and
|
||||
apps/cms/src/components/**. Types only - the file compiles away, so it is
|
||||
safe to import from a component as well as from a loader.
|
||||
|
||||
Every field the CMS marks optional is `?: T | null` here, because Strapi
|
||||
returns `null` (not `undefined`) for an empty relation or component, and the
|
||||
pages have to render before a single entry exists.
|
||||
========================================================================= */
|
||||
|
||||
import type { StrapiEntity, StrapiMedia } from "~/lib/strapi";
|
||||
|
||||
/** shared.link */
|
||||
export interface CmsLink {
|
||||
readonly id?: number;
|
||||
readonly label: string;
|
||||
readonly href: string;
|
||||
readonly isExternal?: boolean | null;
|
||||
}
|
||||
|
||||
/** shared.cta - `variant` matches the Button component's variants exactly. */
|
||||
export interface CmsCta {
|
||||
readonly id?: number;
|
||||
readonly label: string;
|
||||
readonly href: string;
|
||||
readonly variant?: "primary" | "secondary" | "dark" | "danger" | "ghost" | null;
|
||||
}
|
||||
|
||||
/** shared.hero */
|
||||
export interface CmsHero {
|
||||
readonly id?: number;
|
||||
readonly eyebrow?: string | null;
|
||||
readonly headline: string;
|
||||
readonly subheading?: string | null;
|
||||
readonly media?: StrapiMedia | null;
|
||||
readonly ctas?: readonly CmsCta[] | null;
|
||||
}
|
||||
|
||||
/** shared.stat - `value` is authored as a string so "48%" and "1,583" survive. */
|
||||
export interface CmsStat {
|
||||
readonly id?: number;
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/** shared.titled-text - pillars and principles. */
|
||||
export interface CmsTitledText {
|
||||
readonly id?: number;
|
||||
readonly title: string;
|
||||
readonly description?: string | null;
|
||||
}
|
||||
|
||||
/** shared.address - one consortium office. */
|
||||
export interface CmsAddress {
|
||||
readonly id?: number;
|
||||
readonly organisation: string;
|
||||
readonly addressLine?: string | null;
|
||||
readonly city?: string | null;
|
||||
readonly country?: string | null;
|
||||
readonly email?: string | null;
|
||||
readonly phone?: string | null;
|
||||
}
|
||||
|
||||
/** shared.seo */
|
||||
export interface CmsSeo {
|
||||
readonly id?: number;
|
||||
readonly metaTitle: string;
|
||||
readonly metaDescription?: string | null;
|
||||
readonly ogImage?: StrapiMedia | null;
|
||||
readonly canonicalUrl?: string | null;
|
||||
readonly noIndex?: boolean | null;
|
||||
}
|
||||
|
||||
/** page.workstream */
|
||||
export interface CmsWorkstream {
|
||||
readonly id?: number;
|
||||
readonly title: string;
|
||||
readonly description?: string | null;
|
||||
readonly icon?: StrapiMedia | null;
|
||||
}
|
||||
|
||||
export type CmsPartnerRole =
|
||||
| "consortium-lead"
|
||||
| "consortium-partner"
|
||||
| "cso-partner"
|
||||
| "institution";
|
||||
|
||||
/** api::partner.partner */
|
||||
export interface CmsPartner extends StrapiEntity {
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly logo?: StrapiMedia | null;
|
||||
readonly logoDark?: StrapiMedia | null;
|
||||
readonly description?: string | null;
|
||||
readonly website?: string | null;
|
||||
readonly partnerRole: CmsPartnerRole;
|
||||
readonly country?: string | null;
|
||||
}
|
||||
|
||||
/** page.consortium-member */
|
||||
export interface CmsConsortiumMember {
|
||||
readonly id?: number;
|
||||
readonly partner?: CmsPartner | null;
|
||||
readonly roleDescription?: string | null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
DYNAMIC ZONE
|
||||
Only the blocks these four pages actually render are modelled; anything else
|
||||
in the zone is matched by the fallback member and skipped.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface CmsImageGalleryBlock {
|
||||
readonly __component: "blocks.image-gallery";
|
||||
readonly id: number;
|
||||
readonly images?: readonly StrapiMedia[] | null;
|
||||
readonly caption?: string | null;
|
||||
}
|
||||
|
||||
export interface CmsRichTextBlock {
|
||||
readonly __component: "blocks.rich-text";
|
||||
readonly id: number;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export interface CmsUnknownBlock {
|
||||
readonly __component: string;
|
||||
readonly id: number;
|
||||
}
|
||||
|
||||
export type CmsBlock =
|
||||
| CmsImageGalleryBlock
|
||||
| CmsRichTextBlock
|
||||
| CmsUnknownBlock;
|
||||
|
||||
export function isImageGalleryBlock(
|
||||
block: CmsBlock,
|
||||
): block is CmsImageGalleryBlock {
|
||||
return block.__component === "blocks.image-gallery";
|
||||
}
|
||||
|
||||
export function isRichTextBlock(block: CmsBlock): block is CmsRichTextBlock {
|
||||
return block.__component === "blocks.rich-text";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
SINGLE TYPES
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** api::home-page.home-page */
|
||||
export interface CmsHomePage extends StrapiEntity {
|
||||
readonly hero?: CmsHero | null;
|
||||
readonly stats?: readonly CmsStat[] | null;
|
||||
readonly blocks?: readonly CmsBlock[] | null;
|
||||
readonly partnersHeadline?: string | null;
|
||||
readonly partners?: readonly CmsPartner[] | null;
|
||||
readonly seo?: CmsSeo | null;
|
||||
}
|
||||
|
||||
/** api::about-page.about-page */
|
||||
export interface CmsAboutPage extends StrapiEntity {
|
||||
readonly hero?: CmsHero | null;
|
||||
readonly body?: string | null;
|
||||
readonly blocks?: readonly CmsBlock[] | null;
|
||||
readonly consortium?: readonly CmsConsortiumMember[] | null;
|
||||
readonly pillars?: readonly CmsTitledText[] | null;
|
||||
readonly legacyHeadline?: string | null;
|
||||
readonly legacyBody?: string | null;
|
||||
readonly legacyLink?: CmsLink | null;
|
||||
readonly seo?: CmsSeo | null;
|
||||
}
|
||||
|
||||
/** api::what-we-do-page.what-we-do-page */
|
||||
export interface CmsWhatWeDoPage extends StrapiEntity {
|
||||
readonly hero?: CmsHero | null;
|
||||
readonly intro?: string | null;
|
||||
readonly workstreams?: readonly CmsWorkstream[] | null;
|
||||
readonly principles?: readonly CmsTitledText[] | null;
|
||||
/** A JSON field: the CMS authors it as a plain array of strings. */
|
||||
readonly guidingValues?: unknown;
|
||||
readonly blocks?: readonly CmsBlock[] | null;
|
||||
readonly seo?: CmsSeo | null;
|
||||
}
|
||||
|
||||
/** api::contact-page.contact-page */
|
||||
export interface CmsContactPage extends StrapiEntity {
|
||||
readonly hero?: CmsHero | null;
|
||||
readonly intro?: string | null;
|
||||
readonly generalEmail?: string | null;
|
||||
readonly websiteLabel?: string | null;
|
||||
readonly socialLinks?: readonly CmsLink[] | null;
|
||||
readonly consortiumContacts?: readonly CmsAddress[] | null;
|
||||
readonly seo?: CmsSeo | null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* ============================================================================
|
||||
CMS ACCESS - server only
|
||||
----------------------------------------------------------------------------
|
||||
`.server.ts`, so the bundler keeps this (and the Strapi token behind it) out
|
||||
of the browser build entirely. Call it from a `loader` / `action`.
|
||||
|
||||
Everything here is deliberately forgiving. The CMS is empty today - and in
|
||||
development it is often not running at all - so a page must render its
|
||||
approved draft copy rather than a 500 when Strapi answers 404, times out or
|
||||
refuses the connection. `loadSingle` / `loadMany` therefore turn any
|
||||
transport- or Strapi-level failure into `null` / `[]` and log it once,
|
||||
server-side. A genuinely missing *entity* on a $slug route is a different
|
||||
thing: use `requireEntity()` from ~/lib/strapi for that, so the root
|
||||
ErrorBoundary can render the localised 404.
|
||||
========================================================================= */
|
||||
|
||||
import {
|
||||
getMany,
|
||||
getSingle,
|
||||
isStrapiError,
|
||||
mediaAlt,
|
||||
mediaFormatUrl,
|
||||
type StrapiMedia,
|
||||
type StrapiQuery,
|
||||
} from "~/lib/strapi";
|
||||
|
||||
import type { CmsPartner } from "./cms-types";
|
||||
|
||||
/** A picture, resolved to absolute URLs at load time. */
|
||||
export interface ResolvedImage {
|
||||
readonly src: string;
|
||||
readonly alt: string;
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
}
|
||||
|
||||
/** A partner, flattened to exactly what the partner grid renders. */
|
||||
export interface ResolvedPartner {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly website?: string;
|
||||
readonly logo?: ResolvedImage;
|
||||
}
|
||||
|
||||
function warn(resource: string, error: unknown): void {
|
||||
const reason = isStrapiError(error)
|
||||
? `${error.status || "network"} ${error.message}`
|
||||
: String(error);
|
||||
console.warn(`[cms] "${resource}" is unavailable - rendering fallback copy (${reason})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of a single type, or `null` when the CMS has none / is unreachable.
|
||||
*/
|
||||
export async function loadSingle<T>(
|
||||
resource: string,
|
||||
query?: StrapiQuery,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await getSingle<T>(resource, query);
|
||||
} catch (error) {
|
||||
warn(resource, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A collection page, or `[]` when the CMS has none / is unreachable. */
|
||||
export async function loadMany<T>(
|
||||
resource: string,
|
||||
query?: StrapiQuery,
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const { data } = await getMany<T>(resource, query);
|
||||
return data;
|
||||
} catch (error) {
|
||||
warn(resource, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Strapi media entity as a plain, absolute-URL image.
|
||||
*
|
||||
* `format` picks the responsive derivative Strapi generated; it silently falls
|
||||
* back to the original when that derivative does not exist (small uploads).
|
||||
* `alt` comes from the CMS and stays "" when the editor left it empty, which is
|
||||
* the correct markup for a decorative image.
|
||||
*/
|
||||
export function toImage(
|
||||
media: StrapiMedia | null | undefined,
|
||||
format: "thumbnail" | "small" | "medium" | "large" = "large",
|
||||
): ResolvedImage | undefined {
|
||||
if (!media) return undefined;
|
||||
|
||||
const src = mediaFormatUrl(media, format);
|
||||
if (!src) return undefined;
|
||||
|
||||
return {
|
||||
src,
|
||||
alt: mediaAlt(media),
|
||||
...(media.width ? { width: media.width } : {}),
|
||||
...(media.height ? { height: media.height } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Flatten a populated partner relation for the grid. */
|
||||
export function toPartner(partner: CmsPartner): ResolvedPartner {
|
||||
return {
|
||||
id: partner.documentId ?? String(partner.id),
|
||||
name: partner.name,
|
||||
...(partner.website ? { website: partner.website } : {}),
|
||||
...(toImage(partner.logo ?? partner.logoDark, "small")
|
||||
? { logo: toImage(partner.logo ?? partner.logoDark, "small") }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A Strapi `json` field authored as a list of strings ("Dialogue",
|
||||
* "Inclusion", ...). Anything else in the field is ignored rather than thrown
|
||||
* over, because an editor can put arbitrary JSON there.
|
||||
*/
|
||||
export function toStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string" && item.trim() !== "");
|
||||
}
|
||||
|
||||
/** Drop `null`/`undefined` and empty arrays in one expression. */
|
||||
export function list<T>(value: readonly T[] | null | undefined): T[] {
|
||||
return value ? [...value] : [];
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/* ============================================================================
|
||||
APPROVED DRAFT COPY - EN / AR
|
||||
----------------------------------------------------------------------------
|
||||
SOURCE
|
||||
docs/SHARPER_Website_Draft_Section_EN_AR.docx (sections 1, 2 and 9)
|
||||
docs/SHARPER Home Page .docx (the launch story)
|
||||
Both documents carry the English AND the Arabic, so nothing below is a
|
||||
machine translation of the other column except where a comment says so.
|
||||
|
||||
WHY IT LIVES IN THE CODE
|
||||
This is page prose, not UI chrome, so it does not belong in the i18n
|
||||
catalogue (which holds labels only). It is the FALLBACK: every field is
|
||||
overridden by the matching Strapi field the moment an editor fills it in,
|
||||
and the pages read `cms ?? draft`. Until then this is what renders, which
|
||||
is exactly what the draft document is for.
|
||||
|
||||
WHAT IS DELIBERATELY ABSENT
|
||||
Figures. The draft marks every number "TBD" and says they must not be
|
||||
published until confirmed, so there is no statistic anywhere in this file;
|
||||
the home page renders `statsPending` instead, and real figures arrive
|
||||
through the CMS `stats` component.
|
||||
========================================================================= */
|
||||
|
||||
import type { Locale } from "~/i18n";
|
||||
|
||||
/** A title plus its description - a pillar, a workstream, a principle. */
|
||||
export interface TitledCopy {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/** One consortium member and the role the draft assigns it. */
|
||||
export interface ConsortiumCopy {
|
||||
readonly name: string;
|
||||
readonly role: string;
|
||||
}
|
||||
|
||||
export interface HomeCopy {
|
||||
readonly heroHeadline: string;
|
||||
readonly statsHeading: string;
|
||||
/** Rendered in place of the figures while they are unconfirmed. */
|
||||
readonly statsPending: string;
|
||||
readonly launchHeading: string;
|
||||
readonly launchParagraphs: readonly string[];
|
||||
readonly fundingNote: string;
|
||||
readonly mechanismsHeading: string;
|
||||
readonly mechanisms: readonly string[];
|
||||
readonly partnersHeading: string;
|
||||
readonly partnersEmpty: string;
|
||||
}
|
||||
|
||||
export interface AboutCopy {
|
||||
readonly heroHeadline: string;
|
||||
readonly lead: string;
|
||||
readonly body: string;
|
||||
readonly consortiumHeading: string;
|
||||
readonly consortium: readonly ConsortiumCopy[];
|
||||
readonly pillarsHeading: string;
|
||||
readonly pillars: readonly TitledCopy[];
|
||||
readonly legacyHeading: string;
|
||||
readonly legacyBody: string;
|
||||
readonly legacyLinkLabel: string;
|
||||
readonly fundingNote: string;
|
||||
}
|
||||
|
||||
export interface WhatWeDoCopy {
|
||||
readonly heroHeadline: string;
|
||||
readonly lead: string;
|
||||
readonly workstreamsHeading: string;
|
||||
readonly workstreamsIntro: string;
|
||||
readonly workstreams: readonly TitledCopy[];
|
||||
readonly valuesLabel: string;
|
||||
readonly values: readonly string[];
|
||||
readonly principlesHeading: string;
|
||||
readonly principles: string;
|
||||
}
|
||||
|
||||
export interface ContactCopy {
|
||||
readonly heroHeadline: string;
|
||||
readonly lead: string;
|
||||
readonly formHeading: string;
|
||||
readonly formIntro: string;
|
||||
readonly detailsHeading: string;
|
||||
readonly generalEnquiries: string;
|
||||
readonly websiteLabel: string;
|
||||
readonly websiteHref: string;
|
||||
readonly websiteText: string;
|
||||
readonly contactPending: string;
|
||||
readonly consortiumHeading: string;
|
||||
readonly consortiumEmpty: string;
|
||||
readonly sendAnother: string;
|
||||
readonly errorSummaryTitle: string;
|
||||
readonly errorTooLong: string;
|
||||
readonly honeypotLabel: string;
|
||||
readonly organisationHint: string;
|
||||
}
|
||||
|
||||
export interface PageCopy {
|
||||
readonly home: HomeCopy;
|
||||
readonly about: AboutCopy;
|
||||
readonly whatWeDo: WhatWeDoCopy;
|
||||
readonly contact: ContactCopy;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
ENGLISH
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
const EN: PageCopy = {
|
||||
home: {
|
||||
heroHeadline: "SHARP-ER Project Officially Launches in Libya",
|
||||
statsHeading: "SHARP-ER in numbers",
|
||||
statsPending:
|
||||
"The project’s figures are still being confirmed in the official fact sheet. They will be published here once verified.",
|
||||
launchHeading: "The Journey of the SHARP-ER Launch",
|
||||
launchParagraphs: [
|
||||
"On Tuesday, June 30, the SHARP-ER project officially launched, bringing together representatives from the European Union Delegation, the Ministry of Youth, the Ministry of Local Governance, the Ministry of Women’s Affairs, civil society organizations, partners, and stakeholders from across Libya.",
|
||||
"The event marked an important start for the project, including the launch of the SHARP-ER website and the opening of the Expression of Interest (EOI). Participants also learned more about the project’s objectives, upcoming opportunities, and activities focused on strengthening collaboration, partnerships, and local capacities.",
|
||||
"Thank you to everyone who joined us and contributed to this milestone. We look forward to working together to support locally driven initiatives and create meaningful, lasting impact across Libya.",
|
||||
],
|
||||
fundingNote: "This project is funded by the European Union in Libya.",
|
||||
mechanismsHeading: "What the launch set in motion",
|
||||
mechanisms: [
|
||||
"Building local capacity and skills",
|
||||
"Fostering cross-sector partnerships",
|
||||
"Supporting municipal and community-led initiatives",
|
||||
],
|
||||
partnersHeading: "Meet Our Partners",
|
||||
partnersEmpty:
|
||||
"Partner organisations will be listed here as they join the SHARP-ER network.",
|
||||
},
|
||||
|
||||
about: {
|
||||
heroHeadline: "Strengthening the Rule of Law, Together",
|
||||
lead: "SHARP-ER (Shared Action on Rule of Law Progress – Exit Route) is a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
|
||||
body: "Running from December 2025 to December 2027, SHARP-ER is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union. The project works nationwide across Libya, partnering with civil society organisations, municipalities, and community leaders to strengthen access to justice, build the capacity of civil society to hold institutions accountable, and improve local governance and service delivery.",
|
||||
consortiumHeading: "The consortium",
|
||||
consortium: [
|
||||
{
|
||||
name: "Acted",
|
||||
role: "Lead implementing partner — overall project management and civil society sub-projects",
|
||||
},
|
||||
{
|
||||
name: "United Efforts Consultancy (UEC)",
|
||||
role: "Network strengthening, dialogue, and peer-learning",
|
||||
},
|
||||
{
|
||||
name: "Stichting Bureau Ensemble (SBE)",
|
||||
role: "Research, perception data, and knowledge publications",
|
||||
},
|
||||
],
|
||||
pillarsHeading: "Where we focus",
|
||||
pillars: [
|
||||
{
|
||||
title: "Strengthening Rule of Law",
|
||||
description:
|
||||
"Supporting local institutions to improve access to justice and transparency",
|
||||
},
|
||||
{
|
||||
title: "Empowering Civil Society",
|
||||
description:
|
||||
"Building the capacity of CSOs to strengthen their role in oversight and accountability",
|
||||
},
|
||||
{
|
||||
title: "Supporting Local Governance",
|
||||
description:
|
||||
"Enhancing collaboration between municipalities and local actors to improve service delivery",
|
||||
},
|
||||
],
|
||||
legacyHeading: "Building on SHARP I and II",
|
||||
legacyBody:
|
||||
"SHARP-ER builds on the foundation of SHARP I and II (2019–2024), carrying its network of civil society partners and its dialogue practice into a new phase focused on sustainability and exit.",
|
||||
legacyLinkLabel: "Read about the SHARP legacy",
|
||||
fundingNote: "Funded by the European Union.",
|
||||
},
|
||||
|
||||
whatWeDo: {
|
||||
heroHeadline: "A Participatory Approach to Justice and Governance",
|
||||
lead: "We work directly with civil society, municipalities, and communities — not around them.",
|
||||
workstreamsHeading: "Our areas of work",
|
||||
workstreamsIntro: "SHARP-ER is organised around four connected areas of work:",
|
||||
workstreams: [
|
||||
{
|
||||
title: "Capacity Building",
|
||||
description:
|
||||
"Training civil society organisations and local actors on governance, justice, and management, and equipping the SHARP network with peer-to-peer learning.",
|
||||
},
|
||||
{
|
||||
title: "Institutional Support",
|
||||
description:
|
||||
"Strengthening the systems, procedures, and dialogue channels that connect local institutions with the people they serve.",
|
||||
},
|
||||
{
|
||||
title: "Community Initiatives",
|
||||
description:
|
||||
"Funding and supporting CSO-led sub-projects, Policy-to-Rights (P2R) projects, and human-security initiatives designed and driven by local actors.",
|
||||
},
|
||||
{
|
||||
title: "Dialogue and Coordination",
|
||||
description:
|
||||
"Running SUQ (Search, Understand, Question) dialogues and Civil Society Law Sessions that bring civic and government actors to the same table.",
|
||||
},
|
||||
],
|
||||
valuesLabel: "What guides us",
|
||||
values: [
|
||||
"Dialogue",
|
||||
"Community Leadership",
|
||||
"Learning",
|
||||
"Inclusion",
|
||||
"Responsibility & Accountability",
|
||||
],
|
||||
principlesHeading: "How we work",
|
||||
principles:
|
||||
"Our approach rests on four principles: working with local actors, promoting sustainable — not one-off — solutions, connecting civil society with institutions, and supporting gradual, realistic institutional reform.",
|
||||
},
|
||||
|
||||
contact: {
|
||||
heroHeadline: "Get in Touch",
|
||||
lead: "Questions about SHARP-ER, a partnership, or a specific activity? Reach out.",
|
||||
formHeading: "Send us a message",
|
||||
formIntro:
|
||||
"Fill in the form and the SHARP-ER team will come back to you by email.",
|
||||
detailsHeading: "Contact details",
|
||||
generalEnquiries: "General enquiries",
|
||||
websiteLabel: "Website",
|
||||
websiteHref: "https://sharper.ly",
|
||||
websiteText: "sharper.ly",
|
||||
contactPending:
|
||||
"The project’s general enquiries address will be published here once it is confirmed. In the meantime, use the form and your message will reach the team.",
|
||||
consortiumHeading: "Consortium contacts",
|
||||
consortiumEmpty:
|
||||
"Contact details for Acted Libya, United Efforts Consultancy and Stichting Bureau Ensemble will be published here once they are confirmed.",
|
||||
sendAnother: "Send another message",
|
||||
errorSummaryTitle: "Please check the form",
|
||||
errorTooLong: "This answer is too long.",
|
||||
honeypotLabel: "Leave this field empty",
|
||||
organisationHint: "The organisation you are writing on behalf of, if any.",
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
ARABIC (MSA)
|
||||
The prose below is the Arabic column of the same two documents, verbatim.
|
||||
The few strings the documents do not provide - section headings such as
|
||||
"شركاؤنا", and the three launch captions, which exist only in the English
|
||||
Figma frame - are translated from the approved English and stay inside the
|
||||
vocabulary the Arabic draft already uses.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
const AR: PageCopy = {
|
||||
home: {
|
||||
heroHeadline: "انطلاق مشروع SHARP-ER رسميًا في ليبيا",
|
||||
statsHeading: "SHARP-ER بالأرقام",
|
||||
statsPending:
|
||||
"لا تزال أرقام المشروع قيد التأكيد في ورقة الحقائق الرسمية، وسيتم نشرها هنا فور اعتمادها.",
|
||||
launchHeading: "مسيرة إطلاق مشروع SHARP-ER",
|
||||
launchParagraphs: [
|
||||
"انطلقت يوم الثلاثاء 30 يونيو رسميًا فعاليات مشروع SHARP-ER خلال حدث الإطلاق الذي جمع ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، إلى جانب منظمات المجتمع المدني والشركاء والجهات الفاعلة من مختلف أنحاء ليبيا.",
|
||||
"وشكّل الحدث بداية مهمة للمشروع، حيث تم إطلاق الموقع الإلكتروني الرسمي، والإعلان عن فتح باب التعبير عن الاهتمام (EOI)، إلى جانب استعراض أهداف المشروع والفرص والأنشطة القادمة. وتركّز هذه الأنشطة على تعزيز التعاون والشراكات وبناء القدرات على المستوى المحلي.",
|
||||
"نتوجه بالشكر إلى جميع المشاركين على حضورهم ومساهماتهم القيّمة. ونتطلع إلى العمل معًا خلال المرحلة المقبلة لدعم المبادرات المحلية وتحقيق أثر مستدام في مختلف أنحاء ليبيا.",
|
||||
],
|
||||
fundingNote: "المشروع ممول من الاتحاد الأوروبي في ليبيا.",
|
||||
mechanismsHeading: "ما الذي أطلقه هذا الحدث",
|
||||
mechanisms: [
|
||||
"بناء القدرات والمهارات المحلية",
|
||||
"تعزيز الشراكات بين القطاعات",
|
||||
"دعم المبادرات البلدية والمبادرات المجتمعية",
|
||||
],
|
||||
partnersHeading: "تعرّف على شركائنا",
|
||||
partnersEmpty:
|
||||
"ستُدرج المنظمات الشريكة هنا فور انضمامها إلى شبكة SHARP-ER.",
|
||||
},
|
||||
|
||||
about: {
|
||||
heroHeadline: "نحو تعزيز سيادة القانون، معًا",
|
||||
lead: "SHARP-ER (العمل المشترك لتعزيز سيادة القانون – مسار الخروج) هو مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
|
||||
body: "يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027، ويُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي. يعمل المشروع على مستوى ليبيا بأكملها، بالتعاون مع منظمات المجتمع المدني والبلديات والقيادات المجتمعية، من أجل تحسين الوصول إلى العدالة، وبناء قدرات المجتمع المدني على مساءلة المؤسسات، وتحسين الحوكمة المحلية وتقديم الخدمات.",
|
||||
consortiumHeading: "الائتلاف المنفّذ",
|
||||
consortium: [
|
||||
{
|
||||
name: "Acted",
|
||||
role: "الشريك المنفّذ الرئيسي — الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني",
|
||||
},
|
||||
{
|
||||
name: "United Efforts Consultancy (UEC)",
|
||||
role: "تعزيز الشبكة، والحوار، والتعلّم بين الأقران",
|
||||
},
|
||||
{
|
||||
name: "Stichting Bureau Ensemble (SBE)",
|
||||
role: "البحث، وبيانات الاستطلاع، والمنشورات المعرفية",
|
||||
},
|
||||
],
|
||||
pillarsHeading: "محاور تركيزنا",
|
||||
pillars: [
|
||||
{
|
||||
title: "تعزيز سيادة القانون",
|
||||
description:
|
||||
"دعم المؤسسات المحلية لتحسين الوصول إلى العدالة وتعزيز الشفافية",
|
||||
},
|
||||
{
|
||||
title: "تمكين المجتمع المدني",
|
||||
description:
|
||||
"بناء قدرات منظمات المجتمع المدني لتعزيز دورها في الرقابة والمساءلة",
|
||||
},
|
||||
{
|
||||
title: "دعم الحوكمة المحلية",
|
||||
description:
|
||||
"تعزيز التعاون بين البلديات والجهات الفاعلة المحلية لتحسين تقديم الخدمات",
|
||||
},
|
||||
],
|
||||
legacyHeading: "استكمالًا لمشروعي SHARP الأول والثاني",
|
||||
legacyBody:
|
||||
"يبني SHARP-ER على الأساس الذي أرساه مشروعا SHARP الأول والثاني (2019–2024)، حاملًا معه شبكة الشركاء من المجتمع المدني وممارسة الحوار إلى مرحلة جديدة تركّز على الاستدامة ومسار الخروج.",
|
||||
legacyLinkLabel: "اطّلع على إرث مشروع SHARP",
|
||||
fundingNote: "بتمويل من الاتحاد الأوروبي.",
|
||||
},
|
||||
|
||||
whatWeDo: {
|
||||
heroHeadline: "نهج تشاركي في العدالة والحوكمة",
|
||||
lead: "نعمل مباشرة مع المجتمع المدني والبلديات والمجتمعات المحلية، لا من حولها.",
|
||||
workstreamsHeading: "محاور عملنا",
|
||||
workstreamsIntro: "يقوم عمل SHARP-ER على أربعة محاور مترابطة:",
|
||||
workstreams: [
|
||||
{
|
||||
title: "بناء القدرات",
|
||||
description:
|
||||
"تدريب منظمات المجتمع المدني والجهات المحلية على الحوكمة والعدالة والإدارة، وتزويد شبكة SHARP بالتعلّم بين الأقران.",
|
||||
},
|
||||
{
|
||||
title: "الدعم المؤسسي",
|
||||
description:
|
||||
"تعزيز الأنظمة والإجراءات وقنوات الحوار التي تربط المؤسسات المحلية بالمجتمعات التي تخدمها.",
|
||||
},
|
||||
{
|
||||
title: "المبادرات المجتمعية",
|
||||
description:
|
||||
"تمويل ودعم مشاريع فرعية تقودها منظمات المجتمع المدني، ومشاريع “من السياسة إلى الحقوق” (P2R)، ومبادرات الأمن البشري التي يصممها الفاعلون المحليون ويقودونها.",
|
||||
},
|
||||
{
|
||||
title: "الحوار والتنسيق",
|
||||
description:
|
||||
"تنظيم حوارات SUQ (ابحث، افهم، اسأل) وجلسات قانون المجتمع المدني التي تجمع الفاعلين المدنيين والحكوميين حول طاولة واحدة.",
|
||||
},
|
||||
],
|
||||
valuesLabel: "ما يوجّه عملنا",
|
||||
values: [
|
||||
"الحوار",
|
||||
"القيادة المجتمعية",
|
||||
"التعلّم",
|
||||
"الشمول",
|
||||
"المساءلة والمسؤولية",
|
||||
],
|
||||
principlesHeading: "كيف نعمل",
|
||||
principles:
|
||||
"يسترشد عملنا بأربعة مبادئ: العمل مع الفاعلين المحليين، وتعزيز الحلول المستدامة لا الآنية، والربط بين المجتمع المدني والمؤسسات، ودعم إصلاح مؤسسي تدريجي وواقعي.",
|
||||
},
|
||||
|
||||
contact: {
|
||||
heroHeadline: "تواصل معنا",
|
||||
lead: "لديك استفسار حول SHARP-ER، أو شراكة محتملة، أو نشاط معيّن؟ راسلنا.",
|
||||
formHeading: "أرسل لنا رسالة",
|
||||
formIntro: "املأ النموذج وسيعاود فريق SHARP-ER التواصل معك عبر البريد الإلكتروني.",
|
||||
detailsHeading: "بيانات التواصل",
|
||||
generalEnquiries: "للاستفسارات العامة",
|
||||
websiteLabel: "الموقع الإلكتروني",
|
||||
websiteHref: "https://sharper.ly",
|
||||
websiteText: "sharper.ly",
|
||||
contactPending:
|
||||
"سيُنشر هنا البريد الإلكتروني العام للمشروع فور تأكيده. وفي غضون ذلك، استخدم النموذج وستصل رسالتك إلى الفريق.",
|
||||
consortiumHeading: "جهات اتصال الائتلاف",
|
||||
consortiumEmpty:
|
||||
"ستُنشر هنا بيانات التواصل الخاصة بـ Acted ليبيا، وUnited Efforts Consultancy، وStichting Bureau Ensemble فور تأكيدها.",
|
||||
sendAnother: "إرسال رسالة أخرى",
|
||||
errorSummaryTitle: "يرجى مراجعة النموذج",
|
||||
errorTooLong: "هذه الإجابة طويلة جدًا.",
|
||||
honeypotLabel: "اترك هذا الحقل فارغًا",
|
||||
organisationHint: "المنظمة التي تراسلنا نيابةً عنها، إن وُجدت.",
|
||||
},
|
||||
};
|
||||
|
||||
const COPY: Readonly<Record<Locale, PageCopy>> = { en: EN, ar: AR };
|
||||
|
||||
/** The draft copy for a locale. Always defined for both published locales. */
|
||||
export function pageCopy(locale: Locale): PageCopy {
|
||||
return COPY[locale] ?? EN;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* ============================================================================
|
||||
ROUTE LOCALE
|
||||
----------------------------------------------------------------------------
|
||||
`:lang` is already validated by the locale layout - an unknown segment 404s
|
||||
before a page loader ever runs - so this only narrows `string` to `Locale`
|
||||
for the type system, with the default locale as an unreachable fallback.
|
||||
========================================================================= */
|
||||
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n";
|
||||
|
||||
export function routeLocale(params: { readonly lang?: string }): Locale {
|
||||
return isLocale(params.lang) ? params.lang : DEFAULT_LOCALE;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/* ============================================================================
|
||||
RICH TEXT
|
||||
----------------------------------------------------------------------------
|
||||
Strapi's `richtext` fields are Markdown. This renders the subset the editors
|
||||
actually use - headings, paragraphs, bullet and numbered lists, blockquotes,
|
||||
plus inline bold / italic / code / links - by BUILDING REACT ELEMENTS.
|
||||
|
||||
There is deliberately no `dangerouslySetInnerHTML` and no HTML parser here:
|
||||
CMS prose is data, and any raw markup inside it renders as visible text
|
||||
rather than executing. That also means no markdown dependency to install.
|
||||
|
||||
Link targets are the one exception to "prose is inert": an `href` executes
|
||||
if its scheme says so, so every `[label](url)` target goes through
|
||||
`safeHref` and a target that is not http/https/mailto/tel or an in-site
|
||||
path renders as plain text instead of a link.
|
||||
|
||||
Anything the CMS returns that this does not understand still reaches the
|
||||
reader - it falls through to a paragraph - so no copy is ever silently lost.
|
||||
========================================================================= */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { safeHref } from "~/lib/safe-href";
|
||||
|
||||
import { cx } from "./sections";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
INLINE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** `**bold**`, `*italic*`, `` `code` ``, `[label](/href)`. */
|
||||
const INLINE_PATTERN =
|
||||
/(\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\))/g;
|
||||
|
||||
const LINK_PATTERN = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/;
|
||||
|
||||
/**
|
||||
* Presentation only - it decides `<a>` vs `<Link>`, NOT whether the target is
|
||||
* allowed. `safeHref` has already made that call by the time this is read.
|
||||
*/
|
||||
const LEAVES_THE_ROUTER = /^(?:[a-z][a-z\d+\-.]*:|\/\/|#)/i;
|
||||
|
||||
function renderInline(text: string, keyPrefix: string): ReactNode[] {
|
||||
return text
|
||||
.split(INLINE_PATTERN)
|
||||
.filter((part) => part !== "" && part !== undefined)
|
||||
.map((part, index) => {
|
||||
const key = `${keyPrefix}-${index}`;
|
||||
|
||||
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
|
||||
return <strong key={key}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
|
||||
return <strong key={key}>{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
|
||||
return <em key={key}>{part.slice(1, -1)}</em>;
|
||||
}
|
||||
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
|
||||
return (
|
||||
<code
|
||||
key={key}
|
||||
className="rounded-control bg-[var(--surface-sunken)] px-2xs py-px body-sm"
|
||||
>
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const link = LINK_PATTERN.exec(part);
|
||||
if (link) {
|
||||
const [, label, target] = link as unknown as [string, string, string];
|
||||
const className =
|
||||
"text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]";
|
||||
|
||||
const href = safeHref(target);
|
||||
// A target this site refuses to navigate to is not silently dropped:
|
||||
// the reader still gets the words the editor wrote.
|
||||
if (href === undefined) return label;
|
||||
|
||||
return LEAVES_THE_ROUTER.test(href) ? (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
className={className}
|
||||
{...(href.startsWith("#")
|
||||
? {}
|
||||
: { target: "_blank", rel: "noopener noreferrer" })}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={key} to={href} prefetch="intent" className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
BLOCKS
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
type Block =
|
||||
| { kind: "heading"; level: 2 | 3 | 4; text: string }
|
||||
| { kind: "paragraph"; text: string }
|
||||
| { kind: "quote"; text: string }
|
||||
| { kind: "list"; ordered: boolean; items: string[] };
|
||||
|
||||
const HEADING = /^(#{1,6})\s+(.*)$/;
|
||||
const BULLET = /^[-*+]\s+(.*)$/;
|
||||
const NUMBERED = /^\d+[.)]\s+(.*)$/;
|
||||
const QUOTE = /^>\s?(.*)$/;
|
||||
|
||||
/** Group the source's lines into renderable blocks. */
|
||||
export function parseMarkdown(source: string): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let quote: string[] = [];
|
||||
let listItems: string[] = [];
|
||||
let listOrdered = false;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length > 0) {
|
||||
blocks.push({ kind: "paragraph", text: paragraph.join(" ") });
|
||||
paragraph = [];
|
||||
}
|
||||
};
|
||||
const flushQuote = () => {
|
||||
if (quote.length > 0) {
|
||||
blocks.push({ kind: "quote", text: quote.join(" ") });
|
||||
quote = [];
|
||||
}
|
||||
};
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
blocks.push({ kind: "list", ordered: listOrdered, items: listItems });
|
||||
listItems = [];
|
||||
}
|
||||
};
|
||||
const flushAll = () => {
|
||||
flushParagraph();
|
||||
flushQuote();
|
||||
flushList();
|
||||
};
|
||||
|
||||
for (const rawLine of source.replace(/\r\n?/g, "\n").split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line === "") {
|
||||
flushAll();
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line);
|
||||
if (heading) {
|
||||
flushAll();
|
||||
// The page owns h1, so CMS headings start at h2 and never go past h4.
|
||||
const level = Math.min(Math.max(heading[1]!.length + 1, 2), 4) as 2 | 3 | 4;
|
||||
blocks.push({ kind: "heading", level, text: heading[2]! });
|
||||
continue;
|
||||
}
|
||||
|
||||
const quoted = QUOTE.exec(line);
|
||||
if (quoted) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
quote.push(quoted[1]!);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = BULLET.exec(line);
|
||||
const numbered = NUMBERED.exec(line);
|
||||
if (bullet ?? numbered) {
|
||||
flushParagraph();
|
||||
flushQuote();
|
||||
const ordered = numbered !== null;
|
||||
if (listItems.length > 0 && ordered !== listOrdered) flushList();
|
||||
listOrdered = ordered;
|
||||
listItems.push((numbered?.[1] ?? bullet?.[1])!);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushQuote();
|
||||
flushList();
|
||||
paragraph.push(line);
|
||||
}
|
||||
|
||||
flushAll();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export interface RichTextProps {
|
||||
/** Markdown from a Strapi `richtext` field. */
|
||||
markdown?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Renders a CMS markdown field with the site's type roles and tokens. */
|
||||
export function RichText({ markdown, className }: RichTextProps) {
|
||||
const source = markdown?.trim();
|
||||
if (!source) return null;
|
||||
|
||||
const blocks = parseMarkdown(source);
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cx("flex flex-col gap-md text-start", className)}>
|
||||
{blocks.map((block, index) => {
|
||||
const key = `block-${index}`;
|
||||
|
||||
switch (block.kind) {
|
||||
case "heading": {
|
||||
const Heading = `h${block.level}` as "h2" | "h3" | "h4";
|
||||
return (
|
||||
<Heading
|
||||
key={key}
|
||||
className={cx(
|
||||
"text-[var(--text-primary)]",
|
||||
block.level === 2 ? "heading-h2" : block.level === 3 ? "heading-h3" : "body-lg-bold",
|
||||
index > 0 && "mt-lg",
|
||||
)}
|
||||
>
|
||||
{renderInline(block.text, key)}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote
|
||||
key={key}
|
||||
className="border-s-2 border-[var(--border-accent)] ps-lg body-lg text-[var(--text-secondary)]"
|
||||
>
|
||||
{renderInline(block.text, key)}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
case "list": {
|
||||
const List = block.ordered ? "ol" : "ul";
|
||||
return (
|
||||
<List
|
||||
key={key}
|
||||
className={cx(
|
||||
"flex list-outside flex-col gap-xs ps-lg body-md text-[var(--text-secondary)]",
|
||||
block.ordered ? "list-decimal" : "list-disc",
|
||||
)}
|
||||
>
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<li key={`${key}-${itemIndex}`} className="ps-2xs">
|
||||
{renderInline(item, `${key}-${itemIndex}`)}
|
||||
</li>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<p key={key} className="body-md text-[var(--text-secondary)]">
|
||||
{renderInline(block.text, key)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/* ============================================================================
|
||||
PAGE KIT - section primitives
|
||||
----------------------------------------------------------------------------
|
||||
The handful of layout pieces every page module repeats: the centred content
|
||||
column, a vertical rhythm for sections, a section header, the page hero and
|
||||
the empty state that renders while the CMS has no entry yet.
|
||||
|
||||
RULES THIS FILE FOLLOWS
|
||||
-----------------------
|
||||
* tier-2 semantic tokens only - no hex, no tier-1 primitive, no raw type
|
||||
size (the type role utilities carry family/size/leading/tracking/weight)
|
||||
* logical properties only (ps-/pe-/ms-/me-/start-/end-, text-start) so the
|
||||
Arabic pages mirror without a single `rtl:` override
|
||||
* no component here duplicates Button / Input / Card - those are imported
|
||||
from ~/components/ui by the pages themselves
|
||||
========================================================================= */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LAYOUT
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface ContainerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The centred content column every section shares. */
|
||||
export function Container({ children, className }: ContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"mx-auto w-full max-w-[var(--layout-content-max)] px-[var(--layout-gutter)] lg:px-[var(--layout-gutter-lg)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PageSectionProps {
|
||||
children: ReactNode;
|
||||
id?: string;
|
||||
/** Flip this section (and only this section) to the other surface. */
|
||||
surface?: "dark" | "light";
|
||||
className?: string;
|
||||
/** Content is placed directly in the section, without the centred column. */
|
||||
bleed?: boolean;
|
||||
"aria-labelledby"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One band of the page. `surface` re-points the whole tier-2 palette for the
|
||||
* subtree, so a light band needs no colour classes of its own.
|
||||
*/
|
||||
export function PageSection({
|
||||
children,
|
||||
id,
|
||||
surface,
|
||||
className,
|
||||
bleed = false,
|
||||
...rest
|
||||
}: PageSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
{...(surface ? { "data-surface": surface } : {})}
|
||||
className={cx("py-3xl md:py-4xl", className)}
|
||||
{...rest}
|
||||
>
|
||||
{bleed ? children : <Container>{children}</Container>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A readable measure for long-form prose. */
|
||||
export function Prose({ children, className }: ContainerProps) {
|
||||
return (
|
||||
<div className={cx("flex max-w-[var(--layout-prose-max)] flex-col gap-md", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
HEADINGS
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface SectionHeaderProps {
|
||||
title: string;
|
||||
id?: string;
|
||||
eyebrow?: string;
|
||||
lead?: string;
|
||||
titleAs?: "h1" | "h2" | "h3";
|
||||
/** Figma centres the home page's section headers; inner pages read start. */
|
||||
align?: "start" | "center";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
id,
|
||||
eyebrow,
|
||||
lead,
|
||||
titleAs = "h2",
|
||||
align = "start",
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
const Title = titleAs;
|
||||
const centred = align === "center";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cx(
|
||||
"flex flex-col gap-sm",
|
||||
centred ? "items-center text-center" : "items-start text-start",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
||||
) : null}
|
||||
|
||||
<Title
|
||||
id={id}
|
||||
className={cx(
|
||||
"text-[var(--text-primary)]",
|
||||
titleAs === "h1" ? "heading-h1 md:display-sm" : "heading-h2",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
|
||||
{lead ? (
|
||||
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
|
||||
{lead}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
HERO
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface PageHeroProps {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
lead?: string;
|
||||
/** Buttons, rendered inline-end of the headline on wide viewports. */
|
||||
actions?: ReactNode;
|
||||
/** Wide picture below the headline row, as in Figma 253:4800. */
|
||||
media?: { src: string; alt: string; width?: number; height?: number };
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page hero - Figma "Hero header" (253:4800): headline on the inline-start
|
||||
* side, the call-to-action group on the inline-end side, and a full-width
|
||||
* picture underneath. The picture is only rendered when the CMS supplies one;
|
||||
* an empty media box would otherwise ship as a grey rectangle.
|
||||
*/
|
||||
export function PageHero({
|
||||
title,
|
||||
eyebrow,
|
||||
lead,
|
||||
actions,
|
||||
media,
|
||||
className,
|
||||
}: PageHeroProps) {
|
||||
return (
|
||||
<section className={cx("pt-2xl pb-3xl md:pt-3xl md:pb-4xl", className)}>
|
||||
<Container className="flex flex-col gap-2xl">
|
||||
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="flex max-w-[46rem] flex-col gap-sm">
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
||||
) : null}
|
||||
|
||||
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{lead ? (
|
||||
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
|
||||
{lead}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actions ? (
|
||||
<div className="flex flex-wrap items-center gap-md lg:justify-end">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{media ? (
|
||||
<img
|
||||
src={media.src}
|
||||
alt={media.alt}
|
||||
{...(media.width ? { width: media.width } : {})}
|
||||
{...(media.height ? { height: media.height } : {})}
|
||||
fetchPriority="high"
|
||||
className="aspect-[16/9] w-full max-w-full rounded-surface object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
PANELS
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface InfoPanelProps {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
/** Small line above the title - a number, a partner name, a role. */
|
||||
eyebrow?: string;
|
||||
titleAs?: "h2" | "h3" | "h4";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled block of text with no destination - a pillar, a workstream, a
|
||||
* principle. Deliberately NOT a Card: every card in the design system is a
|
||||
* link, and these are not.
|
||||
*/
|
||||
export function InfoPanel({
|
||||
title,
|
||||
children,
|
||||
eyebrow,
|
||||
titleAs = "h3",
|
||||
className,
|
||||
}: InfoPanelProps) {
|
||||
const Title = titleAs;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
||||
) : null}
|
||||
<Title className="body-lg-bold text-[var(--text-primary)]">{title}</Title>
|
||||
{children ? (
|
||||
<div className="body-md text-[var(--text-secondary)]">{children}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Localised - never an English default. */
|
||||
message: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* What renders where the CMS has nothing yet. It states plainly that the
|
||||
* content is still to come rather than showing an empty grid, and it is always
|
||||
* passed a localised string by the caller.
|
||||
*/
|
||||
export function EmptyState({ message, title, className, children }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-col items-start gap-sm rounded-card border border-dashed border-[var(--border-default)] bg-[var(--surface-sunken)] p-lg text-start",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<p className="body-md-bold text-[var(--text-primary)]">{title}</p>
|
||||
) : null}
|
||||
<p className="body-md text-[var(--text-secondary)]">{message}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
SMALL PIECES
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface ValueChipsProps {
|
||||
values: readonly string[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The "What guides us" row: Dialogue · Community Leadership · Learning · … */
|
||||
export function ValueChips({ values, label, className }: ValueChipsProps) {
|
||||
if (values.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cx("flex flex-col gap-sm", className)}>
|
||||
{label ? (
|
||||
<p className="label-md uppercase text-[var(--text-tertiary)]">{label}</p>
|
||||
) : null}
|
||||
<ul className="flex flex-wrap gap-sm">
|
||||
{values.map((value) => (
|
||||
<li
|
||||
key={value}
|
||||
className="rounded-pill border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] px-md py-2xs body-sm-bold text-[var(--text-primary)]"
|
||||
>
|
||||
{value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StatStripProps {
|
||||
stats: readonly { value: string; label: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The figures band. `value` is authored in the CMS as a string, already
|
||||
* formatted for its locale, because the site must never invent or re-derive a
|
||||
* project figure - see the note the pages render when this list is empty.
|
||||
*/
|
||||
export function StatStrip({ stats, className }: StatStripProps) {
|
||||
if (stats.length === 0) return null;
|
||||
|
||||
return (
|
||||
<dl
|
||||
className={cx(
|
||||
"grid grid-cols-2 gap-lg border-y border-[var(--border-subtle)] py-xl md:grid-cols-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
/* Source order is term-then-description, as the spec requires; the
|
||||
reversed column paints the figure above its label without repeating
|
||||
the label for a screen reader. */
|
||||
<div
|
||||
key={`${stat.value}-${stat.label}`}
|
||||
className="flex flex-col-reverse gap-2xs text-start"
|
||||
>
|
||||
<dt className="body-sm text-[var(--text-secondary)]">{stat.label}</dt>
|
||||
<dd className="heading-h1 text-[var(--text-accent)]">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user