423 lines
18 KiB
TypeScript
423 lines
18 KiB
TypeScript
/* ============================================================================
|
||
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",
|
||
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.",
|
||
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<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,
|
||
};
|
||
}
|