added seeder for legacy apge and added the legacy page

This commit is contained in:
teledrisi
2026-10-05 17:32:06 +02:00
parent a5c531be00
commit 98fae066bc
30 changed files with 1488 additions and 181 deletions
@@ -206,6 +206,46 @@ export interface CmsAboutPage extends StrapiEntity {
readonly seo?: CmsSeo | null;
}
/** page.legacy-metric */
export interface CmsLegacyMetric {
readonly id?: number;
readonly value: string;
readonly label: string;
}
/** page.legacy-focus-area */
export interface CmsLegacyFocusArea {
readonly id?: number;
readonly name: string;
}
/** page.legacy-stage */
export interface CmsLegacyStage {
readonly id?: number;
readonly name?: string | null;
readonly years?: string | null;
readonly summary?: string | null;
readonly description?: string | null;
readonly metrics?: readonly CmsLegacyMetric[] | null;
readonly focusHeading?: string | null;
readonly focusAreas?: readonly CmsLegacyFocusArea[] | null;
readonly galleryHeading?: string | null;
readonly images?: readonly StrapiMedia[] | null;
}
/** api::legacy-page.legacy-page */
export interface CmsLegacyPage extends StrapiEntity {
readonly homeEyebrow?: string | null;
readonly homeHeading?: string | null;
readonly homeLead?: string | null;
readonly hero?: CmsHero | null;
readonly stageOne?: CmsLegacyStage | null;
readonly stageTwo?: CmsLegacyStage | null;
readonly closingHeading?: string | null;
readonly closingBody?: string | null;
readonly seo?: CmsSeo | null;
}
/** api::what-we-do-page.what-we-do-page */
export interface CmsWhatWeDoPage extends StrapiEntity {
readonly hero?: CmsHero | null;
+274 -15
View File
@@ -31,13 +31,10 @@
overridden by the matching Strapi field the moment an editor fills it in,
and the pages read `cms ?? draft`.
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.
Figma hides the home page's figures band outright (frame 253:4835 is
hidden="true") and the third design drop deletes it, so the band is gone
from the page and the `stats` component is gone from the CMS. There is
nowhere left for a figure to be typed in, which is the point.
HISTORICAL FIGURES
The general SHARP-ER home draft marks current-programme figures "TBD";
this file adds only the SHARP I and SHARP II historical figures from the
supplied legacy content.
========================================================================= */
import type { Locale } from "~/i18n";
@@ -104,6 +101,44 @@ export interface HomeCopy {
readonly partnersEmpty: string;
}
export interface LegacyMetric {
readonly value: string;
readonly label: string;
}
export interface LegacyGalleryImage {
readonly src: string;
readonly alt: string;
readonly caption?: string;
}
export interface LegacyStageCopy {
readonly name: string;
readonly years: string;
readonly summary: string;
readonly description: string;
readonly metrics: readonly LegacyMetric[];
readonly focusHeading: string;
readonly focusAreas: readonly string[];
readonly imageHeading: string;
readonly imageEmpty: string;
readonly images: readonly LegacyGalleryImage[];
}
export interface LegacyCopy {
readonly homeEyebrow: string;
readonly homeHeading: string;
readonly homeLead: string;
readonly homeLinkLabel: string;
readonly heroEyebrow: string;
readonly heroTitle: string;
readonly heroLead: string;
readonly stageOne: LegacyStageCopy;
readonly stageTwo: LegacyStageCopy;
readonly closingHeading: string;
readonly closingParagraphs: readonly string[];
}
export interface AboutCopy {
/** Draft eyebrow over the hero headline - "About the project". */
readonly heroEyebrow: string;
@@ -170,8 +205,6 @@ export interface ContactCopy {
readonly websiteHref: string;
readonly websiteText: string;
readonly contactPending: string;
readonly consortiumHeading: string;
readonly consortiumEmpty: string;
readonly sendAnother: string;
readonly errorSummaryTitle: string;
readonly errorTooLong: string;
@@ -184,6 +217,7 @@ export interface ContactCopy {
export interface PageCopy {
readonly home: HomeCopy;
readonly about: AboutCopy;
readonly legacy: LegacyCopy;
readonly whatWeDo: WhatWeDoCopy;
readonly contact: ContactCopy;
}
@@ -241,6 +275,121 @@ const EN: PageCopy = {
"Partner organisations will be listed here as they join the SHARP-ER network.",
},
legacy: {
homeEyebrow: "SHARP I and SHARP II",
homeHeading: "Dialogue and local action in Libya, 2019–2024",
homeLead:
"Two EU-funded programmes supported rule of law dialogue, civil society capacity, and local action across Libya.",
homeLinkLabel: "Explore this phase",
heroEyebrow: "SHARP I and SHARP II",
heroTitle: "Dialogue and local action across Libya",
heroLead:
"From November 2019 to August 2024, two EU-funded programmes supported rule of law dialogue, civil society capacity, and locally led action.",
stageOne: {
name: "SHARP I",
years: "November 2019 – August 2022",
summary:
"Built dialogue and local capacity through research, learning, and community-led action.",
description: `The EU-funded SHARP I programme was implemented by Institute Clingendael and Bureau Ensemble with Libyan civil society organisations, local authorities, and national institutions. From November 2019 to August 2022, it worked to strengthen Libya’s rule of law through dialogue, research, and local action. Budget: €1.996 million.
### Objectives
- Strengthen Libyan institutions’ ability to respond to rule of law challenges.
- Build the organisational capabilities of Libyan change agents.
- Foster dialogue and information-sharing between stakeholders.
### Dialogue and learning
The programme held 102 Search, Understand, and Question (SUQ) dialogues with 1,583 participants; 48% of participants were women. Topics included human rights, transitional justice, and local governance. A further 46 Training & Learning sessions covered facilitation, project management, and monitoring.
### Community action
Twenty-one sub-contract actions addressed conflict mediation, access to justice, women’s rights, labour rights, and civil-society and media engagement. They produced 62 workshops, 6 research papers, 24 training sessions, and 1,555 media outreach activities.
### Results and lasting value
87% of SUQ participants considered the discussions relevant, and 75% believed SHARP could contribute to rule of law change. Projects strengthened CSO reporting skills and partnerships with local authorities. Libyan-led initiatives built community trust; the programme adapted to pandemic and security constraints with virtual activities and local dialogue, helping CSOs develop lasting networks.
### What followed
The EU approved SHARP II to build on these lessons: deepen dialogue, focus on four to six rule of law themes while retaining flexibility, strengthen CSO project design and evaluation, and support durable government–CSO networks.`,
metrics: [
{ value: "102", label: "SUQ dialogues" },
{ value: "1,583", label: "Dialogue participants" },
{ value: "48%", label: "Women participants" },
{ value: "46", label: "Learning sessions" },
{ value: "21", label: "Sub-contract actions" },
{ value: "62", label: "Workshops delivered" },
{ value: "6", label: "Research papers" },
{ value: "24", label: "Training sessions" },
{ value: "1,555", label: "Media outreach activities" },
{ value: "87%", label: "Found dialogues relevant" },
{ value: "75%", label: "Saw potential for change" },
],
focusHeading: "Areas of work",
focusAreas: [
"Human rights",
"Transitional justice",
"Local governance",
"Conflict mediation",
"Access to justice",
"Women’s rights",
"Labour rights",
"Civil society and media",
],
imageHeading: "SHARP I in the field",
imageEmpty: "Dialogue and local action across Libya.",
images: [],
},
stageTwo: {
name: "SHARP II",
years: "September 2022 – August 2024",
summary:
"Expanded a nationwide civil-society network and turned inclusive dialogue into community action.",
description: `Funded by the European Union and implemented by Bureau Ensemble with Libyan partners, SHARP II built on SHARP I to strengthen the rule of law, stability, and participatory governance. From September 2022 to August 2024, the programme had a budget of €1.426 million.
### Priorities
SHARP II aimed to grow a national network of rule of law dialogue groups; build social cohesion and consensus locally, regionally, and nationally; organise inclusive dialogues on rule of law and positive citizenship; and support small community actions.
### Dialogue and network
157 SUQ dialogues engaged 2,554 participants, 44% of them women. Discussions covered access to justice, women’s empowerment, local governance, and the media’s role in the rule of law. The network grew to 64 active CSOs from 27, reaching every region, including marginalised areas in the south.
### Skills and community action
Six Training & Learning sessions covered facilitation, project management, and monitoring. Advanced training reached 100 participants, with 24 certified as trainers. All 34 approved sub-contracts were implemented, addressing legal awareness, reconciliation, women’s rights, gender-based violence, municipal capacity, ethical journalism, and misinformation. Activities reached 1,487 trainees and 1,697 workshop participants; awareness campaigns benefited 2,896 people.
### Policy, communication, and impact
Regional and national dialogues informed recommendations on faster court processes and legal aid, municipal empowerment under Local Governance Law 59, and media transparency and hate speech. Libyan-led monitoring supported adaptive delivery, while 1,555 media outreach activities amplified the work. CSOs led 100% of activities; ministries and municipalities partnered with them, and young people, displaced people, and persons with disabilities took part.
### A sustainable exit
The March–August 2024 exit phase shaped three pillars for continued impact: Access to Justice (legal aid, mediation, and policy reform), TAZIZ (municipal capacity and citizen engagement), and ADWA (ethical journalism and countering misinformation). United Efforts was established and registered as a Libyan legal entity to help sustain the work.`,
metrics: [
{ value: "157", label: "SUQ dialogues" },
{ value: "2,554", label: "Dialogue participants" },
{ value: "64", label: "Active CSOs" },
{ value: "44%", label: "Women participants" },
{ value: "34", label: "Sub-contracts" },
{ value: "100", label: "Advanced training participants" },
{ value: "24", label: "Certified trainers" },
{ value: "1,487", label: "Trainees" },
{ value: "1,697", label: "Workshop participants" },
{ value: "2,896", label: "Awareness campaign beneficiaries" },
{ value: "1,555", label: "Media outreach activities" },
],
focusHeading: "Areas of work",
focusAreas: [
"Access to justice",
"Women’s empowerment",
"Local governance",
"Media and rule of law",
"Social cohesion",
"Positive citizenship",
],
imageHeading: "SHARP II in the field",
imageEmpty: "A nationwide civil-society network in action.",
images: [],
},
closingHeading: "SHARP II’s exit phase",
closingParagraphs: [
"From March to August 2024, the SHARP II exit phase focused on access to justice, local governance through TAZIZ, and media through ADWA.",
"United Efforts was established and registered as a Libyan legal entity. The SHARP II summary identifies a stable nationwide civil society network and a roadmap for future EU–Libya collaboration through SHARP-ER.",
],
},
about: {
heroEyebrow: "About the project",
heroHeadline: "Strengthening the Rule of Law, Together",
@@ -357,9 +506,6 @@ const EN: PageCopy = {
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.",
@@ -423,6 +569,122 @@ const AR: PageCopy = {
"ستُدرج المنظمات الشريكة هنا فور انضمامها إلى شبكة SHARP-ER.",
},
legacy: {
homeEyebrow: "شارب الأول وشارب الثاني",
homeHeading: "الحوار والعمل المحلي في ليبيا، 2019–2024",
homeLead:
"دعم برنامجان ممولان من الاتحاد الأوروبي الحوار حول سيادة القانون، وقدرات المجتمع المدني، والعمل المحلي في مختلف أنحاء ليبيا.",
homeLinkLabel: "اكتشفوا هذه المرحلة",
heroEyebrow: "شارب الأول وشارب الثاني",
heroTitle: "الحوار والعمل المحلي في مختلف أنحاء ليبيا",
heroLead:
"من نوفمبر 2019 إلى أغسطس 2024، دعم برنامجان ممولان من الاتحاد الأوروبي الحوار حول سيادة القانون، وقدرات المجتمع المدني، والعمل بقيادة محلية.",
stageOne: {
name: "شارب الأول",
years: "نوفمبر 2019 – أغسطس 2022",
summary:
"عزّز الحوار والقدرات المحلية من خلال البحث والتعلّم والعمل المجتمعي.",
description: `نفّذ معهد كلينغندايل ومؤسسة Bureau Ensemble برنامج شارب الأول بتمويل من الاتحاد الأوروبي، وبالتعاون مع منظمات المجتمع المدني والسلطات المحلية والمؤسسات الوطنية الليبية. عمل البرنامج من نوفمبر 2019 إلى أغسطس 2022 على دعم سيادة القانون في ليبيا عبر الحوار والبحث والعمل المحلي، بميزانية بلغت 1.996 مليون يورو.
### الأهداف
- تعزيز قدرة المؤسسات الليبية على الاستجابة لتحديات سيادة القانون.
- بناء القدرات التنظيمية للفاعلين الليبيين الساعين إلى التغيير.
- تشجيع الحوار وتبادل المعلومات بين أصحاب المصلحة.
### الحوار والتعلّم
عُقدت 102 جلسة «ابحث، افهم، واسأل» (SUQ)، بمشاركة 1,583 شخصاً، وكانت نسبة النساء 48%. وتناولت الجلسات حقوق الإنسان والعدالة الانتقالية والحوكمة المحلية. كما عُقدت 46 جلسة للتدريب والتعلّم حول التيسير وإدارة المشاريع والرصد.
### العمل المجتمعي
نُفّذ 21 مشروعاً فرعياً تناولت الوساطة في النزاعات والوصول إلى العدالة وحقوق النساء وحقوق العمال ومشاركة المجتمع المدني والإعلام. وأسفرت عن 62 ورشة عمل و6 أوراق بحثية و24 جلسة تدريبية و1,555 نشاطاً للتواصل الإعلامي.
### النتائج والأثر المستدام
رأى 87% من المشاركين أن جلسات الحوار ذات صلة، واعتقد 75% أن شارب قادر على الإسهام في تغيير واقع سيادة القانون. وعززت المشاريع مهارات إعداد التقارير لدى منظمات المجتمع المدني وشراكاتها مع السلطات المحلية. كما دعمت المبادرات بقيادة ليبية الثقة المجتمعية، وتكيّف البرنامج مع قيود الجائحة والأوضاع الأمنية عبر الأنشطة الافتراضية والحوار المحلي، بما ساعد المنظمات على بناء شبكات طويلة الأمد.
### ما تلا ذلك
وافق الاتحاد الأوروبي على شارب الثاني للاستفادة من هذه الخبرات: تعميق الحوار، والتركيز على أربعة إلى ستة موضوعات مع الحفاظ على المرونة، وتعزيز مهارات تصميم المشاريع وتقييمها لدى منظمات المجتمع المدني، ودعم شبكات مستدامة بين الحكومة والمجتمع المدني.`,
metrics: [
{ value: "102", label: "جلسة حوار SUQ" },
{ value: "1,583", label: "مشاركاً في الحوار" },
{ value: "48%", label: "مشاركة النساء" },
{ value: "46", label: "جلسة للتعلّم" },
{ value: "21", label: "عملاً بعقد فرعي" },
{ value: "62", label: "ورشة عمل" },
{ value: "6", label: "أوراق بحثية" },
{ value: "24", label: "جلسة تدريبية" },
{ value: "1,555", label: "نشاطاً للتواصل الإعلامي" },
{ value: "87%", label: "وجدوا الحوارات ذات صلة" },
{ value: "75%", label: "رأوا إمكانية إحداث تغيير" },
],
focusHeading: "مجالات العمل",
focusAreas: [
"حقوق الإنسان",
"العدالة الانتقالية",
"الحوكمة المحلية",
"الوساطة في النزاعات",
"الوصول إلى العدالة",
"حقوق النساء",
"حقوق العمال",
"المجتمع المدني والإعلام",
],
imageHeading: "شارب الأول ميدانياً",
imageEmpty: "حوار وعمل محلي في مختلف أنحاء ليبيا.",
images: [],
},
stageTwo: {
name: "شارب الثاني",
years: "سبتمبر 2022 – أغسطس 2024",
summary:
"وسّع شبكة المجتمع المدني الوطنية وحوّل الحوار الشامل إلى عمل مجتمعي.",
description: `بتمويل من الاتحاد الأوروبي وتنفيذ Bureau Ensemble بالشراكة مع فاعلين ليبيين، بنى شارب الثاني على إنجازات شارب الأول لتعزيز سيادة القانون والاستقرار والحوكمة التشاركية. امتد البرنامج من سبتمبر 2022 إلى أغسطس 2024 بميزانية 1.426 مليون يورو.
### الأولويات
عمل شارب الثاني على توسيع شبكة وطنية لمجموعات الحوار حول سيادة القانون، وتعزيز التماسك الاجتماعي والتوافق محلياً وإقليمياً ووطنياً، وتنظيم حوارات شاملة حول سيادة القانون والمواطنة الإيجابية، ودعم مبادرات مجتمعية صغيرة.
### الحوار وتوسيع الشبكة
عُقدت 157 جلسة حوار SUQ بمشاركة 2,554 شخصاً، وكانت نسبة النساء 44%. وتناولت الحوارات الوصول إلى العدالة وتمكين المرأة والحوكمة المحلية ودور الإعلام في سيادة القانون. ونمت الشبكة إلى 64 منظمة مجتمع مدني ناشطة بعد أن كانت 27، وامتدت إلى جميع المناطق بما فيها المناطق المهمشة في الجنوب.
### بناء القدرات والعمل المجتمعي
عُقدت 6 جلسات للتدريب والتعلّم حول التيسير وإدارة المشاريع والرصد. وشمل التدريب المتقدم 100 مشارك، حصل 24 منهم على اعتماد مدربين. ونُفّذت العقود الفرعية الـ34 المعتمدة، وتناولت التوعية القانونية والمصالحة وحقوق المرأة ومكافحة العنف القائم على النوع الاجتماعي وبناء قدرات البلديات والصحافة الأخلاقية ومواجهة المعلومات المضللة. ووصلت الأنشطة إلى 1,487 متدرباً و1,697 مشاركاً في الورش؛ واستفاد 2,896 شخصاً من حملات التوعية.
### السياسات والتواصل والأثر
أسهمت الحوارات الإقليمية والوطنية في صياغة توصيات لتسريع إجراءات المحاكم وتوفير مكاتب للمساعدة القانونية، وتمكين البلديات بموجب قانون الحكم المحلي رقم 59، ودعم الشفافية الإعلامية ومكافحة خطاب الكراهية. وأسهم الرصد بقيادة ليبية في تكييف التنفيذ، كما عززت 1,555 نشاطاً إعلامياً أثر البرنامج. وقادت منظمات المجتمع المدني 100% من الأنشطة، وتعاونت معها الوزارات والبلديات، وشارك الشباب والنازحون والأشخاص ذوو الإعاقة.
### خطة خروج مستدامة
صممت مرحلة الخروج، من مارس إلى أغسطس 2024، ثلاثة مشاريع لضمان الأثر على المدى الطويل: الوصول إلى العدالة عبر المساعدة القانونية والتدريب على الوساطة والإصلاحات؛ و«تعزيز» (TAZIZ) لبناء قدرات البلديات ومشاركة المواطنين؛ و«أضواء» (ADWA) للصحافة الأخلاقية ومواجهة المعلومات المضللة. كما أُسست وسُجلت «United Efforts» ككيان قانوني ليبي لضمان استمرارية العمل.`,
metrics: [
{ value: "157", label: "جلسة حوار SUQ" },
{ value: "2,554", label: "مشاركاً في الحوار" },
{ value: "64", label: "منظمة مجتمع مدني ناشطة" },
{ value: "44%", label: "مشاركة النساء" },
{ value: "34", label: "عقداً فرعياً" },
{ value: "100", label: "مشاركاً في التدريب المتقدم" },
{ value: "24", label: "مدرباً معتمداً" },
{ value: "1,487", label: "متدرباً" },
{ value: "1,697", label: "مشاركاً في الورش" },
{ value: "2,896", label: "مستفيداً من حملات التوعية" },
{ value: "1,555", label: "نشاطاً للتواصل الإعلامي" },
],
focusHeading: "مجالات العمل",
focusAreas: [
"الوصول إلى العدالة",
"تمكين المرأة",
"الحوكمة المحلية",
"الإعلام وسيادة القانون",
"التماسك الاجتماعي",
"سيادة القانون",
"المواطنة الإيجابية",
],
imageHeading: "شارب الثاني ميدانياً",
imageEmpty: "شبكة وطنية من منظمات المجتمع المدني تتحرك على الأرض.",
images: [],
},
closingHeading: "مرحلة خروج شارب الثاني",
closingParagraphs: [
"من مارس إلى أغسطس 2024، ركزت مرحلة خروج شارب الثاني على الوصول إلى العدالة، والحوكمة المحلية عبر «تعزيز» (TAZIZ)، والإعلام عبر «أضواء» (ADWA).",
"أُسست «United Efforts» وسُجلت ككيان قانوني ليبي. ويشير ملخص شارب الثاني إلى شبكة وطنية مستقرة من منظمات المجتمع المدني وخارطة طريق لتعاون الاتحاد الأوروبي وليبيا مستقبلاً عبر شارب-إي آر.",
],
},
about: {
heroEyebrow: "عن المشروع",
heroHeadline: "نحو تعزيز سيادة القانون، معًا",
@@ -544,9 +806,6 @@ const AR: PageCopy = {
websiteText: "sharper.ly",
contactPending:
"سيُنشر هنا البريد الإلكتروني العام للمشروع فور تأكيده. وفي غضون ذلك، استخدم النموذج وستصل رسالتك إلى الفريق.",
consortiumHeading: "جهات اتصال الائتلاف",
consortiumEmpty:
"ستُنشر هنا بيانات التواصل الخاصة بـ Acted ليبيا، وUnited Efforts Consultancy، وStichting Bureau Ensemble فور تأكيدها.",
sendAnother: "إرسال رسالة أخرى",
errorSummaryTitle: "يرجى مراجعة النموذج",
errorTooLong: "هذه الإجابة طويلة جدًا.",
@@ -0,0 +1,45 @@
/* Server-side mapping from the editable Strapi phase component to page copy. */
import type { CmsLegacyStage } from "./cms-types";
import { list, toImage } from "./cms.server";
import type { LegacyGalleryImage, LegacyStageCopy } from "./copy";
const textOr = (value: string | null | undefined, fallback: string): string =>
value?.trim() ? value : fallback;
export function resolveLegacyStage(
source: CmsLegacyStage | null | undefined,
fallback: LegacyStageCopy,
): LegacyStageCopy {
const metrics = list(source?.metrics)
.filter((metric) => metric.value.trim() !== "" && metric.label.trim() !== "")
.map(({ value, label }) => ({ value, label }));
const focusAreas = list(source?.focusAreas)
.map((area) => area.name.trim())
.filter(Boolean);
const images: LegacyGalleryImage[] = list(source?.images)
.slice(0, 5)
.flatMap((media) => {
const image = toImage(media, "large");
return image
? [{
src: image.src,
alt: image.alt,
...(media.caption ? { caption: media.caption } : {}),
}]
: [];
});
return {
...fallback,
name: textOr(source?.name, fallback.name),
years: textOr(source?.years, fallback.years),
summary: textOr(source?.summary, fallback.summary),
description: textOr(source?.description, fallback.description),
metrics: metrics.length > 0 ? metrics : fallback.metrics,
focusHeading: textOr(source?.focusHeading, fallback.focusHeading),
focusAreas: focusAreas.length > 0 ? focusAreas : fallback.focusAreas,
imageHeading: textOr(source?.galleryHeading, fallback.imageHeading),
images,
};
}
@@ -256,12 +256,6 @@ export interface PageHeroProps {
* footer for EU visibility.
*/
funderBadge?: ReactNode;
/**
* A short status line drawn as a pill above the headline - the redesign's
* "Now live" chip. Only rendered when the caller passes one, so it can never
* announce a state the CMS has not authored.
*/
statusPill?: ReactNode;
/** Buttons, rendered inline-end of the headline on wide viewports. */
actions?: ReactNode;
/**
@@ -378,7 +372,6 @@ export function PageHero({
lead,
wordmark,
funderBadge,
statusPill,
actions,
leadWithActions = false,
ambient,
@@ -454,8 +447,6 @@ export function PageHero({
</div>
) : null}
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
{leadWithActions ? (
<>
{/* Headline across the top ... */}
+2 -32
View File
@@ -361,43 +361,13 @@ export default function About({ loaderData }: Route.ComponentProps) {
<div className="flex flex-wrap items-center gap-md">
<Button
as={Link}
to={sectionPath("whatWeDo", locale)}
to={sectionPath("legacy", locale)}
prefetch="intent"
size="lg"
iconEnd={<ArrowIcon />}
>
{t("actions.learnWhatWeDo")}
{copy.legacyLinkLabel}
</Button>
{/* The draft marks the legacy page as "[link: …]" with no target
yet, so this second button appears only once the CMS gives it
a real one. */}
{legacy.link ? (
legacy.link.isExternal ? (
<Button
as="a"
href={legacy.link.href}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
size="lg"
iconEnd={<ArrowIcon />}
>
{legacy.link.label}
</Button>
) : (
<Button
as={Link}
to={legacy.link.href}
prefetch="intent"
variant="secondary"
size="lg"
iconEnd={<ArrowIcon />}
>
{legacy.link.label}
</Button>
)
) : null}
</div>
</div>
</PageSection>
+1 -58
View File
@@ -46,14 +46,12 @@ import { getServerEnv } from "~/lib/env.server";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/contact";
import { Accordion, type AccordionItem } from "./_page-kit/accordion";
import type { CmsContactPage } from "./_page-kit/cms-types";
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
EmptyState,
PageHero,
PageSection,
Prose,
@@ -74,7 +72,6 @@ export async function loader({ params }: Route.LoaderArgs) {
populate: {
hero: { populate: { media: true, ctas: true } },
socialLinks: true,
consortiumContacts: true,
seo: { populate: { ogImage: true } },
},
});
@@ -95,15 +92,6 @@ export async function loader({ params }: Route.LoaderArgs) {
href: link.href,
isExternal: link.isExternal ?? true,
})),
consortiumContacts: list(page?.consortiumContacts).map((address) => ({
id: String(address.id ?? address.organisation),
organisation: address.organisation,
addressLine: address.addressLine ?? null,
city: address.city ?? null,
country: address.country ?? null,
email: address.email ?? null,
phone: address.phone ?? null,
})),
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
@@ -362,8 +350,7 @@ export default function Contact({
loaderData,
actionData,
}: Route.ComponentProps) {
const { locale, hero, intro, generalEmail, socialLinks, consortiumContacts } =
loaderData;
const { locale, hero, intro, generalEmail, socialLinks } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
@@ -390,32 +377,6 @@ export default function Contact({
(message): message is string => typeof message === "string",
);
/* The draft draws the consortium addresses as a disclosure list, which the
approved draft also calls for ("optional accordion"). The answers are
Markdown because that is what <Accordion /> renders; every value in them
comes from the CMS, and the whole list is replaced by the localised empty
state while the addresses are still "TBD". */
const consortiumItems: readonly AccordionItem[] = consortiumContacts.map(
(contact) => {
const place = [contact.city, contact.country].filter(Boolean).join(", ");
return {
id: contact.id,
question: contact.organisation,
answer: [
contact.addressLine,
place === "" ? null : place,
contact.email ? `[${contact.email}](mailto:${contact.email})` : null,
contact.phone
? `[${contact.phone}](tel:${contact.phone.replace(/\s+/g, "")})`
: null,
]
.filter((line): line is string => typeof line === "string")
.join("\n\n"),
};
},
);
/* A failed submit re-renders the page with the summary at the top of the
form. Screen-reader users are left wherever they were, and keyboard users
below the fold, so focus is moved onto the summary itself - it is
@@ -664,24 +625,6 @@ export default function Contact({
</dl>
</div>
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them.
The draft labels the list with an accent eyebrow rather than a
section heading, so the heading takes the label role. */}
<div className="sh-reveal flex flex-col gap-sm">
<h2
id="contact-consortium-heading"
className="label-md uppercase text-[var(--text-accent)]"
>
{copy.consortiumHeading}
</h2>
{consortiumItems.length > 0 ? (
<Accordion items={consortiumItems} defaultOpenIndex={null} />
) : (
<EmptyState message={copy.consortiumEmpty} />
)}
</div>
{/* EU VISIBILITY - the funding note the draft prints at the foot of
this column. The emblem and the statement come from the shared
badge, so the wording and the artwork stay contractual. */}
+95 -21
View File
@@ -6,7 +6,7 @@
alternating the two dark surfaces the way the draft alternates #0B0B0A and
#1D1D1B:
Hero EU badge, an optional status pill, the headline, the lead,
Hero EU badge, the headline, the lead,
the CTA pair, the wide picture and the brand stripe. Two
slow washes drift behind it.
Approach "A nationwide project, delivered locally": what the project
@@ -73,6 +73,7 @@ import {
isImageGalleryBlock,
type CmsFaq,
type CmsHomePage,
type CmsLegacyPage,
type CmsNewsArticle,
type CmsPartner,
} from "./_page-kit/cms-types";
@@ -86,6 +87,7 @@ import {
type ResolvedPartner,
} from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { resolveLegacyStage } from "./_page-kit/legacy-cms.server";
import { routeLocale } from "./_page-kit/locale";
import {
cx,
@@ -181,6 +183,13 @@ function toNewsCard(article: CmsNewsArticle, locale: Locale): NewsCard {
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const legacyPagePromise = loadSingle<CmsLegacyPage>("legacy-page", {
locale,
populate: {
stageOne: { populate: { metrics: true, focusAreas: true } },
stageTwo: { populate: { metrics: true, focusAreas: true } },
},
});
const page = await loadSingle<CmsHomePage>("home-page", {
locale,
@@ -194,6 +203,8 @@ export async function loader({ params }: Route.LoaderArgs) {
seo: { populate: { ogImage: true } },
},
});
const legacyPage = await legacyPagePromise;
const legacyFallback = pageCopy(locale).legacy;
/* The curated relation wins; otherwise show every partner the CMS knows, so
the wall fills in as soon as partners exist - before anyone remembers to
@@ -243,8 +254,15 @@ export async function loader({ params }: Route.LoaderArgs) {
return {
locale,
legacy: {
homeEyebrow: legacyPage?.homeEyebrow ?? legacyFallback.homeEyebrow,
homeHeading: legacyPage?.homeHeading ?? legacyFallback.homeHeading,
homeLead: legacyPage?.homeLead ?? legacyFallback.homeLead,
homeLinkLabel: legacyFallback.homeLinkLabel,
stageOne: resolveLegacyStage(legacyPage?.stageOne, legacyFallback.stageOne),
stageTwo: resolveLegacyStage(legacyPage?.stageTwo, legacyFallback.stageTwo),
},
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
@@ -334,24 +352,6 @@ function HeroCta({ href, label, variant }: HeroCtaProps) {
);
}
/**
* The hero's status line. The draft hard-codes "Now live · Expression of
* Interest open"; here the words are the CMS hero eyebrow, so the pill only
* ever announces a state an editor has actually written - and the hero simply
* has no pill until then. The dot pulses; the text does not move.
*/
function StatusPill({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-xs rounded-pill border border-[var(--border-subtle)] bg-[var(--surface-raised)] py-2xs ps-sm pe-md">
<span
aria-hidden="true"
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
/>
<span className="label-md uppercase text-[var(--text-secondary)]">{label}</span>
</span>
);
}
/**
* The approach band's pictures. The CMS alt text doubles as the visible
* caption - so the picture is described once, in the caption, and the <img>
@@ -546,6 +546,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
const { locale, hero, approach, gallery, partners, news, faqs } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).home;
const legacyCopy = loaderData.legacy;
/* The hero badge sits on the page's own surface, so it follows the theme
exactly as the footer's badge does: same root cookie, same emblem. */
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
@@ -603,7 +604,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
title={headline}
lead={""}
leadWithActions
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
ambient={[
{ top: -220, from: "start", offset: "8%", width: 780, height: 620, durationSeconds: 18 },
{
@@ -641,6 +641,80 @@ reveal
))}
/>
{/* THE TWO EARLIER SHARP PHASES */}
<PageSection
aria-labelledby="home-legacy-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="flex flex-col gap-2xl">
<div className="sh-reveal flex flex-col gap-md border-b border-[var(--border-default)] pb-lg md:max-w-[52rem]">
<p className="label-md uppercase text-[var(--text-accent)]">
{legacyCopy.homeEyebrow}
</p>
<SectionHeader
id="home-legacy-heading"
title={legacyCopy.homeHeading}
lead={legacyCopy.homeLead}
size="display"
/>
</div>
<div className="grid grid-cols-1 gap-lg lg:grid-cols-2">
{[
{
stage: legacyCopy.stageOne,
anchor: "sharp-i",
},
{
stage: legacyCopy.stageTwo,
anchor: "sharp-ii",
},
].map(({ stage, anchor }) => (
<article
key={stage.name}
className="sh-lift flex h-full flex-col overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)]"
>
<div className="flex flex-1 flex-col gap-lg p-lg md:p-xl">
<div className="flex flex-wrap items-baseline justify-between gap-sm">
<h3 className="heading-h2 text-[var(--text-primary)]">
{stage.name}
</h3>
<span className="label-md text-[var(--text-accent)]">
{stage.years}
</span>
</div>
<p className="body-md text-[var(--text-secondary)]">
{stage.summary}
</p>
<dl className="grid grid-cols-3 gap-md border-y border-[var(--border-subtle)] py-md">
{stage.metrics.slice(0, 3).map((metric) => (
<div key={metric.label} className="flex flex-col gap-2xs">
<dt className="label-md text-[var(--text-secondary)]">
{metric.label}
</dt>
<dd className="heading-h2 text-[var(--text-accent)]">
{metric.value}
</dd>
</div>
))}
</dl>
<Button
as={Link}
to={`${sectionPath("legacy", locale)}#${anchor}`}
prefetch="intent"
variant="ghost"
className="self-start"
iconEnd={<ArrowIcon />}
>
{legacyCopy.homeLinkLabel}
</Button>
</div>
</article>
))}
</div>
</div>
</PageSection>
{/* WHAT THE PROJECT IS, AND WHO DELIVERS IT */}
{/* `bleed` drops the 80rem Container so the band's content - the three
pictures especially - runs the full width of the viewport, the way
+458
View File
@@ -0,0 +1,458 @@
/* ==========================================================================
SHARP LEGACY - /en/legacy and /ar/legacy
--------------------------------------------------------------------------
The full programme record stays available from the CMS. The page introduces
each phase with a short story, its own gallery, and three highlights.
========================================================================== */
import { useRef, useState } from "react";
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { createTranslator, sectionPath, type Translator } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/legacy";
import type { CmsLegacyPage } from "./_page-kit/cms-types";
import { loadSingle, toImage } from "./_page-kit/cms.server";
import type { LegacyGalleryImage, LegacyStageCopy } from "./_page-kit/copy";
import { pageCopy } from "./_page-kit/copy";
import { resolveLegacyStage } from "./_page-kit/legacy-cms.server";
import { routeLocale } from "./_page-kit/locale";
import { cx, PageHero, PageSection, SectionHeader } from "./_page-kit/sections";
import { RichText } from "./_page-kit/rich-text";
import "./_page-kit/motion.css";
const STAGES = [
{
id: "sharp-i",
band: true,
},
{
id: "sharp-ii",
band: false,
},
] as const;
/** Keep the short page narrative in step with the editor's CMS description. */
function storyPreview(markdown: string) {
const [introduction = "", ...rawChapters] = markdown
.trim()
.replace(/\r\n?/g, "\n")
.split(/\n(?=###\s)/);
const chapters = rawChapters.flatMap((raw) => {
const match = /^###\s+([^\n]+)\n([\s\S]*)$/.exec(raw.trim());
return match
? [{ heading: match[1]!, body: match[2]!.trim() }]
: [];
});
const featuredIndexes = new Set(
chapters.length > 3 ? [1, 2, chapters.length - 1] : chapters.map((_, index) => index),
);
const firstSentences = (value: string, count: number) =>
value.trim().split(/(?<=[.!?؟])\s+/u).slice(0, count).join(" ");
return {
introduction: firstSentences(introduction, 2),
highlights: chapters
.filter((_, index) => featuredIndexes.has(index))
.map(({ heading, body }) => ({
heading,
preview: firstSentences(body.split(/\n\s*\n/)[0] ?? body, 2),
})),
};
}
export function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
return loadSingle<CmsLegacyPage>("legacy-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
stageOne: { populate: { metrics: true, focusAreas: true, images: true } },
stageTwo: { populate: { metrics: true, focusAreas: true, images: true } },
seo: { populate: { ogImage: true } },
},
}).then((page) => {
const fallback = pageCopy(locale).legacy;
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
title: page?.hero?.headline ?? null,
lead: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
stageOne: resolveLegacyStage(page?.stageOne, fallback.stageOne),
stageTwo: resolveLegacyStage(page?.stageTwo, fallback.stageTwo),
closingHeading: page?.closingHeading ?? null,
closingBody: page?.closingBody ?? null,
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
});
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).legacy;
const seo = loaderData?.seo;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: seo?.title ?? t("nav.sections.legacy"),
description: seo?.description ?? loaderData?.hero.lead ?? copy.heroLead,
...(seo?.image
? { image: { url: seo.image.src, alt: seo.image.alt } }
: {}),
noIndex: seo?.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{ name: t("nav.sections.about"), pathname: sectionPath("about", locale) },
{ name: t("nav.sections.legacy"), pathname: sectionPath("legacy", locale) },
]),
});
}
function LegacyImageGallery({
images,
stageName,
heading,
empty,
t,
}: {
images: readonly LegacyGalleryImage[];
stageName: string;
heading: string;
empty: string;
t: Translator;
}) {
const trackRef = useRef<HTMLUListElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const scrollToImage = (index: number) => {
trackRef.current?.children.item(index)?.scrollIntoView({
behavior: "auto",
block: "nearest",
inline: "start",
});
setActiveIndex(index);
};
const updateActiveImage = () => {
const track = trackRef.current;
if (!track) return;
const bounds = track.getBoundingClientRect();
const center = (bounds.left + bounds.right) / 2;
let closestIndex = 0;
let closestDistance = Number.POSITIVE_INFINITY;
Array.from(track.children).forEach((slide, index) => {
const rect = slide.getBoundingClientRect();
const distance = Math.abs((rect.left + rect.right) / 2 - center);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
});
setActiveIndex((current) => current === closestIndex ? current : closestIndex);
};
if (images.length === 0) {
return (
<div className="relative flex aspect-[4/3] flex-col justify-end overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl">
<span aria-hidden="true" className="absolute -end-md -top-xl display-sm text-[var(--text-accent)] opacity-10">
{stageName}
</span>
<p className="relative max-w-[20rem] body-md text-[var(--text-secondary)]">
{empty}
</p>
</div>
);
}
if (images.length === 1) {
const image = images[0]!;
return (
<figure className="flex flex-col gap-sm">
<img
src={image.src}
alt={image.alt}
loading="lazy"
decoding="async"
className="aspect-[4/3] w-full rounded-card border border-[var(--border-subtle)] object-cover shadow-[var(--elevation-card)]"
/>
{image.caption ? (
<figcaption className="body-sm text-[var(--text-secondary)]">
{image.caption}
</figcaption>
) : null}
</figure>
);
}
return (
<div role="region" aria-label={heading} className="flex min-w-0 flex-col gap-sm">
<ul
ref={trackRef}
tabIndex={0}
onScroll={updateActiveImage}
className="flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain rounded-card motion-safe:scroll-smooth focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
{images.map((image, index) => (
<li key={`${image.src}-${index}`} className="w-full shrink-0 snap-start">
<figure className="flex flex-col gap-sm">
<img
src={image.src}
alt={image.alt}
loading="lazy"
decoding="async"
className="aspect-[4/3] w-full rounded-card border border-[var(--border-subtle)] object-cover shadow-[var(--elevation-card)]"
/>
{image.caption ? (
<figcaption className="body-sm text-[var(--text-secondary)]">
{image.caption}
</figcaption>
) : null}
</figure>
</li>
))}
</ul>
<div className="flex items-center justify-between gap-sm">
<p aria-live="polite" className="label-md text-[var(--text-secondary)]">
{t("legacy.imageCount", { current: activeIndex + 1, total: images.length })}
</p>
<div className="flex items-center gap-xs">
<button
type="button"
aria-label={t("legacy.previousImage", { programme: stageName })}
disabled={activeIndex === 0}
onClick={() => scrollToImage(activeIndex - 1)}
className="flex size-11 items-center justify-center rounded-control border border-[var(--border-default)] text-[var(--text-primary)] transition-colors hover:border-[var(--border-accent)] hover:text-[var(--text-accent)] disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
<ArrowIcon direction="backward" />
</button>
<button
type="button"
aria-label={t("legacy.nextImage", { programme: stageName })}
disabled={activeIndex === images.length - 1}
onClick={() => scrollToImage(activeIndex + 1)}
className="flex size-11 items-center justify-center rounded-control border border-[var(--border-default)] text-[var(--text-primary)] transition-colors hover:border-[var(--border-accent)] hover:text-[var(--text-accent)] disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
<ArrowIcon direction="forward" />
</button>
</div>
</div>
<div aria-hidden="true" className="flex gap-xs">
{images.map((image, index) => (
<span
key={`${image.src}-${index}`}
className={cx(
"h-1 flex-1 rounded-chip transition-colors",
index === activeIndex ? "bg-[var(--border-accent)]" : "bg-[var(--border-subtle)]",
)}
/>
))}
</div>
</div>
);
}
function StageSection({
stage,
id,
band,
t,
}: {
stage: LegacyStageCopy;
id: string;
band: boolean;
t: Translator;
}) {
const story = storyPreview(stage.description);
return (
<PageSection
id={id}
aria-labelledby={`${id}-heading`}
{...(band ? { band: true } : {})}
>
<div className="flex flex-col gap-2xl">
<div className="grid items-center gap-xl lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] lg:gap-2xl">
<div className="flex min-w-0 flex-col gap-lg">
<SectionHeader
id={`${id}-heading`}
eyebrow={stage.years}
title={stage.name}
lead={stage.summary}
size="display"
/>
{story.introduction ? (
<p className="body-lg text-[var(--text-secondary)]">
{story.introduction}
</p>
) : null}
<dl className="grid grid-cols-3 gap-sm border-t border-[var(--border-default)] pt-lg">
{stage.metrics.slice(0, 3).map((metric) => (
<div key={metric.label} className="flex min-w-0 flex-col gap-xs">
<dt className="order-2 label-md text-[var(--text-secondary)]">
{metric.label}
</dt>
<dd className="order-1 heading-h2 text-[var(--text-accent)]">
{metric.value}
</dd>
</div>
))}
</dl>
</div>
<div className={cx("flex min-w-0 flex-col gap-sm", id === "sharp-ii" && "lg:order-first")}>
<h3 className="label-md uppercase text-[var(--text-accent)]">
{stage.imageHeading}
</h3>
<LegacyImageGallery
images={stage.images}
stageName={stage.name}
heading={stage.imageHeading}
empty={stage.imageEmpty}
t={t}
/>
</div>
</div>
{story.highlights.length > 0 ? (
<div className="grid gap-md md:grid-cols-3">
{story.highlights.map((highlight) => (
<article
key={highlight.heading}
className="flex flex-col gap-sm border-s-2 border-[var(--border-accent)] bg-[var(--surface-card)] p-lg"
>
<h3 className="heading-h3 text-[var(--text-primary)]">
{highlight.heading}
</h3>
<p className="body-md text-[var(--text-secondary)]">
{highlight.preview}
</p>
</article>
))}
</div>
) : null}
<details className="group rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<summary className="flex cursor-pointer list-none items-center justify-between gap-md p-lg body-md-bold text-[var(--text-primary)] focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)] [&::-webkit-details-marker]:hidden">
{t("legacy.fullRecord", { programme: stage.name })}
<span aria-hidden="true" className="heading-h3 text-[var(--text-accent)] group-open:rotate-45">
+
</span>
</summary>
<div className="flex flex-col gap-xl border-t border-[var(--border-subtle)] px-lg py-xl">
<RichText markdown={stage.description} className="w-full" />
{stage.focusAreas.length > 0 ? (
<div className="flex flex-col gap-sm">
<h3 className="heading-h3 text-[var(--text-primary)]">
{stage.focusHeading}
</h3>
<ul className="flex flex-wrap gap-sm">
{stage.focusAreas.map((area) => (
<li
key={area}
className="rounded-chip border border-[var(--border-subtle)] px-md py-xs body-sm text-[var(--text-secondary)]"
>
{area}
</li>
))}
</ul>
</div>
) : null}
</div>
</details>
</div>
</PageSection>
);
}
export default function Legacy({ loaderData }: Route.ComponentProps) {
const locale = loaderData.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).legacy;
const stageEntries = [
{ ...STAGES[0], stage: loaderData.stageOne },
{ ...STAGES[1], stage: loaderData.stageTwo },
] as const;
return (
<>
<PageHero
eyebrow={loaderData.hero.eyebrow ?? copy.heroEyebrow}
title={loaderData.hero.title ?? copy.heroTitle}
lead={loaderData.hero.lead ?? copy.heroLead}
{...(loaderData.hero.media ? { media: loaderData.hero.media } : {})}
ambient={[
{
top: -150,
from: "start",
offset: "5%",
width: 560,
height: 470,
},
{
top: 100,
from: "end",
offset: "-80px",
width: 460,
height: 420,
tone: "danger",
reverse: true,
},
]}
/>
{stageEntries.map(({ stage, ...config }) => (
<StageSection
key={`${config.id}-${locale}`}
stage={stage}
t={t}
{...config}
/>
))}
<PageSection
aria-labelledby="legacy-closing-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="mx-auto flex max-w-[var(--layout-prose-max)] flex-col items-start gap-lg">
<SectionHeader
id="legacy-closing-heading"
title={loaderData.closingHeading ?? copy.closingHeading}
/>
<p className="body-lg text-[var(--text-secondary)]">
{(loaderData.closingBody ?? copy.closingParagraphs.join(" "))
.trim()
.split(/(?<=[.!?؟])\s+/u)
.slice(0, 2)
.join(" ")}
</p>
<Button
as={Link}
to={sectionPath("about", locale)}
prefetch="intent"
variant="primary"
iconEnd={<ArrowIcon />}
>
{t("actions.discoverSharper")}
</Button>
</div>
</PageSection>
</>
);
}