initial commit
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
/* ============================================================================
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user