Files
sharper.ly/apps/web/app/routes/activities.content.ts
T
2026-09-11 02:46:56 +02:00

423 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
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.",
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,
};
}