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
@@ -0,0 +1,49 @@
{
"kind": "singleType",
"collectionName": "legacy_pages",
"info": {
"singularName": "legacy-page",
"pluralName": "legacy-pages",
"displayName": "Legacy Page",
"description": "SHARP I and SHARP II programme history, achievements, metrics, and featured images."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"homeEyebrow": { "type": "string", "pluginOptions": { "i18n": { "localized": true } } },
"homeHeading": { "type": "string", "pluginOptions": { "i18n": { "localized": true } } },
"homeLead": { "type": "text", "pluginOptions": { "i18n": { "localized": true } } },
"hero": {
"type": "component",
"repeatable": false,
"component": "shared.hero",
"pluginOptions": { "i18n": { "localized": true } }
},
"stageOne": {
"type": "component",
"repeatable": false,
"component": "page.legacy-stage",
"pluginOptions": { "i18n": { "localized": true } }
},
"stageTwo": {
"type": "component",
"repeatable": false,
"component": "page.legacy-stage",
"pluginOptions": { "i18n": { "localized": true } }
},
"closingHeading": { "type": "string", "pluginOptions": { "i18n": { "localized": true } } },
"closingBody": { "type": "richtext", "pluginOptions": { "i18n": { "localized": true } } },
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": { "i18n": { "localized": true } }
}
}
}
@@ -0,0 +1,7 @@
/**
* legacy-page controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::legacy-page.legacy-page');
@@ -0,0 +1,7 @@
/**
* legacy-page router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::legacy-page.legacy-page');
@@ -0,0 +1,7 @@
/**
* legacy-page service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::legacy-page.legacy-page');
@@ -0,0 +1,11 @@
{
"collectionName": "components_page_legacy_focus_areas",
"info": {
"displayName": "Legacy Focus Area",
"icon": "bulletList"
},
"options": {},
"attributes": {
"name": { "type": "string", "required": true }
}
}
@@ -0,0 +1,12 @@
{
"collectionName": "components_page_legacy_metrics",
"info": {
"displayName": "Legacy Metric",
"icon": "chartBubble"
},
"options": {},
"attributes": {
"value": { "type": "string", "required": true },
"label": { "type": "string", "required": true }
}
}
@@ -0,0 +1,26 @@
{
"collectionName": "components_page_legacy_stages",
"info": {
"displayName": "Legacy Phase",
"icon": "landmark",
"description": "One SHARP programme phase with its copy, figures, areas of work, and image gallery. The website displays up to five selected images."
},
"options": {},
"attributes": {
"name": { "type": "string", "required": true },
"years": { "type": "string" },
"summary": { "type": "text" },
"description": { "type": "richtext" },
"metrics": { "type": "component", "repeatable": true, "component": "page.legacy-metric" },
"focusHeading": { "type": "string" },
"focusAreas": { "type": "component", "repeatable": true, "component": "page.legacy-focus-area" },
"galleryHeading": { "type": "string" },
"images": {
"type": "media",
"multiple": true,
"required": false,
"description": "Select up to five images for this programme. The gallery shows the first five in this order.",
"allowedTypes": ["images"]
}
}
}
+189 -7
View File
@@ -1,9 +1,10 @@
/* ============================================================================
SEED - APPROVED CONTENT
----------------------------------------------------------------------------
Everything in this file is transcribed from docs/, in both languages, and
nothing in it is invented. That is the whole point of keeping it apart from
demo.ts: this content is safe to publish.
Everything in this file is transcribed from docs/ or the supplied project
content suggestions, in both languages, and nothing in it is invented. That
is the whole point of keeping it apart from demo.ts: this content is safe to
publish.
SOURCES
docs/SHARPER_Website_Draft_Section_EN_AR.docx sections 1-9, EN and AR
@@ -13,6 +14,9 @@
docs/SHARPER Home Page .docx the launch article, EN/AR
docs/UEC_SHARPER_Website_TOR V3.docx the consortium and the
project's purpose
SHARP-ER Website Content suggestions submitted by Isam Saidi, supplied by
the project team: SHARP I/II
legacy text and historical figures
WHAT docs/ DOES NOT SUPPLY, AND IS THEREFORE NOT HERE
* Figures. Every number in the draft is bracketed and marked "TBD ... must
@@ -424,10 +428,6 @@ export const LAUNCH_ARTICLE = {
export const HOME_PAGE: Localised<SeedData> = {
en: {
hero: {
/* No `eyebrow`. The design draws a status pill reading "Now open -
Funding calls for Libyan organisations"; whether a call is open on any
given day is a fact about the project, not about the design, so the
pill stays unwritten until an editor writes it. */
headline: 'Advancing Justice and Accountability, Led by Libyan Communities',
subheading:
'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — works with civil society organisations, municipalities, and community leaders across Libya to strengthen access to justice, accountability, and local governance. Funded by the European Union, running to December 2027.',
@@ -547,6 +547,188 @@ export const ABOUT_PAGE: Localised<SeedData> = {
},
};
/**
* Facts and figures below come from "SHARP I Programme SUMMARY.docx" and
* "SHARP II Programme SUMMARY.docx". English page copy paraphrases the source;
* Arabic page copy translates it. UI headings are editorial labels, not quotes.
* The home Legacy section is stored here with the two programme records.
*/
export const LEGACY_PAGE: Localised<SeedData> = {
en: {
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.',
hero: {
eyebrow: 'SHARP I and SHARP II',
headline: 'Dialogue and local action across Libya',
subheading:
'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.\n\n### Objectives\n- Strengthen Libyan institutions’ ability to respond to rule of law challenges.\n- Build the organisational capabilities of Libyan change agents.\n- Foster dialogue and information-sharing between stakeholders.\n\n### Dialogue and learning\nThe 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.\n\n### Community action\nTwenty-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.\n\n### Results and lasting value\n87% 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.\n\n### What followed\nThe 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: [
{ name: 'Human rights' },
{ name: 'Transitional justice' },
{ name: 'Local governance' },
{ name: 'Conflict mediation' },
{ name: 'Access to justice' },
{ name: 'Women’s rights' },
{ name: 'Labour rights' },
{ name: 'Civil society and media' },
],
galleryHeading: 'SHARP I in the field',
},
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.\n\n### Priorities\nSHARP 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.\n\n### Dialogue and network\n157 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.\n\n### Skills and community action\nSix 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.\n\n### Policy, communication, and impact\nRegional 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.\n\n### A sustainable exit\nThe 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: [
{ name: 'Access to justice' },
{ name: 'Women’s empowerment' },
{ name: 'Local governance' },
{ name: 'Media and rule of law' },
{ name: 'Social cohesion' },
{ name: 'Rule of law' },
{ name: 'Positive citizenship' },
],
galleryHeading: 'SHARP II in the field',
},
closingHeading: 'SHARP II’s exit phase',
closingBody: [
'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.',
].join('\n\n'),
seo: {
metaTitle: 'SHARP Legacy',
metaDescription:
'Discover the experience, achievements, and networks built through SHARP I and SHARP II in Libya.',
},
},
ar: {
homeEyebrow: 'شارب الأول وشارب الثاني',
homeHeading: 'الحوار والعمل المحلي في ليبيا، 2019–2024',
homeLead:
'دعم برنامجان ممولان من الاتحاد الأوروبي الحوار حول سيادة القانون، وقدرات المجتمع المدني، والعمل المحلي في مختلف أنحاء ليبيا.',
hero: {
eyebrow: 'شارب الأول وشارب الثاني',
headline: 'الحوار والعمل المحلي في مختلف أنحاء ليبيا',
subheading:
'من نوفمبر 2019 إلى أغسطس 2024، دعم برنامجان ممولان من الاتحاد الأوروبي الحوار حول سيادة القانون، وقدرات المجتمع المدني، والعمل بقيادة محلية.',
},
stageOne: {
name: 'شارب الأول',
years: 'نوفمبر 2019 – أغسطس 2022',
summary: 'عزّز الحوار والقدرات المحلية من خلال البحث والتعلّم والعمل المجتمعي.',
description:
`نفّذ معهد كلينغندايل ومؤسسة Bureau Ensemble برنامج شارب الأول بتمويل من الاتحاد الأوروبي، وبالتعاون مع منظمات المجتمع المدني والسلطات المحلية والمؤسسات الوطنية الليبية. عمل البرنامج من نوفمبر 2019 إلى أغسطس 2022 على دعم سيادة القانون في ليبيا عبر الحوار والبحث والعمل المحلي، بميزانية بلغت 1.996 مليون يورو.\n\n### الأهداف\n- تعزيز قدرة المؤسسات الليبية على الاستجابة لتحديات سيادة القانون.\n- بناء القدرات التنظيمية للفاعلين الليبيين الساعين إلى التغيير.\n- تشجيع الحوار وتبادل المعلومات بين أصحاب المصلحة.\n\n### الحوار والتعلّم\nعُقدت 102 جلسة «ابحث، افهم، واسأل» (SUQ)، بمشاركة 1,583 شخصاً، وكانت نسبة النساء 48%. وتناولت الجلسات حقوق الإنسان والعدالة الانتقالية والحوكمة المحلية. كما عُقدت 46 جلسة للتدريب والتعلّم حول التيسير وإدارة المشاريع والرصد.\n\n### العمل المجتمعي\nنُفّذ 21 مشروعاً فرعياً تناولت الوساطة في النزاعات والوصول إلى العدالة وحقوق النساء وحقوق العمال ومشاركة المجتمع المدني والإعلام. وأسفرت عن 62 ورشة عمل و6 أوراق بحثية و24 جلسة تدريبية و1,555 نشاطاً للتواصل الإعلامي.\n\n### النتائج والأثر المستدام\nرأى 87% من المشاركين أن جلسات الحوار ذات صلة، واعتقد 75% أن شارب قادر على الإسهام في تغيير واقع سيادة القانون. وعززت المشاريع مهارات إعداد التقارير لدى منظمات المجتمع المدني وشراكاتها مع السلطات المحلية. كما دعمت المبادرات بقيادة ليبية الثقة المجتمعية، وتكيّف البرنامج مع قيود الجائحة والأوضاع الأمنية عبر الأنشطة الافتراضية والحوار المحلي، بما ساعد المنظمات على بناء شبكات طويلة الأمد.\n\n### ما تلا ذلك\nوافق الاتحاد الأوروبي على شارب الثاني للاستفادة من هذه الخبرات: تعميق الحوار، والتركيز على أربعة إلى ستة موضوعات مع الحفاظ على المرونة، وتعزيز مهارات تصميم المشاريع وتقييمها لدى منظمات المجتمع المدني، ودعم شبكات مستدامة بين الحكومة والمجتمع المدني.`,
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: [
{ name: 'حقوق الإنسان' },
{ name: 'العدالة الانتقالية' },
{ name: 'الحوكمة المحلية' },
{ name: 'الوساطة في النزاعات' },
{ name: 'الوصول إلى العدالة' },
{ name: 'حقوق النساء' },
{ name: 'حقوق العمال' },
{ name: 'المجتمع المدني والإعلام' },
],
galleryHeading: 'شارب الأول ميدانياً',
},
stageTwo: {
name: 'شارب الثاني',
years: 'سبتمبر 2022 – أغسطس 2024',
summary:
'وسّع شبكة المجتمع المدني الوطنية وحوّل الحوار الشامل إلى عمل مجتمعي.',
description:
`بتمويل من الاتحاد الأوروبي وتنفيذ Bureau Ensemble بالشراكة مع فاعلين ليبيين، بنى شارب الثاني على إنجازات شارب الأول لتعزيز سيادة القانون والاستقرار والحوكمة التشاركية. امتد البرنامج من سبتمبر 2022 إلى أغسطس 2024 بميزانية 1.426 مليون يورو.\n\n### الأولويات\nعمل شارب الثاني على توسيع شبكة وطنية لمجموعات الحوار حول سيادة القانون، وتعزيز التماسك الاجتماعي والتوافق محلياً وإقليمياً ووطنياً، وتنظيم حوارات شاملة حول سيادة القانون والمواطنة الإيجابية، ودعم مبادرات مجتمعية صغيرة.\n\n### الحوار وتوسيع الشبكة\nعُقدت 157 جلسة حوار SUQ بمشاركة 2,554 شخصاً، وكانت نسبة النساء 44%. وتناولت الحوارات الوصول إلى العدالة وتمكين المرأة والحوكمة المحلية ودور الإعلام في سيادة القانون. ونمت الشبكة إلى 64 منظمة مجتمع مدني ناشطة بعد أن كانت 27، وامتدت إلى جميع المناطق بما فيها المناطق المهمشة في الجنوب.\n\n### بناء القدرات والعمل المجتمعي\nعُقدت 6 جلسات للتدريب والتعلّم حول التيسير وإدارة المشاريع والرصد. وشمل التدريب المتقدم 100 مشارك، حصل 24 منهم على اعتماد مدربين. ونُفّذت العقود الفرعية الـ34 المعتمدة، وتناولت التوعية القانونية والمصالحة وحقوق المرأة ومكافحة العنف القائم على النوع الاجتماعي وبناء قدرات البلديات والصحافة الأخلاقية ومواجهة المعلومات المضللة. ووصلت الأنشطة إلى 1,487 متدرباً و1,697 مشاركاً في الورش؛ واستفاد 2,896 شخصاً من حملات التوعية.\n\n### السياسات والتواصل والأثر\nأسهمت الحوارات الإقليمية والوطنية في صياغة توصيات لتسريع إجراءات المحاكم وتوفير مكاتب للمساعدة القانونية، وتمكين البلديات بموجب قانون الحكم المحلي رقم 59، ودعم الشفافية الإعلامية ومكافحة خطاب الكراهية. وأسهم الرصد بقيادة ليبية في تكييف التنفيذ، كما عززت 1,555 نشاطاً إعلامياً أثر البرنامج. وقادت منظمات المجتمع المدني 100% من الأنشطة، وتعاونت معها الوزارات والبلديات، وشارك الشباب والنازحون والأشخاص ذوو الإعاقة.\n\n### خطة خروج مستدامة\nصممت مرحلة الخروج، من مارس إلى أغسطس 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: [
{ name: 'الوصول إلى العدالة' },
{ name: 'تمكين المرأة' },
{ name: 'الحوكمة المحلية' },
{ name: 'الإعلام وسيادة القانون' },
{ name: 'التماسك الاجتماعي' },
{ name: 'سيادة القانون' },
{ name: 'المواطنة الإيجابية' },
],
galleryHeading: 'شارب الثاني ميدانياً',
},
closingHeading: 'مرحلة خروج شارب الثاني',
closingBody: [
'من مارس إلى أغسطس 2024، ركزت مرحلة خروج شارب الثاني على الوصول إلى العدالة، والحوكمة المحلية عبر «تعزيز» (TAZIZ)، والإعلام عبر «أضواء» (ADWA).',
'أُسست «United Efforts» وسُجلت ككيان قانوني ليبي. ويشير ملخص شارب الثاني إلى شبكة وطنية مستقرة من منظمات المجتمع المدني وخارطة طريق لتعاون الاتحاد الأوروبي وليبيا مستقبلاً عبر شارب-إي آر.',
].join('\n\n'),
seo: {
metaTitle: 'إرث شارب',
metaDescription:
'اكتشفوا الخبرات والإنجازات والشبكات التي بُنيت من خلال مشروعي شارب الأول والثاني في ليبيا.',
},
},
};
/** The role chip and its line on the About page's consortium cards. */
export const CONSORTIUM_ROLES: Readonly<
Record<string, Localised<{ role: string; roleDescription: string }>>
-12
View File
@@ -911,15 +911,3 @@ export const demoKnowledge = (now: Date): readonly DemoEntry[] => {
),
];
};
/**
* The home page fields the demo set overrides.
*
* The status pill is the one piece of approved-looking copy the demo writes,
* and it is true of the demo data: `demo-call-for-expression-of-interest-
* round-two` really is open on a demo-seeded site.
*/
export const DEMO_HOME_PAGE: Localised<SeedData> = {
en: { hero: { eyebrow: 'Now open · Funding calls for Libyan organisations' } },
ar: { hero: { eyebrow: 'مفتوح الآن · دعوات تمويل للمنظمات الليبية' } },
};
+74 -12
View File
@@ -4,6 +4,7 @@
Run it with `pnpm seed` from the repository root, or from apps/cms:
pnpm seed the approved content only (approved.ts)
pnpm seed -- --legacy-only only create/update the Legacy single type
pnpm seed --demo the same, plus the invented fixtures (demo.ts)
pnpm seed --demo --clean remove the invented fixtures, leave the rest
@@ -17,10 +18,9 @@
documentId, and a relation can only be set once its target exists.
WHAT IT DOES NOT TOUCH
* Media. The seed uploads nothing. Where a page already has a picture -
the home hero, most of all - the existing file is read back and written
out again with the new copy, because a component is replaced wholesale
on write and anything left out of the payload is gone.
* Media. The seed uploads nothing. Existing home and Legacy hero/gallery
media are read back and written out again with the copy, because a
component is replaced wholesale on write and omitted media is removed.
* Submissions. `application`, `event-registration` and `contact-submission`
hold personal data. Nothing writes to them here, not even in demo mode.
* Locales and permissions. Those are reconciled at boot by src/bootstrap.
@@ -35,6 +35,7 @@ import {
CONSORTIUM_ROLES,
CONTACT_PAGE,
HOME_PAGE,
LEGACY_PAGE,
LAUNCH_ARTICLE,
SITE_SETTINGS,
TOPICS,
@@ -43,7 +44,6 @@ import {
} from './approved';
import {
DEMO_FAQS,
DEMO_HOME_PAGE,
DEMO_PARTNERS,
DEMO_PARTNER_SLUGS,
DEMO_PREFIX,
@@ -59,6 +59,7 @@ import {
deleteBySlugPrefix,
deleteWhere,
mediaId,
mediaIds,
readExisting,
upsert,
type Localised,
@@ -71,12 +72,15 @@ export interface SeedOptions {
readonly demo: boolean;
/** With `demo`, remove those fixtures instead of writing them. */
readonly clean: boolean;
/** Write only the bilingual Legacy single type, preserving its media. */
readonly legacyOnly: boolean;
}
const UID = {
siteSettings: 'api::site-settings.site-settings',
homePage: 'api::home-page.home-page',
aboutPage: 'api::about-page.about-page',
legacyPage: 'api::legacy-page.legacy-page',
whatWeDoPage: 'api::what-we-do-page.what-we-do-page',
contactPage: 'api::contact-page.contact-page',
partner: 'api::partner.partner',
@@ -218,6 +222,8 @@ async function seedApproved(strapi: Core.Strapi): Promise<{
});
log(strapi, 'about page');
await seedLegacyPage(strapi);
await upsert(strapi, UID.whatWeDoPage, WHAT_WE_DO_PAGE);
log(strapi, 'what we do page');
@@ -227,6 +233,57 @@ async function seedApproved(strapi: Core.Strapi): Promise<{
return { partners, topics, launchArticleId: launch.documentId };
}
/** Seed only the Legacy single type, leaving every other CMS record untouched. */
async function seedLegacyPage(strapi: Core.Strapi): Promise<void> {
/* A component is replaced wholesale on update. Carry editor-selected hero
and phase-gallery media across reseeds so the approved copy never drops it. */
const populate = [
'hero.media',
'stageOne.images',
'stageTwo.images',
];
const existingArabic = await readExisting(strapi, UID.legacyPage, populate);
const existingEnglish = await readExisting(strapi, UID.legacyPage, populate, undefined, 'en');
const existingFor = (locale: 'en' | 'ar') =>
locale === 'en' ? existingEnglish ?? existingArabic : existingArabic ?? existingEnglish;
const mediaFor = (locale: 'en' | 'ar') => {
const existing = existingFor(locale);
return {
hero: mediaId(existing?.hero, 'media'),
one: mediaIds(existing?.stageOne, 'images'),
two: mediaIds(existing?.stageTwo, 'images'),
};
};
const enMedia = mediaFor('en');
const arMedia = mediaFor('ar');
const withGallery = (stage: unknown, images: number[]): SeedData => ({
...(stage as SeedData),
...(images.length > 0 ? { images } : {}),
});
await upsert(strapi, UID.legacyPage, {
en: {
...LEGACY_PAGE.en,
hero: {
...(LEGACY_PAGE.en.hero as SeedData),
...(enMedia.hero ? { media: enMedia.hero } : {}),
},
stageOne: withGallery(LEGACY_PAGE.en.stageOne, enMedia.one),
stageTwo: withGallery(LEGACY_PAGE.en.stageTwo, enMedia.two),
},
ar: {
...LEGACY_PAGE.ar,
hero: {
...(LEGACY_PAGE.ar.hero as SeedData),
...(arMedia.hero ? { media: arMedia.hero } : {}),
},
stageOne: withGallery(LEGACY_PAGE.ar.stageOne, arMedia.one),
stageTwo: withGallery(LEGACY_PAGE.ar.stageTwo, arMedia.two),
},
});
log(strapi, 'legacy page');
}
/* ---------------------------------------------------------------------------
THE DEMO SET
------------------------------------------------------------------------ */
@@ -326,11 +383,10 @@ async function seedDemo(
}
log(strapi, `${knowledge.length} demo knowledge items`);
/* Finally the home page, which the demo set only adds to: the status pill,
the network logo wall (twelve organisations, which is what turns the wall
into the design's marquee), and two network stories beside the real launch
article. The hero is rewritten in full, media included, because writing a
component replaces it. */
/* Finally the home page: the demo set adds twelve organisations to the
network logo wall and two network stories beside the real launch article.
The hero is rewritten in full, media included, because writing a component
replaces it. */
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media');
@@ -344,7 +400,6 @@ async function seedDemo(
...HOME_PAGE[locale],
hero: {
...(HOME_PAGE[locale].hero as SeedData),
...(DEMO_HOME_PAGE[locale].hero as SeedData),
...(heroMedia ? { media: heroMedia } : {}),
},
featuredNews: featured,
@@ -376,7 +431,7 @@ async function cleanDemo(strapi: Core.Strapi): Promise<void> {
/* The home page is approved content with demo fields written onto it, so it
is put back rather than deleted. Deleting the partners above already
emptied the wall; this takes the status pill off again. */
emptied the wall. */
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media');
@@ -420,6 +475,12 @@ export async function runSeed(strapi: Core.Strapi, options: SeedOptions): Promis
return;
}
if (options.legacyOnly) {
if (options.demo) throw new Error('[seed] --legacy-only cannot be combined with --demo.');
await seedLegacyPage(strapi);
return;
}
const context = await seedApproved(strapi);
if (options.demo) {
@@ -435,6 +496,7 @@ export function parseSeedOptions(argv: readonly string[]): SeedOptions {
return {
demo: argv.includes('--demo'),
clean: argv.includes('--clean'),
legacyOnly: argv.includes('--legacy-only'),
};
}
+15 -2
View File
@@ -218,11 +218,12 @@ export async function readExisting(
strapi: Core.Strapi,
uid: SeedUid,
populate: string[],
find?: SeedData
find?: SeedData,
locale: SeedLocale = 'ar'
): Promise<SeedDocument | null> {
return documents(strapi, uid).findFirst({
...(find ? { filters: find } : {}),
locale: 'ar',
locale,
...draftFilter(strapi, uid),
populate,
});
@@ -236,3 +237,15 @@ export function mediaId(component: unknown, field: string): number | undefined {
const id = (media as Record<string, unknown>).id;
return typeof id === 'number' ? id : undefined;
}
/** The ids of populated media entries nested in a component. */
export function mediaIds(component: unknown, field: string): number[] {
if (!component || typeof component !== 'object') return [];
const media = (component as Record<string, unknown>)[field];
if (!Array.isArray(media)) return [];
return media.flatMap((item: unknown) => {
if (!item || typeof item !== 'object') return [];
const id = (item as Record<string, unknown>).id;
return typeof id === 'number' ? [id] : [];
});
}
+46
View File
@@ -196,6 +196,49 @@ export interface PageConsortiumMember extends Struct.ComponentSchema {
};
}
export interface PageLegacyFocusArea extends Struct.ComponentSchema {
collectionName: 'components_page_legacy_focus_areas';
info: {
displayName: 'Legacy Focus Area';
icon: 'bulletList';
};
attributes: {
name: Schema.Attribute.String & Schema.Attribute.Required;
};
}
export interface PageLegacyMetric extends Struct.ComponentSchema {
collectionName: 'components_page_legacy_metrics';
info: {
displayName: 'Legacy Metric';
icon: 'chartBubble';
};
attributes: {
label: Schema.Attribute.String & Schema.Attribute.Required;
value: Schema.Attribute.String & Schema.Attribute.Required;
};
}
export interface PageLegacyStage extends Struct.ComponentSchema {
collectionName: 'components_page_legacy_stages';
info: {
description: 'One SHARP programme phase with copy, figures, areas of work, and its own image gallery.';
displayName: 'Legacy Phase';
icon: 'landmark';
};
attributes: {
description: Schema.Attribute.RichText;
focusAreas: Schema.Attribute.Component<'page.legacy-focus-area', true>;
focusHeading: Schema.Attribute.String;
galleryHeading: Schema.Attribute.String;
images: Schema.Attribute.Media<'images', true>;
metrics: Schema.Attribute.Component<'page.legacy-metric', true>;
name: Schema.Attribute.String & Schema.Attribute.Required;
summary: Schema.Attribute.Text;
years: Schema.Attribute.String;
};
}
export interface PageWorkstream extends Struct.ComponentSchema {
collectionName: 'components_page_workstreams';
info: {
@@ -355,6 +398,9 @@ declare module '@strapi/strapi' {
'navigation.nav-group': NavigationNavGroup;
'page.band-intro': PageBandIntro;
'page.consortium-member': PageConsortiumMember;
'page.legacy-focus-area': PageLegacyFocusArea;
'page.legacy-metric': PageLegacyMetric;
'page.legacy-stage': PageLegacyStage;
'page.workstream': PageWorkstream;
'shared.accordion-item': SharedAccordionItem;
'shared.address': SharedAddress;
+87
View File
@@ -1393,6 +1393,92 @@ export interface ApiKnowledgeItemKnowledgeItem
};
}
export interface ApiLegacyPageLegacyPage extends Struct.SingleTypeSchema {
collectionName: 'legacy_pages';
info: {
description: 'SHARP I and SHARP II legacy content, hero media, and phase galleries.';
displayName: 'Legacy Page';
pluralName: 'legacy-pages';
singularName: 'legacy-page';
};
options: {
draftAndPublish: true;
};
pluginOptions: {
i18n: {
localized: true;
};
};
attributes: {
closingBody: Schema.Attribute.RichText &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
closingHeading: Schema.Attribute.String &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
hero: Schema.Attribute.Component<'shared.hero', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
homeEyebrow: Schema.Attribute.String &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
homeHeading: Schema.Attribute.String &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
homeLead: Schema.Attribute.Text &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
locale: Schema.Attribute.String;
localizations: Schema.Attribute.Relation<
'oneToMany',
'api::legacy-page.legacy-page'
>;
publishedAt: Schema.Attribute.DateTime;
seo: Schema.Attribute.Component<'shared.seo', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
stageOne: Schema.Attribute.Component<'page.legacy-stage', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
stageTwo: Schema.Attribute.Component<'page.legacy-stage', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
collectionName: 'news_articles';
info: {
@@ -2789,6 +2875,7 @@ declare module '@strapi/strapi' {
'api::faq.faq': ApiFaqFaq;
'api::home-page.home-page': ApiHomePageHomePage;
'api::knowledge-item.knowledge-item': ApiKnowledgeItemKnowledgeItem;
'api::legacy-page.legacy-page': ApiLegacyPageLegacyPage;
'api::news-article.news-article': ApiNewsArticleNewsArticle;
'api::opportunity.opportunity': ApiOpportunityOpportunity;
'api::partner.partner': ApiPartnerPartner;
+1 -1
View File
@@ -28,7 +28,7 @@ the canonical URL is derived from the request).
```
app/
├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, <Navigation>, <Footer>
├── routes.ts Route config: "/" redirect, the ":lang" layout, the ten sections
├── routes.ts Route config: "/" redirect, the ":lang" layout, the eleven sections
├── routes/
│ ├── *.tsx One module per page, serving BOTH locales
│ ├── _page-kit/ Section/hero/prose primitives, markdown renderer, CMS helpers
@@ -1,7 +1,7 @@
/* ============================================================================
DEFAULT INFORMATION ARCHITECTURE
----------------------------------------------------------------------------
The ten top-level destinations from the Figma navigation (frame 20:236) and
The top-level destinations from the Figma navigation (frame 20:236) and
ToR 3.2. The app shell normally feeds `items` to <Navigation /> from the
Strapi site-settings single type; this module is the typed fallback that
renders when Strapi has not been wired yet.
@@ -30,6 +30,7 @@ import type { NavItem, NavigationStrings, NavigationStringsByLocale } from "./ty
export const NAV_PATHS = {
home: "/",
about: "/about",
legacy: "/legacy",
whatWeDo: "/what-we-do",
activities: "/activities-and-initiatives",
events: "/events-calendar",
@@ -53,6 +54,7 @@ const LABELS: Record<Locale, Record<LabelKey, string>> = {
home: "Home",
aboutAndStrategy: "About and Strategy",
about: "About",
legacy: "SHARP Legacy",
whatWeDo: "What We Do",
activities: "Activities and Initiatives",
engagementAndOpportunities: "Engagement and Opportunities",
@@ -68,6 +70,7 @@ const LABELS: Record<Locale, Record<LabelKey, string>> = {
home: "الرئيسية",
aboutAndStrategy: "عن المشروع والاستراتيجية",
about: "عن المشروع",
legacy: "إرث شارب",
whatWeDo: "ماذا نفعل",
activities: "الأنشطة والمبادرات",
engagementAndOpportunities: "المشاركة والفرص",
@@ -96,6 +99,7 @@ export function getDefaultNavItems(locale: Locale): NavItem[] {
label: label.aboutAndStrategy,
children: [
{ id: "about", label: label.about, href: href("about") },
{ id: "legacy", label: label.legacy, href: href("legacy") },
{ id: "what-we-do", label: label.whatWeDo, href: href("whatWeDo") },
{ id: "activities", label: label.activities, href: href("activities") },
],
+1 -1
View File
@@ -56,7 +56,7 @@ export interface Translation {
readonly messages: Messages;
/** `/{locale}` prefixed URL for an unprefixed path. */
readonly localizedPath: (path: string) => string;
/** The locale-prefixed URL of one of the ten sections. */
/** The locale-prefixed URL of one of the eleven sections. */
readonly sectionPath: (key: RouteSlugKey) => string;
}
+7
View File
@@ -24,6 +24,7 @@
"home": "الرئيسية",
"aboutAndStrategy": "عن المشروع والاستراتيجية",
"about": "عن المشروع",
"legacy": "إرث شارب",
"whatWeDo": "ماذا نفعل",
"activities": "الأنشطة والمبادرات",
"engagementAndOpportunities": "المشاركة والفرص",
@@ -42,6 +43,12 @@
"en": "English",
"ar": "العربية"
},
"legacy": {
"fullRecord": "اقرأ التفاصيل الكاملة لبرنامج {programme}",
"previousImage": "الصورة السابقة من {programme}",
"nextImage": "الصورة التالية من {programme}",
"imageCount": "الصورة {current} من {total}"
},
"actions": {
"readMore": "اقرأ المزيد",
"learnMore": "اعرف المزيد",
+7
View File
@@ -24,6 +24,7 @@
"home": "Home",
"aboutAndStrategy": "About and Strategy",
"about": "About",
"legacy": "SHARP Legacy",
"whatWeDo": "What We Do",
"activities": "Activities and Initiatives",
"engagementAndOpportunities": "Engagement and Opportunities",
@@ -42,6 +43,12 @@
"en": "English",
"ar": "العربية"
},
"legacy": {
"fullRecord": "Read the full {programme} record",
"previousImage": "Previous {programme} image",
"nextImage": "Next {programme} image",
"imageCount": "Image {current} of {total}"
},
"actions": {
"readMore": "Read more",
"learnMore": "Learn more",
+1 -1
View File
@@ -60,7 +60,7 @@ export function alternatePath(
return buildLocalizedHref(pathname, targetLocale, options);
}
/** The locale-prefixed URL of one of the ten sections. */
/** The locale-prefixed URL of one of the eleven sections. */
export function sectionPath(key: RouteSlugKey, locale: Locale): string {
return localizePath(sectionSlugPath(key), locale);
}
+4 -3
View File
@@ -1,5 +1,5 @@
/* ============================================================================
ROUTE SLUGS - the single source of truth for the site's ten sections
ROUTE SLUGS - the single source of truth for the site's eleven sections
----------------------------------------------------------------------------
This module has ZERO imports on purpose: it is loaded by `app/routes.ts`,
which the React Router dev plugin evaluates in Node while building the route
@@ -23,6 +23,7 @@
export const ROUTE_SLUGS = {
home: "",
about: "about",
legacy: "legacy",
whatWeDo: "what-we-do",
activities: "activities-and-initiatives",
events: "events-calendar",
@@ -41,7 +42,7 @@ export type RouteSlugKey = keyof typeof ROUTE_SLUGS;
*/
export type RouteSlugs = typeof ROUTE_SLUGS;
/** The ten section keys in navigation order. */
/** The section keys in navigation order. */
export const ROUTE_SLUG_KEYS = Object.keys(ROUTE_SLUGS) as readonly RouteSlugKey[];
/**
@@ -57,7 +58,7 @@ export function sectionSlugPath(key: RouteSlugKey): string {
export const ROUTE_SECTION_GROUPS = [
{
id: "about-and-strategy",
children: ["about", "whatWeDo", "activities"],
children: ["about", "legacy", "whatWeDo", "activities"],
},
{
id: "engagement-and-opportunities",
+5 -4
View File
@@ -5,7 +5,7 @@
/ -> 302 to /en or /ar (Accept-Language)
/:lang -> home
/:lang/<section> -> one of the ten top-level sections
/:lang/<section> -> one of the eleven top-level sections
/:lang/<section>/<slug> -> one entry of a CMS-backed section
/:lang/events-calendar/<slug>.ics -> that event as an RFC 5545 file
anything else -> 404
@@ -43,7 +43,7 @@
ROUTE IDS
---------
"root" | "routes/locale-redirect" | "locale" | "routes/not-found", one per
section named after its key: "home", "about", "whatWeDo", "activities",
section named after its key: "home", "about", "legacy", "whatWeDo", "activities",
"events", "applications", "news", "contact", "resources", "knowledgeHub" -
and one per detail route, the section key plus "Detail" ("newsDetail",
"eventsDetail", ...), plus "eventsIcs". So `useRouteLoaderData("locale")`
@@ -76,6 +76,7 @@ import { ROUTE_SLUGS, type RouteSlugKey } from "./lib/route-slugs";
const SECTION_MODULES = {
home: "routes/home.tsx",
about: "routes/about.tsx",
legacy: "routes/legacy.tsx",
whatWeDo: "routes/what-we-do.tsx",
activities: "routes/activities-and-initiatives.tsx",
events: "routes/events-calendar.tsx",
@@ -89,7 +90,7 @@ const SECTION_MODULES = {
/**
* Section -> the module that renders ONE entry of it, at
* `<section slug>/:slug`. Only the six CMS-backed sections have one; "about",
* "what-we-do", "contact" and the home page are single pages with nothing to
* "legacy", "what-we-do", "contact" and the home page are single pages with nothing to
* drill into.
*/
const DETAIL_MODULES = {
@@ -117,7 +118,7 @@ const RESOURCE_ROUTES = [
const detailKeys = Object.keys(DETAIL_MODULES) as (keyof typeof DETAIL_MODULES)[];
/**
* Every page under ":lang": the ten sections, then the six detail routes, then
* Every page under ":lang": the eleven sections, then the six detail routes, then
* the .ics resource route. Flat on purpose - see "LISTING AND DETAIL ARE
* SIBLINGS" above.
*/
@@ -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>
</>
);
}