/* ============================================================================ 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 > = { "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> = Object.fromEntries( ACTIVITY_STREAM_KEYS.map((key) => [ACTIVITY_STREAM_CMS_VALUES[key], key]), ) as Record; /** 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>> > = { 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 > = { en: { eyebrow: "Activities and Initiatives", headline: "What’s Happening Across the SHARP-ER Network", lede: "From regional network meetings to nationwide dialogues — here’s where the project stands today.", 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", lede: "من اجتماعات الشبكة الإقليمية إلى الحوارات الوطنية، إليكم أين وصل المشروع اليوم.", streamsNavLabel: "محاور الأنشطة", figuresHeading: "SHARP-ER بالأرقام", emptyTitle: "لم يتم نشر أي أنشطة حتى الآن.", emptyBody: "تُنشر الأنشطة تباعًا مع إنجازها من قِبل فريق المشروع وشركائه. في هذه الأثناء، يعرض التقويم الزمني للفعاليات ما هو قادم في مختلف أنحاء ليبيا.", streamEmpty: "لم يتم نشر أي أنشطة ضمن هذا المحور حتى الآن.", streamListLabel: "الأنشطة المنشورة", metaDescription: "اجتماعات الشبكة الإقليمية، والمشاريع الفرعية لمنظمات المجتمع المدني، وحوارات SUQ، ومبادرات الأمن البشري، والبحوث الاستطلاعية الوطنية عبر شبكة SHARP-ER في ليبيا.", }, }; export const ACTIVITY_DETAIL_COPY: Readonly> = { 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> { const groups = Object.fromEntries( ACTIVITY_STREAM_KEYS.map((key) => [key, [] as ActivitySummary[]]), ) as Record; 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; const data = match.loaderData ?? match.data; const fields = ( typeof data === "object" && data !== null ? data : {} ) as Partial; 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, }; }