2026-09-11 02:46:56 +02:00
/* ============================================================================
ACTIVITIES AND INITIATIVES - shared content, types and copy
----------------------------------------------------------------------------
Imported by BOTH activity route modules:
routes/activities-and-initiatives.tsx the grouped index
routes/activities-and-initiatives.$slug.tsx one activity
Pure and client-safe on purpose - nothing here touches ~/lib/strapi or
~/lib/env.server, so the module can be imported from component code as well
as from a loader.
WHAT LIVES HERE vs WHAT LIVES IN THE CMS
----------------------------------------
The five activity streams are the project's fixed structure: they come from
the approved draft copy (docs/SHARPER_Website_Draft_Section_EN_AR.docx,
section 3 "Activities and Initiatives" EN/AR) and from the ToR, not from an
editor, so their titles, descriptions and implementing partners are held
here in both languages. Everything that changes over the project's life -
the activities themselves, their figures, their prose - comes from Strapi.
The UI chrome catalogue (~/i18n) deliberately holds no page prose - it is
labels, actions and status words, reused across sections - so the
section-specific strings below are declared per locale here rather than
added to en.json / ar.json.
========================================================================= */
import { DEFAULT_LOCALE , isLocale , type Locale } from "~/i18n/config" ;
import { sectionPath } from "~/i18n/paths" ;
/* ---------------------------------------------------------------------------
STREAMS
------------------------------------------------------------------------ */
/**
* The five activity streams of the draft copy. These are the WEB identifiers -
* they name the in-page anchors (#stream-suq-dialogues) and the copy table
* below, and they come from the approved draft, not from an editor.
*
* Two of them are spelled differently in the CMS enumeration
* `activity.activityStream`, so the two vocabularies are mapped explicitly by
* `ACTIVITY_STREAM_CMS_VALUES` / `toActivityStreamKey` rather than assumed to
* match - assuming they matched is what made the loaders filter on a value
* Strapi rejects.
*/
export type ActivityStreamKey =
| "sharp-network"
| "cso-sub-projects"
| "suq-dialogues"
| "human-security"
| "research-perception" ;
/** The streams in the order the draft copy lists them. */
export const ACTIVITY_STREAM_KEYS = [
"sharp-network" ,
"cso-sub-projects" ,
"suq-dialogues" ,
"human-security" ,
"research-perception" ,
] as const satisfies readonly ActivityStreamKey [];
export function isActivityStreamKey ( value : unknown ) : value is ActivityStreamKey {
return (
typeof value === "string" &&
( ACTIVITY_STREAM_KEYS as readonly string []). includes ( value )
);
}
/**
* WEB key -> the `activity.activityStream` enumeration value in Strapi
* (apps/cms/src/api/activity/content-types/activity/schema.json). Only two
* differ, but a filter that sends the wrong one is a 400, not an empty list.
*/
export const ACTIVITY_STREAM_CMS_VALUES : Readonly <
Record < ActivityStreamKey , string >
> = {
"sharp-network" : "sharp-network" ,
"cso-sub-projects" : "cso-subprojects-p2r" ,
"suq-dialogues" : "suq-dialogues" ,
"human-security" : "human-security" ,
"research-perception" : "research" ,
};
const CMS_VALUE_TO_STREAM_KEY : Readonly < Record < string , ActivityStreamKey >> =
Object . fromEntries (
ACTIVITY_STREAM_KEYS . map (( key ) => [ ACTIVITY_STREAM_CMS_VALUES [ key ], key ]),
) as Record < string , ActivityStreamKey >;
/** The web key for a CMS enumeration value, or null when it is unknown. */
export function toActivityStreamKey ( value : unknown ) : ActivityStreamKey | null {
if ( typeof value !== "string" ) return null ;
return CMS_VALUE_TO_STREAM_KEY [ value ] ?? null ;
}
export interface ActivityStreamCopy {
readonly title : string ;
readonly description : string ;
/** "Implementing partner" / "Implementing partners" - the draft copy uses both. */
readonly partnerLabel : string ;
/** The partner organisation(s), exactly as the draft copy names them. */
readonly partners : string ;
}
/**
* Stream titles, descriptions and implementing partners, verbatim from the
* draft copy in both languages.
*/
export const ACTIVITY_STREAMS : Readonly <
Record < Locale , Readonly < Record < ActivityStreamKey , ActivityStreamCopy >>>
> = {
en : {
"sharp-network" : {
title : "The SHARP Network" ,
description :
"Regional meetings, international events, and peer-to-peer learning that keep the civil society network connected." ,
partnerLabel : "Implementing partner" ,
partners : "UEC" ,
},
"cso-sub-projects" : {
title : "CSO Sub-Projects & Policy-to-Rights" ,
description :
"Collective sub-projects selected through open Calls for Expression of Interest, fostering cross-country cooperation, plus Policy-to-Rights (P2R) projects." ,
partnerLabel : "Implementing partner" ,
partners : "Acted" ,
},
"suq-dialogues" : {
title : "SUQ Dialogues & Civil Society Law Sessions" ,
description :
"Search, Understand, Question (SUQ) dialogues driven by local needs, dialogue-facilitation training, and joint sessions with the Commission on Civil Society (CCS)." ,
partnerLabel : "Implementing partners" ,
partners : "UEC / Acted" ,
},
"human-security" : {
title : "Human Security Initiatives" ,
description :
"16 individual sub-projects designed to foster human security at the community level." ,
partnerLabel : "Implementing partner" ,
partners : "UEC" ,
},
"research-perception" : {
title : "Research & Perception Data" ,
description :
"Nationwide perception-based data collection, analysis, and dissemination." ,
partnerLabel : "Implementing partner" ,
partners : "SBE" ,
},
},
ar : {
"sharp-network" : {
title : "شبكة SHARP" ,
description :
"اجتماعات إقليمية وفعاليات دولية وتعلّم بين الأقران تُبقي شبكة المجتمع المدني على تواصل." ,
partnerLabel : "الشريك المنفّذ" ,
partners : "UEC" ,
},
"cso-sub-projects" : {
title : "المشاريع الفرعية لمنظمات المجتمع المدني ومشاريع P2R" ,
description :
"مشاريع فرعية جماعية تُختار عبر دعوات مفتوحة للتعبير عن الاهتمام لتعزيز التعاون العابر للمناطق، إضافة إلى مشاريع “من السياسة إلى الحقوق”." ,
partnerLabel : "الشريك المنفّذ" ,
partners : "Acted" ,
},
"suq-dialogues" : {
title : "حوارات SUQ وجلسات قانون المجتمع المدني" ,
description :
"حوارات “ابحث، افهم، اسأل” (SUQ) مدفوعة باحتياجات محلية، وتدريب على تيسير الحوار، وجلسات مشتركة مع لجنة المجتمع المدني (CCS)." ,
partnerLabel : "الشريكان المنفّذان" ,
partners : "UEC وActed" ,
},
"human-security" : {
title : "مبادرات الأمن البشري" ,
description :
"16 مشروعًا فرديًا فرعيًا مصمَّمًا لتعزيز الأمن البشري على المستوى المجتمعي." ,
partnerLabel : "الشريك المنفّذ" ,
partners : "UEC" ,
},
"research-perception" : {
title : "البحث وبيانات الاستطلاع" ,
description :
"جمع بيانات استطلاعية على المستوى الوطني وتحليلها ونشرها." ,
partnerLabel : "الشريك المنفّذ" ,
partners : "SBE" ,
},
},
};
/** The stream copy for a locale, falling back to English. */
export function getStreamCopy (
stream : ActivityStreamKey ,
locale : Locale ,
) : ActivityStreamCopy {
return ( ACTIVITY_STREAMS [ locale ] ?? ACTIVITY_STREAMS [ DEFAULT_LOCALE ])[ stream ];
}
/* ---------------------------------------------------------------------------
PAGE COPY
------------------------------------------------------------------------ */
export interface ActivitiesIndexCopy {
readonly eyebrow : string ;
readonly headline : string ;
readonly lede : string ;
/** Label of the in-page "jump to a stream" navigation. */
readonly streamsNavLabel : string ;
/** Heading above the confirmed figures, when the CMS publishes any. */
readonly figuresHeading : string ;
readonly emptyTitle : string ;
readonly emptyBody : string ;
/** Shown inside a stream that has no published activity yet. */
readonly streamEmpty : string ;
/** Visually hidden heading for the list of activities inside a stream. */
readonly streamListLabel : string ;
readonly metaDescription : string ;
}
export interface ActivityDetailCopy {
readonly backToIndex : string ;
readonly streamLabel : string ;
readonly partnerLabel : string ;
readonly locationLabel : string ;
readonly datesLabel : string ;
readonly detailsHeading : string ;
/** Rendered when the entry exists but carries no body text yet. */
readonly bodyMissing : string ;
readonly relatedHeading : string ;
readonly relatedAction : string ;
}
export const ACTIVITIES_INDEX_COPY : Readonly <
Record < Locale , ActivitiesIndexCopy >
> = {
en : {
eyebrow : "Activities and Initiatives" ,
headline : "What’ s Happening Across the SHARP-ER Network" ,
2026-10-07 20:27:06 +02:00
lede : "From regional network meetings to nationwide dialogues — here’ s where the project stands today. Our activities focus on inclusive dialogue, civil-society capacity building, community-led initiatives, and digital awareness across Libya." ,
2026-09-11 02:46:56 +02:00
streamsNavLabel : "Activity streams" ,
figuresHeading : "SHARP-ER in figures" ,
emptyTitle : "No activities have been published yet." ,
emptyBody :
"Activities are published as the project team and its partners complete them. In the meantime, the events calendar lists what is coming next across Libya." ,
streamEmpty : "No activities have been published under this stream yet." ,
streamListLabel : "Published activities" ,
metaDescription :
"Regional network meetings, CSO sub-projects, SUQ dialogues, human security initiatives and nationwide perception research across the SHARP-ER network in Libya." ,
},
ar : {
eyebrow : "الأنشطة والمبادرات" ,
headline : "ماذا يحدث عبر شبكة SHARP-ER" ,
2026-10-07 20:27:06 +02:00
lede : "من اجتماعات الشبكات الإقليمية إلى الحوارات الوطنية — إليك ما وصل إليه المشروع اليوم. تركز أنشطتنا على الحوار الشامل، وبناء قدرات المجتمع المدني، والمبادرات التي تقودها المجتمعات المحلية، والتوعية الرقمية في جميع أنحاء ليبيا." ,
2026-09-11 02:46:56 +02:00
streamsNavLabel : "محاور الأنشطة" ,
figuresHeading : "SHARP-ER بالأرقام" ,
emptyTitle : "لم يتم نشر أي أنشطة حتى الآن." ,
emptyBody :
"تُنشر الأنشطة تباعًا مع إنجازها من قِبل فريق المشروع وشركائه. في هذه الأثناء، يعرض التقويم الزمني للفعاليات ما هو قادم في مختلف أنحاء ليبيا." ,
streamEmpty : "لم يتم نشر أي أنشطة ضمن هذا المحور حتى الآن." ,
streamListLabel : "الأنشطة المنشورة" ,
metaDescription :
"اجتماعات الشبكة الإقليمية، والمشاريع الفرعية لمنظمات المجتمع المدني، وحوارات SUQ، ومبادرات الأمن البشري، والبحوث الاستطلاعية الوطنية عبر شبكة SHARP-ER في ليبيا." ,
},
};
export const ACTIVITY_DETAIL_COPY : Readonly < Record < Locale , ActivityDetailCopy >> =
{
en : {
backToIndex : "All activities and initiatives" ,
streamLabel : "Activity stream" ,
partnerLabel : "Implementing partner" ,
locationLabel : "Location" ,
datesLabel : "Dates" ,
detailsHeading : "About this activity" ,
bodyMissing : "A full description of this activity is being prepared." ,
relatedHeading : "More from this stream" ,
relatedAction : "View details" ,
},
ar : {
backToIndex : "جميع الأنشطة والمبادرات" ,
streamLabel : "محور النشاط" ,
partnerLabel : "الشريك المنفّذ" ,
locationLabel : "الموقع" ,
datesLabel : "التاريخ" ,
detailsHeading : "عن هذا النشاط" ,
bodyMissing : "يجري إعداد وصف كامل لهذا النشاط." ,
relatedHeading : "المزيد من هذا المحور" ,
relatedAction : "عرض التفاصيل" ,
},
};
export function getIndexCopy ( locale : Locale ) : ActivitiesIndexCopy {
return ACTIVITIES_INDEX_COPY [ locale ] ?? ACTIVITIES_INDEX_COPY [ DEFAULT_LOCALE ];
}
export function getDetailCopy ( locale : Locale ) : ActivityDetailCopy {
return ACTIVITY_DETAIL_COPY [ locale ] ?? ACTIVITY_DETAIL_COPY [ DEFAULT_LOCALE ];
}
/* ---------------------------------------------------------------------------
PATHS
------------------------------------------------------------------------ */
/** `/en/activities-and-initiatives` - the section index. */
export function activitiesIndexPath ( locale : Locale ) : string {
return sectionPath ( "activities" , locale );
}
/** `/ar/activities-and-initiatives/suq-tripoli` - one activity. */
export function activityPath ( slug : string , locale : Locale ) : string {
return ` ${ activitiesIndexPath ( locale ) } / ${ encodeURIComponent ( slug ) } ` ;
}
/** The DOM id of a stream section, used by the in-page jump navigation. */
export function streamAnchorId ( stream : ActivityStreamKey ) : string {
return `stream- ${ stream } ` ;
}
/* ---------------------------------------------------------------------------
THE VIEW MODEL BOTH ROUTES HAND TO THE COMPONENTS
Loader output, not the raw Strapi entity: media URLs are absolutised on the
server (mediaUrl() reads STRAPI_URL, which never reaches the browser) and
every field is already a plain, serialisable value.
------------------------------------------------------------------------ */
export interface ActivitySummary {
readonly id : string ;
readonly slug : string ;
readonly title : string ;
/** null when an editor has not yet filed the entry under a stream. */
readonly stream : ActivityStreamKey | null ;
readonly summary : string | null ;
readonly imageUrl : string | null ;
readonly imageAlt : string ;
/**
* Names of the related region entries, joined. An activity has no dates in
* the CMS - it is a workstream, not an event - so there is no date line.
*/
readonly location : string | null ;
/** Related partner names. Overrides the stream default when it differs. */
readonly implementingPartner : string | null ;
/** `activity.activityStatus`: planned | ongoing | completed. */
readonly status : ActivityStatus | null ;
}
/** `activity.activityStatus` in the CMS. */
export type ActivityStatus = "planned" | "ongoing" | "completed" ;
export function isActivityStatus ( value : unknown ) : value is ActivityStatus {
return value === "planned" || value === "ongoing" || value === "completed" ;
}
/** One confirmed figure published by the CMS. See the note in the index route. */
export interface ActivityFigure {
readonly id : string ;
/** Pre-formatted by the editor, so Arabic keeps the digits the copy uses. */
readonly value : string ;
readonly label : string ;
readonly caption : string | null ;
}
/**
* Activities bucketed into the five streams, in the draft copy's order.
* Pure, so the index route can group in the component rather than the loader.
*/
export function groupActivitiesByStream (
activities : readonly ActivitySummary [],
) : Readonly < Record < ActivityStreamKey , ActivitySummary [] >> {
const groups = Object . fromEntries (
ACTIVITY_STREAM_KEYS . map (( key ) => [ key , [] as ActivitySummary []]),
) as Record < ActivityStreamKey , ActivitySummary [] >;
for ( const activity of activities ) {
if ( activity . stream !== null ) groups [ activity . stream ]. push ( activity );
}
return groups ;
}
/* ---------------------------------------------------------------------------
META HELPERS
------------------------------------------------------------------------ */
/** What the root loader publishes for every route's `meta`. */
export interface RootRouteMeta {
readonly locale : Locale ;
readonly pathname : string ;
readonly siteUrl : string ;
}
const FALLBACK_SITE_URL = "https://sharper.ly" ;
/**
* Read the root route's loader data out of `meta`'s `matches`.
*
* React Router 8 exposes a match's data as `loaderData`; v7 called it `data`,
* and both are accepted here so the helper survives either. `meta` also runs
* on a client navigation and during an error render, where a match's data can
* legitimately be missing, so every field falls back rather than throwing.
*/
export function readRootMeta (
matches : readonly unknown [],
fallbackPathname : string ,
) : RootRouteMeta {
const root = matches [ 0 ];
const match = (
typeof root === "object" && root !== null ? root : {}
) as Record < string , unknown >;
const data = match . loaderData ?? match . data ;
const fields = (
typeof data === "object" && data !== null ? data : {}
) as Partial < RootRouteMeta >;
const locale = isLocale ( fields . locale ) ? fields.locale : DEFAULT_LOCALE ;
return {
locale ,
pathname :
typeof fields . pathname === "string" && fields . pathname . length > 0
? fields.pathname
: fallbackPathname ,
siteUrl :
typeof fields . siteUrl === "string" && fields . siteUrl . length > 0
? fields.siteUrl
: FALLBACK_SITE_URL ,
};
}