initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -0,0 +1,247 @@
/* ============================================================================
ROUTE COPY - Events Calendar / Applications and Opportunities
----------------------------------------------------------------------------
The message catalogue in ~/i18n holds UI chrome that every page shares
(actions, filters, form labels, statuses). Page prose belongs to the page,
and lives here.
Every string below is taken from the approved draft,
docs/SHARPER_Website_Draft_Section_EN_AR.docx - section 4 "Events Calendar"
and section 8 "Applications and Opportunities" - in both languages. Where
the draft marks a value "TBD" (network eligibility criteria, the response
time in weeks, the project email, activity figures) NOTHING is invented: the
sentence either omits it or the value comes from the CMS.
Parity is enforced by the type: `RouteCopy` is derived from the English
object, so a key added on one side and forgotten on the other is a compile
error rather than a blank paragraph in production.
========================================================================= */
import type { Locale } from "~/i18n";
const EN = {
events: {
/** Draft 4, EN headline. */
heading: "Upcoming Events",
/** Draft 4, EN standfirst. */
intro:
"Register for a SUQ dialogue, join a training, or track what’s coming next across Libya.",
metaDescription:
"Dialogues, trainings and Civil Society Law Sessions across Libya. Register for a SUQ dialogue, join a training, or track what’s coming next.",
listLabel: "Events",
statusFilterLabel: "Status",
emptyHint:
"Dialogues, trainings and sessions are published here as soon as they are scheduled.",
unavailable:
"The events calendar cannot be loaded right now. Please try again in a moment.",
formatHybrid: "In person and online",
},
eventDetail: {
backToList: "Back to the events calendar",
aboutHeading: "About this event",
detailsHeading: "Event details",
registrationDeadlineLabel: "Registration deadline",
registerHeading: "Register for this event",
waitlistHeading: "Join the waitlist",
formIntro:
"We use these details only to manage your place and to send joining information.",
registrationClosed: "Registration for this event is closed.",
/** `event.registrationStatus === "not-required"`. */
registrationNotRequired: "No registration required",
registrationNotRequiredNote:
"This event is open to all - no registration is needed.",
registrationFull:
"This event is fully booked. Join the waitlist and we will contact you if a place becomes available.",
eventCompleted: "This event has already taken place.",
joinOnlineLabel: "Joining link",
addToCalendarHint:
"Downloads an .ics file you can open in any calendar application.",
timeZoneNote: "All times are shown in Libya time (UTC+2).",
/**
* `event-registration.consentGdpr` is REQUIRED in the CMS, so the reader
* has to be asked in plain words rather than opted in silently.
*/
consentLabel:
"I agree that SHARP-ER may store the details above to manage my registration for this event.",
consentHint:
"We keep them only for as long as the event needs, and never share them with anyone outside the project. You can ask us to delete them at any time.",
/** Draft 4, EN confirmation screen, reused for the waitlist variant. */
waitlistConfirmationTitle: "You’re on the waitlist.",
waitlistConfirmationBody:
"Thank you — {event} on {date} is fully booked, so we have added you to the waitlist. We will email you if a place becomes available.",
},
opportunities: {
/** Draft 8, EN headline. */
heading: "Get Involved",
/** Draft 8, EN standfirst. */
intro:
"Whether you’re a civil society organisation or an individual, here’s how to take part in SHARP-ER.",
metaDescription:
"Calls for expression of interest, SHARP network membership and upcoming sessions — how civil society organisations and individuals take part in SHARP-ER.",
pathwaysHeading: "Ways to take part",
pathwayCallsTitle: "Apply for a Call for Expression of Interest",
pathwayCallsBody:
"Open calls for CSO sub-projects and Policy-to-Rights (P2R) initiatives.",
pathwayNetworkTitle: "Join the SHARP Network",
pathwayNetworkBody:
"Become part of the civil society network behind SHARP-ER: regional meetings, peer learning, and joint events.",
pathwaySessionsTitle: "Register for a SUQ Dialogue or Training",
pathwaySessionsBody:
"Take part in an upcoming Search, Understand, Question dialogue or a dialogue-facilitation training.",
listHeading: "Current calls",
listLabel: "Calls and opportunities",
statusFilterLabel: "Status",
empty: "There are no calls or opportunities to show yet.",
emptyHint:
"Calls for expression of interest are published here as soon as they open.",
unavailable:
"Calls and opportunities cannot be loaded right now. Please try again in a moment.",
},
/** `opportunity.opportunityType`, verbatim, with the draft's own wording. */
opportunityTypes: {
"call-eoi": "Call for Expression of Interest",
"network-membership": "Network membership",
training: "Training",
grant: "Grant",
other: "Opportunity",
},
opportunityDetail: {
backToList: "Back to applications and opportunities",
aboutHeading: "About this opportunity",
detailsHeading: "Call details",
howToApplyHeading: "How to apply",
attachmentsHeading: "Documents",
applyHeading: "Apply",
statusLabel: "Status",
typeLabel: "Type",
opensLabel: "Opens",
deadlineLabel: "Deadline",
formIntro:
"Tell us briefly why you are applying. Our team reviews every application.",
messageHint:
"Briefly describe your organisation and why you are applying.",
closedNote: "This call is closed. It stays published for reference.",
upcomingNote: "This call opens on {date}.",
externalApplication:
"Applications for this call are handled on an external site.",
/**
* Draft 8 marks the response time "[X weeks] - TBD" and the CMS has no
* field for it, so no number is ever named.
*/
applicationReceivedBody:
"Thank you for your application. Our team will review it and follow up by email.",
},
form: {
/** Honeypot label - never seen by a human, read by a bot. */
honeypotLabel: "Leave this field empty",
submissionFailed:
"Your details could not be submitted. Please try again in a moment.",
},
};
export type RouteCopy = typeof EN;
const AR: RouteCopy = {
events: {
heading: "الفعاليات القادمة",
intro:
"سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم في مختلف أنحاء ليبيا.",
metaDescription:
"حوارات وتدريبات وجلسات قانون المجتمع المدني في مختلف أنحاء ليبيا. سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم.",
listLabel: "الفعاليات",
statusFilterLabel: "الحالة",
emptyHint: "تُنشر هنا الحوارات والتدريبات والجلسات فور تحديد مواعيدها.",
unavailable:
"تعذّر تحميل تقويم الفعاليات في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
formatHybrid: "حضوري وعبر الإنترنت",
},
eventDetail: {
backToList: "العودة إلى تقويم الفعاليات",
aboutHeading: "عن الفعالية",
detailsHeading: "تفاصيل الفعالية",
registrationDeadlineLabel: "الموعد النهائي للتسجيل",
registerHeading: "سجّل في هذه الفعالية",
waitlistHeading: "انضم إلى قائمة الانتظار",
formIntro:
"نستخدم هذه البيانات فقط لإدارة مكانك وإرسال تفاصيل الانضمام.",
registrationClosed: "أُغلق التسجيل في هذه الفعالية.",
registrationNotRequired: "لا يلزم التسجيل",
registrationNotRequiredNote:
"هذه الفعالية مفتوحة للجميع، ولا حاجة إلى التسجيل.",
registrationFull:
"اكتمل العدد في هذه الفعالية. انضم إلى قائمة الانتظار وسنتواصل معك في حال توفّر مكان.",
eventCompleted: "انتهت هذه الفعالية.",
joinOnlineLabel: "رابط الانضمام",
addToCalendarHint: "يُنزّل ملف .ics يمكن فتحه في أي تطبيق تقويم.",
timeZoneNote: "جميع الأوقات معروضة بتوقيت ليبيا (UTC+2).",
consentLabel:
"أوافق على أن يحتفظ مشروع SHARP-ER بالبيانات أعلاه لإدارة تسجيلي في هذه الفعالية.",
consentHint:
"نحتفظ بها فقط للمدة التي تتطلبها الفعالية، ولا نشاركها مع أي جهة خارج المشروع. ويمكنك أن تطلب حذفها في أي وقت.",
waitlistConfirmationTitle: "تمت إضافتك إلى قائمة الانتظار.",
waitlistConfirmationBody:
"شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.",
},
opportunities: {
heading: "شارك معنا",
intro:
"سواء كنتم منظمة مجتمع مدني أو فردًا، إليكم كيفية المشاركة في SHARP-ER.",
metaDescription:
"دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.",
pathwaysHeading: "طرق المشاركة",
pathwayCallsTitle: "التقديم على دعوة للتعبير عن الاهتمام",
pathwayCallsBody:
"دعوات مفتوحة للمشاريع الفرعية لمنظمات المجتمع المدني ومبادرات «من السياسة إلى الحقوق» (P2R).",
pathwayNetworkTitle: "انضم إلى شبكة SHARP",
pathwayNetworkBody:
"كن جزءًا من شبكة المجتمع المدني وراء SHARP-ER: اجتماعات إقليمية، وتعلّم بين الأقران، وفعاليات مشتركة.",
pathwaySessionsTitle: "سجّل في حوار SUQ أو تدريب",
pathwaySessionsBody:
"شارك في حوار قادم من نوع «ابحث، افهم، اسأل» أو في تدريب على تيسير الحوار.",
listHeading: "الدعوات الحالية",
listLabel: "الدعوات والفرص",
statusFilterLabel: "الحالة",
empty: "لا توجد دعوات أو فرص لعرضها حتى الآن.",
emptyHint: "تُنشر هنا دعوات التعبير عن الاهتمام فور فتحها.",
unavailable:
"تعذّر تحميل الدعوات والفرص في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
},
opportunityTypes: {
"call-eoi": "دعوة للتعبير عن الاهتمام",
"network-membership": "عضوية الشبكة",
training: "تدريب",
grant: "منحة",
other: "فرصة",
},
opportunityDetail: {
backToList: "العودة إلى طلبات وفرص المشاركة",
aboutHeading: "عن هذه الفرصة",
detailsHeading: "تفاصيل الدعوة",
howToApplyHeading: "كيفية التقديم",
attachmentsHeading: "المستندات",
applyHeading: "قدّم طلبك",
statusLabel: "الحالة",
typeLabel: "النوع",
opensLabel: "تاريخ الفتح",
deadlineLabel: "الموعد النهائي",
formIntro: "أخبرنا باختصار عن سبب تقديمك. يراجع فريقنا كل طلب.",
messageHint: "صف باختصار منظمتك وسبب تقديمك.",
closedNote: "أُغلقت هذه الدعوة، وتبقى منشورة للاطّلاع.",
upcomingNote: "تُفتح هذه الدعوة بتاريخ {date}.",
externalApplication: "يتم التقديم على هذه الدعوة عبر موقع خارجي.",
applicationReceivedBody:
"شكرًا لتقديم طلبك. سيراجعه فريقنا ويتواصل معك عبر البريد الإلكتروني.",
},
form: {
honeypotLabel: "اترك هذا الحقل فارغًا",
submissionFailed:
"تعذّر إرسال بياناتك. يرجى المحاولة مرة أخرى بعد قليل.",
},
};
const COPY: Readonly<Record<Locale, RouteCopy>> = { en: EN, ar: AR };
/** The route copy for a locale. Falls back to English, never to "". */
export function getRouteCopy(locale: Locale): RouteCopy {
return COPY[locale] ?? EN;
}
@@ -0,0 +1,231 @@
/* ============================================================================
EVENTS - Strapi access. SERVER ONLY.
----------------------------------------------------------------------------
Maps the CMS entity onto the plain view model in ./events.ts, so the route
component never sees a Strapi shape and the loader payload stays small and
serialisable. The fault-tolerant fetch helpers come from ../_shared/cms, so
this section degrades exactly like Resources, Knowledge Hub and News do.
CONTENT TYPE api::event.event (collection, i18n enabled, draft & publish)
The field names below are copied from
apps/cms/src/api/event/content-types/event/schema.json - they are the
contract, and getting one wrong is a 400 from Strapi, not a blank field.
slug uid, required, targets `title`
title string, required
description text (max 500) - the card excerpt
body richtext - the detail page body
eventType enum suq-dialogue | training | law-session |
network-meeting | launch | other
startDateTime datetime, required
endDateTime datetime
timezone string, default "Africa/Tripoli"
format enum in-person | online | hybrid
venue string - "Bab Al-Bahr Hotel". There is NO
`city` field.
onlineUrl string - joining link for online / hybrid
region RELATION -> api::region.region (populate it; a
region is an entry, not a string)
registrationStatus enum open | closed | full | waitlist | not-required
capacity integer
waitlistEnabled boolean
registrationDeadline datetime
recapUrl string - "Read recap" for a past event
coverImage media (single image)
There is no external "registrationUrl" on this type: registration is either
handled by the on-site form or it is not open.
WHY THE WHOLE SET IS FETCHED
Upcoming / Ongoing / Completed are derived from the dates (see ./events.ts)
and cannot be expressed as a Strapi filter without duplicating the rule in
two places, where it would drift. The calendar of a two-year project is
small, so one request brings it back and the filtering, sorting and paging
happen in memory. EVENTS_FETCH_LIMIT is the ceiling; raise it or move to
date-range queries if the project ever outgrows it.
========================================================================= */
import type { Locale } from "~/i18n";
import type { StrapiEntity, StrapiMedia } from "~/lib/strapi";
import {
collectLocalizedSlugs,
LOCALIZATION_POPULATE,
type CmsLocalizable,
type LocalizedSlugs,
} from "../_shared/alternates";
import {
fetchBySlug,
fetchCollection,
toImage,
type CmsRichText,
type CmsTaxonomy,
} from "../_shared/cms";
import {
deriveTimeStatus,
EVENT_REGISTRATION_STATUSES,
isDateOnly,
type EventCardData,
type EventDetailData,
type EventFormat,
type EventRegistrationStatus,
} from "./events";
export const EVENTS_RESOURCE = "events";
/** Hard ceiling on one listing request. */
export const EVENTS_FETCH_LIMIT = 200;
interface StrapiEvent extends StrapiEntity, CmsLocalizable {
readonly slug: string;
readonly title: string;
readonly description?: string | null;
readonly body?: CmsRichText;
readonly startDateTime: string;
readonly endDateTime?: string | null;
readonly format?: EventFormat | null;
readonly venue?: string | null;
readonly region?: CmsTaxonomy | null;
readonly onlineUrl?: string | null;
readonly registrationStatus?: EventRegistrationStatus | null;
readonly registrationDeadline?: string | null;
readonly waitlistEnabled?: boolean | null;
readonly recapUrl?: string | null;
readonly coverImage?: StrapiMedia | null;
}
const FORMATS: readonly EventFormat[] = ["in-person", "online", "hybrid"];
/** What one listing request populates. Named so the two queries cannot drift. */
const LIST_POPULATE = {
coverImage: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
region: { fields: ["name", "slug"] },
} as const;
/* ---------------------------------------------------------------------------
MAPPING
------------------------------------------------------------------------ */
/** Trimmed, or undefined - so an empty CMS string never renders as a gap. */
function text(value: string | null | undefined): string | undefined {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed.length > 0 ? trimmed : undefined;
}
function oneOf<T extends string>(
value: unknown,
allowed: readonly T[],
): T | undefined {
return typeof value === "string" && (allowed as readonly string[]).includes(value)
? (value as T)
: undefined;
}
function toCard(entry: StrapiEvent, now: Date): EventCardData {
const start = entry.startDateTime;
const end = text(entry.endDateTime);
const allDay = isDateOnly(start);
return {
slug: entry.slug,
title: entry.title,
summary: text(entry.description),
start,
end,
allDay,
format: oneOf(entry.format, FORMATS),
place: text(entry.venue),
region: text(entry.region?.name),
/* The CMS default is "not-required", not "open": an event nobody has
opened registration for must not invite one. */
registrationStatus:
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
"not-required",
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
recapUrl: text(entry.recapUrl),
image: toImage(entry.coverImage, "medium"),
};
}
function toDetail(entry: StrapiEvent, now: Date): EventDetailData {
return {
...toCard(entry, now),
documentId: entry.documentId,
description: entry.body ?? undefined,
onlineUrl: text(entry.onlineUrl),
registrationDeadline: text(entry.registrationDeadline),
venue: text(entry.venue),
waitlistEnabled: entry.waitlistEnabled === true,
};
}
/* ---------------------------------------------------------------------------
QUERIES
------------------------------------------------------------------------ */
export interface EventListResult {
readonly events: EventCardData[];
/**
* False when the collection could not be read at all. The listing then
* renders its empty state with a notice rather than a 500 - an unreachable
* calendar is not a broken page, and the rest of the site still works.
*/
readonly available: boolean;
}
/** Every published event for a locale, mapped against a single "now". */
export async function fetchEvents(
locale: Locale,
now: Date = new Date(),
): Promise<EventListResult> {
const result = await fetchCollection<StrapiEvent>(EVENTS_RESOURCE, {
locale,
sort: ["startDateTime:asc"],
populate: LIST_POPULATE,
pagination: { page: 1, pageSize: EVENTS_FETCH_LIMIT },
});
return {
events: result.entries
.filter((entry) => Boolean(entry?.slug && entry.startDateTime))
.map((entry) => toCard(entry, now)),
available: result.available,
};
}
/**
* One event by slug, or null when the CMS has no such entry.
*
* A detail page does not degrade the way the listing does: a missing entry is
* a genuine 404, and a CMS outage propagates as a 500 rather than telling a
* crawler that a real event does not exist.
*/
export interface EventDetailResult {
readonly event: EventDetailData;
/** The slug of this event in every language the CMS holds it in. */
readonly alternates: LocalizedSlugs;
}
export async function fetchEvent(
slug: string,
locale: Locale,
now: Date = new Date(),
): Promise<EventDetailResult | null> {
const entry = await fetchBySlug<StrapiEvent>(EVENTS_RESOURCE, slug, {
locale,
populate: {
...LIST_POPULATE,
// The Arabic translation has its own slug - see ../_shared/alternates.ts.
localizations: LOCALIZATION_POPULATE,
},
});
if (!entry) return null;
return {
event: toDetail(entry, now),
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
};
}
@@ -0,0 +1,408 @@
/* ============================================================================
EVENTS - view model, status derivation, formatting
----------------------------------------------------------------------------
PURE. No server import, so the same helpers run in the loader and in the
component. Everything that talks to Strapi lives in ./events.server.ts.
The three calendar states the draft asks for (Upcoming / Ongoing /
Completed, draft section 4) are DERIVED from the dates rather than stored,
so an editor cannot forget to move an event to "Completed" the morning
after it happened. The registration state (Open / Closed / Full) is a real
editorial decision and does come from the CMS.
========================================================================= */
import {
formatDate,
formatDateRange,
translate,
type Locale,
type TranslationKey,
} from "~/i18n";
import type { CmsImage, CmsRichText } from "../_shared/cms";
import { getRouteCopy } from "./copy";
/* ---------------------------------------------------------------------------
TIME ZONE
The project is nationwide Libya, which sits on UTC+2 all year (no DST since
2013). Pinning the zone keeps the server-rendered string and the hydrated
one identical whatever the reader's device is set to, and shows the local
time of the room the event is held in.
------------------------------------------------------------------------ */
export const EVENT_TIME_ZONE = "Africa/Tripoli";
const EVENT_UTC_OFFSET = "+02:00";
/* ---------------------------------------------------------------------------
TYPES
------------------------------------------------------------------------ */
/** How an event is attended. Mirrors the CMS enumeration. */
export type EventFormat = "in-person" | "online" | "hybrid";
/**
* Editorial registration state. The five values are the CMS enumeration
* `event.registrationStatus`, verbatim - draft 4 names only the first three,
* but an editor can also mark a full event as taking a waitlist, or say that
* an event needs no registration at all.
*/
export type EventRegistrationStatus =
| "open"
| "closed"
| "full"
| "waitlist"
| "not-required";
export const EVENT_REGISTRATION_STATUSES = [
"open",
"closed",
"full",
"waitlist",
"not-required",
] as const satisfies readonly EventRegistrationStatus[];
/** Derived from the dates, never stored. */
export type EventTimeStatus = "upcoming" | "ongoing" | "completed";
/** The filter tabs: All - Upcoming - Ongoing - Completed (draft 4). */
export type EventStatusFilter = "all" | EventTimeStatus;
export const EVENT_STATUS_FILTERS: readonly EventStatusFilter[] = [
"all",
"upcoming",
"ongoing",
"completed",
];
export function isEventStatusFilter(value: unknown): value is EventStatusFilter {
return (
typeof value === "string" &&
(EVENT_STATUS_FILTERS as readonly string[]).includes(value)
);
}
/** What a card needs. Plain and serialisable - it crosses the loader boundary. */
export interface EventCardData {
readonly slug: string;
readonly title: string;
readonly summary?: string;
/** ISO 8601. Date-only when `allDay`. */
readonly start: string;
readonly end?: string;
readonly allDay: boolean;
readonly format?: EventFormat;
/** The venue. Absent for an online-only event - the CMS has no `city`. */
readonly place?: string;
/** Name of the related region entry, not a free-text string. */
readonly region?: string;
readonly registrationStatus: EventRegistrationStatus;
readonly timeStatus: EventTimeStatus;
/** Set on a past event that has a write-up - drives the "Read recap" label. */
readonly recapUrl?: string;
readonly image?: CmsImage;
}
/** Everything the detail page renders. */
export interface EventDetailData extends EventCardData {
readonly documentId: string;
/** The long body. There is no external registration portal field in the CMS. */
readonly description?: CmsRichText;
readonly onlineUrl?: string;
readonly registrationDeadline?: string;
readonly venue?: string;
/** Editorial: the CMS offers a waitlist once the event is full. */
readonly waitlistEnabled: boolean;
}
/* ---------------------------------------------------------------------------
TIME
------------------------------------------------------------------------ */
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
/** True for a CMS `date` field ("2026-03-12"), false for a `datetime`. */
export function isDateOnly(value: string | null | undefined): boolean {
return typeof value === "string" && DATE_ONLY.test(value.trim());
}
/** "2026-03-12" for an instant, read in Libyan local time. */
function libyanCalendarDay(value: Date): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: EVENT_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(value);
}
/** The last instant of the Libyan calendar day that contains `value`. */
function endOfLibyanDay(value: Date): Date {
return new Date(
`${libyanCalendarDay(value)}T23:59:59.999${EVENT_UTC_OFFSET}`,
);
}
interface EventInterval {
readonly start: string;
readonly end?: string;
readonly allDay: boolean;
}
/**
* When an event is over.
* - explicit `end` on a timed event -> that instant
* - all-day (with or without `end`) -> the end of the last day
* - timed event with no `end` -> the end of its own day, so an
* evening dialogue reads "Ongoing" for the rest of that day and
* "Completed" from midnight, rather than the instant it starts.
*/
export function eventEndsAt(event: EventInterval): Date | null {
const start = new Date(event.start);
if (Number.isNaN(start.getTime())) return null;
if (event.allDay) {
const last = event.end ? new Date(event.end) : start;
return endOfLibyanDay(Number.isNaN(last.getTime()) ? start : last);
}
if (event.end) {
const end = new Date(event.end);
if (!Number.isNaN(end.getTime())) return end;
}
return endOfLibyanDay(start);
}
/** Upcoming / Ongoing / Completed for an instant in time. */
export function deriveTimeStatus(
event: EventInterval,
now: Date = new Date(),
): EventTimeStatus {
const start = new Date(event.start);
if (Number.isNaN(start.getTime())) return "upcoming";
const end = eventEndsAt(event) ?? start;
const at = now.getTime();
if (at < start.getTime()) return "upcoming";
return at <= end.getTime() ? "ongoing" : "completed";
}
/* ---------------------------------------------------------------------------
FORMATTING
------------------------------------------------------------------------ */
/**
* "12 March 2026, 14:00" / "12–14 March 2026" for an all-day range.
* `withTimeZone` adds the zone abbreviation - use it on the detail page,
* where the reader is deciding whether they can attend.
*/
export function formatEventWhen(
event: EventInterval,
locale: Locale,
options: { readonly withTimeZone?: boolean } = {},
): string {
if (event.allDay) {
// A date-only value parses as UTC midnight, so it is read back in UTC to
// land on the day the editor typed.
return formatDateRange(event.start, event.end ?? null, locale, {
dateStyle: "long",
timeZone: "UTC",
});
}
// `dateStyle`/`timeStyle` cannot be combined with `timeZoneName`, so the
// components are spelled out.
const parts: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: EVENT_TIME_ZONE,
...(options.withTimeZone ? { timeZoneName: "short" } : {}),
};
if (event.end && !isDateOnly(event.end)) {
return formatDateRange(event.start, event.end, locale, parts);
}
return formatDate(event.start, locale, parts);
}
/** The short date used in a confirmation sentence ("… on 12 March 2026"). */
export function formatEventDay(
event: EventInterval,
locale: Locale,
): string {
return formatDate(event.start, locale, {
dateStyle: "long",
timeZone: event.allDay ? "UTC" : EVENT_TIME_ZONE,
});
}
/* ---------------------------------------------------------------------------
LIST OPERATIONS
The calendar of a two-year project is small enough to sort, filter and
paginate in memory, which keeps the derived Upcoming/Ongoing/Completed
states honest - Strapi cannot express them without duplicating the rule in
a query. `EVENTS_FETCH_LIMIT` in ./events.server.ts is the ceiling.
------------------------------------------------------------------------ */
const MS_PER_DAY = 86_400_000;
function startTime(event: EventCardData): number {
const value = new Date(event.start).getTime();
return Number.isNaN(value) ? 0 : value;
}
/**
* Upcoming and ongoing events first, soonest at the top; completed ones after
* them, most recent first. Filtering to a single tab keeps that tab's order.
*/
export function sortEvents(
events: readonly EventCardData[],
): EventCardData[] {
return [...events].sort((a, b) => {
const aPast = a.timeStatus === "completed";
const bPast = b.timeStatus === "completed";
if (aPast !== bPast) return aPast ? 1 : -1;
return aPast
? startTime(b) - startTime(a)
: startTime(a) - startTime(b);
});
}
export interface EventFilters {
readonly status: EventStatusFilter;
readonly region: string | null;
}
export function filterEvents(
events: readonly EventCardData[],
filters: EventFilters,
): EventCardData[] {
return events.filter((event) => {
if (filters.status !== "all" && event.timeStatus !== filters.status) {
return false;
}
if (filters.region && event.region !== filters.region) return false;
return true;
});
}
/** Every region that actually has an event, in the reader's collation order. */
export function collectRegions(
events: readonly EventCardData[],
locale: Locale,
): string[] {
const regions = new Set<string>();
for (const event of events) {
if (event.region) regions.add(event.region);
}
return [...regions].sort((a, b) => a.localeCompare(b, locale));
}
/* ---------------------------------------------------------------------------
REGISTRATION
------------------------------------------------------------------------ */
/**
* True when the on-site form should be shown at all. "not-required" means the
* reader simply turns up, so there is nothing to submit; "closed" speaks for
* itself; "full" and "waitlist" still take names for the waiting list.
*/
export function isRegistrationOpen(event: EventCardData): boolean {
return (
event.timeStatus !== "completed" &&
(event.registrationStatus === "open" ||
event.registrationStatus === "full" ||
event.registrationStatus === "waitlist")
);
}
/**
* The label of the card's call to action. Draft section 4 names all four:
* "Register", "Apply", "Read recap" and, for an event whose registration has
* not opened or has closed, plain "View details".
*/
export function eventActionKey(event: EventCardData): TranslationKey {
if (event.timeStatus === "completed") {
return event.recapUrl ? "actions.readRecap" : "actions.viewDetails";
}
if (
event.registrationStatus === "full" ||
event.registrationStatus === "waitlist"
) {
return "events.joinWaitlist";
}
if (
event.registrationStatus === "closed" ||
event.registrationStatus === "not-required"
) {
return "actions.viewDetails";
}
return "actions.register";
}
/** Catalogue key for the Upcoming / Ongoing / Completed badge. */
export function eventTimeStatusKey(status: EventTimeStatus): TranslationKey {
return status === "upcoming"
? "filters.upcoming"
: status === "ongoing"
? "filters.ongoing"
: "filters.completed";
}
/**
* The registration badge label.
*
* Not a catalogue KEY on its own: the shared catalogue names Open / Closed /
* Full, and `event.registrationStatus` has two states beyond those, so
* "not-required" comes from this section's copy in ./copy.ts and "waitlist"
* reads "Full" - which is what a reader needs to know about the places left.
*/
export function eventRegistrationLabel(
status: EventRegistrationStatus,
locale: Locale,
): string {
if (status === "not-required") {
return getRouteCopy(locale).eventDetail.registrationNotRequired;
}
const key: TranslationKey =
status === "open"
? "events.statusOpen"
: status === "closed"
? "events.statusClosed"
: "events.statusFull";
return translate(locale, key);
}
/**
* True when a submission joins the waitlist rather than taking a place.
*
* KNOWN SERVER-SIDE LIMITATION: the CMS middleware
* (apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts)
* hard-sets `registrationState: "confirmed"` on every create and the field is
* not in the client whitelist, so this intent CANNOT be transmitted. The page
* tells the reader the truth about the EVENT (it is full, this is a waitlist
* request); the stored row still says "confirmed" until that middleware is
* fixed, and staff triage it. Nothing here fakes a waitlist flag the API
* would reject.
*/
export function isWaitlisted(event: EventCardData): boolean {
return (
event.registrationStatus === "full" ||
event.registrationStatus === "waitlist"
);
}
/** Days until an event starts - used only to decide "registers soon" copy. */
export function daysUntil(event: EventInterval, now: Date = new Date()): number {
const start = new Date(event.start).getTime();
if (Number.isNaN(start)) return 0;
return Math.ceil((start - now.getTime()) / MS_PER_DAY);
}
@@ -0,0 +1,152 @@
/* ============================================================================
FORM PARSING AND VALIDATION - pure
----------------------------------------------------------------------------
Actions validate, components render. The action returns error CODES, never
sentences, so the same result is correct on /en and /ar and the message
catalogue stays the only place a wording lives.
Both forms work without JavaScript: they are plain <Form method="post">
submissions, so validation has to be complete on the server. There is no
client mirror of these rules to drift out of sync.
========================================================================= */
import type { TranslationKey } from "~/i18n";
/**
* The failures a reader can act on. Codes, not sentences.
*
* `consent` is its own code because "This field is required." is the wrong
* sentence for an unticked checkbox: an unticked box is a decision, not an
* omission, so it gets its own wording.
*/
export type FieldErrorCode = "required" | "email" | "consent";
export type FieldErrors = Readonly<Record<string, FieldErrorCode>>;
/**
* Message catalogue key for each code - TOTAL over `FieldErrorCode`, so a new
* code cannot be added without giving it a sentence in both languages. The
* catalogue is the only place a wording lives (see the header), which is why
* `consent` resolves here and not out of this section's own copy.
*/
export const FIELD_ERROR_KEYS: Readonly<
Record<FieldErrorCode, TranslationKey>
> = {
required: "form.errorRequired",
email: "form.errorEmail",
consent: "form.errorConsent",
};
/**
* A field no human fills in. A bot that fills every input gets a 200 and an
* acknowledgement, and nothing is written to the CMS.
*/
export const HONEYPOT_FIELD = "website";
/* Deliberately permissive: one @, no spaces, a dot in the domain. Anything
stricter rejects valid addresses, and only a delivered email proves one. */
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
/** Upper bounds, applied by truncation rather than by an error message. */
const LIMITS = {
name: 120,
organisation: 160,
email: 254,
phone: 40,
message: 4000,
} as const;
function field(form: FormData, name: string, limit: number): string {
const value = form.get(name);
return typeof value === "string" ? value.trim().slice(0, limit) : "";
}
export function isHoneypotFilled(form: FormData): boolean {
const value = form.get(HONEYPOT_FIELD);
return typeof value === "string" && value.trim().length > 0;
}
/* ---------------------------------------------------------------------------
EVENT REGISTRATION - draft 4
------------------------------------------------------------------------ */
export interface RegistrationValues {
readonly fullName: string;
readonly email: string;
readonly organisation: string;
readonly phone: string;
/**
* `event-registration.consentGdpr` is REQUIRED in the CMS: a submission
* without it is rejected with a 400, so the checkbox is a real, labelled
* control on the form and a real validation rule here - never a hidden
* input that consents on the reader's behalf.
*/
readonly consentGdpr: boolean;
}
/** The name of the consent checkbox, shared by the form and the validator. */
export const CONSENT_FIELD = "consentGdpr";
/** A checkbox is either absent or carries its value ("on" by default). */
function checkbox(form: FormData, name: string): boolean {
const value = form.get(name);
return typeof value === "string" && value.length > 0 && value !== "false";
}
export interface Validated<T> {
readonly values: T;
readonly errors: FieldErrors;
readonly valid: boolean;
}
export function validateRegistration(
form: FormData,
): Validated<RegistrationValues> {
const values: RegistrationValues = {
fullName: field(form, "fullName", LIMITS.name),
email: field(form, "email", LIMITS.email),
organisation: field(form, "organisation", LIMITS.organisation),
phone: field(form, "phone", LIMITS.phone),
consentGdpr: checkbox(form, CONSENT_FIELD),
};
const errors: Record<string, FieldErrorCode> = {};
if (!values.fullName) errors.fullName = "required";
if (!values.email) errors.email = "required";
else if (!EMAIL.test(values.email)) errors.email = "email";
if (!values.consentGdpr) errors[CONSENT_FIELD] = "consent";
return { values, errors, valid: Object.keys(errors).length === 0 };
}
/* ---------------------------------------------------------------------------
OPPORTUNITY APPLICATION - draft 8
------------------------------------------------------------------------ */
export interface ApplicationValues {
readonly fullName: string;
readonly email: string;
readonly organisation: string;
readonly message: string;
}
export function validateApplication(
form: FormData,
): Validated<ApplicationValues> {
const values: ApplicationValues = {
fullName: field(form, "fullName", LIMITS.name),
email: field(form, "email", LIMITS.email),
organisation: field(form, "organisation", LIMITS.organisation),
message: field(form, "message", LIMITS.message),
};
const errors: Record<string, FieldErrorCode> = {};
if (!values.fullName) errors.fullName = "required";
if (!values.email) errors.email = "required";
else if (!EMAIL.test(values.email)) errors.email = "email";
if (!values.message) errors.message = "required";
return { values, errors, valid: Object.keys(errors).length === 0 };
}
@@ -0,0 +1,219 @@
/* ============================================================================
iCALENDAR (RFC 5545)
----------------------------------------------------------------------------
Builds the file behind "Add to calendar". Pure, dependency-free and written
against the spec rather than against one client's tolerance:
* CRLF line endings, and content lines folded at 75 OCTETS - folded on
character boundaries, so an Arabic title (2 bytes per letter) is never
cut in half;
* TEXT values escaped for \ ; , and newlines;
* timed events written as UTC (`...Z`), all-day events as VALUE=DATE with
a DTEND on the day AFTER the last day, which is what "exclusive end"
means in the spec and what every calendar client expects;
* a stable UID, so re-importing an updated file replaces the entry
instead of duplicating it.
No ORGANIZER property: it requires a real mailto: address, and the project
inbox is still marked TBD in the draft copy. An invented one would end up in
readers' calendars.
========================================================================= */
const CRLF = "\r\n";
/** RFC 5545 3.1: a content line is at most 75 octets, excluding the CRLF. */
const MAX_OCTETS = 75;
const encoder = new TextEncoder();
export type IcsStatus = "CONFIRMED" | "TENTATIVE" | "CANCELLED";
export interface IcsEvent {
/** Globally unique and STABLE for this event. */
readonly uid: string;
/** ISO 8601. */
readonly start: string;
readonly end?: string | undefined;
/** Write the all-day form (VALUE=DATE) instead of a timed one. */
readonly allDay?: boolean;
readonly summary: string;
readonly description?: string | undefined;
readonly location?: string | undefined;
readonly url?: string | undefined;
readonly status?: IcsStatus;
/** DTSTAMP. Defaults to now; pass a fixed value in a test. */
readonly stamp?: Date;
}
export interface IcsCalendarOptions {
/** PRODID. Identifies the software that wrote the file. */
readonly prodId?: string;
/** X-WR-CALNAME, shown by some clients when a file is imported. */
readonly name?: string;
}
const DEFAULT_PROD_ID = "-//SHARP-ER//Events Calendar//EN";
/* ---------------------------------------------------------------------------
VALUE ENCODING
------------------------------------------------------------------------ */
/**
* Control characters a TEXT value may not carry. TAB is legal and the two
* newline characters are escaped before this runs, so neither is in the class.
*/
const CONTROL_CHARS =
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
/** RFC 5545 3.3.11 TEXT escaping, plus control characters dropped. */
export function escapeIcsText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r\n|\r|\n/g, "\\n")
.replace(CONTROL_CHARS, "");
}
/** "20260312T140000Z" */
function toUtcStamp(date: Date): string {
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
/** "20260312" - the calendar day of a date-only value, read in UTC. */
function toDateValue(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, "");
}
function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 86_400_000);
}
/**
* Folds one content line to 75 octets. Continuation lines begin with a single
* space, which the parser strips again.
*/
export function foldIcsLine(line: string): string {
if (encoder.encode(line).length <= MAX_OCTETS) return line;
const parts: string[] = [];
let current = "";
let octets = 0;
// Iterating the string yields whole code points, so a multi-byte character
// is never split across two lines.
for (const char of line) {
const size = encoder.encode(char).length;
if (octets + size > MAX_OCTETS) {
parts.push(current);
current = " ";
octets = 1;
}
current += char;
octets += size;
}
parts.push(current);
return parts.join(CRLF);
}
/* ---------------------------------------------------------------------------
BUILDER
------------------------------------------------------------------------ */
function property(name: string, value: string): string {
return foldIcsLine(`${name}:${value}`);
}
function eventLines(event: IcsEvent): string[] {
const start = new Date(event.start);
if (Number.isNaN(start.getTime())) return [];
const parsedEnd = event.end ? new Date(event.end) : undefined;
const end =
parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : undefined;
const lines: string[] = ["BEGIN:VEVENT"];
lines.push(property("UID", escapeIcsText(event.uid)));
lines.push(property("DTSTAMP", toUtcStamp(event.stamp ?? new Date())));
if (event.allDay) {
lines.push(`DTSTART;VALUE=DATE:${toDateValue(start)}`);
// DTEND is exclusive: the day after the last day of the event.
lines.push(`DTEND;VALUE=DATE:${toDateValue(addDays(end ?? start, 1))}`);
} else {
lines.push(property("DTSTART", toUtcStamp(start)));
if (end && end.getTime() > start.getTime()) {
lines.push(property("DTEND", toUtcStamp(end)));
}
}
lines.push(property("SUMMARY", escapeIcsText(event.summary)));
if (event.description) {
lines.push(property("DESCRIPTION", escapeIcsText(event.description)));
}
if (event.location) {
lines.push(property("LOCATION", escapeIcsText(event.location)));
}
if (event.url) {
// A URI value is not TEXT and must not be escaped.
lines.push(property("URL", event.url));
}
lines.push(property("STATUS", event.status ?? "CONFIRMED"));
lines.push("END:VEVENT");
return lines;
}
/** A complete VCALENDAR document, CRLF-terminated as the spec requires. */
export function buildIcsCalendar(
events: readonly IcsEvent[],
options: IcsCalendarOptions = {},
): string {
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
property("PRODID", options.prodId ?? DEFAULT_PROD_ID),
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
];
if (options.name) {
lines.push(property("X-WR-CALNAME", escapeIcsText(options.name)));
}
for (const event of events) lines.push(...eventLines(event));
lines.push("END:VCALENDAR");
return `${lines.join(CRLF)}${CRLF}`;
}
/* ---------------------------------------------------------------------------
RESPONSE
------------------------------------------------------------------------ */
/** ASCII-safe file name, with the UTF-8 original kept in `filename*`. */
function contentDisposition(filename: string): string {
const ascii = filename.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
const fallback = ascii.length > 4 ? ascii : "event.ics";
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(
filename,
)}`;
}
/** The whole HTTP response for a .ics download. */
export function icsResponse(body: string, filename: string): Response {
return new Response(body, {
status: 200,
headers: {
"content-type": "text/calendar; charset=utf-8",
"content-disposition": contentDisposition(filename),
// The file changes whenever the event does, and it is cheap to rebuild.
"cache-control": "public, max-age=0, must-revalidate",
},
});
}
@@ -0,0 +1,212 @@
/* ============================================================================
OPPORTUNITIES - Strapi access. SERVER ONLY.
----------------------------------------------------------------------------
CONTENT TYPE api::opportunity.opportunity
(collection, i18n enabled, draft & publish)
Field names copied from
apps/cms/src/api/opportunity/content-types/opportunity/schema.json.
slug uid, required, targets `title`
title string, required
summary text (max 500) - the card excerpt
body richtext
eligibility richtext - draft §8 marks the network
criteria "TBD"; the page shows
this block only when it is filled
howToApply richtext
opportunityType enum call-eoi | network-membership | training |
grant | other
opportunityStatus enum upcoming | open | closed (required, default
"upcoming" - see deriveOpportunityStatus)
openDate date - before it, the call is "Upcoming"
deadline datetime - inclusive; after it, "Closed"
externalApplicationUrl string - an EXTERNAL portal; when set,
the on-site form is not rendered
attachments media (multiple) - ToR, guidelines, annexes
coverImage media (single image)
There is NO `applicationEmail` and NO `responseWeeks` on this type: the
draft marks the response time TBD and the CMS never grew a field for it, so
the acknowledgement never names a number.
Where the applications collection is written: ./applications.server.ts.
========================================================================= */
import type { Locale } from "~/i18n";
import type { StrapiEntity, StrapiMedia } from "~/lib/strapi";
import {
collectLocalizedSlugs,
LOCALIZATION_POPULATE,
type CmsLocalizable,
type LocalizedSlugs,
} from "../_shared/alternates";
import {
fetchBySlug,
fetchCollection,
toFile,
toImage,
type CmsFile,
type CmsRichText,
} from "../_shared/cms";
import {
deriveOpportunityStatus,
OPPORTUNITY_TYPES,
type OpportunityCardData,
type OpportunityDetailData,
type OpportunityStatus,
type OpportunityType,
} from "./opportunities";
export const OPPORTUNITIES_RESOURCE = "opportunities";
/** Hard ceiling on one listing request. */
export const OPPORTUNITIES_FETCH_LIMIT = 100;
interface StrapiOpportunity extends StrapiEntity, CmsLocalizable {
readonly slug: string;
readonly title: string;
readonly summary?: string | null;
readonly body?: CmsRichText;
readonly eligibility?: CmsRichText;
readonly howToApply?: CmsRichText;
readonly opportunityType?: OpportunityType | null;
readonly opportunityStatus?: OpportunityStatus | null;
readonly openDate?: string | null;
readonly deadline?: string | null;
readonly externalApplicationUrl?: string | null;
readonly attachments?: readonly StrapiMedia[] | null;
readonly coverImage?: StrapiMedia | null;
}
const STATUSES: readonly OpportunityStatus[] = ["open", "closed", "upcoming"];
const LIST_POPULATE = {
coverImage: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
} as const;
/* ---------------------------------------------------------------------------
MAPPING
------------------------------------------------------------------------ */
function text(value: string | null | undefined): string | undefined {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed.length > 0 ? trimmed : undefined;
}
function oneOf<T extends string>(
value: unknown,
allowed: readonly T[],
): T | undefined {
return typeof value === "string" && (allowed as readonly string[]).includes(value)
? (value as T)
: undefined;
}
function toCard(entry: StrapiOpportunity, now: Date): OpportunityCardData {
return {
slug: entry.slug,
title: entry.title,
summary: text(entry.summary),
status: deriveOpportunityStatus(
{
openDate: text(entry.openDate),
deadline: text(entry.deadline),
status: oneOf(entry.opportunityStatus, STATUSES),
},
now,
),
openDate: text(entry.openDate),
deadline: text(entry.deadline),
opportunityType: oneOf(entry.opportunityType, OPPORTUNITY_TYPES),
image: toImage(entry.coverImage, "medium"),
};
}
function toDetail(entry: StrapiOpportunity, now: Date): OpportunityDetailData {
const attachments = (entry.attachments ?? [])
.map((media) => toFile(media))
.filter((file): file is CmsFile => file !== undefined);
return {
...toCard(entry, now),
documentId: entry.documentId,
description: entry.body ?? undefined,
eligibility: entry.eligibility ?? undefined,
howToApply: entry.howToApply ?? undefined,
externalApplicationUrl: text(entry.externalApplicationUrl),
attachments,
};
}
/* ---------------------------------------------------------------------------
QUERIES
------------------------------------------------------------------------ */
export interface OpportunityListResult {
readonly opportunities: OpportunityCardData[];
/** False when the collection could not be read at all. */
readonly available: boolean;
}
/**
* Every published opportunity for a locale. Open / Upcoming / Closed is
* derived from the dates (see ./opportunities.ts), so - as with the events
* calendar - the set is fetched once and filtered in memory rather than
* duplicating that rule in a Strapi query.
*/
export async function fetchOpportunities(
locale: Locale,
now: Date = new Date(),
): Promise<OpportunityListResult> {
const result = await fetchCollection<StrapiOpportunity>(
OPPORTUNITIES_RESOURCE,
{
locale,
sort: ["deadline:asc", "title:asc"],
populate: LIST_POPULATE,
pagination: { page: 1, pageSize: OPPORTUNITIES_FETCH_LIMIT },
},
);
return {
opportunities: result.entries
.filter((entry) => Boolean(entry?.slug && entry.title))
.map((entry) => toCard(entry, now)),
available: result.available,
};
}
export interface OpportunityDetailResult {
readonly opportunity: OpportunityDetailData;
readonly alternates: LocalizedSlugs;
}
/** One opportunity by slug, or null when the CMS has no such entry. */
export async function fetchOpportunity(
slug: string,
locale: Locale,
now: Date = new Date(),
): Promise<OpportunityDetailResult | null> {
const entry = await fetchBySlug<StrapiOpportunity>(
OPPORTUNITIES_RESOURCE,
slug,
{
locale,
populate: {
...LIST_POPULATE,
attachments: { fields: ["url", "name", "ext", "mime", "size"] },
localizations: LOCALIZATION_POPULATE,
},
},
);
if (!entry) return null;
return {
opportunity: toDetail(entry, now),
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
};
}
@@ -0,0 +1,227 @@
/* ============================================================================
OPPORTUNITIES - view model, status derivation
----------------------------------------------------------------------------
PURE. Strapi access lives in ./opportunities.server.ts.
Draft section 8 asks for a status tag of Open / Closed, and the listing
additionally distinguishes a call that has been announced but has not opened
yet. The status is derived from `openDate` / `deadline` so it cannot go
stale; `opportunityStatus` in the CMS is required and defaults to
"upcoming", so it is used as an editorial CLOSE and as the fallback when a
call carries no dates at all - never as a blanket override, which would let
a forgotten default hide an open call.
========================================================================= */
import {
formatDate,
formatFileSize,
type Locale,
type TranslationKey,
} from "~/i18n";
import type { CmsFile, CmsImage, CmsRichText } from "../_shared/cms";
/* ---------------------------------------------------------------------------
TYPES
------------------------------------------------------------------------ */
export type OpportunityStatus = "open" | "closed" | "upcoming";
/** The filter tabs: All - Open - Upcoming - Closed. */
export type OpportunityStatusFilter = "all" | OpportunityStatus;
export const OPPORTUNITY_STATUS_FILTERS: readonly OpportunityStatusFilter[] = [
"all",
"open",
"upcoming",
"closed",
];
export function isOpportunityStatusFilter(
value: unknown,
): value is OpportunityStatusFilter {
return (
typeof value === "string" &&
(OPPORTUNITY_STATUS_FILTERS as readonly string[]).includes(value)
);
}
/**
* `opportunity.opportunityType` in the CMS, verbatim. Only
* `network-membership` carries behaviour - it is what the "Join the SHARP
* Network" card links to when the CMS holds such an entry.
*/
export type OpportunityType =
| "call-eoi"
| "network-membership"
| "training"
| "grant"
| "other";
export const OPPORTUNITY_TYPES = [
"call-eoi",
"network-membership",
"training",
"grant",
"other",
] as const satisfies readonly OpportunityType[];
export interface OpportunityCardData {
readonly slug: string;
readonly title: string;
readonly summary?: string;
readonly status: OpportunityStatus;
/** ISO date the call opens - `openDate` in the CMS. */
readonly openDate?: string;
/** ISO date applications close. */
readonly deadline?: string;
readonly opportunityType?: OpportunityType;
readonly image?: CmsImage;
}
export interface OpportunityDetailData extends OpportunityCardData {
readonly documentId: string;
readonly description?: CmsRichText;
readonly eligibility?: CmsRichText;
readonly howToApply?: CmsRichText;
/** An external application portal. When set, the on-site form is not shown. */
readonly externalApplicationUrl?: string;
/** ToR, guidelines, annexes - `attachments` in the CMS. */
readonly attachments: readonly CmsFile[];
}
/* ---------------------------------------------------------------------------
STATUS
------------------------------------------------------------------------ */
export interface OpportunityWindow {
readonly openDate?: string | null;
readonly deadline?: string | null;
/** `opportunity.opportunityStatus`. Required in the CMS, default "upcoming". */
readonly status?: OpportunityStatus | null;
}
/** A deadline is inclusive: a call closes at the end of its closing day. */
function endOfDay(value: string): number {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return Number.NaN;
date.setUTCHours(23, 59, 59, 999);
return date.getTime();
}
/**
* Precedence, in order:
* 1. an editorial "closed" - a call can be suspended or closed early, and
* that decision must win over a deadline that has not passed yet;
* 2. the dates, when the entry carries any - they cannot go stale;
* 3. whatever `opportunityStatus` says, for a call with no dates at all.
*/
export function deriveOpportunityStatus(
entry: OpportunityWindow,
now: Date = new Date(),
): OpportunityStatus {
if (entry.status === "closed") return "closed";
const at = now.getTime();
let dated = false;
if (entry.openDate) {
const opens = new Date(entry.openDate).getTime();
if (!Number.isNaN(opens)) {
dated = true;
if (at < opens) return "upcoming";
}
}
if (entry.deadline) {
const closes = endOfDay(entry.deadline);
if (!Number.isNaN(closes)) {
dated = true;
if (at > closes) return "closed";
}
}
return dated ? "open" : (entry.status ?? "open");
}
/** Catalogue key for the Open / Upcoming / Closed badge. */
export function opportunityStatusKey(
status: OpportunityStatus,
): TranslationKey {
return status === "open"
? "opportunities.statusOpen"
: status === "upcoming"
? "filters.upcoming"
: "opportunities.statusClosed";
}
/** Label of the card's call to action - only an open call invites an apply. */
export function opportunityActionKey(
status: OpportunityStatus,
): TranslationKey {
return status === "open" ? "actions.apply" : "actions.viewDetails";
}
/* ---------------------------------------------------------------------------
LIST OPERATIONS
------------------------------------------------------------------------ */
function deadlineTime(item: OpportunityCardData): number {
const value = item.deadline ? new Date(item.deadline).getTime() : Number.NaN;
return Number.isNaN(value) ? Number.POSITIVE_INFINITY : value;
}
const STATUS_ORDER: Readonly<Record<OpportunityStatus, number>> = {
open: 0,
upcoming: 1,
closed: 2,
};
/** Open calls first with the nearest deadline at the top, closed ones last. */
export function sortOpportunities(
items: readonly OpportunityCardData[],
): OpportunityCardData[] {
return [...items].sort((a, b) => {
const byStatus = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
if (byStatus !== 0) return byStatus;
return a.status === "closed"
? deadlineTime(b) - deadlineTime(a)
: deadlineTime(a) - deadlineTime(b);
});
}
export function filterOpportunities(
items: readonly OpportunityCardData[],
status: OpportunityStatusFilter,
): OpportunityCardData[] {
if (status === "all") return [...items];
return items.filter((item) => item.status === status);
}
/** The CMS entry the "Join the SHARP Network" card should link to, if any. */
export function findNetworkOpportunity(
items: readonly OpportunityCardData[],
): OpportunityCardData | undefined {
return items.find((item) => item.opportunityType === "network-membership");
}
/* ---------------------------------------------------------------------------
FORMATTING
------------------------------------------------------------------------ */
/** "12 March 2026" - a deadline is a calendar day, never a time of day. */
export function formatOpportunityDate(
value: string | null | undefined,
locale: Locale,
): string {
return formatDate(value, locale, { dateStyle: "long", timeZone: "UTC" });
}
/** "PDF · 1.2 MB", or just the type when the upload records no size. */
export function formatAttachmentMeta(file: CmsFile, locale: Locale): string {
const size =
file.sizeBytes > 0 ? formatFileSize(file.sizeBytes, locale) : undefined;
return [file.kind || undefined, size].filter(Boolean).join(" · ");
}
@@ -0,0 +1,71 @@
/* ============================================================================
ROOT LOADER DATA, READ FROM `meta`
----------------------------------------------------------------------------
`buildMeta` needs the locale, the pathname and the site origin, and all
three are resolved once per request by the root loader. A `meta` function
reaches them through its `matches`.
React Router 8 names that property `loaderData` on every match (see
`CreateMetaArgs` in react-router/lib/types/route-module-annotations.d.ts and
the `Meta` component that builds the array). Version 7 called it `data`, and
plenty of examples still do, so both are read here.
Defensive rather than indexed: `matches[0]` has no data at all while the
root loader is in flight or on an error render, and a `meta` that throws
takes the whole document down with it.
========================================================================= */
import { DEFAULT_LOCALE, isLocale, localizedPath, type Locale } from "~/i18n";
export interface RootSeo {
readonly locale: Locale;
/** Pathname INCLUDING the locale prefix. */
readonly pathname: string;
/** Site origin without a trailing slash. */
readonly siteUrl: string;
}
/** Matches the default in ~/lib/env.server, for the case root data is absent. */
const FALLBACK_SITE_URL = "https://sharper.ly";
function readMatchData(match: unknown): Record<string, unknown> | undefined {
if (!match || typeof match !== "object") return undefined;
const { id, loaderData, data } = match as {
id?: unknown;
loaderData?: unknown;
data?: unknown;
};
if (id !== "root") return undefined;
const payload = loaderData ?? data;
return payload && typeof payload === "object"
? (payload as Record<string, unknown>)
: undefined;
}
/** The root loader's `{ locale, pathname, siteUrl }`, with safe fallbacks. */
export function readRootSeo(matches: readonly unknown[]): RootSeo {
for (const match of matches) {
const record = readMatchData(match);
if (!record) continue;
const locale = isLocale(record.locale) ? record.locale : DEFAULT_LOCALE;
return {
locale,
pathname:
typeof record.pathname === "string"
? record.pathname
: localizedPath("/", locale),
siteUrl:
typeof record.siteUrl === "string" ? record.siteUrl : FALLBACK_SITE_URL,
};
}
return {
locale: DEFAULT_LOCALE,
pathname: localizedPath("/", DEFAULT_LOCALE),
siteUrl: FALLBACK_SITE_URL,
};
}
@@ -0,0 +1,112 @@
/* ============================================================================
STRAPI WRITE - SERVER ONLY
----------------------------------------------------------------------------
~/lib/strapi.ts is a read client: the site renders from the CMS and never
mutates it. The two public forms in this section are the exception - an
event registration and an application to a call - so the POST lives here,
next to the routes that need it, and reuses that module's error type so a
caller handles one failure shape.
The `.server` suffix keeps it out of the browser bundle; it is imported by
`action` only.
========================================================================= */
import { getServerEnv } from "~/lib/env.server";
import { StrapiError } from "~/lib/strapi";
const DEFAULT_TIMEOUT_MS = 10_000;
export interface StrapiWriteInit {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
}
interface StrapiErrorEnvelope {
readonly error?: {
readonly status?: number;
readonly name?: string;
readonly message?: string;
readonly details?: unknown;
};
}
/**
* `POST /api/{resource}` with Strapi's `{ data }` envelope, returning the
* created entity. Every failure - transport, timeout, 4xx, 5xx - arrives as a
* StrapiError, so the action can answer with one localised message instead of
* leaking the CMS's own wording to the reader.
*/
export async function createStrapiEntry<T>(
resource: string,
data: Readonly<Record<string, unknown>>,
init: StrapiWriteInit = {},
): Promise<T> {
const env = getServerEnv();
const url = `${env.STRAPI_URL}/api/${resource.replace(/^\/+/, "")}`;
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "application/json",
};
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const signal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal;
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ data }),
signal,
});
} catch (cause) {
const timedOut = cause instanceof Error && cause.name === "TimeoutError";
throw new StrapiError({
message: timedOut
? `Strapi write timed out after ${timeoutMs}ms: ${url}`
: `Strapi write failed: ${url}`,
status: 0,
url,
cause,
});
}
if (!response.ok) {
let body: StrapiErrorEnvelope["error"];
try {
body = ((await response.json()) as StrapiErrorEnvelope).error;
} catch {
body = undefined;
}
throw new StrapiError({
message:
body?.message ??
`Strapi responded ${response.status} ${response.statusText} for ${url}`,
status: response.status,
statusText: response.statusText,
url,
body,
});
}
if (response.status === 204) return undefined as T;
try {
const payload = (await response.json()) as { data?: T };
return payload.data as T;
} catch (cause) {
throw new StrapiError({
message: `Strapi returned a non-JSON body for ${url}`,
status: response.status,
statusText: response.statusText,
url,
cause,
});
}
}
@@ -0,0 +1,106 @@
/* ============================================================================
FORM SUBMISSIONS -> STRAPI. SERVER ONLY.
----------------------------------------------------------------------------
The two public forms of this section write into their own collections. Both
are ordinary <Form method="post"> actions, so they work with scripting off.
THE PAYLOAD IS THE WHITELIST, NOTHING MORE
Each collection has a `sanitize-submission` middleware in the CMS that
copies out a fixed set of fields and DROPS everything else, then sets the
workflow state and the timestamps server-side. Sending a field that is not
on the list is not merely ignored - a required field that is missing is a
400 and the reader loses what they typed. So the two payloads below are
exactly the whitelists, read from:
apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts
event · fullName · email · phone · organisation · role ·
dietaryOrAccess · consentGdpr
apps/cms/src/api/application/middlewares/sanitize-submission.ts
opportunity · applicantName · email · phone · organisation ·
organisationType · answers
`attachments` exists on the `application` content type but is NOT on that
whitelist and must never be sent: it is a media relation, so its value is a
list of ids of files already in the Media Library, and an unauthenticated
caller who could set it would bind arbitrary uploads onto a row reviewers
open. A public upload flow needs its own authenticated endpoint first.
There is no `language` field on either type ("locale" is reserved by the
i18n plugin and `language` was never modelled), and no `waitlist` boolean.
THE ENDPOINTS
The URL is the content type's PLURAL name:
api::event-registration.event-registration -> /api/event-registrations
api::application.application -> /api/applications
(not "opportunity-applications", which does not exist and 404s).
WAITLIST IS DERIVED SERVER-SIDE, NEVER SENT
`registrationState` is not on the registration whitelist, and it is not an
omission to work around: the middleware works it out from the event itself -
places taken vs `capacity`, plus `registrationStatus` / `waitlistEnabled` -
and answers `confirmed`, `waitlisted`, or a 400 when the event is full with
no waitlist to offer. That decision needs a count no client can be trusted
to report, so this module sends only what the registrant actually supplied
and lets the CMS decide what it is worth.
PERMISSIONS
`create` on both collections is granted to the Public role; `find` /
`findOne` are NOT, or anyone could read everyone else's contact details.
========================================================================= */
import type { ApplicationValues, RegistrationValues } from "./forms";
import { createStrapiEntry } from "./strapi-write.server";
export const EVENT_REGISTRATIONS_RESOURCE = "event-registrations";
export const APPLICATIONS_RESOURCE = "applications";
/** Strapi rejects "" on an email field; absent is the honest value. */
function optional(value: string): string | undefined {
return value.length > 0 ? value : undefined;
}
export interface RegistrationSubmission {
readonly values: RegistrationValues;
/** documentId of the event being registered for. */
readonly eventId: string;
}
export async function submitRegistration(
input: RegistrationSubmission,
): Promise<void> {
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, {
event: input.eventId,
fullName: input.values.fullName,
email: input.values.email,
phone: optional(input.values.phone),
organisation: optional(input.values.organisation),
// Required in the schema: without it every POST is a 400.
consentGdpr: input.values.consentGdpr,
});
}
export interface ApplicationSubmission {
readonly values: ApplicationValues;
/** documentId of the opportunity being applied to. */
readonly opportunityId: string;
}
/** The key the free-text answer is filed under inside `answers` (json). */
export const APPLICATION_MESSAGE_KEY = "message";
export async function submitApplication(
input: ApplicationSubmission,
): Promise<void> {
await createStrapiEntry(APPLICATIONS_RESOURCE, {
opportunity: input.opportunityId,
// `fullName` is the registration's field name; an application's is
// `applicantName`.
applicantName: input.values.fullName,
email: input.values.email,
organisation: optional(input.values.organisation),
// `answers` is a json field - the form's one free-text question goes in
// it, keyed, so more questions can be added without another migration.
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
});
}
@@ -0,0 +1,432 @@
/* ============================================================================
EVENTS AND OPPORTUNITIES - the pieces these two sections add
----------------------------------------------------------------------------
Everything generic already exists and is imported, not rebuilt:
~/components/ui/Button Button, ArrowIcon
~/components/ui/Card CardShell, CardMedia, CardLink, CardActionHint
- the primitives the card family exports "for
composing new card types"
../_shared/listing-ui PageShell, PageHeader, EmptyState, TabLinks,
Pagination, MetaList, MetaLine, BackLink
What is genuinely new here is the status badge the draft asks for on every
event and every call ("Open / Closed / Full", "Upcoming / Ongoing /
Completed"), the two cards that carry the field set of draft sections 4 and
8, and the notice panel a detail page uses to explain a closed registration.
RULES: tier-2 semantic tokens only, logical properties only, type from the
published roles. Nothing here knows about a hex value or a direction.
========================================================================= */
import type { ReactNode } from "react";
import {
CardActionHint,
CardLink,
CardMedia,
CardShell,
} from "~/components/ui/Card";
import type { CardElement, CardTitleElement } from "~/components/ui/Card";
import { toIsoDate, useTranslation } from "~/i18n";
import { MetaLine } from "../_shared/listing-ui";
import {
eventActionKey,
eventRegistrationLabel,
eventTimeStatusKey,
formatEventWhen,
type EventCardData,
} from "./events";
import { getRouteCopy } from "./copy";
import {
formatOpportunityDate,
opportunityActionKey,
opportunityStatusKey,
type OpportunityCardData,
} from "./opportunities";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ---------------------------------------------------------------------------
STATUS BADGE
------------------------------------------------------------------------ */
/** Accent = live and joinable, neutral = informational, danger = full. */
export type BadgeTone = "accent" | "neutral" | "danger";
const BADGE_TONE: Readonly<Record<BadgeTone, string>> = {
accent: "border-border-accent bg-surface-accent-subtle text-text-accent",
neutral: "border-border-subtle bg-surface-sunken text-text-tertiary",
danger: "border-border-danger bg-surface-danger-subtle text-text-danger",
};
/**
* Colour is never the only carrier: the badge always spells the state out, so
* it survives a monochrome print and a colour-blind reader.
*/
export function StatusBadge({
tone = "neutral",
children,
}: {
tone?: BadgeTone;
children: ReactNode;
}) {
return (
<span
className={cx(
"inline-flex items-center gap-2xs rounded-pill border px-xs py-2xs label-md",
BADGE_TONE[tone],
)}
>
{children}
</span>
);
}
/** A row of badges above a card title or a detail heading. */
export function BadgeRow({ children }: { children: ReactNode }) {
return <div className="flex flex-wrap items-center gap-xs">{children}</div>;
}
/* ---------------------------------------------------------------------------
NOTICE
------------------------------------------------------------------------ */
export type NoticeTone = "info" | "accent" | "danger";
const NOTICE_TONE: Readonly<Record<NoticeTone, string>> = {
info: "border-border-default bg-surface-sunken text-text-secondary",
accent: "border-border-accent bg-surface-accent-subtle text-text-primary",
danger: "border-border-danger bg-surface-danger-subtle text-text-primary",
};
/**
* The panel that explains a state: registration closed, the call is not open
* yet, the CMS could not be reached. `role` is left to the caller - a notice
* that appears in response to a submission needs "status", one that is part of
* the page does not.
*/
export function Notice({
tone = "info",
title,
children,
role,
}: {
tone?: NoticeTone;
title?: string;
children?: ReactNode;
role?: "status" | "alert";
}) {
return (
<div
role={role}
className={cx(
"flex flex-col gap-xs rounded-card border p-lg",
NOTICE_TONE[tone],
)}
>
{title ? <p className="body-md-bold text-text-primary">{title}</p> : null}
{children ? <div className="body-md">{children}</div> : null}
</div>
);
}
/* ---------------------------------------------------------------------------
EVENT CARD - draft section 4
title - date and time - format/location - registration status - CTA
------------------------------------------------------------------------ */
export function EventCard({
event,
href,
as = "article",
titleAs = "h3",
className,
}: {
event: EventCardData;
href: string;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}) {
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const Title = titleAs;
const when = formatEventWhen(event, locale);
const formatLabel =
event.format === "online"
? t("events.online")
: event.format === "hybrid"
? copy.events.formatHybrid
: event.format === "in-person"
? t("events.inPerson")
: undefined;
const isPast = event.timeStatus === "completed";
return (
<CardShell
as={as}
linked
className={cx("flex-col overflow-hidden", className)}
>
{event.image ? (
<CardMedia
image={{ src: event.image.src, alt: event.image.alt }}
className="aspect-[9/5] w-full"
/>
) : null}
<div className="flex flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
<BadgeRow>
<StatusBadge tone={isPast ? "neutral" : "accent"}>
{t(eventTimeStatusKey(event.timeStatus))}
</StatusBadge>
{/* A finished event's registration state is history, not news. */}
{isPast ? null : (
<StatusBadge
tone={
event.registrationStatus === "full"
? "danger"
: event.registrationStatus === "closed"
? "neutral"
: "accent"
}
>
{eventRegistrationLabel(event.registrationStatus, locale)}
</StatusBadge>
)}
</BadgeRow>
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{event.title}
</CardLink>
</Title>
<p className="body-sm-bold text-text-secondary">
<time dateTime={toIsoDate(event.start)}>{when}</time>
</p>
<MetaLine items={[formatLabel, event.place, event.region].filter(
(item): item is string => Boolean(item),
)} />
{event.summary ? (
<p className="body-sm text-text-secondary line-clamp-3">
{event.summary}
</p>
) : null}
</div>
<CardActionHint label={t(eventActionKey(event))} />
</div>
</CardShell>
);
}
/* ---------------------------------------------------------------------------
OPPORTUNITY CARD - draft section 8
title - status tag - deadline - CTA
------------------------------------------------------------------------ */
export function OpportunityCard({
opportunity,
href,
as = "article",
titleAs = "h3",
className,
}: {
opportunity: OpportunityCardData;
href: string;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}) {
const { locale, t } = useTranslation();
const Title = titleAs;
const isOpen = opportunity.status === "open";
/* An open or closed call is defined by its deadline; a call that has not
opened yet is defined by its opening date. */
const dateLine =
opportunity.status === "upcoming" && opportunity.openDate
? formatOpportunityDate(opportunity.openDate, locale)
: opportunity.deadline
? t("opportunities.deadline", {
date: formatOpportunityDate(opportunity.deadline, locale),
})
: undefined;
const dateValue =
opportunity.status === "upcoming" && opportunity.openDate
? opportunity.openDate
: opportunity.deadline;
return (
<CardShell
as={as}
linked
className={cx("flex-col overflow-hidden", className)}
>
{opportunity.image ? (
<CardMedia
image={{ src: opportunity.image.src, alt: opportunity.image.alt }}
className="aspect-[9/5] w-full"
/>
) : null}
<div className="flex flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
<BadgeRow>
<StatusBadge tone={isOpen ? "accent" : "neutral"}>
{t(opportunityStatusKey(opportunity.status))}
</StatusBadge>
{opportunity.opportunityType ? (
<StatusBadge>
{getRouteCopy(locale).opportunityTypes[
opportunity.opportunityType
]}
</StatusBadge>
) : null}
</BadgeRow>
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{opportunity.title}
</CardLink>
</Title>
{dateLine ? (
<p className="body-sm-bold text-text-secondary">
<time dateTime={toIsoDate(dateValue)}>{dateLine}</time>
</p>
) : null}
{opportunity.summary ? (
<p className="body-sm text-text-secondary line-clamp-3">
{opportunity.summary}
</p>
) : null}
</div>
<CardActionHint label={t(opportunityActionKey(opportunity.status))} />
</div>
</CardShell>
);
}
/* ---------------------------------------------------------------------------
DEFINITION GRID - the "Event details" block
------------------------------------------------------------------------ */
export interface DetailItem {
readonly label: string;
readonly value: ReactNode;
}
/**
* A description list rather than a table: the pairs are a key/value set, they
* reflow to one column on a phone, and a screen reader announces the label
* with the value.
*/
export function DetailList({
heading,
items,
}: {
heading: string;
items: readonly DetailItem[];
}) {
const present = items.filter((item) => item.value !== undefined && item.value !== null);
if (present.length === 0) return null;
return (
<section
aria-label={heading}
className="flex flex-col gap-md rounded-card border border-border-subtle bg-surface-raised p-lg"
>
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
<dl className="grid gap-md sm:grid-cols-2">
{present.map((item) => (
<div key={item.label} className="flex flex-col gap-2xs">
<dt className="body-sm text-text-tertiary">{item.label}</dt>
<dd className="body-md-bold text-text-primary">{item.value}</dd>
</div>
))}
</dl>
</section>
);
}
/* ---------------------------------------------------------------------------
CONSENT CHECKBOX
----------------------------------------------------------------------------
`event-registration.consentGdpr` is a REQUIRED boolean in the CMS, so the
reader has to be asked - out loud, in a real <label>, with the reason next
to it. ~/components/ui has no Checkbox (this is the only one in the site),
so the control is composed here from the same tier-2 tokens the rest of the
section uses; promote it to ~/components/ui when a second form needs one.
Accessibility: the label wraps nothing - it is associated by `htmlFor`, so
the hint and the error can sit outside it and still be announced, via
`aria-describedby`. `aria-invalid` marks the failure for a screen reader,
and the message is text, never colour alone.
------------------------------------------------------------------------ */
export function ConsentCheckbox({
name,
label,
hint,
error,
defaultChecked = false,
}: {
name: string;
label: string;
hint?: string;
error?: string;
defaultChecked?: boolean;
}) {
const hintId = hint ? `${name}-hint` : undefined;
const errorId = error ? `${name}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="flex flex-col gap-2xs">
<div className="flex items-start gap-sm">
<input
id={name}
name={name}
type="checkbox"
value="true"
defaultChecked={defaultChecked}
required
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className="mt-2xs size-md shrink-0 rounded-[var(--radius-control)] border border-border-strong accent-[var(--interactive-primary-bg)]"
/>
<label htmlFor={name} className="body-sm text-text-primary">
{label}
</label>
</div>
{hint ? (
<p id={hintId} className="body-sm text-text-tertiary ps-lg">
{hint}
</p>
) : null}
{error ? (
<p id={errorId} className="body-sm text-text-danger ps-lg">
{error}
</p>
) : null}
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
/* ============================================================================
CMS SHAPES
----------------------------------------------------------------------------
Hand-written mirrors of the Strapi 5 schemas in apps/cms/src/api/** and
apps/cms/src/components/**. Types only - the file compiles away, so it is
safe to import from a component as well as from a loader.
Every field the CMS marks optional is `?: T | null` here, because Strapi
returns `null` (not `undefined`) for an empty relation or component, and the
pages have to render before a single entry exists.
========================================================================= */
import type { StrapiEntity, StrapiMedia } from "~/lib/strapi";
/** shared.link */
export interface CmsLink {
readonly id?: number;
readonly label: string;
readonly href: string;
readonly isExternal?: boolean | null;
}
/** shared.cta - `variant` matches the Button component's variants exactly. */
export interface CmsCta {
readonly id?: number;
readonly label: string;
readonly href: string;
readonly variant?: "primary" | "secondary" | "dark" | "danger" | "ghost" | null;
}
/** shared.hero */
export interface CmsHero {
readonly id?: number;
readonly eyebrow?: string | null;
readonly headline: string;
readonly subheading?: string | null;
readonly media?: StrapiMedia | null;
readonly ctas?: readonly CmsCta[] | null;
}
/** shared.stat - `value` is authored as a string so "48%" and "1,583" survive. */
export interface CmsStat {
readonly id?: number;
readonly value: string;
readonly label: string;
}
/** shared.titled-text - pillars and principles. */
export interface CmsTitledText {
readonly id?: number;
readonly title: string;
readonly description?: string | null;
}
/** shared.address - one consortium office. */
export interface CmsAddress {
readonly id?: number;
readonly organisation: string;
readonly addressLine?: string | null;
readonly city?: string | null;
readonly country?: string | null;
readonly email?: string | null;
readonly phone?: string | null;
}
/** shared.seo */
export interface CmsSeo {
readonly id?: number;
readonly metaTitle: string;
readonly metaDescription?: string | null;
readonly ogImage?: StrapiMedia | null;
readonly canonicalUrl?: string | null;
readonly noIndex?: boolean | null;
}
/** page.workstream */
export interface CmsWorkstream {
readonly id?: number;
readonly title: string;
readonly description?: string | null;
readonly icon?: StrapiMedia | null;
}
export type CmsPartnerRole =
| "consortium-lead"
| "consortium-partner"
| "cso-partner"
| "institution";
/** api::partner.partner */
export interface CmsPartner extends StrapiEntity {
readonly name: string;
readonly slug: string;
readonly logo?: StrapiMedia | null;
readonly logoDark?: StrapiMedia | null;
readonly description?: string | null;
readonly website?: string | null;
readonly partnerRole: CmsPartnerRole;
readonly country?: string | null;
}
/** page.consortium-member */
export interface CmsConsortiumMember {
readonly id?: number;
readonly partner?: CmsPartner | null;
readonly roleDescription?: string | null;
}
/* ---------------------------------------------------------------------------
DYNAMIC ZONE
Only the blocks these four pages actually render are modelled; anything else
in the zone is matched by the fallback member and skipped.
------------------------------------------------------------------------ */
export interface CmsImageGalleryBlock {
readonly __component: "blocks.image-gallery";
readonly id: number;
readonly images?: readonly StrapiMedia[] | null;
readonly caption?: string | null;
}
export interface CmsRichTextBlock {
readonly __component: "blocks.rich-text";
readonly id: number;
readonly body: string;
}
export interface CmsUnknownBlock {
readonly __component: string;
readonly id: number;
}
export type CmsBlock =
| CmsImageGalleryBlock
| CmsRichTextBlock
| CmsUnknownBlock;
export function isImageGalleryBlock(
block: CmsBlock,
): block is CmsImageGalleryBlock {
return block.__component === "blocks.image-gallery";
}
export function isRichTextBlock(block: CmsBlock): block is CmsRichTextBlock {
return block.__component === "blocks.rich-text";
}
/* ---------------------------------------------------------------------------
SINGLE TYPES
------------------------------------------------------------------------ */
/** api::home-page.home-page */
export interface CmsHomePage extends StrapiEntity {
readonly hero?: CmsHero | null;
readonly stats?: readonly CmsStat[] | null;
readonly blocks?: readonly CmsBlock[] | null;
readonly partnersHeadline?: string | null;
readonly partners?: readonly CmsPartner[] | null;
readonly seo?: CmsSeo | null;
}
/** api::about-page.about-page */
export interface CmsAboutPage extends StrapiEntity {
readonly hero?: CmsHero | null;
readonly body?: string | null;
readonly blocks?: readonly CmsBlock[] | null;
readonly consortium?: readonly CmsConsortiumMember[] | null;
readonly pillars?: readonly CmsTitledText[] | null;
readonly legacyHeadline?: string | null;
readonly legacyBody?: string | null;
readonly legacyLink?: CmsLink | null;
readonly seo?: CmsSeo | null;
}
/** api::what-we-do-page.what-we-do-page */
export interface CmsWhatWeDoPage extends StrapiEntity {
readonly hero?: CmsHero | null;
readonly intro?: string | null;
readonly workstreams?: readonly CmsWorkstream[] | null;
readonly principles?: readonly CmsTitledText[] | null;
/** A JSON field: the CMS authors it as a plain array of strings. */
readonly guidingValues?: unknown;
readonly blocks?: readonly CmsBlock[] | null;
readonly seo?: CmsSeo | null;
}
/** api::contact-page.contact-page */
export interface CmsContactPage extends StrapiEntity {
readonly hero?: CmsHero | null;
readonly intro?: string | null;
readonly generalEmail?: string | null;
readonly websiteLabel?: string | null;
readonly socialLinks?: readonly CmsLink[] | null;
readonly consortiumContacts?: readonly CmsAddress[] | null;
readonly seo?: CmsSeo | null;
}
+131
View File
@@ -0,0 +1,131 @@
/* ============================================================================
CMS ACCESS - server only
----------------------------------------------------------------------------
`.server.ts`, so the bundler keeps this (and the Strapi token behind it) out
of the browser build entirely. Call it from a `loader` / `action`.
Everything here is deliberately forgiving. The CMS is empty today - and in
development it is often not running at all - so a page must render its
approved draft copy rather than a 500 when Strapi answers 404, times out or
refuses the connection. `loadSingle` / `loadMany` therefore turn any
transport- or Strapi-level failure into `null` / `[]` and log it once,
server-side. A genuinely missing *entity* on a $slug route is a different
thing: use `requireEntity()` from ~/lib/strapi for that, so the root
ErrorBoundary can render the localised 404.
========================================================================= */
import {
getMany,
getSingle,
isStrapiError,
mediaAlt,
mediaFormatUrl,
type StrapiMedia,
type StrapiQuery,
} from "~/lib/strapi";
import type { CmsPartner } from "./cms-types";
/** A picture, resolved to absolute URLs at load time. */
export interface ResolvedImage {
readonly src: string;
readonly alt: string;
readonly width?: number;
readonly height?: number;
}
/** A partner, flattened to exactly what the partner grid renders. */
export interface ResolvedPartner {
readonly id: string;
readonly name: string;
readonly website?: string;
readonly logo?: ResolvedImage;
}
function warn(resource: string, error: unknown): void {
const reason = isStrapiError(error)
? `${error.status || "network"} ${error.message}`
: String(error);
console.warn(`[cms] "${resource}" is unavailable - rendering fallback copy (${reason})`);
}
/**
* One entry of a single type, or `null` when the CMS has none / is unreachable.
*/
export async function loadSingle<T>(
resource: string,
query?: StrapiQuery,
): Promise<T | null> {
try {
return await getSingle<T>(resource, query);
} catch (error) {
warn(resource, error);
return null;
}
}
/** A collection page, or `[]` when the CMS has none / is unreachable. */
export async function loadMany<T>(
resource: string,
query?: StrapiQuery,
): Promise<T[]> {
try {
const { data } = await getMany<T>(resource, query);
return data;
} catch (error) {
warn(resource, error);
return [];
}
}
/**
* A Strapi media entity as a plain, absolute-URL image.
*
* `format` picks the responsive derivative Strapi generated; it silently falls
* back to the original when that derivative does not exist (small uploads).
* `alt` comes from the CMS and stays "" when the editor left it empty, which is
* the correct markup for a decorative image.
*/
export function toImage(
media: StrapiMedia | null | undefined,
format: "thumbnail" | "small" | "medium" | "large" = "large",
): ResolvedImage | undefined {
if (!media) return undefined;
const src = mediaFormatUrl(media, format);
if (!src) return undefined;
return {
src,
alt: mediaAlt(media),
...(media.width ? { width: media.width } : {}),
...(media.height ? { height: media.height } : {}),
};
}
/** Flatten a populated partner relation for the grid. */
export function toPartner(partner: CmsPartner): ResolvedPartner {
return {
id: partner.documentId ?? String(partner.id),
name: partner.name,
...(partner.website ? { website: partner.website } : {}),
...(toImage(partner.logo ?? partner.logoDark, "small")
? { logo: toImage(partner.logo ?? partner.logoDark, "small") }
: {}),
};
}
/**
* A Strapi `json` field authored as a list of strings ("Dialogue",
* "Inclusion", ...). Anything else in the field is ignored rather than thrown
* over, because an editor can put arbitrary JSON there.
*/
export function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.trim() !== "");
}
/** Drop `null`/`undefined` and empty arrays in one expression. */
export function list<T>(value: readonly T[] | null | undefined): T[] {
return value ? [...value] : [];
}
+386
View File
@@ -0,0 +1,386 @@
/* ============================================================================
APPROVED DRAFT COPY - EN / AR
----------------------------------------------------------------------------
SOURCE
docs/SHARPER_Website_Draft_Section_EN_AR.docx (sections 1, 2 and 9)
docs/SHARPER Home Page .docx (the launch story)
Both documents carry the English AND the Arabic, so nothing below is a
machine translation of the other column except where a comment says so.
WHY IT LIVES IN THE CODE
This is page prose, not UI chrome, so it does not belong in the i18n
catalogue (which holds labels only). It is the FALLBACK: every field is
overridden by the matching Strapi field the moment an editor fills it in,
and the pages read `cms ?? draft`. Until then this is what renders, which
is exactly what the draft document is for.
WHAT IS DELIBERATELY ABSENT
Figures. The draft marks every number "TBD" and says they must not be
published until confirmed, so there is no statistic anywhere in this file;
the home page renders `statsPending` instead, and real figures arrive
through the CMS `stats` component.
========================================================================= */
import type { Locale } from "~/i18n";
/** A title plus its description - a pillar, a workstream, a principle. */
export interface TitledCopy {
readonly title: string;
readonly description: string;
}
/** One consortium member and the role the draft assigns it. */
export interface ConsortiumCopy {
readonly name: string;
readonly role: string;
}
export interface HomeCopy {
readonly heroHeadline: string;
readonly statsHeading: string;
/** Rendered in place of the figures while they are unconfirmed. */
readonly statsPending: string;
readonly launchHeading: string;
readonly launchParagraphs: readonly string[];
readonly fundingNote: string;
readonly mechanismsHeading: string;
readonly mechanisms: readonly string[];
readonly partnersHeading: string;
readonly partnersEmpty: string;
}
export interface AboutCopy {
readonly heroHeadline: string;
readonly lead: string;
readonly body: string;
readonly consortiumHeading: string;
readonly consortium: readonly ConsortiumCopy[];
readonly pillarsHeading: string;
readonly pillars: readonly TitledCopy[];
readonly legacyHeading: string;
readonly legacyBody: string;
readonly legacyLinkLabel: string;
readonly fundingNote: string;
}
export interface WhatWeDoCopy {
readonly heroHeadline: string;
readonly lead: string;
readonly workstreamsHeading: string;
readonly workstreamsIntro: string;
readonly workstreams: readonly TitledCopy[];
readonly valuesLabel: string;
readonly values: readonly string[];
readonly principlesHeading: string;
readonly principles: string;
}
export interface ContactCopy {
readonly heroHeadline: string;
readonly lead: string;
readonly formHeading: string;
readonly formIntro: string;
readonly detailsHeading: string;
readonly generalEnquiries: string;
readonly websiteLabel: string;
readonly websiteHref: string;
readonly websiteText: string;
readonly contactPending: string;
readonly consortiumHeading: string;
readonly consortiumEmpty: string;
readonly sendAnother: string;
readonly errorSummaryTitle: string;
readonly errorTooLong: string;
readonly honeypotLabel: string;
readonly organisationHint: string;
}
export interface PageCopy {
readonly home: HomeCopy;
readonly about: AboutCopy;
readonly whatWeDo: WhatWeDoCopy;
readonly contact: ContactCopy;
}
/* ---------------------------------------------------------------------------
ENGLISH
------------------------------------------------------------------------ */
const EN: PageCopy = {
home: {
heroHeadline: "SHARP-ER Project Officially Launches in Libya",
statsHeading: "SHARP-ER in numbers",
statsPending:
"The project’s figures are still being confirmed in the official fact sheet. They will be published here once verified.",
launchHeading: "The Journey of the SHARP-ER Launch",
launchParagraphs: [
"On Tuesday, June 30, the SHARP-ER project officially launched, bringing together representatives from the European Union Delegation, the Ministry of Youth, the Ministry of Local Governance, the Ministry of Women’s Affairs, civil society organizations, partners, and stakeholders from across Libya.",
"The event marked an important start for the project, including the launch of the SHARP-ER website and the opening of the Expression of Interest (EOI). Participants also learned more about the project’s objectives, upcoming opportunities, and activities focused on strengthening collaboration, partnerships, and local capacities.",
"Thank you to everyone who joined us and contributed to this milestone. We look forward to working together to support locally driven initiatives and create meaningful, lasting impact across Libya.",
],
fundingNote: "This project is funded by the European Union in Libya.",
mechanismsHeading: "What the launch set in motion",
mechanisms: [
"Building local capacity and skills",
"Fostering cross-sector partnerships",
"Supporting municipal and community-led initiatives",
],
partnersHeading: "Meet Our Partners",
partnersEmpty:
"Partner organisations will be listed here as they join the SHARP-ER network.",
},
about: {
heroHeadline: "Strengthening the Rule of Law, Together",
lead: "SHARP-ER (Shared Action on Rule of Law Progress – Exit Route) is a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
body: "Running from December 2025 to December 2027, SHARP-ER is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union. The project works nationwide across Libya, partnering with civil society organisations, municipalities, and community leaders to strengthen access to justice, build the capacity of civil society to hold institutions accountable, and improve local governance and service delivery.",
consortiumHeading: "The consortium",
consortium: [
{
name: "Acted",
role: "Lead implementing partner — overall project management and civil society sub-projects",
},
{
name: "United Efforts Consultancy (UEC)",
role: "Network strengthening, dialogue, and peer-learning",
},
{
name: "Stichting Bureau Ensemble (SBE)",
role: "Research, perception data, and knowledge publications",
},
],
pillarsHeading: "Where we focus",
pillars: [
{
title: "Strengthening Rule of Law",
description:
"Supporting local institutions to improve access to justice and transparency",
},
{
title: "Empowering Civil Society",
description:
"Building the capacity of CSOs to strengthen their role in oversight and accountability",
},
{
title: "Supporting Local Governance",
description:
"Enhancing collaboration between municipalities and local actors to improve service delivery",
},
],
legacyHeading: "Building on SHARP I and II",
legacyBody:
"SHARP-ER builds on the foundation of SHARP I and II (2019–2024), carrying its network of civil society partners and its dialogue practice into a new phase focused on sustainability and exit.",
legacyLinkLabel: "Read about the SHARP legacy",
fundingNote: "Funded by the European Union.",
},
whatWeDo: {
heroHeadline: "A Participatory Approach to Justice and Governance",
lead: "We work directly with civil society, municipalities, and communities — not around them.",
workstreamsHeading: "Our areas of work",
workstreamsIntro: "SHARP-ER is organised around four connected areas of work:",
workstreams: [
{
title: "Capacity Building",
description:
"Training civil society organisations and local actors on governance, justice, and management, and equipping the SHARP network with peer-to-peer learning.",
},
{
title: "Institutional Support",
description:
"Strengthening the systems, procedures, and dialogue channels that connect local institutions with the people they serve.",
},
{
title: "Community Initiatives",
description:
"Funding and supporting CSO-led sub-projects, Policy-to-Rights (P2R) projects, and human-security initiatives designed and driven by local actors.",
},
{
title: "Dialogue and Coordination",
description:
"Running SUQ (Search, Understand, Question) dialogues and Civil Society Law Sessions that bring civic and government actors to the same table.",
},
],
valuesLabel: "What guides us",
values: [
"Dialogue",
"Community Leadership",
"Learning",
"Inclusion",
"Responsibility & Accountability",
],
principlesHeading: "How we work",
principles:
"Our approach rests on four principles: working with local actors, promoting sustainable — not one-off — solutions, connecting civil society with institutions, and supporting gradual, realistic institutional reform.",
},
contact: {
heroHeadline: "Get in Touch",
lead: "Questions about SHARP-ER, a partnership, or a specific activity? Reach out.",
formHeading: "Send us a message",
formIntro:
"Fill in the form and the SHARP-ER team will come back to you by email.",
detailsHeading: "Contact details",
generalEnquiries: "General enquiries",
websiteLabel: "Website",
websiteHref: "https://sharper.ly",
websiteText: "sharper.ly",
contactPending:
"The project’s general enquiries address will be published here once it is confirmed. In the meantime, use the form and your message will reach the team.",
consortiumHeading: "Consortium contacts",
consortiumEmpty:
"Contact details for Acted Libya, United Efforts Consultancy and Stichting Bureau Ensemble will be published here once they are confirmed.",
sendAnother: "Send another message",
errorSummaryTitle: "Please check the form",
errorTooLong: "This answer is too long.",
honeypotLabel: "Leave this field empty",
organisationHint: "The organisation you are writing on behalf of, if any.",
},
};
/* ---------------------------------------------------------------------------
ARABIC (MSA)
The prose below is the Arabic column of the same two documents, verbatim.
The few strings the documents do not provide - section headings such as
"شركاؤنا", and the three launch captions, which exist only in the English
Figma frame - are translated from the approved English and stay inside the
vocabulary the Arabic draft already uses.
------------------------------------------------------------------------ */
const AR: PageCopy = {
home: {
heroHeadline: "انطلاق مشروع SHARP-ER رسميًا في ليبيا",
statsHeading: "SHARP-ER بالأرقام",
statsPending:
"لا تزال أرقام المشروع قيد التأكيد في ورقة الحقائق الرسمية، وسيتم نشرها هنا فور اعتمادها.",
launchHeading: "مسيرة إطلاق مشروع SHARP-ER",
launchParagraphs: [
"انطلقت يوم الثلاثاء 30 يونيو رسميًا فعاليات مشروع SHARP-ER خلال حدث الإطلاق الذي جمع ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، إلى جانب منظمات المجتمع المدني والشركاء والجهات الفاعلة من مختلف أنحاء ليبيا.",
"وشكّل الحدث بداية مهمة للمشروع، حيث تم إطلاق الموقع الإلكتروني الرسمي، والإعلان عن فتح باب التعبير عن الاهتمام (EOI)، إلى جانب استعراض أهداف المشروع والفرص والأنشطة القادمة. وتركّز هذه الأنشطة على تعزيز التعاون والشراكات وبناء القدرات على المستوى المحلي.",
"نتوجه بالشكر إلى جميع المشاركين على حضورهم ومساهماتهم القيّمة. ونتطلع إلى العمل معًا خلال المرحلة المقبلة لدعم المبادرات المحلية وتحقيق أثر مستدام في مختلف أنحاء ليبيا.",
],
fundingNote: "المشروع ممول من الاتحاد الأوروبي في ليبيا.",
mechanismsHeading: "ما الذي أطلقه هذا الحدث",
mechanisms: [
"بناء القدرات والمهارات المحلية",
"تعزيز الشراكات بين القطاعات",
"دعم المبادرات البلدية والمبادرات المجتمعية",
],
partnersHeading: "تعرّف على شركائنا",
partnersEmpty:
"ستُدرج المنظمات الشريكة هنا فور انضمامها إلى شبكة SHARP-ER.",
},
about: {
heroHeadline: "نحو تعزيز سيادة القانون، معًا",
lead: "SHARP-ER (العمل المشترك لتعزيز سيادة القانون – مسار الخروج) هو مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
body: "يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027، ويُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي. يعمل المشروع على مستوى ليبيا بأكملها، بالتعاون مع منظمات المجتمع المدني والبلديات والقيادات المجتمعية، من أجل تحسين الوصول إلى العدالة، وبناء قدرات المجتمع المدني على مساءلة المؤسسات، وتحسين الحوكمة المحلية وتقديم الخدمات.",
consortiumHeading: "الائتلاف المنفّذ",
consortium: [
{
name: "Acted",
role: "الشريك المنفّذ الرئيسي — الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني",
},
{
name: "United Efforts Consultancy (UEC)",
role: "تعزيز الشبكة، والحوار، والتعلّم بين الأقران",
},
{
name: "Stichting Bureau Ensemble (SBE)",
role: "البحث، وبيانات الاستطلاع، والمنشورات المعرفية",
},
],
pillarsHeading: "محاور تركيزنا",
pillars: [
{
title: "تعزيز سيادة القانون",
description:
"دعم المؤسسات المحلية لتحسين الوصول إلى العدالة وتعزيز الشفافية",
},
{
title: "تمكين المجتمع المدني",
description:
"بناء قدرات منظمات المجتمع المدني لتعزيز دورها في الرقابة والمساءلة",
},
{
title: "دعم الحوكمة المحلية",
description:
"تعزيز التعاون بين البلديات والجهات الفاعلة المحلية لتحسين تقديم الخدمات",
},
],
legacyHeading: "استكمالًا لمشروعي SHARP الأول والثاني",
legacyBody:
"يبني SHARP-ER على الأساس الذي أرساه مشروعا SHARP الأول والثاني (2019–2024)، حاملًا معه شبكة الشركاء من المجتمع المدني وممارسة الحوار إلى مرحلة جديدة تركّز على الاستدامة ومسار الخروج.",
legacyLinkLabel: "اطّلع على إرث مشروع SHARP",
fundingNote: "بتمويل من الاتحاد الأوروبي.",
},
whatWeDo: {
heroHeadline: "نهج تشاركي في العدالة والحوكمة",
lead: "نعمل مباشرة مع المجتمع المدني والبلديات والمجتمعات المحلية، لا من حولها.",
workstreamsHeading: "محاور عملنا",
workstreamsIntro: "يقوم عمل SHARP-ER على أربعة محاور مترابطة:",
workstreams: [
{
title: "بناء القدرات",
description:
"تدريب منظمات المجتمع المدني والجهات المحلية على الحوكمة والعدالة والإدارة، وتزويد شبكة SHARP بالتعلّم بين الأقران.",
},
{
title: "الدعم المؤسسي",
description:
"تعزيز الأنظمة والإجراءات وقنوات الحوار التي تربط المؤسسات المحلية بالمجتمعات التي تخدمها.",
},
{
title: "المبادرات المجتمعية",
description:
"تمويل ودعم مشاريع فرعية تقودها منظمات المجتمع المدني، ومشاريع “من السياسة إلى الحقوق” (P2R)، ومبادرات الأمن البشري التي يصممها الفاعلون المحليون ويقودونها.",
},
{
title: "الحوار والتنسيق",
description:
"تنظيم حوارات SUQ (ابحث، افهم، اسأل) وجلسات قانون المجتمع المدني التي تجمع الفاعلين المدنيين والحكوميين حول طاولة واحدة.",
},
],
valuesLabel: "ما يوجّه عملنا",
values: [
"الحوار",
"القيادة المجتمعية",
"التعلّم",
"الشمول",
"المساءلة والمسؤولية",
],
principlesHeading: "كيف نعمل",
principles:
"يسترشد عملنا بأربعة مبادئ: العمل مع الفاعلين المحليين، وتعزيز الحلول المستدامة لا الآنية، والربط بين المجتمع المدني والمؤسسات، ودعم إصلاح مؤسسي تدريجي وواقعي.",
},
contact: {
heroHeadline: "تواصل معنا",
lead: "لديك استفسار حول SHARP-ER، أو شراكة محتملة، أو نشاط معيّن؟ راسلنا.",
formHeading: "أرسل لنا رسالة",
formIntro: "املأ النموذج وسيعاود فريق SHARP-ER التواصل معك عبر البريد الإلكتروني.",
detailsHeading: "بيانات التواصل",
generalEnquiries: "للاستفسارات العامة",
websiteLabel: "الموقع الإلكتروني",
websiteHref: "https://sharper.ly",
websiteText: "sharper.ly",
contactPending:
"سيُنشر هنا البريد الإلكتروني العام للمشروع فور تأكيده. وفي غضون ذلك، استخدم النموذج وستصل رسالتك إلى الفريق.",
consortiumHeading: "جهات اتصال الائتلاف",
consortiumEmpty:
"ستُنشر هنا بيانات التواصل الخاصة بـ Acted ليبيا، وUnited Efforts Consultancy، وStichting Bureau Ensemble فور تأكيدها.",
sendAnother: "إرسال رسالة أخرى",
errorSummaryTitle: "يرجى مراجعة النموذج",
errorTooLong: "هذه الإجابة طويلة جدًا.",
honeypotLabel: "اترك هذا الحقل فارغًا",
organisationHint: "المنظمة التي تراسلنا نيابةً عنها، إن وُجدت.",
},
};
const COPY: Readonly<Record<Locale, PageCopy>> = { en: EN, ar: AR };
/** The draft copy for a locale. Always defined for both published locales. */
export function pageCopy(locale: Locale): PageCopy {
return COPY[locale] ?? EN;
}
+13
View File
@@ -0,0 +1,13 @@
/* ============================================================================
ROUTE LOCALE
----------------------------------------------------------------------------
`:lang` is already validated by the locale layout - an unknown segment 404s
before a page loader ever runs - so this only narrows `string` to `Locale`
for the type system, with the default locale as an unreachable fallback.
========================================================================= */
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n";
export function routeLocale(params: { readonly lang?: string }): Locale {
return isLocale(params.lang) ? params.lang : DEFAULT_LOCALE;
}
+272
View File
@@ -0,0 +1,272 @@
/* ============================================================================
RICH TEXT
----------------------------------------------------------------------------
Strapi's `richtext` fields are Markdown. This renders the subset the editors
actually use - headings, paragraphs, bullet and numbered lists, blockquotes,
plus inline bold / italic / code / links - by BUILDING REACT ELEMENTS.
There is deliberately no `dangerouslySetInnerHTML` and no HTML parser here:
CMS prose is data, and any raw markup inside it renders as visible text
rather than executing. That also means no markdown dependency to install.
Link targets are the one exception to "prose is inert": an `href` executes
if its scheme says so, so every `[label](url)` target goes through
`safeHref` and a target that is not http/https/mailto/tel or an in-site
path renders as plain text instead of a link.
Anything the CMS returns that this does not understand still reaches the
reader - it falls through to a paragraph - so no copy is ever silently lost.
========================================================================= */
import type { ReactNode } from "react";
import { Link } from "react-router";
import { safeHref } from "~/lib/safe-href";
import { cx } from "./sections";
/* ---------------------------------------------------------------------------
INLINE
------------------------------------------------------------------------ */
/** `**bold**`, `*italic*`, `` `code` ``, `[label](/href)`. */
const INLINE_PATTERN =
/(\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\))/g;
const LINK_PATTERN = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/;
/**
* Presentation only - it decides `<a>` vs `<Link>`, NOT whether the target is
* allowed. `safeHref` has already made that call by the time this is read.
*/
const LEAVES_THE_ROUTER = /^(?:[a-z][a-z\d+\-.]*:|\/\/|#)/i;
function renderInline(text: string, keyPrefix: string): ReactNode[] {
return text
.split(INLINE_PATTERN)
.filter((part) => part !== "" && part !== undefined)
.map((part, index) => {
const key = `${keyPrefix}-${index}`;
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
return <strong key={key}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
return <strong key={key}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
return <em key={key}>{part.slice(1, -1)}</em>;
}
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
return (
<code
key={key}
className="rounded-control bg-[var(--surface-sunken)] px-2xs py-px body-sm"
>
{part.slice(1, -1)}
</code>
);
}
const link = LINK_PATTERN.exec(part);
if (link) {
const [, label, target] = link as unknown as [string, string, string];
const className =
"text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]";
const href = safeHref(target);
// A target this site refuses to navigate to is not silently dropped:
// the reader still gets the words the editor wrote.
if (href === undefined) return label;
return LEAVES_THE_ROUTER.test(href) ? (
<a
key={key}
href={href}
className={className}
{...(href.startsWith("#")
? {}
: { target: "_blank", rel: "noopener noreferrer" })}
>
{label}
</a>
) : (
<Link key={key} to={href} prefetch="intent" className={className}>
{label}
</Link>
);
}
return part;
});
}
/* ---------------------------------------------------------------------------
BLOCKS
------------------------------------------------------------------------ */
type Block =
| { kind: "heading"; level: 2 | 3 | 4; text: string }
| { kind: "paragraph"; text: string }
| { kind: "quote"; text: string }
| { kind: "list"; ordered: boolean; items: string[] };
const HEADING = /^(#{1,6})\s+(.*)$/;
const BULLET = /^[-*+]\s+(.*)$/;
const NUMBERED = /^\d+[.)]\s+(.*)$/;
const QUOTE = /^>\s?(.*)$/;
/** Group the source's lines into renderable blocks. */
export function parseMarkdown(source: string): Block[] {
const blocks: Block[] = [];
let paragraph: string[] = [];
let quote: string[] = [];
let listItems: string[] = [];
let listOrdered = false;
const flushParagraph = () => {
if (paragraph.length > 0) {
blocks.push({ kind: "paragraph", text: paragraph.join(" ") });
paragraph = [];
}
};
const flushQuote = () => {
if (quote.length > 0) {
blocks.push({ kind: "quote", text: quote.join(" ") });
quote = [];
}
};
const flushList = () => {
if (listItems.length > 0) {
blocks.push({ kind: "list", ordered: listOrdered, items: listItems });
listItems = [];
}
};
const flushAll = () => {
flushParagraph();
flushQuote();
flushList();
};
for (const rawLine of source.replace(/\r\n?/g, "\n").split("\n")) {
const line = rawLine.trim();
if (line === "") {
flushAll();
continue;
}
const heading = HEADING.exec(line);
if (heading) {
flushAll();
// The page owns h1, so CMS headings start at h2 and never go past h4.
const level = Math.min(Math.max(heading[1]!.length + 1, 2), 4) as 2 | 3 | 4;
blocks.push({ kind: "heading", level, text: heading[2]! });
continue;
}
const quoted = QUOTE.exec(line);
if (quoted) {
flushParagraph();
flushList();
quote.push(quoted[1]!);
continue;
}
const bullet = BULLET.exec(line);
const numbered = NUMBERED.exec(line);
if (bullet ?? numbered) {
flushParagraph();
flushQuote();
const ordered = numbered !== null;
if (listItems.length > 0 && ordered !== listOrdered) flushList();
listOrdered = ordered;
listItems.push((numbered?.[1] ?? bullet?.[1])!);
continue;
}
flushQuote();
flushList();
paragraph.push(line);
}
flushAll();
return blocks;
}
export interface RichTextProps {
/** Markdown from a Strapi `richtext` field. */
markdown?: string | null;
className?: string;
}
/** Renders a CMS markdown field with the site's type roles and tokens. */
export function RichText({ markdown, className }: RichTextProps) {
const source = markdown?.trim();
if (!source) return null;
const blocks = parseMarkdown(source);
if (blocks.length === 0) return null;
return (
<div className={cx("flex flex-col gap-md text-start", className)}>
{blocks.map((block, index) => {
const key = `block-${index}`;
switch (block.kind) {
case "heading": {
const Heading = `h${block.level}` as "h2" | "h3" | "h4";
return (
<Heading
key={key}
className={cx(
"text-[var(--text-primary)]",
block.level === 2 ? "heading-h2" : block.level === 3 ? "heading-h3" : "body-lg-bold",
index > 0 && "mt-lg",
)}
>
{renderInline(block.text, key)}
</Heading>
);
}
case "quote":
return (
<blockquote
key={key}
className="border-s-2 border-[var(--border-accent)] ps-lg body-lg text-[var(--text-secondary)]"
>
{renderInline(block.text, key)}
</blockquote>
);
case "list": {
const List = block.ordered ? "ol" : "ul";
return (
<List
key={key}
className={cx(
"flex list-outside flex-col gap-xs ps-lg body-md text-[var(--text-secondary)]",
block.ordered ? "list-decimal" : "list-disc",
)}
>
{block.items.map((item, itemIndex) => (
<li key={`${key}-${itemIndex}`} className="ps-2xs">
{renderInline(item, `${key}-${itemIndex}`)}
</li>
))}
</List>
);
}
default:
return (
<p key={key} className="body-md text-[var(--text-secondary)]">
{renderInline(block.text, key)}
</p>
);
}
})}
</div>
);
}
+362
View File
@@ -0,0 +1,362 @@
/* ============================================================================
PAGE KIT - section primitives
----------------------------------------------------------------------------
The handful of layout pieces every page module repeats: the centred content
column, a vertical rhythm for sections, a section header, the page hero and
the empty state that renders while the CMS has no entry yet.
RULES THIS FILE FOLLOWS
-----------------------
* tier-2 semantic tokens only - no hex, no tier-1 primitive, no raw type
size (the type role utilities carry family/size/leading/tracking/weight)
* logical properties only (ps-/pe-/ms-/me-/start-/end-, text-start) so the
Arabic pages mirror without a single `rtl:` override
* no component here duplicates Button / Input / Card - those are imported
from ~/components/ui by the pages themselves
========================================================================= */
import type { ReactNode } from "react";
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ---------------------------------------------------------------------------
LAYOUT
------------------------------------------------------------------------ */
export interface ContainerProps {
children: ReactNode;
className?: string;
}
/** The centred content column every section shares. */
export function Container({ children, className }: ContainerProps) {
return (
<div
className={cx(
"mx-auto w-full max-w-[var(--layout-content-max)] px-[var(--layout-gutter)] lg:px-[var(--layout-gutter-lg)]",
className,
)}
>
{children}
</div>
);
}
export interface PageSectionProps {
children: ReactNode;
id?: string;
/** Flip this section (and only this section) to the other surface. */
surface?: "dark" | "light";
className?: string;
/** Content is placed directly in the section, without the centred column. */
bleed?: boolean;
"aria-labelledby"?: string;
}
/**
* One band of the page. `surface` re-points the whole tier-2 palette for the
* subtree, so a light band needs no colour classes of its own.
*/
export function PageSection({
children,
id,
surface,
className,
bleed = false,
...rest
}: PageSectionProps) {
return (
<section
id={id}
{...(surface ? { "data-surface": surface } : {})}
className={cx("py-3xl md:py-4xl", className)}
{...rest}
>
{bleed ? children : <Container>{children}</Container>}
</section>
);
}
/** A readable measure for long-form prose. */
export function Prose({ children, className }: ContainerProps) {
return (
<div className={cx("flex max-w-[var(--layout-prose-max)] flex-col gap-md", className)}>
{children}
</div>
);
}
/* ---------------------------------------------------------------------------
HEADINGS
------------------------------------------------------------------------ */
export interface SectionHeaderProps {
title: string;
id?: string;
eyebrow?: string;
lead?: string;
titleAs?: "h1" | "h2" | "h3";
/** Figma centres the home page's section headers; inner pages read start. */
align?: "start" | "center";
className?: string;
}
export function SectionHeader({
title,
id,
eyebrow,
lead,
titleAs = "h2",
align = "start",
className,
}: SectionHeaderProps) {
const Title = titleAs;
const centred = align === "center";
return (
<header
className={cx(
"flex flex-col gap-sm",
centred ? "items-center text-center" : "items-start text-start",
className,
)}
>
{eyebrow ? (
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
) : null}
<Title
id={id}
className={cx(
"text-[var(--text-primary)]",
titleAs === "h1" ? "heading-h1 md:display-sm" : "heading-h2",
)}
>
{title}
</Title>
{lead ? (
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
{lead}
</p>
) : null}
</header>
);
}
/* ---------------------------------------------------------------------------
HERO
------------------------------------------------------------------------ */
export interface PageHeroProps {
title: string;
eyebrow?: string;
lead?: string;
/** Buttons, rendered inline-end of the headline on wide viewports. */
actions?: ReactNode;
/** Wide picture below the headline row, as in Figma 253:4800. */
media?: { src: string; alt: string; width?: number; height?: number };
className?: string;
}
/**
* The page hero - Figma "Hero header" (253:4800): headline on the inline-start
* side, the call-to-action group on the inline-end side, and a full-width
* picture underneath. The picture is only rendered when the CMS supplies one;
* an empty media box would otherwise ship as a grey rectangle.
*/
export function PageHero({
title,
eyebrow,
lead,
actions,
media,
className,
}: PageHeroProps) {
return (
<section className={cx("pt-2xl pb-3xl md:pt-3xl md:pb-4xl", className)}>
<Container className="flex flex-col gap-2xl">
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
<div className="flex max-w-[46rem] flex-col gap-sm">
{eyebrow ? (
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
) : null}
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
{title}
</h1>
{lead ? (
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
{lead}
</p>
) : null}
</div>
{actions ? (
<div className="flex flex-wrap items-center gap-md lg:justify-end">
{actions}
</div>
) : null}
</div>
{media ? (
<img
src={media.src}
alt={media.alt}
{...(media.width ? { width: media.width } : {})}
{...(media.height ? { height: media.height } : {})}
fetchPriority="high"
className="aspect-[16/9] w-full max-w-full rounded-surface object-cover"
/>
) : null}
</Container>
</section>
);
}
/* ---------------------------------------------------------------------------
PANELS
------------------------------------------------------------------------ */
export interface InfoPanelProps {
title: string;
children?: ReactNode;
/** Small line above the title - a number, a partner name, a role. */
eyebrow?: string;
titleAs?: "h2" | "h3" | "h4";
className?: string;
}
/**
* A titled block of text with no destination - a pillar, a workstream, a
* principle. Deliberately NOT a Card: every card in the design system is a
* link, and these are not.
*/
export function InfoPanel({
title,
children,
eyebrow,
titleAs = "h3",
className,
}: InfoPanelProps) {
const Title = titleAs;
return (
<div
className={cx(
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg",
className,
)}
>
{eyebrow ? (
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
) : null}
<Title className="body-lg-bold text-[var(--text-primary)]">{title}</Title>
{children ? (
<div className="body-md text-[var(--text-secondary)]">{children}</div>
) : null}
</div>
);
}
export interface EmptyStateProps {
/** Localised - never an English default. */
message: string;
title?: string;
className?: string;
children?: ReactNode;
}
/**
* What renders where the CMS has nothing yet. It states plainly that the
* content is still to come rather than showing an empty grid, and it is always
* passed a localised string by the caller.
*/
export function EmptyState({ message, title, className, children }: EmptyStateProps) {
return (
<div
className={cx(
"flex flex-col items-start gap-sm rounded-card border border-dashed border-[var(--border-default)] bg-[var(--surface-sunken)] p-lg text-start",
className,
)}
>
{title ? (
<p className="body-md-bold text-[var(--text-primary)]">{title}</p>
) : null}
<p className="body-md text-[var(--text-secondary)]">{message}</p>
{children}
</div>
);
}
/* ---------------------------------------------------------------------------
SMALL PIECES
------------------------------------------------------------------------ */
export interface ValueChipsProps {
values: readonly string[];
label?: string;
className?: string;
}
/** The "What guides us" row: Dialogue · Community Leadership · Learning · … */
export function ValueChips({ values, label, className }: ValueChipsProps) {
if (values.length === 0) return null;
return (
<div className={cx("flex flex-col gap-sm", className)}>
{label ? (
<p className="label-md uppercase text-[var(--text-tertiary)]">{label}</p>
) : null}
<ul className="flex flex-wrap gap-sm">
{values.map((value) => (
<li
key={value}
className="rounded-pill border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] px-md py-2xs body-sm-bold text-[var(--text-primary)]"
>
{value}
</li>
))}
</ul>
</div>
);
}
export interface StatStripProps {
stats: readonly { value: string; label: string }[];
className?: string;
}
/**
* The figures band. `value` is authored in the CMS as a string, already
* formatted for its locale, because the site must never invent or re-derive a
* project figure - see the note the pages render when this list is empty.
*/
export function StatStrip({ stats, className }: StatStripProps) {
if (stats.length === 0) return null;
return (
<dl
className={cx(
"grid grid-cols-2 gap-lg border-y border-[var(--border-subtle)] py-xl md:grid-cols-4",
className,
)}
>
{stats.map((stat) => (
/* Source order is term-then-description, as the spec requires; the
reversed column paints the figure above its label without repeating
the label for a screen reader. */
<div
key={`${stat.value}-${stat.label}`}
className="flex flex-col-reverse gap-2xs text-start"
>
<dt className="body-sm text-[var(--text-secondary)]">{stat.label}</dt>
<dd className="heading-h1 text-[var(--text-accent)]">{stat.value}</dd>
</div>
))}
</dl>
);
}
+236
View File
@@ -0,0 +1,236 @@
/* ============================================================================
RICH TEXT
----------------------------------------------------------------------------
Renders a Strapi 5 blocks field, or a plain-text field, as React elements.
No `dangerouslySetInnerHTML` anywhere: CMS prose is authored by project and
partner staff, and turning it into markup would make an editor account an
XSS vector. Every node below is mapped to a known element, and text is
rendered as text.
That is not enough on its own for links: an `href` is executable if its
scheme says so, so every link node's URL goes through `safeHref`, and a URL
that is not http/https/mailto/tel or an in-site path renders as plain text
rather than as an anchor.
Colour, size and rhythm come from tier-2 tokens and the published type
roles, and direction is left entirely to the document: RTL falls out of
`<html dir>` plus logical properties, with no mirrored stylesheet.
========================================================================= */
import type { ReactNode } from "react";
import { safeHref } from "~/lib/safe-href";
import type { CmsBlockNode, CmsInlineNode, CmsRichText } from "./cms";
/* ---------------------------------------------------------------------------
INLINE
------------------------------------------------------------------------ */
function isLinkNode(node: CmsInlineNode): boolean {
return node.type === "link" && typeof node.url === "string";
}
/**
* External links open in a new tab; in-site links stay a plain anchor. This is
* presentation only - `safeHref` decides whether there is a link at all.
*/
const EXTERNAL_HREF = /^(?:https?:)?\/\//i;
function InlineText({ node }: { node: CmsInlineNode }) {
if (isLinkNode(node)) {
const href = safeHref(node.url);
// A URL this site refuses to navigate to still keeps its words: the label
// renders as text instead of becoming an anchor with a dangerous target.
if (href === undefined) return <>{renderInline(node.children)}</>;
const external = EXTERNAL_HREF.test(href);
return (
<a
href={href}
className="text-text-link underline underline-offset-2 hover:text-text-link-hover"
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{renderInline(node.children)}
</a>
);
}
const text = node.text ?? "";
if (text === "") return null;
let element: ReactNode = text;
if (node.code) {
element = (
<code className="rounded-control bg-surface-sunken px-2xs py-0 font-mono text-[0.9em]">
{element}
</code>
);
}
if (node.bold) element = <strong className="font-bold">{element}</strong>;
if (node.italic) element = <em>{element}</em>;
if (node.underline) element = <u>{element}</u>;
if (node.strikethrough) element = <s>{element}</s>;
return <>{element}</>;
}
function renderInline(children: readonly CmsInlineNode[] | undefined): ReactNode {
if (!children || children.length === 0) return null;
return children.map((child, index) => (
<InlineText key={index} node={child} />
));
}
/* ---------------------------------------------------------------------------
BLOCKS
------------------------------------------------------------------------ */
/**
* Heading levels are clamped so a document that starts at H1 cannot introduce
* a second H1 into a page that already has one.
*/
const HEADING_CLASS: Readonly<Record<2 | 3 | 4, string>> = {
2: "heading-h2 text-text-primary",
3: "heading-h3 text-text-primary",
4: "body-lg-bold text-text-primary",
};
function Block({ node }: { node: CmsBlockNode }) {
switch (node.type) {
case "heading": {
const level = Math.min(4, Math.max(2, node.level ?? 2)) as 2 | 3 | 4;
const content = renderInline(node.children);
if (level === 2) return <h2 className={HEADING_CLASS[2]}>{content}</h2>;
if (level === 3) return <h3 className={HEADING_CLASS[3]}>{content}</h3>;
return <h4 className={HEADING_CLASS[4]}>{content}</h4>;
}
case "list": {
const ordered = node.format === "ordered";
const List = ordered ? "ol" : "ul";
return (
<List
className={`flex list-outside flex-col gap-xs ps-lg body-md text-text-secondary ${
ordered ? "list-decimal" : "list-disc"
}`}
>
{(node.children ?? []).map((item, index) => (
<li key={index}>
{item.type === "list" ? (
<Block node={item} />
) : (
renderInline((item as { children?: readonly CmsInlineNode[] }).children)
)}
</li>
))}
</List>
);
}
case "quote":
return (
<blockquote className="border-s-2 border-border-accent ps-md body-lg text-text-primary">
{renderInline(node.children)}
</blockquote>
);
case "code":
return (
<pre className="overflow-x-auto rounded-card bg-surface-sunken p-md body-sm text-text-secondary">
<code>{renderInline(node.children)}</code>
</pre>
);
case "image": {
const image = node.image;
if (!image?.url) return null;
return (
<figure className="flex flex-col gap-xs">
<img
src={image.url}
alt={image.alternativeText ?? ""}
width={image.width ?? undefined}
height={image.height ?? undefined}
loading="lazy"
decoding="async"
className="w-full rounded-card border border-border-subtle"
/>
{image.alternativeText ? (
<figcaption className="body-sm text-text-tertiary">
{image.alternativeText}
</figcaption>
) : null}
</figure>
);
}
case "paragraph":
default:
return (
<p className="body-md text-text-secondary">
{renderInline((node as { children?: readonly CmsInlineNode[] }).children)}
</p>
);
}
}
/* ---------------------------------------------------------------------------
ENTRY POINT
------------------------------------------------------------------------ */
/** A blank paragraph separates plain-text bodies into paragraphs. */
function paragraphsOf(value: string): string[] {
return value
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
}
export interface RichTextProps {
/** A Strapi blocks value, a plain string, or nothing at all. */
value: CmsRichText | undefined;
className?: string;
}
/**
* Renders whichever of the two shapes the field turned out to be, and nothing
* at all when it is empty - so a body that has not been written yet leaves no
* stray heading or empty box behind.
*/
export function RichText({ value, className }: RichTextProps) {
if (!value) return null;
const stack = `flex flex-col gap-md${className ? ` ${className}` : ""}`;
if (typeof value === "string") {
const paragraphs = paragraphsOf(value);
if (paragraphs.length === 0) return null;
return (
<div className={stack}>
{paragraphs.map((paragraph, index) => (
// `whitespace-pre-line` keeps single newlines inside a paragraph.
<p key={index} className="body-md whitespace-pre-line text-text-secondary">
{paragraph}
</p>
))}
</div>
);
}
if (value.length === 0) return null;
return (
<div className={stack}>
{value.map((node, index) => (
<Block key={index} node={node} />
))}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
/* ============================================================================
LOCALISED DETAIL URLS - canonical and hreflang
----------------------------------------------------------------------------
PURE and client-safe: this runs in a `meta` function, which also executes in
the browser on a client-side navigation, so nothing here may touch the
server environment.
WHY THIS EXISTS
Strapi 5 gives every localisation of an entry its OWN `slug` - the field is
a `uid` with `i18n.localized: true`, so the Arabic translation of
"civil-society-law-session" is a different string, not the same one behind
another prefix. Swapping only the "/en/" segment for "/ar/" therefore points
the hreflang alternate at a URL that does not exist, and a crawler follows
it into a 404. That is defect 18.
THE CONVENTION
1. A detail loader populates the counterpart:
populate: { localizations: LOCALIZATION_POPULATE }
and returns `alternates: collectLocalizedSlugs(...)` in its payload.
2. The route's `meta` appends `detailAlternateDescriptors(...)` to whatever
`buildMeta` produced, which emits <link rel="canonical"> plus one
<link rel="alternate" hreflang> per locale that actually has a slug, and
x-default pointing at English when English exists.
3. The route declares `export const handle = SELF_ALTERNATES_HANDLE`, which
tells the app shell that this page emits its own pair and that the
shell's prefix-swapped set must be suppressed for it. The shell reads it
with `readSelfAlternates`, exactly the way it already reads
`readCanonicalPath` from ~/lib/route-handle.
A locale with no localisation in the CMS gets NO alternate at all, which is
the honest answer: the page has not been translated yet.
========================================================================= */
import type { MetaDescriptor } from "react-router";
import { SUPPORTED_LOCALES, type Locale } from "~/i18n/config";
import { sectionPath, type RouteSlugKey } from "~/i18n/paths";
import { canonicalUrl } from "~/lib/seo";
/* ---------------------------------------------------------------------------
THE STRAPI SIDE
------------------------------------------------------------------------ */
/**
* The `populate` fragment every detail loader passes for `localizations`.
* Only the two fields the alternate needs are asked for, so the counterpart
* entry's whole body is not dragged across the wire.
*/
export const LOCALIZATION_POPULATE = {
fields: ["slug", "locale"],
} as const;
/** One entry of Strapi 5's `localizations` array, with everything optional. */
export interface CmsLocalizationRef {
readonly slug?: string | null;
readonly locale?: string | null;
}
/** An entity that may carry its counterpart translations. */
export interface CmsLocalizable {
readonly localizations?: readonly CmsLocalizationRef[] | null;
}
/** Slug per locale. A locale is absent when the CMS has no translation. */
export type LocalizedSlugs = Readonly<Partial<Record<Locale, string>>>;
function isLocaleCode(value: unknown): value is Locale {
return (
typeof value === "string" &&
(SUPPORTED_LOCALES as readonly string[]).includes(value)
);
}
/**
* The slug of this entry in every language the CMS actually holds it in:
* the locale being rendered plus each entry of `localizations`.
*
* Serialisable, so a loader returns it straight to the route.
*/
export function collectLocalizedSlugs(
locale: Locale,
slug: string,
localizations: readonly CmsLocalizationRef[] | null | undefined,
): LocalizedSlugs {
const slugs: Partial<Record<Locale, string>> = {};
if (slug) slugs[locale] = slug;
for (const entry of localizations ?? []) {
const code = entry?.locale;
const value = entry?.slug;
if (isLocaleCode(code) && typeof value === "string" && value.length > 0) {
slugs[code] = value;
}
}
return slugs;
}
/* ---------------------------------------------------------------------------
THE URL SIDE
------------------------------------------------------------------------ */
export interface LocalizedDetailPath {
readonly locale: Locale;
readonly path: string;
}
/** `/ar/news-and-updates/<arabic-slug>` for every locale that has a slug. */
export function localizedDetailPaths(
section: RouteSlugKey,
slugs: LocalizedSlugs,
): LocalizedDetailPath[] {
return SUPPORTED_LOCALES.flatMap((locale) => {
const slug = slugs[locale];
if (!slug) return [];
return [
{
locale,
path: `${sectionPath(section, locale)}/${encodeURIComponent(slug)}`,
},
];
});
}
export interface DetailAlternatesInput {
readonly siteUrl: string;
readonly section: RouteSlugKey;
/** The locale being rendered. */
readonly locale: Locale;
readonly slugs: LocalizedSlugs;
}
/**
* `<link rel="canonical">` for this page plus the hreflang set built from the
* REAL slugs. Returns [] when the current locale has no slug at all, so an
* error render never claims a canonical it cannot build.
*/
export function detailAlternateDescriptors({
siteUrl,
section,
locale,
slugs,
}: DetailAlternatesInput): MetaDescriptor[] {
const paths = localizedDetailPaths(section, slugs);
const own = paths.find((entry) => entry.locale === locale);
if (!own) return [];
const descriptors: MetaDescriptor[] = [
{ tagName: "link", rel: "canonical", href: canonicalUrl(siteUrl, own.path) },
];
for (const entry of paths) {
descriptors.push({
tagName: "link",
rel: "alternate",
hrefLang: entry.locale,
href: canonicalUrl(siteUrl, entry.path),
});
}
// x-default is the English URL, because bare "/" negotiates towards English.
const fallback = paths.find((entry) => entry.locale === "en");
if (fallback) {
descriptors.push({
tagName: "link",
rel: "alternate",
hrefLang: "x-default",
href: canonicalUrl(siteUrl, fallback.path),
});
}
return descriptors;
}
/* ---------------------------------------------------------------------------
THE SHELL SIDE
------------------------------------------------------------------------ */
/**
* A route handle saying "this page emits its own canonical and hreflang - do
* not add the prefix-swapped pair". Read by the shell with `readSelfAlternates`.
*/
export const SELF_ALTERNATES_HANDLE = { selfAlternates: true } as const;
/* The reader lives with the other handle readers in ~/lib/route-handle, so the
shell does not have to reach into a route folder. Re-exported here because
this module is where the convention is documented. */
export { readSelfAlternates } from "~/lib/route-handle";
+480
View File
@@ -0,0 +1,480 @@
/* ============================================================================
CMS CONTENT MODEL - Knowledge and Impact + News
----------------------------------------------------------------------------
SERVER ONLY. Everything here reaches ~/lib/strapi, which imports
~/lib/env.server, so these helpers may only be called from a `loader`.
The three collections below are what the Strapi agent has to create; the
API ids and field names are the contract. Nothing in this file invents
content: when a collection does not exist yet, or Strapi is unreachable, a
listing degrades to `available: false` and the route renders its localised
empty state instead of a 500.
resources Reports, research and official documents (§6 of the draft)
knowledge-items Training modules, guides, case studies, FAQs (§7)
news-articles SHARP-ER news and network updates (§5)
plus the two taxonomies that supply filter options:
topics partners
FIELD NAMES ARE THE ONES IN apps/cms/src/api/<type>/content-types/<type>/
schema.json - not a guess. `resource.authors` in particular is a plain
STRING field, not a relation, so there is no /api/authors to read.
========================================================================= */
import type { CmsLocalizable } from "./alternates";
import {
findFirst,
getBySlug,
getMany,
isStrapiError,
mediaAlt,
mediaFormatUrl,
mediaUrl,
type StrapiEntity,
type StrapiMedia,
type StrapiPagination,
type StrapiQuery,
} from "~/lib/strapi";
/* ---------------------------------------------------------------------------
RESOURCE NAMES
------------------------------------------------------------------------ */
export const CMS = {
resources: "resources",
knowledgeItems: "knowledge-items",
newsArticles: "news-articles",
topics: "topics",
partners: "partners",
} as const;
/* ---------------------------------------------------------------------------
SHARED SHAPES
------------------------------------------------------------------------ */
/** A taxonomy entry. Only `name` and `slug` are ever read. */
export interface CmsTaxonomy extends StrapiEntity {
readonly name: string;
readonly slug: string;
}
/** One option of a filter `<select>`. */
export interface FilterOption {
readonly value: string;
readonly label: string;
}
/**
* A Strapi blocks-rich-text value, or a plain markdown string when the field
* was modelled as `text`. Both are rendered safely - see _shared/RichText.tsx.
*/
export type CmsRichText = string | readonly CmsBlockNode[] | null;
export type CmsBlockNode =
| { readonly type: "paragraph"; readonly children?: readonly CmsInlineNode[] }
| {
readonly type: "heading";
readonly level?: number;
readonly children?: readonly CmsInlineNode[];
}
| {
readonly type: "list";
readonly format?: "ordered" | "unordered";
readonly children?: readonly CmsBlockNode[];
}
| { readonly type: "list-item"; readonly children?: readonly CmsInlineNode[] }
| { readonly type: "quote"; readonly children?: readonly CmsInlineNode[] }
| { readonly type: "code"; readonly children?: readonly CmsInlineNode[] }
| {
readonly type: "image";
readonly image?: {
readonly url?: string;
readonly alternativeText?: string | null;
readonly width?: number | null;
readonly height?: number | null;
} | null;
};
/**
* One inline node: a run of text, or a link wrapping more inline nodes.
* Modelled as a single open shape rather than a discriminated union because
* this is untrusted CMS JSON - `type` may be absent on a text run, which is
* exactly the case a union cannot narrow, and every field has to be treated
* as possibly missing anyway.
*/
export interface CmsInlineNode {
readonly type?: "text" | "link";
readonly text?: string;
/** Present on a link node. */
readonly url?: string;
/** Present on a link node. */
readonly children?: readonly CmsInlineNode[];
readonly bold?: boolean;
readonly italic?: boolean;
readonly underline?: boolean;
readonly strikethrough?: boolean;
readonly code?: boolean;
}
/* ---------------------------------------------------------------------------
COLLECTIONS
------------------------------------------------------------------------ */
/** §6 Resources and Publications - `resource.resourceType`. */
export type ResourceType =
| "research-perception"
| "policy-article"
| "project-report"
| "visibility-factsheet";
export const RESOURCE_TYPES = [
"research-perception",
"policy-article",
"project-report",
"visibility-factsheet",
] as const satisfies readonly ResourceType[];
/** `resource.resourceLanguage`. */
export type ResourceLanguage = "en" | "ar" | "bilingual";
export interface ResourceEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
/** The card excerpt. `description` in the CMS, not `summary`. */
readonly description?: string | null;
readonly resourceType?: ResourceType | null;
/** Publication date of the document itself, not of the CMS entry. */
readonly publicationDate?: string | null;
/**
* A free-text credit line ("Ahmed Ali, Fatima Saleh"), NOT a relation:
* there is no author content type, so the author filter is built from the
* distinct values this field actually holds - see `fetchDistinctValues`.
*/
readonly authors?: string | null;
readonly partnerOrganisations?: readonly CmsTaxonomy[] | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly thumbnail?: StrapiMedia | null;
readonly file?: StrapiMedia | null;
/** Used when the document is hosted elsewhere instead of uploaded. */
readonly externalUrl?: string | null;
readonly fileSizeLabel?: string | null;
readonly pageCount?: number | null;
readonly resourceLanguage?: ResourceLanguage | null;
}
/** §7 Knowledge Hub - `knowledge-item.itemType`. */
export type KnowledgeItemType =
| "training-module"
| "toolkit"
| "law-session-guide"
| "case-study"
| "faq";
export const KNOWLEDGE_TYPES = [
"training-module",
"toolkit",
"law-session-guide",
"case-study",
"faq",
] as const satisfies readonly KnowledgeItemType[];
export interface KnowledgeEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
readonly summary?: string | null;
readonly body?: CmsRichText;
readonly itemType?: KnowledgeItemType | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly thumbnail?: StrapiMedia | null;
/**
* Multiple media. There is NO `externalUrl` on this type: a knowledge item
* is either downloadable or it is read on the page.
*/
readonly attachments?: readonly StrapiMedia[] | null;
readonly publicationDate?: string | null;
readonly keywords?: unknown;
}
/** §5 News and Updates. `category` drives the two tabs of the listing. */
export type NewsCategory = "sharper-news" | "from-the-network";
export const NEWS_CATEGORIES = [
"sharper-news",
"from-the-network",
] as const satisfies readonly NewsCategory[];
export interface NewsEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
readonly excerpt?: string | null;
readonly body?: CmsRichText;
readonly category?: NewsCategory | null;
/**
* Credit line for a "From the Network" update. A RELATION to a partner, so
* it has to be populated - an unpopulated relation comes back as undefined
* and the card silently loses its byline.
*/
readonly authorOrganisation?: CmsTaxonomy | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly regions?: readonly CmsTaxonomy[] | null;
readonly coverImage?: StrapiMedia | null;
readonly publishDate?: string | null;
readonly featured?: boolean | null;
}
/* ---------------------------------------------------------------------------
MEDIA -> PLAIN LOADER DATA
`mediaUrl` reads the environment, so media is flattened in the loader and
the component never sees a StrapiMedia.
------------------------------------------------------------------------ */
export interface CmsImage {
readonly src: string;
readonly alt: string;
readonly width?: number;
readonly height?: number;
}
export interface CmsFile {
readonly url: string;
readonly name: string;
/** "PDF", "DOCX", ... - upper case, without the dot. */
readonly kind: string;
readonly mime: string;
/** Bytes. Strapi stores `size` in kilobytes, so it is converted here. */
readonly sizeBytes: number;
}
export function toImage(
media: StrapiMedia | null | undefined,
format: "thumbnail" | "small" | "medium" | "large" = "medium",
): CmsImage | undefined {
const src = mediaFormatUrl(media, format);
if (!src || !media) return undefined;
const chosen = media.formats?.[format];
const width = chosen?.width ?? media.width ?? undefined;
const height = chosen?.height ?? media.height ?? undefined;
return {
src,
alt: mediaAlt(media),
...(width ? { width } : {}),
...(height ? { height } : {}),
};
}
export function toFile(media: StrapiMedia | null | undefined): CmsFile | undefined {
const url = mediaUrl(media?.url);
if (!url || !media) return undefined;
const extension = (media.ext ?? "").replace(/^\./, "").toUpperCase();
const fallback = media.mime.split("/").pop()?.toUpperCase() ?? "FILE";
return {
url,
name: media.name,
kind: extension || fallback,
mime: media.mime,
// Strapi's upload plugin records `size` in KB (a float), not in bytes.
sizeBytes: Math.round(Math.max(0, media.size) * 1024),
};
}
/** Names of a taxonomy list, in CMS order. */
export function taxonomyNames(
entries: readonly CmsTaxonomy[] | null | undefined,
): string[] {
return (entries ?? [])
.map((entry) => entry?.name)
.filter((name): name is string => typeof name === "string" && name.length > 0);
}
/* ---------------------------------------------------------------------------
FAULT-TOLERANT FETCHING
The CMS is empty today and its collections may not exist at all, so a
listing must degrade rather than throw. A DETAIL page does the opposite:
a missing entry is a genuine 404 and a transport failure is a real error.
------------------------------------------------------------------------ */
export interface CollectionResult<T> {
readonly entries: readonly T[];
readonly pagination: StrapiPagination;
/** False when the collection could not be read at all. */
readonly available: boolean;
}
function emptyPagination(page: number, pageSize: number): StrapiPagination {
return { page, pageSize, pageCount: 0, total: 0 };
}
function warn(resource: string, error: unknown): void {
const reason = isStrapiError(error)
? `${error.status || "no response"} ${error.message}`
: String(error);
console.warn(`[cms] "${resource}" is unavailable - rendering the empty state (${reason})`);
}
/**
* A paginated collection, or an empty one when Strapi cannot answer.
* Only `StrapiError` is swallowed: a bug in this module still throws.
*/
export async function fetchCollection<T>(
resource: string,
query: StrapiQuery,
): Promise<CollectionResult<T>> {
const page = query.pagination?.page ?? 1;
const pageSize = query.pagination?.pageSize ?? 25;
try {
const result = await getMany<T>(resource, query);
return {
entries: result.data,
pagination: result.pagination ?? {
page,
pageSize,
pageCount: result.data.length > 0 ? 1 : 0,
total: result.data.length,
},
available: true,
};
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return { entries: [], pagination: emptyPagination(page, pageSize), available: false };
}
}
/**
* Filter options from a taxonomy collection. Returns [] - never throws - so a
* taxonomy that has not been created yet simply renders as "All".
*/
export async function fetchFilterOptions(
resource: string,
locale: string,
): Promise<FilterOption[]> {
try {
const result = await getMany<CmsTaxonomy>(resource, {
locale,
fields: ["name", "slug"],
sort: "name:asc",
pagination: { pageSize: 100 },
});
return result.data
.filter((entry) => Boolean(entry?.slug && entry?.name))
.map((entry) => ({ value: entry.slug, label: entry.name }));
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
/**
* The distinct values a plain STRING field actually holds, as filter options.
*
* `resource.authors` is free text, not a taxonomy, so there is no /api/authors
* to read - asking for one is a 404. The options are derived from the entries
* themselves instead, which also means the filter never offers a name that
* has nothing behind it, and offers nothing at all while the CMS is empty.
*/
export async function fetchDistinctValues(
resource: string,
locale: string,
field: string,
limit = 200,
): Promise<FilterOption[]> {
try {
const result = await getMany<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:asc`,
pagination: { page: 1, pageSize: limit },
});
const seen = new Map<string, string>();
for (const entry of result.data) {
const raw = entry?.[field];
if (typeof raw !== "string") continue;
const value = raw.trim();
if (value.length === 0 || value.length > 200) continue;
if (!seen.has(value)) seen.set(value, value);
}
return [...seen.values()]
.sort((a, b) => a.localeCompare(b, locale))
.map((value) => ({ value, label: value }));
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
/**
* The calendar years actually covered by a collection, newest first. Derived
* from the oldest and newest entry rather than hard-coded, so the "Date"
* filter never offers a year that has nothing behind it - and offers nothing
* at all while the CMS is empty.
*/
export async function fetchYearOptions(
resource: string,
locale: string,
field: string,
): Promise<number[]> {
try {
const [newest, oldest] = await Promise.all([
findFirst<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:desc`,
}),
findFirst<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:asc`,
}),
]);
const to = yearOf(newest?.[field]);
const from = yearOf(oldest?.[field]);
if (to === undefined || from === undefined) return [];
const years: number[] = [];
for (let year = Math.max(from, to); year >= Math.min(from, to); year -= 1) {
years.push(year);
}
return years;
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
function yearOf(value: unknown): number | undefined {
if (typeof value !== "string" && typeof value !== "number") return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.getUTCFullYear();
}
/**
* One entry by slug, or null. A 404 from Strapi (including "this collection
* does not exist yet") resolves to null so the route can throw its own 404;
* every other failure propagates, because a CMS outage is not a missing page.
*/
export async function fetchBySlug<T>(
resource: string,
slug: string,
query: StrapiQuery,
): Promise<T | null> {
try {
return await getBySlug<T>(resource, slug, query);
} catch (error) {
if (isStrapiError(error) && error.status === 404) return null;
throw error;
}
}
+526
View File
@@ -0,0 +1,526 @@
/* ============================================================================
LISTING UI
----------------------------------------------------------------------------
The pieces the three listings share: page header, filter bar, result
summary, empty state, tabs, pagination, metadata list and detail-page
scaffolding.
None of this duplicates the component library - Button, Input and the Card
family come from ~/components/ui and are used as they are; what is here is
layout and the handful of controls (a native <select>, a tab strip, a
pagination list) the library does not ship.
RULES FOLLOWED THROUGHOUT
* colour, radius, spacing and motion are tier-2 semantic tokens only,
spent through the Tailwind bridge (bg-surface-raised, text-text-primary)
or as arbitrary values (shadow-[var(--elevation-card)]);
* every directional property is logical (ps-/pe-/ms-/me-/start-/end-,
border-s), so the Arabic build mirrors with no second stylesheet;
* type comes from the published roles - display-sm, heading-h2, body-md,
label-md - never a raw font-size.
========================================================================= */
import type { ReactNode } from "react";
import { Form, Link, useSubmit } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import type { Translator } from "~/i18n";
import type { PaginationModel, SelectOption } from "./listing";
import { withParam } from "./listing";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ---------------------------------------------------------------------------
PAGE SCAFFOLDING
------------------------------------------------------------------------ */
/** The centred column every page in this section uses. */
export function PageShell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cx(
"mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col",
"gap-2xl px-[var(--layout-gutter)] py-3xl",
className,
)}
>
{children}
</div>
);
}
export function PageHeader({
heading,
intro,
eyebrow,
children,
}: {
heading: string;
intro?: string;
eyebrow?: string;
children?: ReactNode;
}) {
return (
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
{eyebrow ? <p className="label-md uppercase text-text-accent">{eyebrow}</p> : null}
<h1 className="display-sm text-text-primary">{heading}</h1>
{intro ? <p className="body-lg text-text-secondary">{intro}</p> : null}
{children}
</header>
);
}
/* ---------------------------------------------------------------------------
FILTERS
------------------------------------------------------------------------ */
/**
* A native <select>. Native on purpose: it is keyboard- and screen-reader
* correct in every browser, it works before hydration, and `color-scheme` on
* the surface already makes the dropdown itself paint dark or light.
*/
export function FilterSelect({
name,
label,
value,
options,
allLabel,
disabled,
}: {
name: string;
label: string;
value: string;
options: readonly SelectOption[];
/**
* Label of the "no filter" option, e.g. "All". Omit it for a control that
* always has a value - a sort order has no "unset" state, and offering one
* would duplicate whichever option is the default.
*/
allLabel?: string;
disabled?: boolean;
}) {
return (
<div className="flex min-w-0 flex-col gap-2xs">
<label className="label-md text-text-tertiary" htmlFor={`filter-${name}`}>
{label}
</label>
<select
id={`filter-${name}`}
name={name}
defaultValue={value}
disabled={disabled || options.length === 0}
className={cx(
"body-md w-full rounded-[var(--radius-control)] border bg-field-bg",
"border-field-border px-md py-sm text-[var(--field-fg)]",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)] hover:border-[var(--field-border-hover)]",
"disabled:cursor-not-allowed disabled:bg-[var(--field-bg-disabled)]",
"disabled:text-[var(--field-fg-disabled)]",
)}
>
{allLabel === undefined ? null : <option value="">{allLabel}</option>}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}
/**
* The filter bar. A real `<form method="get">`, so every combination of
* filters is a URL: shareable, indexable, and usable with scripting off. When
* scripting is on, changing a control submits immediately; the Apply button
* stays for the case where it is not.
*
* `page` is deliberately NOT a field: submitting the form drops it, which
* returns the reader to page 1 of the new result set.
*/
export function FilterBar({
legend,
action,
applyLabel,
clearLabel,
clearHref,
showClear,
children,
}: {
legend: string;
/** The listing's own pathname - keeps the GET target explicit. */
action: string;
applyLabel: string;
clearLabel: string;
clearHref: string;
showClear: boolean;
children: ReactNode;
}) {
const submit = useSubmit();
return (
<Form
method="get"
action={action}
aria-label={legend}
onChange={(event) => {
// Progressive enhancement: with JS, changing a control applies at once.
if (event.target instanceof HTMLSelectElement) {
submit(event.currentTarget, { method: "get" });
}
}}
className={cx(
"flex flex-col gap-md rounded-card border border-border-subtle",
"bg-surface-raised p-lg shadow-[var(--elevation-raised)]",
)}
>
<div className="grid gap-md sm:grid-cols-2 lg:grid-cols-4">{children}</div>
<div className="flex flex-wrap items-center gap-sm">
<Button type="submit" variant="secondary" size="sm">
{applyLabel}
</Button>
{showClear ? (
<Button as={Link} to={clearHref} variant="ghost" size="sm">
{clearLabel}
</Button>
) : null}
</div>
</Form>
);
}
/** The search box of the knowledge hub. Submits with the surrounding form. */
export function FilterSearch({
label,
placeholder,
defaultValue,
name = "q",
}: {
label: string;
placeholder: string;
defaultValue: string;
name?: string;
}) {
return (
<Input
type="search"
name={name}
label={label}
placeholder={placeholder}
defaultValue={defaultValue}
className="min-w-0"
/>
);
}
/* ---------------------------------------------------------------------------
RESULTS
------------------------------------------------------------------------ */
/** "Showing 1–12 of 34". Announced politely when a filter changes the count. */
export function ResultSummary({
pagination,
t,
}: {
pagination: PaginationModel;
t: Translator;
}) {
return (
<p className="body-sm text-text-tertiary" role="status">
{pagination.total === 0
? null
: t("pagination.showing", {
from: pagination.from,
to: pagination.to,
total: pagination.total,
})}
</p>
);
}
export function EmptyState({
title,
body,
children,
}: {
title: string;
body: string;
children?: ReactNode;
}) {
return (
<div
className={cx(
"flex flex-col items-start gap-md rounded-card border border-dashed",
"border-border-default bg-surface-sunken p-2xl",
)}
>
<p className="heading-h3 text-text-primary">{title}</p>
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">{body}</p>
{children}
</div>
);
}
/* ---------------------------------------------------------------------------
TABS
------------------------------------------------------------------------ */
export interface TabLink {
readonly id: string;
readonly label: string;
readonly href: string;
readonly isCurrent: boolean;
}
/**
* The News tab strip. Links rather than buttons, because each tab is its own
* URL - the reader can share "From the Network" and a crawler can reach it.
*/
export function TabLinks({ label, tabs }: { label: string; tabs: readonly TabLink[] }) {
return (
<nav aria-label={label}>
<ul className="flex flex-wrap gap-xs border-b border-border-subtle">
{tabs.map((tab) => (
<li key={tab.id}>
<Link
to={tab.href}
aria-current={tab.isCurrent ? "page" : undefined}
className={cx(
"-mb-px inline-block border-b-2 px-md py-sm body-md-bold",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)]",
tab.isCurrent
? "border-border-accent text-text-accent"
: "border-transparent text-text-secondary hover:text-text-primary",
)}
>
{tab.label}
</Link>
</li>
))}
</ul>
</nav>
);
}
/* ---------------------------------------------------------------------------
PAGINATION
------------------------------------------------------------------------ */
const PAGE_LINK = cx(
"inline-flex min-w-[2.75rem] items-center justify-center rounded-[var(--radius-control)]",
"border px-sm py-2xs body-sm-bold transition-colors",
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
);
/**
* Numbered pagination, rendered as ordinary links so the whole archive is
* crawlable. The arrows follow reading order: `ArrowIcon` mirrors itself in
* RTL, so "next" points left on /ar without a second component.
*/
export function Pagination({
model,
pathname,
params,
t,
}: {
model: PaginationModel;
pathname: string;
params: URLSearchParams;
t: Translator;
}) {
if (model.pageCount <= 1) return null;
const href = (page: number) => `${pathname}${withParam(params, "page", page)}`;
return (
<nav aria-label={t("pagination.label")} className="flex flex-col gap-md">
<ul className="flex flex-wrap items-center gap-xs">
<li>
{model.previous === undefined ? (
<Button variant="ghost" size="sm" disabled iconStart={<ArrowIcon direction="backward" />}>
{t("pagination.previous")}
</Button>
) : (
<Button
as={Link}
to={href(model.previous)}
variant="ghost"
size="sm"
iconStart={<ArrowIcon direction="backward" />}
>
{t("pagination.previous")}
</Button>
)}
</li>
{model.pages.map((entry, index) => {
const previousPage = model.pages[index - 1]?.page;
const gap = previousPage !== undefined && entry.page - previousPage > 1;
return (
<li key={entry.page} className="flex items-center gap-xs">
{gap ? (
<span aria-hidden="true" className="body-sm text-text-tertiary">
…
</span>
) : null}
<Link
to={href(entry.page)}
aria-current={entry.isCurrent ? "page" : undefined}
aria-label={t("pagination.goToPage", { page: entry.page })}
className={cx(
PAGE_LINK,
entry.isCurrent
? "border-border-accent bg-surface-accent-subtle text-text-accent"
: "border-border-subtle text-text-secondary hover:border-border-default hover:text-text-primary",
)}
>
{entry.page}
</Link>
</li>
);
})}
<li>
{model.next === undefined ? (
<Button variant="ghost" size="sm" disabled iconEnd={<ArrowIcon />}>
{t("pagination.next")}
</Button>
) : (
<Button
as={Link}
to={href(model.next)}
variant="ghost"
size="sm"
iconEnd={<ArrowIcon />}
>
{t("pagination.next")}
</Button>
)}
</li>
</ul>
<p className="body-sm text-text-tertiary">
{t("pagination.page", { page: model.page, pageCount: model.pageCount })}
</p>
</nav>
);
}
/* ---------------------------------------------------------------------------
DETAIL PAGES
------------------------------------------------------------------------ */
export interface MetaItem {
readonly label: string;
readonly value: ReactNode;
}
/** The "Published / Author / Partner organisation" block of a detail page. */
export function MetaList({ heading, items }: { heading: string; items: readonly MetaItem[] }) {
if (items.length === 0) return null;
return (
<section
aria-label={heading}
className={cx(
"flex flex-col gap-md rounded-card border border-border-subtle",
"bg-surface-raised p-lg",
)}
>
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
<dl className="flex flex-col gap-sm">
{items.map((item) => (
<div key={item.label} className="flex flex-col gap-2xs">
<dt className="body-sm text-text-tertiary">{item.label}</dt>
<dd className="body-md-bold text-text-primary">{item.value}</dd>
</div>
))}
</dl>
</section>
);
}
/** Topic chips. Each one links back into the listing, pre-filtered. */
export function TopicChips({
heading,
topics,
}: {
heading: string;
topics: readonly { label: string; href?: string }[];
}) {
if (topics.length === 0) return null;
return (
<section aria-label={heading} className="flex flex-col gap-xs">
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
<ul className="flex flex-wrap gap-xs">
{topics.map((topic) => (
<li key={topic.label}>
{topic.href ? (
<Link
to={topic.href}
className={cx(
"inline-block rounded-pill border border-border-subtle px-sm py-2xs",
"body-sm text-text-secondary transition-colors",
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
"hover:border-border-accent hover:text-text-accent",
)}
>
{topic.label}
</Link>
) : (
<span className="inline-block rounded-pill border border-border-subtle px-sm py-2xs body-sm text-text-secondary">
{topic.label}
</span>
)}
</li>
))}
</ul>
</section>
);
}
/** The link out of a detail page, arrow pointing against reading order. */
export function BackLink({ to, label }: { to: string; label: string }) {
return (
<Button
as={Link}
to={to}
variant="ghost"
size="sm"
iconStart={<ArrowIcon direction="backward" />}
>
{label}
</Button>
);
}
/** A short, dot-separated metadata line under a card or a detail title. */
export function MetaLine({ items }: { items: readonly string[] }) {
const present = items.filter(Boolean);
if (present.length === 0) return null;
return (
<p className="body-sm text-text-tertiary">
{present.map((item, index) => (
<span key={`${item}-${index}`}>
{index > 0 ? <span aria-hidden="true"> · </span> : null}
{item}
</span>
))}
</p>
);
}
+321
View File
@@ -0,0 +1,321 @@
/* ============================================================================
LISTING STATE - URL search parameters
----------------------------------------------------------------------------
Every filter, the sort order and the page number live in the query string,
so a filtered listing is a real URL: shareable, bookmarkable, back-button
correct, and reachable by a crawler that only follows links.
Pure and client-safe - the same helpers parse in the loader and build hrefs
in the component, which is what keeps the two in step.
PARAMETER NAMES (shared by /en and /ar - the locale lives in the path):
resources ?type= &topic= &author= &partner= &year= &sort= &page=
knowledge-hub ?type= &topic= &q= &page=
news-and-updates ?tab= &page=
========================================================================= */
import { data } from "react-router";
import {
DEFAULT_LOCALE,
isLocale,
localizedPath,
translate,
type Locale,
type TranslationKey,
} from "~/i18n";
/** Entries per page. 12 divides cleanly into the 1 / 2 / 3 column grids. */
export const PAGE_SIZE = 12;
/** Longest search term we will forward to the CMS. */
const MAX_QUERY_LENGTH = 100;
/** Slugs and enum values are `[a-z0-9-]` - anything else is dropped, never sent. */
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type SortOrder = "newest" | "oldest";
export const SORT_ORDERS = ["newest", "oldest"] as const satisfies readonly SortOrder[];
/* ---------------------------------------------------------------------------
PARSING
------------------------------------------------------------------------ */
/** A 1-based page number. Anything unparseable, zero or negative becomes 1. */
export function parsePage(params: URLSearchParams): number {
const raw = Number.parseInt(params.get("page") ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 10_000) : 1;
}
/** One of `allowed`, or `fallback`. Used for every enum-backed filter. */
export function parseEnum<T extends string>(
params: URLSearchParams,
key: string,
allowed: readonly T[],
fallback: T,
): T {
const value = params.get(key);
return allowed.find((option) => option === value) ?? fallback;
}
/** One of `allowed`, or undefined when the parameter is absent or unknown. */
export function parseOptionalEnum<T extends string>(
params: URLSearchParams,
key: string,
allowed: readonly T[],
): T | undefined {
const value = params.get(key);
return allowed.find((option) => option === value);
}
/**
* A taxonomy slug. Validated rather than trusted: the value is interpolated
* into a Strapi filter, so only the shape a slug can actually have is passed on.
*/
export function parseSlug(params: URLSearchParams, key: string): string | undefined {
const value = params.get(key)?.trim().toLowerCase();
if (!value || value.length > 80 || !SLUG_PATTERN.test(value)) return undefined;
return value;
}
/**
* An exact value for a filter on a plain STRING field - `resource.authors` is
* free text, not a taxonomy, so its filter cannot be a slug. Trimmed, length
* capped and stripped of control characters before it is interpolated into a
* Strapi `$eq`, so only a value a reader could have picked out of the select
* is ever forwarded.
*/
export function parseExactText(
params: URLSearchParams,
key: string,
maxLength = 200,
): string | undefined {
const raw = params.get(key);
if (typeof raw !== "string") return undefined;
// eslint-disable-next-line no-control-regex
const value = raw.replace(/[\u0000-\u001F\u007F]/g, "").trim();
if (value.length === 0 || value.length > maxLength) return undefined;
return value;
}
/** A four-digit year inside a plausible range, or undefined. */
export function parseYear(params: URLSearchParams, key = "year"): number | undefined {
const raw = Number.parseInt(params.get(key) ?? "", 10);
if (!Number.isFinite(raw) || raw < 1900 || raw > 2200) return undefined;
return raw;
}
/** A free-text search term, trimmed and length-capped. */
export function parseQuery(params: URLSearchParams, key = "q"): string | undefined {
const value = params.get(key)?.trim();
if (!value) return undefined;
return value.slice(0, MAX_QUERY_LENGTH);
}
export function parseSort(params: URLSearchParams): SortOrder {
return parseEnum(params, "sort", SORT_ORDERS, "newest");
}
/* ---------------------------------------------------------------------------
STRAPI QUERY FRAGMENTS
------------------------------------------------------------------------ */
/** `"publicationDate:desc"` / `":asc"`, with a stable tiebreaker. */
export function sortFor(field: string, order: SortOrder): string[] {
return order === "oldest" ? [`${field}:asc`, "title:asc"] : [`${field}:desc`, "title:asc"];
}
/** A whole calendar year as a half-open range on a date field. */
export function yearFilter(year: number): { $gte: string; $lt: string } {
return {
$gte: `${year}-01-01`,
$lt: `${year + 1}-01-01`,
};
}
/** Drop the keys whose value is undefined, so an empty filter is not sent. */
export function compactFilters(
filters: Record<string, unknown>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(filters).filter(([, value]) => value !== undefined),
);
}
/* ---------------------------------------------------------------------------
HREFS
------------------------------------------------------------------------ */
/**
* The same listing with one parameter changed. `undefined` removes it, and
* changing anything other than the page resets back to page 1 - a filter that
* kept you on page 7 of a shorter result set is the classic empty page.
*/
export function withParam(
params: URLSearchParams,
key: string,
value: string | number | undefined,
): string {
const next = new URLSearchParams(params);
if (value === undefined || value === "") next.delete(key);
else next.set(key, String(value));
if (key !== "page") next.delete("page");
if (key === "page" && String(value) === "1") next.delete("page");
const search = next.toString();
return search ? `?${search}` : "";
}
/** The listing with every filter cleared. */
export function clearedHref(pathname: string): string {
return pathname;
}
/* ---------------------------------------------------------------------------
PAGINATION MODEL
------------------------------------------------------------------------ */
export interface PageLink {
readonly page: number;
readonly isCurrent: boolean;
}
export interface PaginationModel {
readonly page: number;
readonly pageCount: number;
readonly total: number;
readonly from: number;
readonly to: number;
readonly previous: number | undefined;
readonly next: number | undefined;
readonly pages: readonly PageLink[];
}
/**
* A windowed page list: first, last, and up to two neighbours either side of
* the current page. Gaps are rendered as an ellipsis by the component.
*/
export function buildPagination(
page: number,
pageSize: number,
total: number,
): PaginationModel {
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
const current = Math.min(Math.max(1, page), pageCount);
const pageWindow = new Set<number>([1, pageCount, current]);
for (let offset = 1; offset <= 2; offset += 1) {
if (current - offset >= 1) pageWindow.add(current - offset);
if (current + offset <= pageCount) pageWindow.add(current + offset);
}
const pages = [...pageWindow]
.sort((a, b) => a - b)
.map((value) => ({ page: value, isCurrent: value === current }));
return {
page: current,
pageCount,
total,
from: total === 0 ? 0 : (current - 1) * pageSize + 1,
to: Math.min(current * pageSize, total),
previous: current > 1 ? current - 1 : undefined,
next: current < pageCount ? current + 1 : undefined,
pages,
};
}
/* ---------------------------------------------------------------------------
OPTION LISTS
------------------------------------------------------------------------ */
export interface SelectOption {
readonly value: string;
readonly label: string;
}
/** Turn an enum plus its catalogue keys into `<option>`s in the page locale. */
export function enumOptions<T extends string>(
values: readonly T[],
keys: Readonly<Record<T, TranslationKey>>,
locale: Locale,
): SelectOption[] {
return values.map((value) => ({
value,
label: translate(locale, keys[value]),
}));
}
/** Years as `<option>`s. Digits follow the locale's `latn` numbering system. */
export function yearOptions(years: readonly number[], locale: Locale): SelectOption[] {
return years.map((year) => ({
value: String(year),
label: new Intl.NumberFormat(`${locale}-u-nu-latn`, {
useGrouping: false,
}).format(year),
}));
}
/* ---------------------------------------------------------------------------
ROUTE CONTEXT
------------------------------------------------------------------------ */
/**
* `params.lang` as a `Locale`. The ":lang" layout has already validated the
* segment, so this only ever narrows the type - but it narrows it honestly:
* an impossible value throws the same 404 the layout would have.
*/
export function requireLocale(value: string | undefined): Locale {
if (isLocale(value)) return value;
throw data("Not Found", { status: 404, statusText: "Not Found" });
}
/** What `buildMeta` needs from the root loader. */
export interface RootSeo {
readonly locale: Locale;
readonly pathname: string;
readonly siteUrl: string;
}
const FALLBACK_SITE_URL = "https://sharper.ly";
/**
* The root loader's data, read out of a `meta` function's `matches`.
*
* React Router 8 exposes it as `match.loaderData` (v7's `match.data` is gone),
* and it is looked up BY ROUTE ID rather than by index: `matches[0]` is only
* the root by convention, and the entry is undefined while the root loader is
* still in flight or on an error render - a `meta` that throws takes the whole
* document with it.
*/
export function readRootSeo(matches: readonly unknown[]): RootSeo {
for (const match of matches) {
if (!match || typeof match !== "object") continue;
const { id, loaderData } = match as { id?: unknown; loaderData?: unknown };
if (id !== "root" || !loaderData || typeof loaderData !== "object") continue;
const record = loaderData as Record<string, unknown>;
const locale = isLocale(record.locale) ? record.locale : DEFAULT_LOCALE;
return {
locale,
pathname:
typeof record.pathname === "string"
? record.pathname
: localizedPath("/", locale),
siteUrl:
typeof record.siteUrl === "string" ? record.siteUrl : FALLBACK_SITE_URL,
};
}
return {
locale: DEFAULT_LOCALE,
pathname: localizedPath("/", DEFAULT_LOCALE),
siteUrl: FALLBACK_SITE_URL,
};
}
+229
View File
@@ -0,0 +1,229 @@
/* ============================================================================
PAGE COPY - Knowledge and Impact + News
----------------------------------------------------------------------------
Section headings, intros and empty-state messages for the three sections
this file set owns. The EN and AR strings are taken verbatim from
docs/SHARPER_Website_Draft_Section_EN_AR.docx (§5 News and Updates,
§6 Resources and Publications, §7 Knowledge Hub); the empty-state wording is
the only addition, because the draft has no copy for "the CMS is empty".
This is page prose, not UI chrome, which is why it lives beside the routes
instead of in ~/i18n/messages - the catalogue holds labels the whole site
shares. Everything a reader could otherwise mistake for content - counts,
figures, author names, dates - comes from Strapi, never from here: the draft
marks its own numbers "TBD" and says they must not be published unconfirmed.
========================================================================= */
import type { Locale } from "~/i18n";
export interface SectionCopy {
/** H1 of the section. */
readonly heading: string;
/** The standfirst under the H1. */
readonly intro: string;
/** Shown when the section has no entries at all yet. */
readonly emptyTitle: string;
readonly emptyBody: string;
/** `<meta name="description">` for the listing. */
readonly metaDescription: string;
}
type Localised<T> = Readonly<Record<Locale, T>>;
/* ---------------------------------------------------------------------------
§6 RESOURCES AND PUBLICATIONS
------------------------------------------------------------------------ */
export const RESOURCES_COPY: Localised<SectionCopy> = {
en: {
heading: "Reports, Research, and Publications",
intro:
"Findings, articles, and official documents produced through SHARP-ER’s research and dialogue work.",
emptyTitle: "No publications have been added yet.",
emptyBody:
"Reports, research and official documents will appear here as they are published.",
metaDescription:
"Findings, articles and official documents produced through SHARP-ER’s research and dialogue work. Filter by topic, date, author and partner organisation.",
},
ar: {
heading: "تقارير وأبحاث ومنشورات",
intro:
"نتائج ومقالات ووثائق رسمية صادرة عن أعمال البحث والحوار في مشروع SHARP-ER.",
emptyTitle: "لم تُضف أي منشورات بعد.",
emptyBody: "ستظهر هنا التقارير والأبحاث والوثائق الرسمية فور نشرها.",
metaDescription:
"نتائج ومقالات ووثائق رسمية صادرة عن أعمال البحث والحوار في مشروع SHARP-ER، مع إمكانية التصفية حسب الموضوع والتاريخ والكاتب والمنظمة الشريكة.",
},
};
/* ---------------------------------------------------------------------------
§7 KNOWLEDGE HUB
------------------------------------------------------------------------ */
export const KNOWLEDGE_COPY: Localised<SectionCopy> = {
en: {
heading: "Learn From the SHARP Network",
intro:
"Training materials, dialogue guides, and lessons from civil society organisations putting SHARP-ER’s approach into practice.",
emptyTitle: "The knowledge hub is being prepared.",
emptyBody:
"Training modules, dialogue guides, case studies and FAQs will appear here as they are published.",
metaDescription:
"Training modules and toolkits, Civil Society Law Session guides, case studies and plain-language FAQs from across the SHARP network.",
},
ar: {
heading: "تعلّم من شبكة SHARP",
intro:
"مواد تدريبية، وأدلة حوار، ودروس مستفادة من منظمات المجتمع المدني التي تطبّق نهج SHARP-ER على أرض الواقع.",
emptyTitle: "يجري تجهيز مركز المعرفة.",
emptyBody:
"ستظهر هنا الوحدات التدريبية وأدلة الحوار ودراسات الحالة والأسئلة الشائعة فور نشرها.",
metaDescription:
"وحدات تدريبية وأدلة عملية، وأدلة جلسات قانون المجتمع المدني، ودراسات حالة، وأسئلة شائعة بلغة مبسّطة من شبكة SHARP.",
},
};
/**
* The five kinds of material the hub carries - the values are the
* `knowledge-item.itemType` enumeration in the CMS, verbatim.
*
* The LABELS live here rather than in ~/i18n because the CMS enumeration is
* the source of truth and it keeps training modules and toolkits apart, so
* each of the five needs its own distinct label - a filter cannot offer two
* options reading the same. The wording is the draft's own.
*/
export const KNOWLEDGE_TYPE_LABELS: Localised<Readonly<Record<string, string>>> = {
en: {
"training-module": "Training modules",
toolkit: "Toolkits",
"law-session-guide": "Civil Society Law Session guides",
"case-study": "Case studies",
faq: "FAQs",
},
ar: {
"training-module": "وحدات تدريبية",
toolkit: "أدلة عملية",
"law-session-guide": "أدلة جلسات قانون المجتمع المدني",
"case-study": "دراسات حالة",
faq: "الأسئلة الشائعة",
},
};
/** The one-line description that sits under each type. */
export const KNOWLEDGE_TYPE_BLURBS: Localised<Readonly<Record<string, string>>> = {
en: {
"training-module": "From peer-to-peer learning and dialogue-facilitation training.",
toolkit: "Practical materials civil society organisations can put to work directly.",
"law-session-guide": "From joint sessions with the Commission on Civil Society (CCS).",
"case-study": "From CSO sub-projects across the network.",
faq: "Plain-language answers on rule of law, governance, and civic participation basics.",
},
ar: {
"training-module": "من التعلّم بين الأقران وتدريب تيسير الحوار.",
toolkit: "مواد عملية يمكن لمنظمات المجتمع المدني استخدامها مباشرة.",
"law-session-guide": "من الجلسات المشتركة مع لجنة المجتمع المدني (CCS).",
"case-study": "من المشاريع الفرعية لمنظمات المجتمع المدني عبر الشبكة.",
faq: "إجابات بلغة مبسّطة حول أساسيات سيادة القانون والحوكمة والمشاركة المدنية.",
},
};
/* ---------------------------------------------------------------------------
§5 NEWS AND UPDATES
------------------------------------------------------------------------ */
export interface NewsCopy extends SectionCopy {
/** Description of the "SHARP-ER News" tab. */
readonly projectBlurb: string;
/** Description of the "From the Network" tab. */
readonly networkBlurb: string;
}
export const NEWS_COPY: Localised<NewsCopy> = {
en: {
heading: "News from SHARP-ER and the Network",
intro:
"Milestones from the project team, and updates from the civil society organisations we work with.",
projectBlurb:
"Official updates on milestones, campaign launches, publications, and events.",
networkBlurb:
"Updates submitted by CSOs granted as part of the consortium, each card crediting the organisation.",
emptyTitle: "There are no updates to show yet.",
emptyBody:
"News from the project team and from the SHARP network will appear here.",
metaDescription:
"Milestones from the SHARP-ER project team, and updates from the civil society organisations we work with across Libya.",
},
ar: {
heading: "أخبار من SHARP-ER ومن الشبكة",
intro:
"محطات رئيسية من فريق المشروع، ومستجدات من منظمات المجتمع المدني التي نعمل معها.",
projectBlurb:
"تحديثات رسمية حول المحطات الرئيسية، وإطلاق الحملات، والمنشورات، والفعاليات.",
networkBlurb:
"مستجدات تنشرها منظمات المجتمع المدني الشريكة ضمن الائتلاف، وتُنسب كل بطاقة إلى المنظمة الناشرة.",
emptyTitle: "لا توجد مستجدات لعرضها بعد.",
emptyBody: "ستظهر هنا أخبار فريق المشروع ومستجدات شبكة SHARP.",
metaDescription:
"محطات رئيسية من فريق مشروع SHARP-ER، ومستجدات من منظمات المجتمع المدني التي نعمل معها في مختلف أنحاء ليبيا.",
},
};
/* ---------------------------------------------------------------------------
DETAIL PAGES
------------------------------------------------------------------------ */
export interface DetailCopy {
/** "Back to all publications" - the link out of a detail page. */
readonly backToListing: string;
/** Heading of the metadata block. */
readonly aboutThis: string;
readonly published: string;
readonly author: string;
readonly partner: string;
readonly topics: string;
readonly openExternal: string;
}
export const DETAIL_COPY: Localised<DetailCopy> = {
en: {
backToListing: "Back to all publications",
aboutThis: "About this publication",
published: "Published",
author: "Author",
partner: "Partner organisation",
topics: "Topics",
openExternal: "Open the document",
},
ar: {
backToListing: "العودة إلى جميع المنشورات",
aboutThis: "عن هذا المنشور",
published: "تاريخ النشر",
author: "الكاتب",
partner: "المنظمة الشريكة",
topics: "المواضيع",
openExternal: "فتح الوثيقة",
},
};
export const KNOWLEDGE_DETAIL_COPY: Localised<Pick<DetailCopy, "backToListing" | "aboutThis">> =
{
en: {
backToListing: "Back to the knowledge hub",
aboutThis: "About this material",
},
ar: {
backToListing: "العودة إلى مركز المعرفة",
aboutThis: "عن هذه المادة",
},
};
export const NEWS_DETAIL_COPY: Localised<Pick<DetailCopy, "backToListing" | "aboutThis">> = {
en: {
backToListing: "Back to all news",
aboutThis: "About this update",
},
ar: {
backToListing: "العودة إلى جميع الأخبار",
aboutThis: "عن هذا المستجد",
},
};
+266
View File
@@ -0,0 +1,266 @@
/* ============================================================================
ABOUT - /en/about and /ar/about
----------------------------------------------------------------------------
Section 1 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: who SHARP-ER is,
the three consortium members and their roles, the three focus areas, and the
SHARP I / II legacy the project builds on.
The draft copy is the fallback; every block here is overridden field by
field by api::about-page.about-page as soon as an editor fills it in.
========================================================================= */
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { createTranslator, sectionPath } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/about";
import {
isRichTextBlock,
type CmsAboutPage,
} from "./_page-kit/cms-types";
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
InfoPanel,
PageHero,
PageSection,
Prose,
SectionHeader,
} from "./_page-kit/sections";
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const page = await loadSingle<CmsAboutPage>("about-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
consortium: { populate: { partner: { populate: { logo: true } } } },
pillars: true,
legacyLink: true,
blocks: { on: { "blocks.rich-text": { populate: "*" } } },
seo: { populate: { ogImage: true } },
},
});
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
body: page?.body ?? null,
richTextBlocks: list(page?.blocks)
.filter(isRichTextBlock)
.map((block) => ({ id: block.id, body: block.body })),
pillars: list(page?.pillars).map((pillar) => ({
title: pillar.title,
description: pillar.description ?? "",
})),
consortium: list(page?.consortium)
.filter((member) => member.partner !== null && member.partner !== undefined)
.map((member) => ({
id: member.partner?.documentId ?? String(member.id ?? member.partner?.id),
name: member.partner?.name ?? "",
role: member.roleDescription ?? member.partner?.description ?? "",
website: member.partner?.website ?? null,
})),
legacy: {
headline: page?.legacyHeadline ?? null,
body: page?.legacyBody ?? null,
link: page?.legacyLink
? {
label: page.legacyLink.label,
href: page.legacyLink.href,
isExternal: page.legacyLink.isExternal ?? false,
}
: null,
},
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).about;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: loaderData?.seo.title ?? t("nav.sections.about"),
description: loaderData?.seo.description ?? copy.lead,
...(loaderData?.seo.image
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
: {}),
noIndex: loaderData?.seo.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{ name: t("nav.sections.about"), pathname: sectionPath("about", locale) },
]),
});
}
export default function About({ loaderData }: Route.ComponentProps) {
const { locale, hero, body, richTextBlocks, legacy } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).about;
const whatWeDoCopy = pageCopy(locale).whatWeDo;
const pillars = loaderData.pillars.length > 0 ? loaderData.pillars : copy.pillars;
const consortium =
loaderData.consortium.length > 0
? loaderData.consortium
: copy.consortium.map((member) => ({
id: member.name,
name: member.name,
role: member.role,
website: null,
}));
return (
<>
<PageHero
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.aboutAndStrategy")}
lead={hero.subheading ?? copy.lead}
{...(hero.media ? { media: hero.media } : {})}
/>
<PageSection className="pt-0 md:pt-0">
<Prose>
{body ? (
<RichText markdown={body} />
) : (
<p className="body-md text-[var(--text-secondary)]">{copy.body}</p>
)}
{richTextBlocks.map((block) => (
<RichText key={block.id} markdown={block.body} />
))}
<p className="body-sm-bold text-[var(--text-accent)]">{copy.fundingNote}</p>
</Prose>
</PageSection>
{/* WHERE WE FOCUS */}
<PageSection aria-labelledby="about-pillars-heading">
<div className="flex flex-col gap-2xl">
<SectionHeader id="about-pillars-heading" title={copy.pillarsHeading} />
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{pillars.map((pillar) => (
<li key={pillar.title}>
<InfoPanel title={pillar.title}>{pillar.description}</InfoPanel>
</li>
))}
</ul>
</div>
</PageSection>
{/* THE CONSORTIUM */}
<PageSection aria-labelledby="about-consortium-heading">
<div className="flex flex-col gap-2xl">
<SectionHeader
id="about-consortium-heading"
title={copy.consortiumHeading}
/>
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{consortium.map((member) => (
<li key={member.id}>
<InfoPanel title={member.name}>
<span className="block">{member.role}</span>
{member.website ? (
<a
href={member.website}
target="_blank"
rel="noopener noreferrer"
className="mt-xs inline-block body-sm-bold text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{member.name}
</a>
) : null}
</InfoPanel>
</li>
))}
</ul>
</div>
</PageSection>
{/* SHARP I AND II */}
<PageSection aria-labelledby="about-legacy-heading">
<div className="flex flex-col gap-lg">
<SectionHeader
id="about-legacy-heading"
title={legacy.headline ?? copy.legacyHeading}
/>
<Prose>
{legacy.body ? (
<RichText markdown={legacy.body} />
) : (
<p className="body-md text-[var(--text-secondary)]">
{copy.legacyBody}
</p>
)}
</Prose>
{/* The draft marks the legacy page as "[link: …]" with no target yet,
so the button appears only once the CMS supplies a real one. */}
{legacy.link ? (
legacy.link.isExternal ? (
<Button
as="a"
href={legacy.link.href}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
iconEnd={<ArrowIcon />}
className="self-start"
>
{legacy.link.label}
</Button>
) : (
<Button
as={Link}
to={legacy.link.href}
prefetch="intent"
variant="secondary"
iconEnd={<ArrowIcon />}
className="self-start"
>
{legacy.link.label}
</Button>
)
) : null}
</div>
</PageSection>
<PageSection>
<Card.CTA
headline={whatWeDoCopy.heroHeadline}
body={whatWeDoCopy.lead}
action={{
href: sectionPath("whatWeDo", locale),
label: t("actions.learnWhatWeDo"),
}}
/>
</PageSection>
</>
);
}
@@ -0,0 +1,666 @@
/* ============================================================================
ONE ACTIVITY OR INITIATIVE
----------------------------------------------------------------------------
URL /en/activities-and-initiatives/:slug · /ar/activities-and-initiatives/:slug
Registered in app/routes.ts as "activitiesDetail", a SIBLING of the listing
route under the ":lang" layout, and typed against its generated `./+types/`
module. A slug the CMS does not know still falls through to the localised
404, which is the correct answer for it.
========================================================================= */
import { Fragment, type ReactNode } from "react";
import {
Link,
useLoaderData,
type MetaDescriptor,
} from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { getMessages, sectionPath, toIsoDate, useTranslation } from "~/i18n";
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n/config";
import {
buildArticleJsonLd,
buildBreadcrumbJsonLd,
buildMeta,
type JsonLd,
} from "~/lib/seo";
import {
getBySlug,
getMany,
isStrapiError,
isStrapiNotFound,
mediaAlt,
mediaUrl,
notFound,
requireEntity,
type StrapiEntity,
type StrapiMedia,
} from "~/lib/strapi";
import type { Route } from "./+types/activities-and-initiatives.$slug";
import {
ACTIVITY_STREAM_CMS_VALUES,
activitiesIndexPath,
activityPath,
getDetailCopy,
getStreamCopy,
isActivityStatus,
readRootMeta,
toActivityStreamKey,
type ActivityStreamKey,
type ActivitySummary,
} from "./activities.content";
import {
collectLocalizedSlugs,
detailAlternateDescriptors,
LOCALIZATION_POPULATE,
SELF_ALTERNATES_HANDLE,
type CmsLocalizationRef,
type LocalizedSlugs,
} from "./_shared/alternates";
/* ---------------------------------------------------------------------------
CMS SHAPES
------------------------------------------------------------------------ */
/**
* A Strapi rich-text value. The Blocks editor emits a node tree; a plain Text
* or Markdown field emits a string. Both are rendered below, so the content
* type can switch field kinds without touching this route.
*/
type RichTextValue = string | readonly RichTextNode[] | null;
interface RichTextImage {
readonly url?: string | null;
readonly alternativeText?: string | null;
readonly width?: number | null;
readonly height?: number | null;
}
interface RichTextNode {
readonly type?: string;
readonly children?: readonly RichTextNode[];
readonly text?: string;
readonly bold?: boolean;
readonly italic?: boolean;
readonly underline?: boolean;
readonly strikethrough?: boolean;
readonly code?: boolean;
readonly url?: string;
readonly level?: number;
readonly format?: string;
readonly image?: RichTextImage;
}
/** A related entry that only contributes its display name. */
interface NamedEntity {
readonly name?: string | null;
readonly slug?: string | null;
}
/**
* `api::activity.activity`, field for field from
* apps/cms/src/api/activity/content-types/activity/schema.json. There is no
* `stream`, no `cover`, no `location` string and no date pair on this type.
*/
interface ActivityEntity extends StrapiEntity {
readonly title: string;
readonly slug: string;
readonly activityStream?: string | null;
readonly activityStatus?: string | null;
readonly summary?: string | null;
readonly body?: RichTextValue;
readonly regions?: readonly NamedEntity[] | null;
readonly implementingPartners?: readonly NamedEntity[] | null;
readonly coverImage?: StrapiMedia | null;
readonly localizations?: readonly CmsLocalizationRef[] | null;
}
const ACTIVITY_SUMMARY_FIELDS = [
"title",
"slug",
"activityStream",
"activityStatus",
"summary",
] as const;
const ACTIVITY_SORT = ["publishedAt:desc", "title:asc"] as const;
/** Names of a relation list, joined - or null when the relation is empty. */
function namesOf(entries: readonly NamedEntity[] | null | undefined): string | null {
const names = (entries ?? [])
.map((entry) => entry?.name?.trim())
.filter((name): name is string => Boolean(name));
return names.length > 0 ? names.join(", ") : null;
}
/** How many other activities from the same stream are offered at the end. */
const RELATED_LIMIT = 3;
/* ---------------------------------------------------------------------------
RICH TEXT - server side
------------------------------------------------------------------------ */
/**
* Absolutise every image URL inside a Blocks tree while still on the server:
* `mediaUrl()` reads STRAPI_URL, so a relative "/uploads/x.jpg" cannot be
* resolved once the tree has been serialised to the browser.
*/
function resolveRichTextMedia(value: RichTextValue): RichTextValue {
if (value === null || typeof value === "string") return value;
return value.map((node) => resolveNodeMedia(node));
}
function resolveNodeMedia(node: RichTextNode): RichTextNode {
const children = node.children?.map((child) => resolveNodeMedia(child));
const url = node.image?.url ? mediaUrl(node.image.url) : undefined;
return {
...node,
...(children ? { children } : {}),
...(node.image ? { image: { ...node.image, url: url ?? null } } : {}),
};
}
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
/** This route emits its own canonical + hreflang - see ./_shared/alternates.ts. */
export const handle = SELF_ALTERNATES_HANDLE;
export async function loader({ params }: Route.LoaderArgs) {
const locale: Locale = isLocale(params.lang) ? params.lang : DEFAULT_LOCALE;
const slug = params.slug;
if (!slug) notFound();
let entity: ActivityEntity | null;
try {
entity = await getBySlug<ActivityEntity>("activities", slug, {
locale,
populate: {
coverImage: {
fields: ["url", "alternativeText", "width", "height"],
},
regions: { fields: ["name", "slug"] },
implementingPartners: { fields: ["name", "slug"] },
// The Arabic translation has its own slug - see ./_shared/alternates.
localizations: LOCALIZATION_POPULATE,
},
});
} catch (error) {
// A 404 from Strapi means "no such activity"; anything else (the CMS is
// down, the content type is missing, the token is wrong) is a real server
// fault and must not masquerade as a missing page, so it is rethrown for
// the root ErrorBoundary to render as an error rather than as a 404.
if (isStrapiNotFound(error)) entity = null;
else throw error;
}
// Throws the localised 404 that the root ErrorBoundary renders.
const activity = requireEntity(entity);
const stream = toActivityStreamKey(activity.activityStream);
return {
locale,
alternates: collectLocalizedSlugs(
locale,
activity.slug,
activity.localizations,
) satisfies LocalizedSlugs,
activity: {
id: activity.documentId,
slug: activity.slug,
title: activity.title,
stream,
status: isActivityStatus(activity.activityStatus)
? activity.activityStatus
: null,
summary: activity.summary ?? null,
body: resolveRichTextMedia(activity.body ?? null),
location: namesOf(activity.regions),
implementingPartner: namesOf(activity.implementingPartners),
imageUrl: mediaUrl(activity.coverImage?.url) ?? null,
imageAlt: mediaAlt(activity.coverImage),
imageWidth: activity.coverImage?.width ?? null,
imageHeight: activity.coverImage?.height ?? null,
publishedAt: activity.publishedAt,
updatedAt: activity.updatedAt,
},
related: await loadRelated(locale, stream, activity.documentId),
};
}
export type ActivityDetailLoaderData = Awaited<ReturnType<typeof loader>>;
/**
* Other activities from the same stream. Purely additive, so a failure here
* degrades to an empty list instead of taking down a page that already has
* everything it needs.
*/
async function loadRelated(
locale: Locale,
stream: ActivityStreamKey | null,
documentId: string,
): Promise<ActivitySummary[]> {
if (!stream) return [];
try {
const response = await getMany<ActivityEntity>("activities", {
locale,
filters: {
// The CMS enumeration value, which is not always the web key.
activityStream: { $eq: ACTIVITY_STREAM_CMS_VALUES[stream] },
documentId: { $ne: documentId },
},
sort: [...ACTIVITY_SORT],
fields: [...ACTIVITY_SUMMARY_FIELDS],
populate: {
coverImage: { fields: ["url", "alternativeText"] },
regions: { fields: ["name", "slug"] },
implementingPartners: { fields: ["name", "slug"] },
},
pagination: { pageSize: RELATED_LIMIT, withCount: false },
});
return response.data
.filter((entity) => Boolean(entity.slug && entity.title))
.map((entity) => ({
id: entity.documentId,
slug: entity.slug,
title: entity.title,
stream,
summary: entity.summary ?? null,
imageUrl: mediaUrl(entity.coverImage?.url) ?? null,
imageAlt: mediaAlt(entity.coverImage),
location: namesOf(entity.regions),
implementingPartner: namesOf(entity.implementingPartners),
status: isActivityStatus(entity.activityStatus)
? entity.activityStatus
: null,
}));
} catch (error) {
if (isStrapiError(error)) {
console.error(`[activity:${documentId}] related unavailable: ${error.message}`);
return [];
}
throw error;
}
}
/* ---------------------------------------------------------------------------
META
The canonical link and the en / ar / x-default hreflang alternates come from
the shell (root.tsx `Layout`) for every route, so `alternates` stays false
here - passing it would emit each of those tags twice.
------------------------------------------------------------------------ */
export function meta({ loaderData, matches }: Route.MetaArgs): MetaDescriptor[] {
const { locale, pathname, siteUrl } = readRootMeta(
matches,
activitiesIndexPath(DEFAULT_LOCALE),
);
const { sections } = getMessages(locale).nav;
const activity = loaderData?.activity;
// No loader data means the boundary is rendering (a 404, most often); the
// shell's own meta covers that case, so only the section title is claimed.
if (!activity) {
return buildMeta({ locale, pathname, siteUrl, title: sections.activities });
}
const streamTitle = activity.stream
? getStreamCopy(activity.stream, locale).title
: undefined;
const description = activity.summary ?? undefined;
const datePublished = toIsoDate(activity.publishedAt ?? activity.updatedAt);
const jsonLd: JsonLd[] = [
buildBreadcrumbJsonLd(siteUrl, [
{ name: sections.home, pathname: sectionPath("home", locale) },
{ name: sections.activities, pathname: activitiesIndexPath(locale) },
{ name: activity.title, pathname: activityPath(activity.slug, locale) },
]),
];
if (datePublished) {
jsonLd.push(
buildArticleJsonLd({
siteUrl,
locale,
headline: activity.title,
pathname: activityPath(activity.slug, locale),
datePublished,
dateModified: toIsoDate(activity.updatedAt) || undefined,
description,
image: activity.imageUrl ?? undefined,
section: streamTitle,
}),
);
}
const descriptors = buildMeta({
locale,
pathname,
siteUrl,
title: activity.title,
description,
type: "article",
image: activity.imageUrl
? {
url: activity.imageUrl,
alt: activity.imageAlt,
width: activity.imageWidth ?? undefined,
height: activity.imageHeight ?? undefined,
}
: undefined,
jsonLd,
});
return [
...descriptors,
...detailAlternateDescriptors({
siteUrl,
section: "activities",
locale,
slugs: loaderData?.alternates ?? {},
}),
];
}
/* ---------------------------------------------------------------------------
VIEW
------------------------------------------------------------------------ */
export default function ActivityDetail() {
const { activity, related } = useLoaderData<typeof loader>();
const { locale, t } = useTranslation();
const copy = getDetailCopy(locale);
const stream = activity.stream ? getStreamCopy(activity.stream, locale) : null;
const partner = activity.implementingPartner ?? stream?.partners ?? null;
return (
<article className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-[var(--layout-section-gap)] px-[var(--layout-gutter)] py-4xl">
<header className="flex flex-col gap-lg">
<Button
as={Link}
to={activitiesIndexPath(locale)}
variant="ghost"
size="sm"
iconStart={<ArrowIcon direction="backward" />}
className="self-start"
>
{copy.backToIndex}
</Button>
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
{stream ? (
<p className="label-md text-text-accent">{stream.title}</p>
) : null}
<h1 className="display-sm text-text-primary">{activity.title}</h1>
{activity.summary ? (
<p className="body-lg text-text-secondary">{activity.summary}</p>
) : null}
</div>
<dl className="flex flex-wrap gap-x-2xl gap-y-md">
{activity.location ? (
<MetaPair label={copy.locationLabel} value={activity.location} />
) : null}
{partner ? (
<MetaPair label={copy.partnerLabel} value={partner} />
) : null}
</dl>
{activity.imageUrl ? (
<img
src={activity.imageUrl}
alt={activity.imageAlt}
width={activity.imageWidth ?? undefined}
height={activity.imageHeight ?? undefined}
className="w-full rounded-card object-cover"
loading="eager"
decoding="async"
/>
) : null}
</header>
<section
aria-labelledby="activity-details"
className="flex max-w-[var(--layout-prose-max)] flex-col gap-md"
>
<h2 id="activity-details" className="sr-only">
{copy.detailsHeading}
</h2>
<RichText value={activity.body} fallback={copy.bodyMissing} />
</section>
{related.length > 0 ? (
<section aria-labelledby="activity-related" className="flex flex-col gap-lg">
<h2 id="activity-related" className="heading-h2 text-text-primary">
{copy.relatedHeading}
</h2>
<ul className="flex list-none flex-col gap-lg">
{related.map((item) => (
<Card.Horizontal
as="li"
key={item.id}
href={activityPath(item.slug, locale)}
title={item.title}
eyebrow={item.location ?? undefined}
excerpt={item.summary ?? undefined}
actionLabel={t("actions.viewDetails")}
image={
item.imageUrl
? { src: item.imageUrl, alt: item.imageAlt }
: undefined
}
/>
))}
</ul>
</section>
) : null}
</article>
);
}
/** One `<dt>`/`<dd>` pair in the activity's fact list. */
function MetaPair({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-2xs">
<dt className="label-md text-text-tertiary">{label}</dt>
<dd className="body-md-bold text-text-primary">{value}</dd>
</div>
);
}
/* ---------------------------------------------------------------------------
RICH TEXT - rendering
Strapi Blocks and plain text both land here. Nothing is injected as HTML:
every node is turned into a React element, so CMS content cannot script the
page, and the styling comes from type roles and semantic tokens only.
------------------------------------------------------------------------ */
function RichText({
value,
fallback,
}: {
value: RichTextValue;
fallback: string;
}) {
if (typeof value === "string") {
const paragraphs = value
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
if (paragraphs.length === 0) return <Prose>{fallback}</Prose>;
return (
<>
{paragraphs.map((paragraph, index) => (
<Prose key={index}>{paragraph}</Prose>
))}
</>
);
}
if (!value || value.length === 0) return <Prose>{fallback}</Prose>;
return (
<>
{value.map((node, index) => (
<RichTextBlock key={index} node={node} />
))}
</>
);
}
function Prose({ children }: { children: ReactNode }) {
return <p className="body-md text-text-secondary">{children}</p>;
}
function RichTextBlock({ node }: { node: RichTextNode }) {
switch (node.type) {
case "heading": {
const level = node.level ?? 2;
if (level <= 2) {
return (
<h3 className="heading-h3 mt-lg text-text-primary">
<RichTextChildren nodes={node.children} />
</h3>
);
}
return (
<h4 className="body-lg-bold mt-md text-text-primary">
<RichTextChildren nodes={node.children} />
</h4>
);
}
case "list": {
const List = node.format === "ordered" ? "ol" : "ul";
return (
<List
className={`ps-lg body-md text-text-secondary ${
node.format === "ordered" ? "list-decimal" : "list-disc"
}`}
>
{node.children?.map((item, index) => (
<li key={index} className="mb-2xs">
<RichTextChildren nodes={item.children} />
</li>
))}
</List>
);
}
case "quote":
return (
<blockquote className="body-lg border-s-2 border-border-accent ps-md text-text-primary">
<RichTextChildren nodes={node.children} />
</blockquote>
);
case "code":
return (
<pre className="body-sm overflow-x-auto rounded-card bg-surface-raised p-md text-text-secondary">
<code>
<RichTextChildren nodes={node.children} />
</code>
</pre>
);
case "image":
return node.image?.url ? (
<img
src={node.image.url}
alt={node.image.alternativeText ?? ""}
width={node.image.width ?? undefined}
height={node.image.height ?? undefined}
className="w-full rounded-card"
loading="lazy"
decoding="async"
/>
) : null;
case "paragraph":
return (
<Prose>
<RichTextChildren nodes={node.children} />
</Prose>
);
// A block kind the editor gains later renders as prose rather than as a
// gap in the page.
default:
return node.children?.length ? (
<Prose>
<RichTextChildren nodes={node.children} />
</Prose>
) : null;
}
}
function RichTextChildren({ nodes }: { nodes?: readonly RichTextNode[] }) {
if (!nodes) return null;
return (
<>
{nodes.map((node, index) => (
<RichTextLeaf key={index} node={node} />
))}
</>
);
}
const EXTERNAL_HREF = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i;
function RichTextLeaf({ node }: { node: RichTextNode }) {
if (node.type === "link" && node.url) {
const children = <RichTextChildren nodes={node.children} />;
const className =
"text-text-link underline underline-offset-2 hover:text-text-link-hover";
return EXTERNAL_HREF.test(node.url) ? (
<a
href={node.url}
className={className}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
) : (
<Link to={node.url} className={className} prefetch="intent">
{children}
</Link>
);
}
if (node.children?.length) {
return <RichTextChildren nodes={node.children} />;
}
const text = node.text;
if (!text) return null;
let content: ReactNode = text;
if (node.code) content = <code className="body-sm">{content}</code>;
if (node.bold) content = <strong className="body-md-bold">{content}</strong>;
if (node.italic) content = <em>{content}</em>;
if (node.underline) content = <u>{content}</u>;
if (node.strikethrough) content = <s>{content}</s>;
return <Fragment>{content}</Fragment>;
}
@@ -0,0 +1,347 @@
/* ============================================================================
ACTIVITIES AND INITIATIVES - the section index
----------------------------------------------------------------------------
URL /en/activities-and-initiatives · /ar/activities-and-initiatives
Route id "activities" (see app/routes.ts)
The page is organised by the five activity streams of the approved draft
copy (docs/SHARPER_Website_Draft_Section_EN_AR.docx, section 3): the stream
headings, descriptions and implementing partners are project structure and
live in ./activities.content, while the activities filed under each stream
come from Strapi.
THE FIELD NAMES BELOW ARE THE CMS'S OWN
apps/cms/src/api/activity/content-types/activity/schema.json:
`activityStream` (not `stream`), `activityStatus`, `coverImage` (not
`cover`), `implementingPartners` and `regions` as RELATIONS, and NO dates
or `location` string. Sorting or filtering on a field the content type does
not have is a 400 from Strapi, so nothing here is guessed.
The CMS is empty today, so the empty state is what actually renders: the
five streams still describe the work, and one localised panel explains that
individual activities are published as they are completed.
========================================================================= */
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { getMessages, sectionPath, useTranslation } from "~/i18n";
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n/config";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import {
getMany,
isStrapiError,
mediaAlt,
mediaUrl,
type StrapiEntity,
type StrapiMedia,
} from "~/lib/strapi";
import {
ACTIVITY_STREAM_KEYS,
activitiesIndexPath,
activityPath,
getIndexCopy,
getStreamCopy,
groupActivitiesByStream,
isActivityStatus,
readRootMeta,
streamAnchorId,
toActivityStreamKey,
type ActivitySummary,
} from "./activities.content";
import type { Route } from "./+types/activities-and-initiatives";
/* ---------------------------------------------------------------------------
CMS SHAPES
------------------------------------------------------------------------ */
/** A related entry that only contributes its display name. */
interface NamedEntity {
readonly name?: string | null;
readonly slug?: string | null;
}
/** `api::activity.activity` - one activity or initiative. */
interface ActivityEntity extends StrapiEntity {
readonly title: string;
readonly slug: string;
readonly activityStream?: string | null;
readonly activityStatus?: string | null;
readonly summary?: string | null;
readonly regions?: readonly NamedEntity[] | null;
readonly implementingPartners?: readonly NamedEntity[] | null;
readonly coverImage?: StrapiMedia | null;
}
/** The fields one listing request asks for. All of them exist on the type. */
const ACTIVITY_FIELDS = [
"title",
"slug",
"activityStream",
"activityStatus",
"summary",
] as const;
const ACTIVITY_POPULATE = {
coverImage: { fields: ["url", "alternativeText"] },
regions: { fields: ["name", "slug"] },
implementingPartners: { fields: ["name", "slug"] },
} as const;
/**
* An activity carries no date in the CMS, so the newest CMS entry leads.
* `publishedAt` is a system field and is always sortable.
*/
const ACTIVITY_SORT = ["publishedAt:desc", "title:asc"] as const;
/** Names of a relation list, joined - or null when the relation is empty. */
function namesOf(entries: readonly NamedEntity[] | null | undefined): string | null {
const names = (entries ?? [])
.map((entry) => entry?.name?.trim())
.filter((name): name is string => Boolean(name));
return names.length > 0 ? names.join(", ") : null;
}
/** How many activities one request pulls. Well above the project's volume. */
const ACTIVITY_PAGE_SIZE = 100;
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
/**
* A CMS read that must never take the page down with it. An unreachable or
* not-yet-modelled Strapi degrades to the empty state - the five stream
* descriptions are still worth serving - and the reason is logged server-side.
* Anything that is not a Strapi transport/HTTP error is rethrown untouched.
*/
async function readFromCms<T>(
label: string,
read: () => Promise<T>,
fallback: T,
): Promise<T> {
try {
return await read();
} catch (error) {
if (isStrapiError(error)) {
console.error(`[activities] ${label} unavailable: ${error.message}`);
return fallback;
}
throw error;
}
}
function toSummary(entity: ActivityEntity): ActivitySummary {
return {
id: entity.documentId,
slug: entity.slug,
title: entity.title,
stream: toActivityStreamKey(entity.activityStream),
summary: entity.summary ?? null,
// Resolved here rather than in the component: mediaUrl() reads STRAPI_URL
// from the server environment, which never reaches the browser bundle.
imageUrl: mediaUrl(entity.coverImage?.url) ?? null,
imageAlt: mediaAlt(entity.coverImage),
location: namesOf(entity.regions),
implementingPartner: namesOf(entity.implementingPartners),
status: isActivityStatus(entity.activityStatus)
? entity.activityStatus
: null,
};
}
/* DRAFT COPY NOTE - the source document ends section 3 with
"[XX+] activities completed · [XX+] CSOs engaged · [X] regions covered -
placeholder only: these figures are marked "TBD" in the official fact
sheet and must not be published until confirmed."
Nothing is hard-coded here and no placeholder is rendered. The figures
strip has also been removed rather than left pointing at an
`activities-page` single type that DOES NOT EXIST in the CMS (the single
types are about-page, contact-page, home-page, site-settings and
what-we-do-page): a request to /api/activities-page is a 404 on every page
load. Give the section a home in Strapi first, then bring the strip back. */
export async function loader({ params }: Route.LoaderArgs) {
// The ":lang" layout has already validated the segment; the guard is here so
// the locale is a `Locale` by inference rather than by assertion.
const locale: Locale = isLocale(params.lang) ? params.lang : DEFAULT_LOCALE;
const activities = await readFromCms(
"activities",
async () => {
const response = await getMany<ActivityEntity>("activities", {
locale,
sort: [...ACTIVITY_SORT],
fields: [...ACTIVITY_FIELDS],
populate: ACTIVITY_POPULATE,
pagination: { pageSize: ACTIVITY_PAGE_SIZE, withCount: false },
});
return response.data;
},
[] as ActivityEntity[],
);
return {
locale,
activities: activities
.filter((entity) => Boolean(entity.slug && entity.title))
.map(toSummary),
};
}
/* ---------------------------------------------------------------------------
META
The canonical link and the en / ar / x-default hreflang alternates are
emitted for every route by the shell (root.tsx `Layout`), because a leaf
`meta` replaces its parents' wholesale - so `alternates` stays false here on
purpose. Passing it would duplicate every one of those tags.
------------------------------------------------------------------------ */
export function meta({ matches }: Route.MetaArgs) {
const root = readRootMeta(matches, activitiesIndexPath(DEFAULT_LOCALE));
const { locale, pathname, siteUrl } = root;
const copy = getIndexCopy(locale);
const { sections } = getMessages(locale).nav;
return buildMeta({
locale,
pathname,
siteUrl,
title: sections.activities,
description: copy.metaDescription,
jsonLd: buildBreadcrumbJsonLd(siteUrl, [
{ name: sections.home, pathname: sectionPath("home", locale) },
{ name: sections.activities, pathname: activitiesIndexPath(locale) },
]),
});
}
/* ---------------------------------------------------------------------------
VIEW
------------------------------------------------------------------------ */
export default function ActivitiesAndInitiatives({
loaderData,
}: Route.ComponentProps) {
const { activities } = loaderData;
const { locale, t } = useTranslation();
const copy = getIndexCopy(locale);
const grouped = groupActivitiesByStream(activities);
const hasActivities = activities.length > 0;
return (
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-[var(--layout-section-gap)] px-[var(--layout-gutter)] py-4xl">
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
<p className="label-md text-text-accent">{copy.eyebrow}</p>
<h1 className="display-sm text-text-primary">{copy.headline}</h1>
<p className="body-lg text-text-secondary">{copy.lede}</p>
</header>
<nav aria-label={copy.streamsNavLabel}>
<h2 className="sr-only">{copy.streamsNavLabel}</h2>
<ul className="flex flex-wrap gap-sm">
{ACTIVITY_STREAM_KEYS.map((stream) => (
<li key={stream}>
<a
href={`#${streamAnchorId(stream)}`}
className="body-sm-bold inline-flex rounded-pill border border-border-subtle px-md py-xs text-text-secondary transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)] hover:border-border-strong hover:text-text-primary"
>
{getStreamCopy(stream, locale).title}
</a>
</li>
))}
</ul>
</nav>
{hasActivities ? null : (
<section
aria-labelledby="activities-empty"
className="flex flex-col items-start gap-md rounded-card border border-border-subtle bg-surface-raised p-xl"
>
<h2 id="activities-empty" className="body-lg-bold text-text-primary">
{copy.emptyTitle}
</h2>
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
{copy.emptyBody}
</p>
<Button
as={Link}
to={sectionPath("events", locale)}
variant="secondary"
size="sm"
iconEnd={<ArrowIcon />}
>
{t("nav.sections.events")}
</Button>
</section>
)}
{ACTIVITY_STREAM_KEYS.map((stream) => {
const streamCopy = getStreamCopy(stream, locale);
const items = grouped[stream];
const anchor = streamAnchorId(stream);
return (
<section
key={stream}
id={anchor}
aria-labelledby={`${anchor}-title`}
className="flex scroll-mt-4xl flex-col gap-lg"
>
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
<h2 id={`${anchor}-title`} className="heading-h2 text-text-primary">
{streamCopy.title}
</h2>
<p className="body-md text-text-secondary">
{streamCopy.description}
</p>
<p className="body-sm text-text-tertiary">
<span className="body-sm-bold text-text-secondary">
{streamCopy.partnerLabel}:
</span>{" "}
{streamCopy.partners}
</p>
</div>
{items.length > 0 ? (
<>
<h3 className="sr-only">{copy.streamListLabel}</h3>
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{items.map((activity) => (
<Card.Content
as="li"
key={activity.id}
titleAs="h4"
href={activityPath(activity.slug, locale)}
title={activity.title}
eyebrow={activityEyebrow(activity)}
excerpt={activity.summary ?? undefined}
actionLabel={t("actions.viewDetails")}
image={
activity.imageUrl
? { src: activity.imageUrl, alt: activity.imageAlt }
: undefined
}
/>
))}
</ul>
</>
) : hasActivities ? (
<p className="body-sm text-text-tertiary">{copy.streamEmpty}</p>
) : null}
</section>
);
})}
</div>
);
}
/** The card kicker: where the activity runs. An activity carries no dates. */
function activityEyebrow(activity: ActivitySummary): string | undefined {
return activity.location ?? undefined;
}
+422
View File
@@ -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,
};
}
@@ -0,0 +1,556 @@
/* ============================================================================
APPLICATIONS AND OPPORTUNITIES - one call
----------------------------------------------------------------------------
/{locale}/applications-and-opportunities/{slug}
Draft section 8: the detail page carries the application form and the
acknowledgement ("Thank you for applying to join the SHARP network. Our team
will review your application and follow up within [X weeks].").
That "[X weeks]" is marked TBD in the draft and the CMS has no field for it,
so the number is NEVER named: the acknowledgement says the team will follow
up by email, and nothing more.
ROUTE
Registered in app/routes.ts as "applicationsDetail", a SIBLING of the listing
route under the ":lang" layout, and typed against its generated `./+types/`
module.
========================================================================= */
import {
data,
Form,
useActionData,
useLoaderData,
useNavigation,
} from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input, Textarea } from "~/components/ui/Input";
import {
interpolate,
localizedPath,
sectionPath,
toIsoDate,
translate,
useTranslation,
} from "~/i18n";
import { safeHref } from "~/lib/safe-href";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError, requireEntity } from "~/lib/strapi";
import type { Route } from "./+types/applications-and-opportunities.$slug";
import { getRouteCopy } from "./_events-opportunities/copy";
import {
FIELD_ERROR_KEYS,
HONEYPOT_FIELD,
isHoneypotFilled,
validateApplication,
type ApplicationValues,
type FieldErrors,
} from "./_events-opportunities/forms";
import {
formatAttachmentMeta,
formatOpportunityDate,
opportunityStatusKey,
} from "./_events-opportunities/opportunities";
import { fetchOpportunity } from "./_events-opportunities/opportunities.server";
import { readRootSeo } from "./_events-opportunities/root-seo";
import { submitApplication } from "./_events-opportunities/submissions.server";
import {
BadgeRow,
DetailList,
Notice,
StatusBadge,
type DetailItem,
} from "./_events-opportunities/ui";
import {
detailAlternateDescriptors,
SELF_ALTERNATES_HANDLE,
} from "./_shared/alternates";
import { requireLocale } from "./_shared/listing";
import { BackLink, PageShell } from "./_shared/listing-ui";
import { RichText } from "./_shared/RichText";
/** This route emits its own canonical + hreflang - see ./_shared/alternates.ts. */
export const handle = SELF_ALTERNATES_HANDLE;
export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const { opportunity, alternates } = requireEntity(
await fetchOpportunity(slug, locale),
);
return { locale, opportunity, alternates };
}
/* ---------------------------------------------------------------------------
APPLICATION
------------------------------------------------------------------------ */
type ActionResult =
| { readonly ok: true }
| {
readonly ok: false;
readonly errors: FieldErrors;
readonly values: ApplicationValues;
/** True when validation passed but the CMS refused the write. */
readonly failed: boolean;
};
export async function action({ params, request }: Route.ActionArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const { opportunity } = requireEntity(await fetchOpportunity(slug, locale));
const form = await request.formData();
/* A closed call, or one that is handled elsewhere, has no form on the page
and must not accept a POST either. */
if (opportunity.status !== "open" || opportunity.externalApplicationUrl) {
throw data("This call is not accepting applications", { status: 409 });
}
if (isHoneypotFilled(form)) {
return { ok: true } satisfies ActionResult;
}
const { values, errors, valid } = validateApplication(form);
if (!valid) {
return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult,
{ status: 400 },
);
}
try {
await submitApplication({ values, opportunityId: opportunity.documentId });
} catch (error) {
if (!isStrapiError(error)) throw error;
console.error(
`[opportunities] application for "${slug}" was rejected: ${error.status} ${error.message}`,
);
return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
{ status: 502 },
);
}
return { ok: true } satisfies ActionResult;
}
/* ---------------------------------------------------------------------------
META
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const result = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
const copy = getRouteCopy(root.locale);
if (!result) {
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: copy.opportunities.heading,
description: copy.opportunities.metaDescription,
});
}
const { opportunity } = result;
const deadline = opportunity.deadline
? translate(root.locale, "opportunities.deadline", {
date: formatOpportunityDate(opportunity.deadline, root.locale),
})
: undefined;
const descriptors = buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: opportunity.title,
description:
opportunity.summary ?? deadline ?? copy.opportunities.metaDescription,
...(opportunity.image
? { image: { url: opportunity.image.src, alt: opportunity.image.alt } }
: {}),
// A closed call stays online for reference, but it should not keep
// attracting search traffic for an application nobody can make.
noIndex: opportunity.status === "closed",
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{
name: translate(root.locale, "nav.sections.home"),
pathname: localizedPath("/", root.locale),
},
{
name: translate(root.locale, "nav.sections.applications"),
pathname: sectionPath("applications", root.locale),
},
{ name: opportunity.title, pathname: root.pathname },
]),
});
return [
...descriptors,
...detailAlternateDescriptors({
siteUrl: root.siteUrl,
section: "applications",
locale: root.locale,
slugs: result.alternates,
}),
];
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function OpportunityDetail() {
const { opportunity } = useLoaderData<typeof loader>();
const result = useActionData() as ActionResult | undefined;
const navigation = useNavigation();
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const listPath = sectionPath("applications", locale);
const detailPath = `${listPath}/${encodeURIComponent(opportunity.slug)}`;
const isSubmitting = navigation.state === "submitting";
const isOpen = opportunity.status === "open";
const details: DetailItem[] = [
{
label: copy.opportunityDetail.statusLabel,
value: t(opportunityStatusKey(opportunity.status)),
},
...(opportunity.opportunityType
? [
{
label: copy.opportunityDetail.typeLabel,
value: copy.opportunityTypes[opportunity.opportunityType],
},
]
: []),
...(opportunity.openDate
? [
{
label: copy.opportunityDetail.opensLabel,
value: (
<time dateTime={toIsoDate(opportunity.openDate)}>
{formatOpportunityDate(opportunity.openDate, locale)}
</time>
),
},
]
: []),
...(opportunity.deadline
? [
{
label: copy.opportunityDetail.deadlineLabel,
value: (
<time dateTime={toIsoDate(opportunity.deadline)}>
{formatOpportunityDate(opportunity.deadline, locale)}
</time>
),
},
]
: []),
];
return (
<PageShell>
<BackLink to={listPath} label={copy.opportunityDetail.backToList} />
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
<BadgeRow>
<StatusBadge tone={isOpen ? "accent" : "neutral"}>
{t(opportunityStatusKey(opportunity.status))}
</StatusBadge>
{opportunity.opportunityType ? (
<StatusBadge>
{copy.opportunityTypes[opportunity.opportunityType]}
</StatusBadge>
) : null}
</BadgeRow>
<h1 className="display-sm text-text-primary">{opportunity.title}</h1>
{opportunity.summary ? (
<p className="body-lg text-text-secondary">{opportunity.summary}</p>
) : null}
</header>
<div className="flex flex-col gap-2xl lg:flex-row lg:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-lg">
{opportunity.description ? (
<section
aria-labelledby="opportunity-about"
className="flex flex-col gap-md"
>
<h2
id="opportunity-about"
className="heading-h2 text-text-primary"
>
{copy.opportunityDetail.aboutHeading}
</h2>
<RichText value={opportunity.description} />
</section>
) : null}
{opportunity.eligibility ? (
<section
aria-labelledby="opportunity-eligibility"
className="flex flex-col gap-md"
>
<h2
id="opportunity-eligibility"
className="heading-h3 text-text-primary"
>
{t("opportunities.eligibility")}
</h2>
<RichText value={opportunity.eligibility} />
</section>
) : null}
{opportunity.howToApply ? (
<section
aria-labelledby="opportunity-how"
className="flex flex-col gap-md"
>
<h2 id="opportunity-how" className="heading-h3 text-text-primary">
{copy.opportunityDetail.howToApplyHeading}
</h2>
<RichText value={opportunity.howToApply} />
</section>
) : null}
<ApplicationSection
detailPath={detailPath}
opportunity={opportunity}
result={result}
isSubmitting={isSubmitting}
/>
</div>
<aside className="flex w-full flex-col gap-lg lg:w-[22rem] lg:shrink-0">
<DetailList
heading={copy.opportunityDetail.detailsHeading}
items={details}
/>
{opportunity.attachments.length > 0 ? (
<section
aria-labelledby="opportunity-documents"
className="flex flex-col gap-md rounded-card border border-border-subtle bg-surface-raised p-lg"
>
<h2
id="opportunity-documents"
className="label-md uppercase text-text-tertiary"
>
{copy.opportunityDetail.attachmentsHeading}
</h2>
<ul className="flex flex-col gap-md">
{opportunity.attachments.map((file) => (
<li key={file.url} className="flex flex-col items-start gap-2xs">
{/* Media URLs come from the CMS too; an unusable one keeps
the file's name on the page as plain text. */}
{safeHref(file.url) ? (
<Button
as="a"
href={safeHref(file.url)}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
size="sm"
>
{file.name}
</Button>
) : (
<span className="body-sm-bold text-text-primary">
{file.name}
</span>
)}
<p className="body-sm text-text-tertiary">
{formatAttachmentMeta(file, locale)}
</p>
</li>
))}
</ul>
</section>
) : null}
</aside>
</div>
</PageShell>
);
}
/* ---------------------------------------------------------------------------
APPLICATION SECTION
Closed, not open yet, handled elsewhere, by email, or the form itself.
------------------------------------------------------------------------ */
function ApplicationSection({
detailPath,
opportunity,
result,
isSubmitting,
}: {
detailPath: string;
opportunity: Awaited<ReturnType<typeof loader>>["opportunity"];
result: ActionResult | undefined;
isSubmitting: boolean;
}) {
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const errors = result && !result.ok ? result.errors : {};
const values = result && !result.ok ? result.values : undefined;
/* This form has no consent checkbox - only the event registration does -
so the "consent" code cannot occur here; it is narrowed away rather than
indexed into a table that has no entry for it. */
const fieldError = (name: string) => {
const code = errors[name];
if (!code || code === "consent") return undefined;
return t(FIELD_ERROR_KEYS[code]);
};
if (result?.ok) {
/* The draft's "[X weeks]" is TBD and the CMS models no response time, so
the acknowledgement never names one. */
return (
<Notice tone="accent" role="status" title={t("form.successTitle")}>
{copy.opportunityDetail.applicationReceivedBody}
</Notice>
);
}
if (opportunity.status === "upcoming") {
return (
<Notice
tone="info"
title={
opportunity.openDate
? interpolate(copy.opportunityDetail.upcomingNote, {
date: formatOpportunityDate(opportunity.openDate, locale),
})
: t("filters.upcoming")
}
/>
);
}
if (opportunity.status === "closed") {
return <Notice tone="info" title={copy.opportunityDetail.closedNote} />;
}
if (opportunity.externalApplicationUrl) {
return (
<Notice tone="info" title={copy.opportunityDetail.externalApplication}>
{/* `safeHref` gates the CMS URL; without a usable one the notice still
tells the reader that applications are handled elsewhere. */}
{safeHref(opportunity.externalApplicationUrl) ? (
<Button
as="a"
href={safeHref(opportunity.externalApplicationUrl)}
target="_blank"
rel="noopener noreferrer"
variant="primary"
iconEnd={<ArrowIcon />}
>
{t("actions.apply")}
</Button>
) : null}
</Notice>
);
}
return (
<section
id="apply"
aria-labelledby="apply-heading"
className="flex flex-col gap-lg rounded-card border border-border-subtle bg-surface-raised p-lg"
>
<div className="flex flex-col gap-xs">
<h2 id="apply-heading" className="heading-h3 text-text-primary">
{copy.opportunityDetail.applyHeading}
</h2>
<p className="body-sm text-text-secondary">
{copy.opportunityDetail.formIntro}
</p>
</div>
{result && !result.ok && result.failed ? (
<Notice tone="danger" role="alert" title={t("form.errorGeneric")}>
{copy.form.submissionFailed}
</Notice>
) : null}
<Form method="post" action={detailPath} className="flex flex-col gap-md">
<div className="grid gap-md sm:grid-cols-2">
<Input
name="fullName"
label={t("form.name")}
required
autoComplete="name"
defaultValue={values?.fullName}
error={fieldError("fullName")}
/>
<Input
name="email"
type="email"
label={t("form.email")}
required
autoComplete="email"
inputMode="email"
defaultValue={values?.email}
error={fieldError("email")}
/>
<Input
name="organisation"
label={t("form.organisation")}
hint={t("form.optional")}
autoComplete="organization"
defaultValue={values?.organisation}
className="sm:col-span-2"
/>
</div>
<Textarea
name="message"
label={t("form.message")}
hint={copy.opportunityDetail.messageHint}
required
rows={6}
defaultValue={values?.message}
error={fieldError("message")}
/>
{/* Honeypot. Hidden from sight and from assistive technology alike. */}
<div aria-hidden="true" className="hidden">
<label htmlFor={HONEYPOT_FIELD}>{copy.form.honeypotLabel}</label>
<input
id={HONEYPOT_FIELD}
name={HONEYPOT_FIELD}
type="text"
tabIndex={-1}
autoComplete="off"
/>
</div>
<div className="flex flex-wrap items-center gap-sm">
<Button
type="submit"
variant="primary"
isLoading={isSubmitting}
iconEnd={<ArrowIcon />}
>
{t("actions.apply")}
</Button>
</div>
</Form>
</section>
);
}
@@ -0,0 +1,271 @@
/* ============================================================================
APPLICATIONS AND OPPORTUNITIES - /{locale}/applications-and-opportunities
----------------------------------------------------------------------------
Draft section 8 ("Get Involved"). Two halves:
1. the three ways into the project, which are editorial and permanent -
a Call for Expression of Interest, the SHARP network, and the SUQ
dialogues and trainings;
2. the current calls, which come from the CMS and carry a status tag of
Open / Upcoming / Closed.
The draft's network card ends "Membership involves [eligibility criteria -
TBD]". That sentence is NOT rendered: eligibility is a CMS field on the
network entry, and the page shows it only once it exists. Nothing here
invents a criterion, a deadline or a figure.
Open / Upcoming / Closed is derived from `openDate` and `deadline` (see
./_events-opportunities/opportunities.ts); an editorial `opportunityStatus`
of "closed" wins over the dates, for a call suspended or closed early.
EMPTY BY DESIGN: with an empty CMS the three pathway cards still render -
they are approved copy, not content - and the calls list shows its localised
"nothing open yet" panel.
========================================================================= */
import { Link, useSearchParams } from "react-router";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import {
localizedPath,
sectionPath,
translate,
useTranslation,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/applications-and-opportunities";
import { getRouteCopy } from "./_events-opportunities/copy";
import {
filterOpportunities,
findNetworkOpportunity,
OPPORTUNITY_STATUS_FILTERS,
opportunityStatusKey,
sortOpportunities,
} from "./_events-opportunities/opportunities";
import { fetchOpportunities } from "./_events-opportunities/opportunities.server";
import { readRootSeo } from "./_events-opportunities/root-seo";
import { Notice, OpportunityCard } from "./_events-opportunities/ui";
import {
buildPagination,
PAGE_SIZE,
parseEnum,
parsePage,
requireLocale,
withParam,
} from "./_shared/listing";
import {
EmptyState,
PageHeader,
PageShell,
Pagination,
ResultSummary,
TabLinks,
} from "./_shared/listing-ui";
/** Anchor of the calls list - the "See current calls" card jumps to it. */
const LIST_ANCHOR = "current-calls";
export async function loader({ params, request }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const url = new URL(request.url);
const now = new Date();
const status = parseEnum(
url.searchParams,
"status",
OPPORTUNITY_STATUS_FILTERS,
"all",
);
const { opportunities, available } = await fetchOpportunities(locale, now);
const matching = sortOpportunities(
filterOpportunities(opportunities, status),
);
const pagination = buildPagination(
parsePage(url.searchParams),
PAGE_SIZE,
matching.length,
);
const start = (pagination.page - 1) * PAGE_SIZE;
/* "Join the SHARP Network" points at the network entry when the CMS has one
and at Contact when it does not - never at a URL that does not exist. */
const network = findNetworkOpportunity(opportunities);
return {
locale,
opportunities: matching.slice(start, start + PAGE_SIZE),
pagination,
status,
available,
hasOpportunities: opportunities.length > 0,
networkSlug: network?.slug ?? null,
};
}
export function meta({ location, matches }: Route.MetaArgs) {
const root = readRootSeo(matches);
const copy = getRouteCopy(root.locale);
const search = new URLSearchParams(location.search);
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: translate(root.locale, "nav.sections.applications"),
description: copy.opportunities.metaDescription,
noIndex: search.has("status") || search.has("page"),
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{
name: translate(root.locale, "nav.sections.home"),
pathname: localizedPath("/", root.locale),
},
{
name: translate(root.locale, "nav.sections.applications"),
pathname: sectionPath("applications", root.locale),
},
]),
});
}
export default function ApplicationsAndOpportunities({
loaderData,
}: Route.ComponentProps) {
const {
opportunities,
pagination,
status,
available,
hasOpportunities,
networkSlug,
} = loaderData;
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const [searchParams] = useSearchParams();
const pathname = sectionPath("applications", locale);
const href = (value: string | undefined) =>
`${pathname}${withParam(searchParams, "status", value)}`;
const tabs = OPPORTUNITY_STATUS_FILTERS.map((value) => ({
id: value,
label: value === "all" ? t("filters.all") : t(opportunityStatusKey(value)),
href: href(value === "all" ? undefined : value),
isCurrent: status === value,
}));
const pathways = [
{
id: "calls",
title: copy.opportunities.pathwayCallsTitle,
excerpt: copy.opportunities.pathwayCallsBody,
href: `#${LIST_ANCHOR}`,
actionLabel: t("actions.seeCurrentCalls"),
},
{
id: "network",
title: copy.opportunities.pathwayNetworkTitle,
excerpt: copy.opportunities.pathwayNetworkBody,
href: networkSlug
? `${pathname}/${encodeURIComponent(networkSlug)}`
: sectionPath("contact", locale),
actionLabel: t("actions.applyToJoin"),
},
{
id: "sessions",
title: copy.opportunities.pathwaySessionsTitle,
excerpt: copy.opportunities.pathwaySessionsBody,
href: sectionPath("events", locale),
actionLabel: t("actions.viewUpcomingSessions"),
},
];
return (
<PageShell>
<PageHeader
heading={copy.opportunities.heading}
intro={copy.opportunities.intro}
/>
<section aria-labelledby="pathways" className="flex flex-col gap-lg">
<h2 id="pathways" className="heading-h2 text-text-primary">
{copy.opportunities.pathwaysHeading}
</h2>
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{pathways.map((pathway) => (
<Card.Content
key={pathway.id}
as="li"
href={pathway.href}
title={pathway.title}
excerpt={pathway.excerpt}
actionLabel={pathway.actionLabel}
/>
))}
</ul>
</section>
<section
id={LIST_ANCHOR}
aria-labelledby="current-calls-heading"
className="flex flex-col gap-lg scroll-mt-2xl"
>
<h2 id="current-calls-heading" className="heading-h2 text-text-primary">
{copy.opportunities.listHeading}
</h2>
{available ? null : (
<Notice tone="info" role="status">
{copy.opportunities.unavailable}
</Notice>
)}
<TabLinks label={copy.opportunities.statusFilterLabel} tabs={tabs} />
<ResultSummary pagination={pagination} t={t} />
{opportunities.length > 0 ? (
<>
<ul
aria-label={copy.opportunities.listLabel}
className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3"
>
{opportunities.map((opportunity) => (
<OpportunityCard
key={opportunity.slug}
as="li"
opportunity={opportunity}
href={`${pathname}/${encodeURIComponent(opportunity.slug)}`}
/>
))}
</ul>
<Pagination
model={pagination}
pathname={pathname}
params={searchParams}
t={t}
/>
</>
) : hasOpportunities && status !== "all" ? (
<EmptyState
title={t("filters.noResults")}
body={t("filters.noResultsHint")}
>
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
</EmptyState>
) : (
<EmptyState
title={copy.opportunities.empty}
body={copy.opportunities.emptyHint}
/>
)}
</section>
</PageShell>
);
}
+601
View File
@@ -0,0 +1,601 @@
/* ============================================================================
CONTACT - /en/contact and /ar/contact
----------------------------------------------------------------------------
Section 9 of docs/SHARPER_Website_Draft_Section_EN_AR.docx.
The form is a real one: it posts to this route's `action`, which validates
every field on the SERVER (so validation cannot be skipped by disabling
JavaScript or by posting directly) and writes an
api::contact-submission.contact-submission entry in Strapi.
Client-side JS is an enhancement only - the page works as a plain HTML form
post, and `useNavigation()` merely swaps the button into its loading state.
Every message the reader can see is localised: field labels and the generic
errors come from the i18n catalogue, the page prose from the approved draft.
========================================================================= */
import { useEffect, useRef } from "react";
import { data, Form, Link, useLocation, useNavigation } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input, Textarea } from "~/components/ui/Input";
import { createTranslator, sectionPath, type Locale } from "~/i18n";
import { getServerEnv } from "~/lib/env.server";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/contact";
import type { CmsContactPage } from "./_page-kit/cms-types";
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
EmptyState,
InfoPanel,
PageHero,
PageSection,
Prose,
SectionHeader,
} from "./_page-kit/sections";
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const page = await loadSingle<CmsContactPage>("contact-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
socialLinks: true,
consortiumContacts: true,
seo: { populate: { ogImage: true } },
},
});
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
intro: page?.intro ?? null,
generalEmail: page?.generalEmail ?? null,
websiteLabel: page?.websiteLabel ?? null,
socialLinks: list(page?.socialLinks).map((link) => ({
label: link.label,
href: link.href,
isExternal: link.isExternal ?? true,
})),
consortiumContacts: list(page?.consortiumContacts).map((address) => ({
id: String(address.id ?? address.organisation),
organisation: address.organisation,
addressLine: address.addressLine ?? null,
city: address.city ?? null,
country: address.country ?? null,
email: address.email ?? null,
phone: address.phone ?? null,
})),
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: loaderData?.seo.title ?? t("nav.sections.contact"),
description: loaderData?.seo.description ?? copy.lead,
...(loaderData?.seo.image
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
: {}),
noIndex: loaderData?.seo.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{
name: t("nav.sections.contact"),
pathname: sectionPath("contact", locale),
},
]),
});
}
/* ---------------------------------------------------------------------------
FORM CONTRACT
------------------------------------------------------------------------ */
const FIELD_NAMES = ["name", "organisation", "email", "subject", "message"] as const;
type ContactField = (typeof FIELD_NAMES)[number];
type ContactValues = Record<ContactField, string>;
type ContactErrors = Partial<Record<ContactField, string>>;
/**
* A field bots fill in and people never see. Named like a real field so a
* naive spam bot completes it; a submission that carries a value is accepted
* with a success response and quietly dropped, which tells the bot nothing.
*/
const HONEYPOT_FIELD = "website";
/**
* The `id` handed to each control. It has to be fixed rather than the `useId()`
* one `Input` generates, because the error summary links at it with a fragment.
*/
function fieldDomId(field: ContactField): string {
return `contact-${field}`;
}
/** Mirrors the maxLength constraints on the Strapi content type. */
const MAX_LENGTH: Record<ContactField, number> = {
name: 200,
organisation: 200,
email: 254,
subject: 250,
message: 5000,
};
/** Deliberately permissive - RFC-valid addresses vary far more than any regex. */
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const EMPTY_VALUES: ContactValues = {
name: "",
organisation: "",
email: "",
subject: "",
message: "",
};
function readValues(form: FormData): ContactValues {
const values = { ...EMPTY_VALUES };
for (const field of FIELD_NAMES) {
const raw = form.get(field);
values[field] = typeof raw === "string" ? raw.trim() : "";
}
return values;
}
function validate(values: ContactValues, locale: Locale): ContactErrors {
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
const errors: ContactErrors = {};
const required: ContactField[] = ["name", "email", "subject", "message"];
for (const field of required) {
if (values[field] === "") errors[field] = t("form.errorRequired");
}
if (!errors.email && !EMAIL_PATTERN.test(values.email)) {
errors.email = t("form.errorEmail");
}
for (const field of FIELD_NAMES) {
if (!errors[field] && values[field].length > MAX_LENGTH[field]) {
errors[field] = copy.errorTooLong;
}
}
return errors;
}
/**
* Write the message to Strapi. Server-only: `getServerEnv` lives in a
* `.server` module, so the token cannot reach the browser bundle.
*
* THE PAYLOAD IS THE WHITELIST, NOTHING MORE
* `contact-submission` has a `sanitize-submission` middleware
* (apps/cms/src/api/contact-submission/middlewares/sanitize-submission.ts)
* that copies out exactly `name · organisation · email · subject · message`
* and drops the rest, then stamps `submissionStatus` and `submittedAt`
* itself. Sending those two from here is not merely redundant - it reads as
* though the browser decides triage state, and the value would be silently
* discarded anyway - so the body below is the whitelist and only it.
*/
async function createContactSubmission(values: ContactValues): Promise<void> {
const env = getServerEnv();
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
},
body: JSON.stringify({
data: {
name: values.name,
// An empty optional field is stored as null, not as "".
organisation: values.organisation === "" ? null : values.organisation,
email: values.email,
subject: values.subject,
message: values.message,
},
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(
`Strapi responded ${response.status} ${response.statusText} for ${url}`,
);
}
}
export async function action({ params, request }: Route.ActionArgs) {
const locale = routeLocale(params);
const form = await request.formData();
// Honeypot: answer exactly like a success, store nothing.
const trap = form.get(HONEYPOT_FIELD);
if (typeof trap === "string" && trap.trim() !== "") {
return data({ status: "success" as const, errors: {} as ContactErrors, values: EMPTY_VALUES });
}
const values = readValues(form);
const errors = validate(values, locale);
if (Object.keys(errors).length > 0) {
return data(
{ status: "invalid" as const, errors, values },
{ status: 400 },
);
}
try {
await createContactSubmission(values);
} catch (error) {
console.error("[contact] submission failed", error);
const t = createTranslator(locale);
return data(
{
status: "failed" as const,
errors: {} as ContactErrors,
values,
formError: t("form.errorGeneric"),
},
{ status: 502 },
);
}
return data({ status: "success" as const, errors: {} as ContactErrors, values: EMPTY_VALUES });
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function Contact({
loaderData,
actionData,
}: Route.ComponentProps) {
const { locale, hero, intro, generalEmail, socialLinks, consortiumContacts } =
loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
const navigation = useNavigation();
const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting";
const errors = actionData?.errors ?? {};
const values = actionData?.values ?? EMPTY_VALUES;
const formError =
actionData && "formError" in actionData ? actionData.formError : undefined;
const isSuccess = actionData?.status === "success";
const errorMessages = Object.values(errors).filter(
(message): message is string => typeof message === "string",
);
/* A failed submit re-renders the page with the summary at the top of the
form. Screen-reader users are left wherever they were, and keyboard users
below the fold, so focus is moved onto the summary itself - it is
tabIndex={-1} for exactly this. Keyed on `actionData`, which is a fresh
object per submission, so a repeated failure focuses again. */
const errorSummaryRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!actionData || actionData.status === "success") return;
errorSummaryRef.current?.focus();
}, [actionData]);
return (
<>
<PageHero
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.engagementAndOpportunities")}
lead={hero.subheading ?? copy.lead}
{...(hero.media ? { media: hero.media } : {})}
/>
<PageSection className="pt-0 md:pt-0">
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
{/* ---------------------------------------------------------------
THE FORM
--------------------------------------------------------------- */}
<div className="flex flex-col gap-lg">
<SectionHeader
id="contact-form-heading"
title={copy.formHeading}
lead={copy.formIntro}
/>
{isSuccess ? (
<div
className="flex flex-col items-start gap-md rounded-card border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] p-lg"
role="status"
>
<p className="body-lg-bold text-[var(--text-primary)]">
{t("form.successTitle")}
</p>
<p className="body-md text-[var(--text-secondary)]">
{t("form.successBody")}
</p>
<Button as={Link} to={pathname} variant="secondary">
{copy.sendAnother}
</Button>
</div>
) : (
<Form
method="post"
aria-labelledby="contact-form-heading"
className="flex flex-col gap-lg"
noValidate
>
{formError || errorMessages.length > 0 ? (
<div
ref={errorSummaryRef}
tabIndex={-1}
role="alert"
className="flex flex-col gap-xs rounded-card border border-[var(--border-danger)] bg-[var(--surface-danger-subtle)] p-md"
>
<p className="body-md-bold text-[var(--text-danger)]">
{formError ?? copy.errorSummaryTitle}
</p>
{errorMessages.length > 0 ? (
<ul className="flex list-disc flex-col gap-2xs ps-lg body-sm text-[var(--text-secondary)]">
{/* Listed in form order, not object order, and each one
is a link at the control it is about. The label is
repeated so the link text still identifies the field
when the messages are read out of context. */}
{FIELD_NAMES.filter((field) => errors[field]).map((field) => (
<li key={field}>
<a
href={`#${fieldDomId(field)}`}
className="underline underline-offset-2 hover:text-[var(--text-primary)]"
>
{t(`form.${field}`)}: {errors[field]}
</a>
</li>
))}
</ul>
) : null}
</div>
) : null}
{/* Honeypot - off-screen, out of the tab order and out of the
accessibility tree, so only an automated client fills it. */}
<div aria-hidden="true" className="sr-only">
<label htmlFor="contact-website">{copy.honeypotLabel}</label>
<input
id="contact-website"
type="text"
name={HONEYPOT_FIELD}
tabIndex={-1}
autoComplete="off"
defaultValue=""
/>
</div>
<div className="grid grid-cols-1 gap-lg md:grid-cols-2">
<Input
name="name"
id={fieldDomId("name")}
label={t("form.name")}
required
autoComplete="name"
maxLength={MAX_LENGTH.name}
defaultValue={values.name}
{...(errors.name ? { error: errors.name } : {})}
/>
<Input
name="organisation"
id={fieldDomId("organisation")}
label={t("form.organisation")}
hint={copy.organisationHint}
autoComplete="organization"
maxLength={MAX_LENGTH.organisation}
defaultValue={values.organisation}
{...(errors.organisation ? { error: errors.organisation } : {})}
/>
<Input
name="email"
id={fieldDomId("email")}
type="email"
label={t("form.email")}
required
autoComplete="email"
maxLength={MAX_LENGTH.email}
defaultValue={values.email}
{...(errors.email ? { error: errors.email } : {})}
/>
<Input
name="subject"
id={fieldDomId("subject")}
label={t("form.subject")}
required
maxLength={MAX_LENGTH.subject}
defaultValue={values.subject}
{...(errors.subject ? { error: errors.subject } : {})}
/>
</div>
<Textarea
name="message"
id={fieldDomId("message")}
label={t("form.message")}
required
rows={6}
maxLength={MAX_LENGTH.message}
defaultValue={values.message}
{...(errors.message ? { error: errors.message } : {})}
/>
<Button
type="submit"
size="lg"
variant="primary"
isLoading={isSubmitting}
iconEnd={<ArrowIcon />}
className="self-start"
>
{isSubmitting ? t("form.sending") : t("form.send")}
</Button>
</Form>
)}
</div>
{/* ---------------------------------------------------------------
CONTACT DETAILS
--------------------------------------------------------------- */}
<div className="flex flex-col gap-lg">
<SectionHeader id="contact-details-heading" title={copy.detailsHeading} />
{intro ? (
<Prose>
<RichText markdown={intro} />
</Prose>
) : null}
<dl className="flex flex-col gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg">
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{copy.generalEnquiries}
</dt>
<dd className="body-md text-[var(--text-secondary)]">
{generalEmail ? (
<a
href={`mailto:${generalEmail}`}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{generalEmail}
</a>
) : (
copy.contactPending
)}
</dd>
</div>
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{copy.websiteLabel}
</dt>
<dd className="body-md">
<a
href={copy.websiteHref}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{loaderData.websiteLabel ?? copy.websiteText}
</a>
</dd>
</div>
{socialLinks.length > 0 ? (
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{t("site.followUs")}
</dt>
<dd>
<ul className="flex flex-wrap gap-md body-md">
{socialLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
{...(link.isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{link.label}
</a>
</li>
))}
</ul>
</dd>
</div>
) : null}
</dl>
</div>
</div>
</PageSection>
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them. */}
<PageSection aria-labelledby="contact-consortium-heading">
<div className="flex flex-col gap-lg">
<SectionHeader
id="contact-consortium-heading"
title={copy.consortiumHeading}
/>
{consortiumContacts.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{consortiumContacts.map((contact) => (
<li key={contact.id}>
<InfoPanel title={contact.organisation}>
<address className="flex flex-col gap-2xs not-italic">
{contact.addressLine ? <span>{contact.addressLine}</span> : null}
{contact.city || contact.country ? (
<span>
{[contact.city, contact.country]
.filter(Boolean)
.join(", ")}
</span>
) : null}
{contact.email ? (
<a
href={`mailto:${contact.email}`}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{contact.email}
</a>
) : null}
{contact.phone ? (
<a
href={`tel:${contact.phone.replace(/\s+/g, "")}`}
dir="ltr"
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{contact.phone}
</a>
) : null}
</address>
</InfoPanel>
</li>
))}
</ul>
) : (
<EmptyState message={copy.consortiumEmpty} />
)}
</div>
</PageSection>
</>
);
}
@@ -0,0 +1,100 @@
/* ============================================================================
EVENT -> iCALENDAR (resource route)
----------------------------------------------------------------------------
/{locale}/events-calendar/{slug}.ics
This module exports a `loader` and nothing else - no component, no
ErrorBoundary - which is what makes React Router treat it as a resource
route and hand the loader's Response straight to the browser.
Because a resource route is served on its own, the ":lang" layout above it
does NOT run: its loader is not called for this request, so the locale
segment is validated here as well. `requireLocale` throws the same 404 the
layout would have.
The file itself is built by ./_events-opportunities/ics.ts, which follows
RFC 5545 rather than one client's tolerance - CRLF endings, 75-octet folding
on character boundaries (an Arabic title is two bytes per letter), escaped
TEXT values and an exclusive DTEND for an all-day event.
========================================================================= */
import { redirect } from "react-router";
import { isLocale, sectionPath, translate } from "~/i18n";
import { getSiteUrl } from "~/lib/env.server";
import { canonicalUrl } from "~/lib/seo";
import { requireEntity } from "~/lib/strapi";
import type { Route } from "./+types/events-calendar.$slug.ics";
import { getRouteCopy } from "./_events-opportunities/copy";
import { fetchEvent } from "./_events-opportunities/events.server";
import { buildIcsCalendar, icsResponse } from "./_events-opportunities/ics";
import { requireLocale } from "./_shared/listing";
/** Only used to name the file; the CMS slug supplies the rest. */
const FILENAME_PREFIX = "sharp-er";
export async function loader({ params, request }: Route.LoaderArgs) {
/* The ":lang" layout normally 301s "/EN/..." to "/en/...", but its loader
does not run for a resource route, so the same normalisation happens here
rather than answering a 404 for a URL the rest of the site accepts. */
const segment = params.lang;
const normalized = segment?.toLowerCase();
if (segment && normalized !== segment && isLocale(normalized)) {
const url = new URL(request.url);
throw redirect(
`${url.pathname.replace(/^\/[^/]+/, `/${normalized}`)}${url.search}`,
301,
);
}
const locale = requireLocale(normalized);
const slug = params.slug ?? "";
const { event } = requireEntity(await fetchEvent(slug, locale));
const siteUrl = getSiteUrl(request);
const pathname = `${sectionPath("events", locale)}/${encodeURIComponent(slug)}`;
const url = canonicalUrl(siteUrl, pathname);
const copy = getRouteCopy(locale);
const location =
event.place ??
(event.format === "online" ? translate(locale, "events.online") : undefined);
/* The joining link belongs in the calendar entry, not only on the page: by
the time the reminder fires, the page is not what is in front of anyone. */
const description = [
event.summary,
event.onlineUrl
? `${copy.eventDetail.joinOnlineLabel}: ${event.onlineUrl}`
: undefined,
url,
]
.filter(Boolean)
.join("\n\n");
const calendar = buildIcsCalendar(
[
{
/* documentId is shared by an entry's translations in Strapi 5, so the
locale is part of the UID - importing the Arabic file must not
overwrite the English entry already in someone's calendar. */
uid: `${event.documentId}.${locale}@sharper.ly`,
start: event.start,
// Only an explicit end is written: inventing a duration would put a
// wrong-length block in a reader's calendar.
end: event.end,
allDay: event.allDay,
summary: event.title,
description,
location,
url,
status: "CONFIRMED",
},
],
{ name: translate(locale, "nav.sections.events") },
);
return icsResponse(calendar, `${FILENAME_PREFIX}-${slug}.ics`);
}
@@ -0,0 +1,643 @@
/* ============================================================================
EVENTS CALENDAR - one event
----------------------------------------------------------------------------
/{locale}/events-calendar/{slug}
Draft section 4: the detail page carries the registration, the confirmation
screen ("You're registered.") and - because an event whose places are gone
still has to be joinable later - a waitlist state.
GDPR CONSENT
`event-registration.consentGdpr` is REQUIRED in the CMS, so the form asks
for it with a real, labelled checkbox in both languages and the action
refuses to submit without it. It is never a hidden input: consent that the
reader did not give is not consent.
"Add to calendar" points at the sibling resource route,
/{locale}/events-calendar/{slug}.ics, which streams a real RFC 5545 file.
ROUTE
Registered in app/routes.ts as "eventsDetail", a SIBLING of the listing route
under the ":lang" layout - a listing renders a page, not an <Outlet/>, so a
detail page can never be its child. Typed against its generated `./+types/`
module.
404 / 503
A slug that does not exist is a 404 - including "the collection has not been
created yet", which `fetchBySlug` maps to null. A CMS outage is NOT turned
into a 404: it propagates, so a crawler is never told that a real event has
been removed.
========================================================================= */
import {
data,
Form,
useActionData,
useLoaderData,
useNavigation,
} from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import {
formatDate,
interpolate,
localizedPath,
sectionPath,
toIsoDate,
translate,
useTranslation,
} from "~/i18n";
import { safeHref } from "~/lib/safe-href";
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError, requireEntity } from "~/lib/strapi";
import type { Route } from "./+types/events-calendar.$slug";
import { getRouteCopy } from "./_events-opportunities/copy";
import {
EVENT_TIME_ZONE,
eventRegistrationLabel,
eventTimeStatusKey,
formatEventDay,
formatEventWhen,
isRegistrationOpen,
isWaitlisted,
} from "./_events-opportunities/events";
import { fetchEvent } from "./_events-opportunities/events.server";
import {
CONSENT_FIELD,
FIELD_ERROR_KEYS,
HONEYPOT_FIELD,
isHoneypotFilled,
validateRegistration,
type FieldErrors,
type RegistrationValues,
} from "./_events-opportunities/forms";
import { readRootSeo } from "./_events-opportunities/root-seo";
import { submitRegistration } from "./_events-opportunities/submissions.server";
import {
BadgeRow,
ConsentCheckbox,
DetailList,
Notice,
StatusBadge,
type DetailItem,
} from "./_events-opportunities/ui";
import {
detailAlternateDescriptors,
SELF_ALTERNATES_HANDLE,
} from "./_shared/alternates";
import { requireLocale } from "./_shared/listing";
import { BackLink, PageShell } from "./_shared/listing-ui";
import { RichText } from "./_shared/RichText";
/**
* A Strapi 5 localisation has its own slug, so this route emits its own
* canonical + hreflang from `alternates` below rather than letting the shell
* swap "/en/" for "/ar/" onto a URL that does not exist.
*/
export const handle = SELF_ALTERNATES_HANDLE;
export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const { event, alternates } = requireEntity(await fetchEvent(slug, locale));
return { locale, event, alternates };
}
/* ---------------------------------------------------------------------------
REGISTRATION
------------------------------------------------------------------------ */
type ActionResult =
| { readonly ok: true; readonly waitlist: boolean }
| {
readonly ok: false;
readonly errors: FieldErrors;
readonly values: RegistrationValues;
/** True when validation passed but the CMS refused the write. */
readonly failed: boolean;
};
export async function action({ params, request }: Route.ActionArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const { event } = requireEntity(await fetchEvent(slug, locale));
const form = await request.formData();
const waitlist = isWaitlisted(event);
/* A form nobody can submit through the page must not be submittable through
a POST either - registration state is checked again on the server. */
if (!isRegistrationOpen(event)) {
throw data("Registration is closed", { status: 409 });
}
/* A bot that fills every field gets the acknowledgement and writes nothing. */
if (isHoneypotFilled(form)) {
return { ok: true, waitlist } satisfies ActionResult;
}
const { values, errors, valid } = validateRegistration(form);
if (!valid) {
return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult,
{ status: 400 },
);
}
try {
/* Only the fields the CMS whitelist accepts are sent - see
./_events-opportunities/submissions.server.ts. `registrationState` is
deliberately NOT among them: the CMS middleware derives it from the
event's own capacity and waitlist settings, which is the only place the
count of taken places can be read without trusting the client. The
`waitlist` flag below is therefore presentational - it decides which
sentence the reader sees, and it is read from the same event fields the
middleware weighs, so the two agree. */
await submitRegistration({ values, eventId: event.documentId });
} catch (error) {
if (!isStrapiError(error)) throw error;
/* A 4xx here is the CMS refusing the registration on its own terms - the
event filled up or closed between the page render and this POST - and a
5xx is an outage. The reader sees one honest "that did not go through"
either way; the distinction is kept in the log for staff. */
console.error(
`[events] registration for "${slug}" was rejected: ${error.status} ${error.message}`,
);
return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
{ status: 502 },
);
}
return { ok: true, waitlist } satisfies ActionResult;
}
/* ---------------------------------------------------------------------------
META
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const result = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
const copy = getRouteCopy(root.locale);
if (!result) {
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: copy.events.heading,
description: copy.events.metaDescription,
});
}
const { event } = result;
const when = formatEventWhen(event, root.locale, { withTimeZone: true });
const description =
event.summary ?? [when, event.place].filter(Boolean).join(" · ");
const descriptors = buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: event.title,
description,
...(event.image
? { image: { url: event.image.src, alt: event.image.alt } }
: {}),
jsonLd: [
buildEventJsonLd({
siteUrl: root.siteUrl,
locale: root.locale,
name: event.title,
pathname: root.pathname,
startDate: toIsoDate(event.start) ?? event.start,
...(event.end ? { endDate: toIsoDate(event.end) ?? event.end } : {}),
...(event.summary ? { description: event.summary } : {}),
...(event.image ? { image: event.image.src } : {}),
...(event.venue ? { locationName: event.venue } : {}),
...(event.region ? { addressLocality: event.region } : {}),
...(event.onlineUrl ? { onlineUrl: event.onlineUrl } : {}),
isOnline: event.format === "online" || event.format === "hybrid",
}),
buildBreadcrumbJsonLd(root.siteUrl, [
{
name: translate(root.locale, "nav.sections.home"),
pathname: localizedPath("/", root.locale),
},
{
name: translate(root.locale, "nav.sections.events"),
pathname: sectionPath("events", root.locale),
},
{ name: event.title, pathname: root.pathname },
]),
],
});
return [
...descriptors,
...detailAlternateDescriptors({
siteUrl: root.siteUrl,
section: "events",
locale: root.locale,
slugs: result.alternates,
}),
];
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function EventDetail() {
const { event } = useLoaderData<typeof loader>();
const result = useActionData() as ActionResult | undefined;
const navigation = useNavigation();
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const listPath = sectionPath("events", locale);
const detailPath = `${listPath}/${encodeURIComponent(event.slug)}`;
const isSubmitting = navigation.state === "submitting";
const isPast = event.timeStatus === "completed";
const waitlist = isWaitlisted(event);
const canRegister = isRegistrationOpen(event);
const errors = result && !result.ok ? result.errors : {};
const values = result && !result.ok ? result.values : undefined;
const fieldError = (name: string) => {
const code = errors[name];
return code ? t(FIELD_ERROR_KEYS[code]) : undefined;
};
const formatLabel =
event.format === "online"
? t("events.online")
: event.format === "hybrid"
? copy.events.formatHybrid
: event.format === "in-person"
? t("events.inPerson")
: undefined;
const details: DetailItem[] = [
{
label: t("events.dateTime"),
value: (
<time dateTime={toIsoDate(event.start)}>
{formatEventWhen(event, locale, { withTimeZone: true })}
</time>
),
},
{
label: t("events.location"),
value: [formatLabel, event.place].filter(Boolean).join(" · ") || undefined,
},
{
label: t("events.registrationStatus"),
value: eventRegistrationLabel(event.registrationStatus, locale),
},
...(event.registrationDeadline
? [
{
label: copy.eventDetail.registrationDeadlineLabel,
value: (
<time dateTime={toIsoDate(event.registrationDeadline)}>
{formatDate(event.registrationDeadline, locale, {
dateStyle: "long",
timeZone: EVENT_TIME_ZONE,
})}
</time>
),
},
]
: []),
...(event.onlineUrl
? [
{
label: copy.eventDetail.joinOnlineLabel,
/* CMS-supplied URL: `safeHref` rejects anything that is not
http/https/mailto/tel, and the reader still sees the address
the editor entered - as text rather than as a link. */
value: safeHref(event.onlineUrl) ? (
<a
href={safeHref(event.onlineUrl)}
target="_blank"
rel="noopener noreferrer"
className="text-text-link underline underline-offset-2 hover:text-text-link-hover"
>
{event.onlineUrl}
</a>
) : (
event.onlineUrl
),
},
]
: []),
].filter((item) => item.value !== undefined && item.value !== "");
return (
<PageShell>
<BackLink to={listPath} label={copy.eventDetail.backToList} />
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
<BadgeRow>
<StatusBadge tone={isPast ? "neutral" : "accent"}>
{t(eventTimeStatusKey(event.timeStatus))}
</StatusBadge>
{isPast ? null : (
<StatusBadge
tone={
waitlist
? "danger"
: event.registrationStatus === "closed"
? "neutral"
: "accent"
}
>
{eventRegistrationLabel(event.registrationStatus, locale)}
</StatusBadge>
)}
{event.region ? <StatusBadge>{event.region}</StatusBadge> : null}
</BadgeRow>
<h1 className="display-sm text-text-primary">{event.title}</h1>
<p className="body-lg text-text-secondary">
<time dateTime={toIsoDate(event.start)}>
{formatEventWhen(event, locale, { withTimeZone: true })}
</time>
</p>
{event.summary ? (
<p className="body-lg text-text-secondary">{event.summary}</p>
) : null}
</header>
{event.image ? (
<img
src={event.image.src}
alt={event.image.alt}
{...(event.image.width ? { width: event.image.width } : {})}
{...(event.image.height ? { height: event.image.height } : {})}
className="w-full rounded-surface border border-border-subtle object-cover"
/>
) : null}
<div className="flex flex-col gap-2xl lg:flex-row lg:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-lg">
{event.description ? (
<section
aria-labelledby="event-about"
className="flex flex-col gap-md"
>
<h2 id="event-about" className="heading-h2 text-text-primary">
{copy.eventDetail.aboutHeading}
</h2>
<RichText value={event.description} />
</section>
) : null}
<RegistrationSection
detailPath={detailPath}
canRegister={canRegister}
isPast={isPast}
waitlist={waitlist}
result={result}
isSubmitting={isSubmitting}
fieldError={fieldError}
values={values}
eventTitle={event.title}
eventDay={formatEventDay(event, locale)}
registrationRequired={event.registrationStatus !== "not-required"}
recapUrl={event.recapUrl}
/>
</div>
<aside className="flex w-full flex-col gap-lg lg:w-[22rem] lg:shrink-0">
<DetailList heading={copy.eventDetail.detailsHeading} items={details} />
<div className="flex flex-col gap-xs">
<Button
as="a"
href={`${detailPath}.ics`}
download
variant="secondary"
fullWidth
>
{t("actions.addToCalendar")}
</Button>
<p className="body-sm text-text-tertiary">
{copy.eventDetail.addToCalendarHint}
</p>
{event.allDay ? null : (
<p className="body-sm text-text-tertiary">
{copy.eventDetail.timeZoneNote}
</p>
)}
</div>
</aside>
</div>
</PageShell>
);
}
/* ---------------------------------------------------------------------------
REGISTRATION SECTION
Its own component so the page above stays readable - the state machine is
small but real: closed, elsewhere, past, waitlist, open, acknowledged.
------------------------------------------------------------------------ */
function RegistrationSection({
detailPath,
canRegister,
isPast,
waitlist,
result,
isSubmitting,
fieldError,
values,
eventTitle,
eventDay,
registrationRequired,
recapUrl,
}: {
detailPath: string;
canRegister: boolean;
isPast: boolean;
waitlist: boolean;
result: ActionResult | undefined;
isSubmitting: boolean;
fieldError: (name: string) => string | undefined;
values: RegistrationValues | undefined;
eventTitle: string;
eventDay: string;
registrationRequired: boolean;
recapUrl?: string;
}) {
const { t, locale } = useTranslation();
const copy = getRouteCopy(locale);
if (result?.ok) {
return (
<Notice
tone="accent"
role="status"
title={
result.waitlist
? copy.eventDetail.waitlistConfirmationTitle
: t("events.confirmationTitle")
}
>
{result.waitlist
? interpolate(copy.eventDetail.waitlistConfirmationBody, {
event: eventTitle,
date: eventDay,
})
: t("events.confirmationBody", { event: eventTitle, date: eventDay })}
</Notice>
);
}
if (isPast) {
return (
<Notice tone="info" title={copy.eventDetail.eventCompleted}>
{/* `safeHref` gates the CMS URL; without a usable one the notice
keeps its text and simply carries no button. */}
{safeHref(recapUrl) ? (
<Button
as="a"
href={safeHref(recapUrl)}
variant="secondary"
size="sm"
iconEnd={<ArrowIcon />}
>
{t("actions.readRecap")}
</Button>
) : null}
</Notice>
);
}
if (!registrationRequired) {
return (
<Notice
tone="info"
title={copy.eventDetail.registrationNotRequired}
>
{copy.eventDetail.registrationNotRequiredNote}
</Notice>
);
}
if (!canRegister) {
return <Notice tone="info" title={copy.eventDetail.registrationClosed} />;
}
return (
<section
id="register"
aria-labelledby="register-heading"
className="flex flex-col gap-lg rounded-card border border-border-subtle bg-surface-raised p-lg"
>
<div className="flex flex-col gap-xs">
<h2 id="register-heading" className="heading-h3 text-text-primary">
{waitlist
? copy.eventDetail.waitlistHeading
: copy.eventDetail.registerHeading}
</h2>
<p className="body-sm text-text-secondary">
{waitlist
? copy.eventDetail.registrationFull
: copy.eventDetail.formIntro}
</p>
</div>
{result && !result.ok && result.failed ? (
<Notice tone="danger" role="alert" title={t("form.errorGeneric")}>
{copy.form.submissionFailed}
</Notice>
) : null}
{/* A plain POST to this same URL: no fetcher, no client validation, and
therefore nothing that stops working with scripting off. */}
<Form method="post" action={detailPath} className="flex flex-col gap-md">
<div className="grid gap-md sm:grid-cols-2">
<Input
name="fullName"
label={t("form.name")}
required
autoComplete="name"
defaultValue={values?.fullName}
error={fieldError("fullName")}
/>
<Input
name="email"
type="email"
label={t("form.email")}
required
autoComplete="email"
inputMode="email"
defaultValue={values?.email}
error={fieldError("email")}
/>
<Input
name="organisation"
label={t("form.organisation")}
hint={t("form.optional")}
autoComplete="organization"
defaultValue={values?.organisation}
/>
<Input
name="phone"
type="tel"
label={t("form.phone")}
hint={t("form.optional")}
autoComplete="tel"
defaultValue={values?.phone}
/>
</div>
{/* REQUIRED by the CMS, and asked for out loud: an unticked box is a
validation error, never a silent default. */}
<ConsentCheckbox
name={CONSENT_FIELD}
label={copy.eventDetail.consentLabel}
hint={copy.eventDetail.consentHint}
defaultChecked={values?.consentGdpr ?? false}
{...(fieldError(CONSENT_FIELD)
? { error: fieldError(CONSENT_FIELD) }
: {})}
/>
{/* Honeypot. Hidden from sight and from assistive technology alike. */}
<div aria-hidden="true" className="hidden">
<label htmlFor={HONEYPOT_FIELD}>{copy.form.honeypotLabel}</label>
<input
id={HONEYPOT_FIELD}
name={HONEYPOT_FIELD}
type="text"
tabIndex={-1}
autoComplete="off"
/>
</div>
<div className="flex flex-wrap items-center gap-sm">
<Button
type="submit"
variant="primary"
isLoading={isSubmitting}
iconEnd={<ArrowIcon />}
>
{waitlist ? t("events.joinWaitlist") : t("actions.register")}
</Button>
</div>
</Form>
</section>
);
}
+253
View File
@@ -0,0 +1,253 @@
/* ============================================================================
EVENTS CALENDAR - /{locale}/events-calendar
----------------------------------------------------------------------------
Draft section 4. The listing the draft describes:
filter tabs All - Upcoming - Ongoing - Completed, plus a region filter
card fields title - date and time - format/location - registration
status - call to action (Register / Apply / Read recap)
Every filter is a real URL (?status=&region=&page=), so a filtered calendar
is shareable, survives the back button and is reachable by a crawler that
only follows links. There is no client-side filtering to fall out of step
with the server, and the page works with scripting off.
Upcoming / Ongoing / Completed are DERIVED from the dates rather than stored
(see ./_events-opportunities/events.ts), so nothing goes stale the morning
after an event.
EMPTY BY DESIGN: the CMS is empty today, so the state that actually renders
is the localised "no events yet" panel - and if Strapi cannot be reached at
all, the page still renders, with a notice, instead of a 500.
========================================================================= */
import { Link, useSearchParams } from "react-router";
import { Button } from "~/components/ui/Button";
import {
localizedPath,
sectionPath,
translate,
useTranslation,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/events-calendar";
import {
collectRegions,
EVENT_STATUS_FILTERS,
eventTimeStatusKey,
filterEvents,
sortEvents,
} from "./_events-opportunities/events";
import { fetchEvents } from "./_events-opportunities/events.server";
import { getRouteCopy } from "./_events-opportunities/copy";
import { readRootSeo } from "./_events-opportunities/root-seo";
import { EventCard, Notice } from "./_events-opportunities/ui";
import {
buildPagination,
PAGE_SIZE,
parseEnum,
parsePage,
requireLocale,
withParam,
} from "./_shared/listing";
import {
EmptyState,
PageHeader,
PageShell,
Pagination,
ResultSummary,
TabLinks,
} from "./_shared/listing-ui";
/** Longest region value we will echo back into a filter. */
const MAX_REGION_LENGTH = 80;
export async function loader({ params, request }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const url = new URL(request.url);
const now = new Date();
const status = parseEnum(
url.searchParams,
"status",
EVENT_STATUS_FILTERS,
"all",
);
const { events, available } = await fetchEvents(locale, now);
/* Region options come from the events themselves, never from a hard-coded
list: an empty CMS offers no regions, and a region only appears once
something is actually happening in it. A requested region that no event
carries is dropped rather than rendering an empty page. */
const regions = collectRegions(events, locale);
const requestedRegion = url.searchParams
.get("region")
?.trim()
.slice(0, MAX_REGION_LENGTH);
const region =
requestedRegion && regions.includes(requestedRegion) ? requestedRegion : null;
const matching = sortEvents(filterEvents(events, { status, region }));
const pagination = buildPagination(
parsePage(url.searchParams),
PAGE_SIZE,
matching.length,
);
const start = (pagination.page - 1) * PAGE_SIZE;
return {
locale,
events: matching.slice(start, start + PAGE_SIZE),
pagination,
status,
region,
regions,
available,
/** Distinguishes "the CMS is empty" from "these filters match nothing". */
hasEvents: events.length > 0,
};
}
export function meta({ location, matches }: Route.MetaArgs) {
const root = readRootSeo(matches);
const copy = getRouteCopy(root.locale);
const search = new URLSearchParams(location.search);
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: translate(root.locale, "nav.sections.events"),
description: copy.events.metaDescription,
// A filtered or paginated view is the same content in another order.
noIndex:
search.has("status") || search.has("region") || search.has("page"),
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{
name: translate(root.locale, "nav.sections.home"),
pathname: localizedPath("/", root.locale),
},
{
name: translate(root.locale, "nav.sections.events"),
pathname: sectionPath("events", root.locale),
},
]),
});
}
export default function EventsCalendar({ loaderData }: Route.ComponentProps) {
const { events, pagination, status, region, regions, available, hasEvents } =
loaderData;
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const [searchParams] = useSearchParams();
const pathname = sectionPath("events", locale);
const href = (key: string, value: string | undefined) =>
`${pathname}${withParam(searchParams, key, value)}`;
const tabs = EVENT_STATUS_FILTERS.map((value) => ({
id: value,
label: value === "all" ? t("filters.all") : t(eventTimeStatusKey(value)),
href: href("status", value === "all" ? undefined : value),
isCurrent: status === value,
}));
const isFiltered = status !== "all" || region !== null;
return (
<PageShell>
<PageHeader heading={copy.events.heading} intro={copy.events.intro} />
{available ? null : (
<Notice tone="info" role="status">
{copy.events.unavailable}
</Notice>
)}
<div className="flex flex-col gap-lg">
<TabLinks label={copy.events.statusFilterLabel} tabs={tabs} />
{regions.length > 0 ? (
<nav aria-label={t("filters.region")} className="flex flex-col gap-xs">
<p className="label-md uppercase text-text-tertiary">
{t("filters.region")}
</p>
<ul className="flex flex-wrap items-center gap-xs">
<li>
<Button
as={Link}
to={href("region", undefined)}
prefetch="intent"
variant={region === null ? "secondary" : "ghost"}
size="sm"
aria-current={region === null ? "true" : undefined}
>
{t("filters.all")}
</Button>
</li>
{regions.map((value) => (
<li key={value}>
<Button
as={Link}
to={href("region", value)}
prefetch="intent"
variant={region === value ? "secondary" : "ghost"}
size="sm"
aria-current={region === value ? "true" : undefined}
>
{value}
</Button>
</li>
))}
</ul>
</nav>
) : null}
<ResultSummary pagination={pagination} t={t} />
</div>
{events.length > 0 ? (
<>
<ul
aria-label={copy.events.listLabel}
className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3"
>
{events.map((event) => (
<EventCard
key={event.slug}
as="li"
event={event}
href={`${pathname}/${encodeURIComponent(event.slug)}`}
/>
))}
</ul>
<Pagination
model={pagination}
pathname={pathname}
params={searchParams}
t={t}
/>
</>
) : hasEvents && isFiltered ? (
<EmptyState
title={t("filters.noResults")}
body={t("filters.noResultsHint")}
>
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
</EmptyState>
) : (
<EmptyState
title={t("events.noEvents")}
body={copy.events.emptyHint}
/>
)}
</PageShell>
);
}
+341
View File
@@ -0,0 +1,341 @@
/* ============================================================================
HOME - /en and /ar
----------------------------------------------------------------------------
Figma frame 253:4799 ("Home-en"), which is laid out as:
Hero header (253:4800) headline + CTA group + wide picture
Stats hidden in the frame, and every figure in the
draft copy is marked TBD - so the band renders a
localised "not yet confirmed" note and only shows
numbers the CMS supplies
Offical launch (361:10853) "The Journey of the SHARP-ER Launch", the
launch story, the photo strip and the three
captions under it
Partners (393:14754) "Meet Our Partners" logo grid
Footer owned by the app shell, not by this module
The launch story is the approved copy from docs/SHARPER Home Page .docx in
both languages; the pictures, the figures and the partner list come from
Strapi and degrade to a localised empty state while it is empty.
========================================================================= */
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { createTranslator, getMessages, sectionPath } from "~/i18n";
import {
buildMeta,
buildOrganizationJsonLd,
buildWebSiteJsonLd,
} from "~/lib/seo";
import { safeHref } from "~/lib/safe-href";
import type { Route } from "./+types/home";
import {
isImageGalleryBlock,
type CmsHomePage,
type CmsPartner,
} from "./_page-kit/cms-types";
import {
list,
loadMany,
loadSingle,
toImage,
toPartner,
type ResolvedImage,
} from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import {
EmptyState,
PageHero,
PageSection,
SectionHeader,
StatStrip,
} from "./_page-kit/sections";
/** Partner logos and CSO tiles - a wide but finite grid. */
const PARTNER_PAGE_SIZE = 36;
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const page = await loadSingle<CmsHomePage>("home-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
stats: true,
// Strapi 5 populates a dynamic zone per component with `on`.
blocks: { on: { "blocks.image-gallery": { populate: { images: true } } } },
partners: { populate: { logo: true, logoDark: true } },
seo: { populate: { ogImage: true } },
},
});
/* The curated relation wins; otherwise show every partner the CMS knows, so
the grid fills in as soon as partners exist - before anyone remembers to
curate the home page itself. */
const curated = list(page?.partners);
const partners =
curated.length > 0
? curated
: await loadMany<CmsPartner>("partners", {
locale,
sort: ["name:asc"],
filters: { partnerRole: { $in: ["cso-partner", "institution"] } },
populate: { logo: true, logoDark: true },
pagination: { pageSize: PARTNER_PAGE_SIZE },
});
const gallery = list(page?.blocks)
.filter(isImageGalleryBlock)
.flatMap((block) => list(block.images))
.map((image) => toImage(image, "medium"))
.filter((image): image is ResolvedImage => image !== undefined);
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
ctas: list(page?.hero?.ctas).map((cta) => ({
label: cta.label,
href: cta.href,
variant: cta.variant ?? "primary",
})),
},
stats: list(page?.stats).map((stat) => ({
value: stat.value,
label: stat.label,
})),
gallery,
partnersHeadline: page?.partnersHeadline ?? null,
partners: partners.map(toPartner),
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const messages = getMessages(locale);
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: loaderData?.seo.title ?? messages.site.metaTitle,
description: loaderData?.seo.description ?? messages.site.description,
...(loaderData?.seo.image
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
: {}),
noIndex: loaderData?.seo.noIndex ?? false,
// Emitted once for the whole site, from its home page.
jsonLd: [
buildOrganizationJsonLd({ siteUrl: root.siteUrl, locale }),
buildWebSiteJsonLd({ siteUrl: root.siteUrl, locale }),
],
});
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { locale, hero, stats, gallery, partners } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).home;
const headline = hero.headline ?? copy.heroHeadline;
const lead = hero.subheading ?? t("site.description");
const partnersHeadline = loaderData.partnersHeadline ?? copy.partnersHeading;
/* The three captions under the photo strip in Figma. Each one points at the
section of the site that carries that strand of work forward, and every
label is a catalogue string so /ar never inherits an English one. */
const mechanisms = [
{
title: copy.mechanisms[0]!,
href: sectionPath("whatWeDo", locale),
actionLabel: t("actions.learnWhatWeDo"),
},
{
title: copy.mechanisms[1]!,
href: sectionPath("activities", locale),
actionLabel: t("actions.seeActivities"),
},
{
title: copy.mechanisms[2]!,
href: sectionPath("applications", locale),
actionLabel: t("actions.seeCurrentCalls"),
},
];
return (
<>
<PageHero
title={headline}
{...(hero.eyebrow ? { eyebrow: hero.eyebrow } : {})}
lead={lead}
{...(hero.media ? { media: hero.media } : {})}
actions={
hero.ctas.length > 0 ? (
hero.ctas.map((cta) => (
<Button
key={`${cta.href}-${cta.label}`}
as={Link}
to={cta.href}
prefetch="intent"
size="lg"
variant={cta.variant}
iconEnd={cta.variant === "primary" ? <ArrowIcon /> : undefined}
>
{cta.label}
</Button>
))
) : (
<>
<Button
as={Link}
to={sectionPath("about", locale)}
prefetch="intent"
size="lg"
variant="primary"
iconEnd={<ArrowIcon />}
>
{t("actions.learnMore")}
</Button>
<Button
as={Link}
to={sectionPath("whatWeDo", locale)}
prefetch="intent"
size="lg"
variant="secondary"
>
{t("actions.learnWhatWeDo")}
</Button>
</>
)
}
/>
{/* FIGURES - never invented here. The draft fact sheet marks them TBD. */}
<PageSection aria-labelledby="home-stats-heading" className="py-2xl md:py-3xl">
<div className="flex flex-col gap-lg">
<SectionHeader id="home-stats-heading" title={copy.statsHeading} />
{stats.length > 0 ? (
<StatStrip stats={stats} />
) : (
<EmptyState message={copy.statsPending} />
)}
</div>
</PageSection>
{/* THE LAUNCH STORY - approved copy, EN and AR. */}
<PageSection aria-labelledby="home-launch-heading">
<div className="flex flex-col gap-2xl">
<SectionHeader
id="home-launch-heading"
title={copy.launchHeading}
align="center"
/>
<div className="mx-auto flex w-full max-w-[var(--layout-prose-max)] flex-col gap-md">
{copy.launchParagraphs.map((paragraph) => (
<p key={paragraph.slice(0, 32)} className="body-md text-[var(--text-secondary)]">
{paragraph}
</p>
))}
<p className="body-sm-bold text-[var(--text-accent)]">{copy.fundingNote}</p>
</div>
{/* Figma prints these three captions under the photo strip with no
heading of their own; the heading exists for the outline only. */}
<h3 id="home-mechanisms-heading" className="sr-only">
{copy.mechanismsHeading}
</h3>
<ul
aria-labelledby="home-mechanisms-heading"
className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3"
>
{mechanisms.map((mechanism, index) => (
<Card.Content
key={mechanism.title}
as="li"
titleAs="h4"
href={mechanism.href}
title={mechanism.title}
actionLabel={mechanism.actionLabel}
{...(gallery[index]
? {
image: {
src: gallery[index]!.src,
alt: gallery[index]!.alt,
},
}
: {})}
/>
))}
</ul>
</div>
</PageSection>
{/* PARTNERS */}
<PageSection aria-labelledby="home-partners-heading">
<div className="flex flex-col gap-2xl">
<SectionHeader
id="home-partners-heading"
title={partnersHeadline}
align="center"
/>
{partners.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{partners.map((partner) => (
<li
key={partner.id}
className="flex items-center gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-md"
>
{partner.logo ? (
<img
src={partner.logo.src}
alt=""
loading="lazy"
className="size-12 shrink-0 rounded-control object-contain"
/>
) : null}
{/* An editor-supplied URL: `safeHref` refuses anything
that is not http/https/mailto/tel, and the plain-text
branch below already renders the partner's name. */}
{safeHref(partner.website) ? (
<a
href={safeHref(partner.website)}
target="_blank"
rel="noopener noreferrer"
className="body-sm-bold text-[var(--text-primary)] underline-offset-2 hover:underline"
>
{partner.name}
</a>
) : (
<span className="body-sm-bold text-[var(--text-primary)]">
{partner.name}
</span>
)}
</li>
))}
</ul>
) : (
<EmptyState message={copy.partnersEmpty} />
)}
</div>
</PageSection>
</>
);
}
+292
View File
@@ -0,0 +1,292 @@
/* ============================================================================
KNOWLEDGE HUB - one item
----------------------------------------------------------------------------
/{locale}/knowledge-hub/{slug}
Registered in app/routes.ts as "knowledgeHubDetail", a SIBLING of the
listing route under the ":lang" layout, and typed against its generated
`./+types/` module.
A missing slug is a 404. A CMS outage is not: `fetchBySlug` only converts
Strapi's own 404 into null and rethrows everything else.
========================================================================= */
import { useLoaderData } from "react-router";
import { Button } from "~/components/ui/Button";
import {
formatDate,
formatFileSize,
sectionPath,
toIsoDate,
useTranslation,
type Locale,
} from "~/i18n";
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { requireEntity } from "~/lib/strapi";
import type { Route } from "./+types/knowledge-hub.$slug";
import {
collectLocalizedSlugs,
detailAlternateDescriptors,
LOCALIZATION_POPULATE,
SELF_ALTERNATES_HANDLE,
} from "./_shared/alternates";
import {
CMS,
fetchBySlug,
toFile,
toImage,
type CmsFile,
type KnowledgeEntry,
} from "./_shared/cms";
import { readRootSeo, requireLocale, withParam } from "./_shared/listing";
import {
BackLink,
MetaLine,
MetaList,
PageHeader,
PageShell,
TopicChips,
type MetaItem,
} from "./_shared/listing-ui";
import { RichText } from "./_shared/RichText";
import {
DETAIL_COPY,
KNOWLEDGE_COPY,
KNOWLEDGE_DETAIL_COPY,
KNOWLEDGE_TYPE_BLURBS,
KNOWLEDGE_TYPE_LABELS,
} from "./_shared/page-copy";
function typeLabel(value: string, locale: Locale): string {
return KNOWLEDGE_TYPE_LABELS[locale][value] ?? value;
}
/**
* Strapi 5 gives the Arabic translation its own slug, so this route emits its
* own canonical + hreflang - see ./_shared/alternates.ts.
*/
export const handle = SELF_ALTERNATES_HANDLE;
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const entry = requireEntity(
await fetchBySlug<KnowledgeEntry>(CMS.knowledgeItems, slug, {
locale,
populate: {
thumbnail: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
// Multiple media, and the only download this type has: there is no
// `externalUrl` field on a knowledge item.
attachments: { fields: ["url", "name", "ext", "mime", "size"] },
topics: { fields: ["name", "slug"] },
localizations: LOCALIZATION_POPULATE,
},
}),
);
const attachments = (entry.attachments ?? [])
.map((media) => toFile(media))
.filter((file): file is CmsFile => file !== undefined)
.map((file) => ({
...file,
label: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}`,
}));
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
return {
slug: entry.slug,
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
title: entry.title,
summary: entry.summary ?? undefined,
body: entry.body ?? undefined,
typeLabel: entry.itemType ? typeLabel(entry.itemType, locale) : undefined,
typeBlurb: entry.itemType
? KNOWLEDGE_TYPE_BLURBS[locale][entry.itemType]
: undefined,
date,
dateIso: toIsoDate(date),
dateLabel: date ? formatDate(date, locale) : undefined,
modifiedIso: toIsoDate(entry.updatedAt),
topics: (entry.topics ?? [])
.filter((topic) => Boolean(topic?.slug && topic?.name))
.map((topic) => ({ label: topic.name, slug: topic.slug })),
cover: toImage(entry.thumbnail, "large"),
attachments,
};
}
/* ---------------------------------------------------------------------------
META
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const entry = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
const section = KNOWLEDGE_COPY[root.locale];
if (!entry) {
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: section.heading,
description: section.metaDescription,
});
}
const descriptors = buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: entry.title,
description: entry.summary ?? entry.typeBlurb ?? section.metaDescription,
type: "article",
...(entry.cover ? { image: { url: entry.cover.src, alt: entry.cover.alt } } : {}),
...(entry.dateIso
? {
article: {
publishedTime: entry.dateIso,
...(entry.modifiedIso ? { modifiedTime: entry.modifiedIso } : {}),
...(entry.typeLabel ? { section: entry.typeLabel } : {}),
tags: entry.topics.map((topic) => topic.label),
},
}
: {}),
jsonLd: [
buildBreadcrumbJsonLd(root.siteUrl, [
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
{ name: section.heading, pathname: sectionPath("knowledgeHub", root.locale) },
{ name: entry.title, pathname: root.pathname },
]),
...(entry.dateIso
? [
buildArticleJsonLd({
siteUrl: root.siteUrl,
locale: root.locale,
headline: entry.title,
pathname: root.pathname,
datePublished: entry.dateIso,
...(entry.modifiedIso ? { dateModified: entry.modifiedIso } : {}),
...(entry.summary ? { description: entry.summary } : {}),
...(entry.cover ? { image: entry.cover.src } : {}),
...(entry.typeLabel ? { section: entry.typeLabel } : {}),
keywords: entry.topics.map((topic) => topic.label),
}),
]
: []),
],
});
return [
...descriptors,
...detailAlternateDescriptors({
siteUrl: root.siteUrl,
section: "knowledgeHub",
locale: root.locale,
slugs: entry.alternates,
}),
];
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function KnowledgeItemDetail() {
const entry = useLoaderData<typeof loader>();
const { t, locale } = useTranslation();
const listingPath = sectionPath("knowledgeHub", locale);
const detail = KNOWLEDGE_DETAIL_COPY[locale];
const shared = DETAIL_COPY[locale];
const metaItems: MetaItem[] = [
...(entry.dateLabel
? [
{
label: shared.published,
value: <time dateTime={entry.dateIso ?? entry.date}>{entry.dateLabel}</time>,
},
]
: []),
...(entry.typeLabel
? [{ label: t("filters.category"), value: entry.typeLabel }]
: []),
];
return (
<PageShell>
<BackLink to={listingPath} label={detail.backToListing} />
<article className="flex flex-col gap-2xl">
<PageHeader
eyebrow={entry.typeLabel}
heading={entry.title}
intro={entry.summary}
>
<MetaLine items={[entry.dateLabel ?? ""]} />
</PageHeader>
{entry.cover ? (
<img
src={entry.cover.src}
alt={entry.cover.alt}
width={entry.cover.width}
height={entry.cover.height}
decoding="async"
className="w-full rounded-card border border-border-subtle object-cover"
/>
) : null}
<div className="grid gap-2xl lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-lg">
<RichText value={entry.body} />
{entry.attachments.length > 0 ? (
<ul className="flex list-none flex-col gap-md">
{entry.attachments.map((file) => (
<li
key={file.url}
className="flex flex-wrap items-center gap-md"
>
<Button
as="a"
href={file.url}
variant="primary"
download={file.name}
>
{t("actions.download")}
</Button>
<span className="body-sm text-text-tertiary">
{file.name} · {file.label}
</span>
</li>
))}
</ul>
) : null}
</div>
<aside className="flex flex-col gap-lg">
<MetaList heading={detail.aboutThis} items={metaItems} />
<TopicChips
heading={shared.topics}
topics={entry.topics.map((topic) => ({
label: topic.label,
href: `${listingPath}${withParam(new URLSearchParams(), "topic", topic.slug)}`,
}))}
/>
</aside>
</div>
</article>
</PageShell>
);
}
+309
View File
@@ -0,0 +1,309 @@
/* ============================================================================
KNOWLEDGE HUB - listing
----------------------------------------------------------------------------
/{locale}/knowledge-hub
§7 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: training modules,
toolkits, Civil Society Law Session guides, case studies and FAQs. Browsing
is by type, by tag and by free-text search, all three held in the query
string so a browse is a shareable, crawlable URL:
?type=case-study&topic=access-to-justice&q=municipality
The ?type= values are the `knowledge-item.itemType` enumeration in the CMS,
verbatim, so the URL and the Strapi filter cannot drift apart.
Tags are the shared `topics` taxonomy - the same vocabulary the resources
listing and the news archive filter on - so a tag means the same thing
everywhere on the site instead of drifting per section.
While the hub is empty the four type cards still describe what will land in
it, using the draft's own one-line descriptions; nothing is invented and no
count is shown.
========================================================================= */
import { Link, useLocation } from "react-router";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import {
formatDate,
sectionPath,
useTranslation,
type Locale,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/knowledge-hub";
import {
CMS,
KNOWLEDGE_TYPES,
fetchCollection,
fetchFilterOptions,
toImage,
type CmsImage,
type KnowledgeEntry,
} from "./_shared/cms";
import {
PAGE_SIZE,
buildPagination,
compactFilters,
parseOptionalEnum,
parsePage,
parseQuery,
parseSlug,
readRootSeo,
requireLocale,
withParam,
} from "./_shared/listing";
import {
EmptyState,
FilterBar,
FilterSearch,
FilterSelect,
PageHeader,
PageShell,
Pagination,
ResultSummary,
} from "./_shared/listing-ui";
import {
KNOWLEDGE_COPY,
KNOWLEDGE_TYPE_BLURBS,
KNOWLEDGE_TYPE_LABELS,
} from "./_shared/page-copy";
const DATE_FIELD = "publicationDate";
/** The label for one `itemType`, or the raw value if a new one is added. */
function typeLabel(value: string, locale: Locale): string {
return KNOWLEDGE_TYPE_LABELS[locale][value] ?? value;
}
interface KnowledgeListItem {
readonly id: string;
readonly slug: string;
readonly title: string;
readonly summary?: string;
readonly typeLabel?: string;
readonly dateLabel?: string;
readonly image?: CmsImage;
}
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
export async function loader({ params, request }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const search = new URL(request.url).searchParams;
const page = parsePage(search);
const type = parseOptionalEnum(search, "type", KNOWLEDGE_TYPES);
const topic = parseSlug(search, "topic");
const query = parseQuery(search);
const filters = compactFilters({
itemType: type ? { $eq: type } : undefined,
topics: topic ? { slug: { $eq: topic } } : undefined,
// Free text hits the title and the summary; the body is deliberately left
// out so a passing mention does not outrank a real match.
$or: query
? [{ title: { $containsi: query } }, { summary: { $containsi: query } }]
: undefined,
});
const [listing, topicOptions] = await Promise.all([
fetchCollection<KnowledgeEntry>(CMS.knowledgeItems, {
locale,
filters,
sort: [`${DATE_FIELD}:desc`, "title:asc"],
populate: {
thumbnail: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
topics: { fields: ["name", "slug"] },
},
pagination: { page, pageSize: PAGE_SIZE, withCount: true },
}),
fetchFilterOptions(CMS.topics, locale),
]);
const entries: KnowledgeListItem[] = listing.entries.map((entry) =>
toListItem(entry, locale),
);
return {
entries,
pagination: buildPagination(page, PAGE_SIZE, listing.pagination.total),
available: listing.available,
filters: {
type: type ?? "",
topic: topic ?? "",
q: query ?? "",
},
facets: {
types: KNOWLEDGE_TYPES.map((value) => ({
value,
label: typeLabel(value, locale),
})),
topics: topicOptions,
},
/** The five type cards, with the draft's descriptions. */
browse: KNOWLEDGE_TYPES.map((value) => ({
value,
label: typeLabel(value, locale),
blurb: KNOWLEDGE_TYPE_BLURBS[locale][value] ?? "",
})),
};
}
function toListItem(entry: KnowledgeEntry, locale: Locale): KnowledgeListItem {
const image = toImage(entry.thumbnail, "small");
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
return {
id: entry.documentId,
slug: entry.slug,
title: entry.title,
...(entry.summary ? { summary: entry.summary } : {}),
...(entry.itemType ? { typeLabel: typeLabel(entry.itemType, locale) } : {}),
...(date ? { dateLabel: formatDate(date, locale) } : {}),
...(image ? { image } : {}),
};
}
/* ---------------------------------------------------------------------------
META
Canonical + hreflang are emitted by the shell for every route, so they are
not repeated here - see root.tsx `Layout`.
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const copy = KNOWLEDGE_COPY[root.locale];
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: copy.heading,
description: copy.metaDescription,
// If the CMS could not be reached the page is an empty shell, not an empty
// section - keep it out of the index rather than teaching a crawler that
// SHARP-ER has published nothing.
noIndex: loaderData?.available === false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
{ name: copy.heading, pathname: sectionPath("knowledgeHub", root.locale) },
]),
});
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function KnowledgeHub({ loaderData }: Route.ComponentProps) {
const { entries, pagination, filters, facets, browse } = loaderData;
const { t, locale } = useTranslation();
const { pathname, search } = useLocation();
const params = new URLSearchParams(search);
const copy = KNOWLEDGE_COPY[locale];
const isFiltered = Boolean(filters.type || filters.topic || filters.q);
return (
<PageShell>
<PageHeader heading={copy.heading} intro={copy.intro} />
{/* The map of the hub. Hidden once a filter is on, where it would just
repeat the control the reader has already used. */}
{!isFiltered ? (
<nav aria-label={t("nav.sections.knowledgeHub")}>
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{browse.map((item) => (
<Card.Content
key={item.value}
as="li"
titleAs="h2"
href={`${pathname}${withParam(new URLSearchParams(), "type", item.value)}`}
title={item.label}
excerpt={item.blurb}
actionLabel={t("actions.browseHub")}
/>
))}
</ul>
</nav>
) : null}
<section className="flex flex-col gap-lg" aria-label={t("filters.heading")}>
<FilterBar
legend={t("filters.heading")}
action={pathname}
applyLabel={t("filters.apply")}
clearLabel={t("filters.clear")}
clearHref={pathname}
showClear={isFiltered}
>
<FilterSearch
label={t("search.label")}
placeholder={t("search.placeholder")}
defaultValue={filters.q}
/>
<FilterSelect
name="type"
label={t("filters.category")}
value={filters.type}
options={facets.types}
allLabel={t("filters.all")}
/>
<FilterSelect
name="topic"
label={t("filters.topic")}
value={filters.topic}
options={facets.topics}
allLabel={t("filters.all")}
/>
</FilterBar>
{filters.q ? (
<p className="body-md text-text-secondary">
{t("search.resultsFor", { query: filters.q })}
</p>
) : null}
<ResultSummary pagination={pagination} t={t} />
{entries.length === 0 ? (
<EmptyState
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
>
{isFiltered ? (
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{entries.map((entry) => (
<Card.Content
key={entry.id}
as="li"
href={`${sectionPath("knowledgeHub", locale)}/${entry.slug}`}
title={entry.title}
actionLabel={t("actions.readMore")}
{...(entry.typeLabel ? { eyebrow: entry.typeLabel } : {})}
{...(entry.summary ? { excerpt: entry.summary } : {})}
{...(entry.dateLabel ? { badge: entry.dateLabel } : {})}
{...(entry.image ? { image: entry.image } : {})}
/>
))}
</ul>
)}
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
</section>
</PageShell>
);
}
+47
View File
@@ -0,0 +1,47 @@
/* ============================================================================
":lang" LAYOUT
----------------------------------------------------------------------------
Owned by the app shell, not by a page agent.
One route tree serves both languages: every page module below this layout is
rendered for /en/... and /ar/... alike, and reads its locale from
`useLocale()` (or from this route's loader data via
`useRouteLoaderData("locale")`).
The layout itself renders nothing but the outlet - the header, the footer
and <html lang/dir> live in the root route, above it - so its whole job is
validating the segment:
/en, /ar -> render
/EN, /Ar -> 301 to the lower-case canonical URL
/fr, /xx -> 404 through the root ErrorBoundary
========================================================================= */
import { data, Outlet, redirect } from "react-router";
import { dirFor, isLocale } from "~/i18n/config";
import type { Route } from "./+types/locale-layout";
export function loader({ params, request }: Route.LoaderArgs) {
const segment = params.lang;
const normalized = segment?.toLowerCase();
if (!isLocale(normalized)) {
throw data("Not Found", { status: 404, statusText: "Not Found" });
}
if (normalized !== segment) {
const url = new URL(request.url);
url.pathname = url.pathname.replace(
/^\/[^/]+/,
`/${normalized}`,
);
throw redirect(`${url.pathname}${url.search}`, 301);
}
return { locale: normalized, dir: dirFor(normalized) };
}
export default function LocaleLayout() {
return <Outlet />;
}
+33
View File
@@ -0,0 +1,33 @@
/* ============================================================================
BARE "/" - ACCEPT-LANGUAGE REDIRECT
----------------------------------------------------------------------------
Owned by the app shell, not by a page agent.
Every page URL carries an explicit locale prefix, so "/" itself is not a
page: it negotiates the reader's language once and sends them to /en or /ar.
`Vary: Accept-Language` keeps a CDN from caching one reader's language for
everybody, and 302 (not 301) keeps the negotiation live if the header
changes.
========================================================================= */
import { redirect } from "react-router";
import { negotiateLocaleFromRequest } from "~/i18n/config";
import { localizedPath } from "~/i18n/paths";
import type { Route } from "./+types/locale-redirect";
export function loader({ request }: Route.LoaderArgs) {
const { search } = new URL(request.url);
const locale = negotiateLocaleFromRequest(request);
return redirect(`${localizedPath("/", locale)}${search}`, {
status: 302,
headers: { Vary: "Accept-Language" },
});
}
/** Never rendered - the loader always redirects. */
export default function LocaleRedirect() {
return null;
}
@@ -0,0 +1,264 @@
/* ============================================================================
NEWS AND UPDATES - one article
----------------------------------------------------------------------------
/{locale}/news-and-updates/{slug}
Registered in app/routes.ts as "newsDetail", a SIBLING of the listing route
under the ":lang" layout, and typed against its generated `./+types/` module.
A "From the Network" update credits the submitting organisation on the page
and in the Article JSON-LD, because the draft is explicit that network
updates are attributed to the CSO that wrote them - the project must not
appear to be the author of somebody else's work.
========================================================================= */
import { Link, useLoaderData } from "react-router";
import { Button } from "~/components/ui/Button";
import {
formatDate,
sectionPath,
toIsoDate,
useTranslation,
} from "~/i18n";
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { requireEntity } from "~/lib/strapi";
import type { Route } from "./+types/news-and-updates.$slug";
import {
collectLocalizedSlugs,
detailAlternateDescriptors,
LOCALIZATION_POPULATE,
SELF_ALTERNATES_HANDLE,
} from "./_shared/alternates";
import { CMS, fetchBySlug, toImage, type NewsEntry } from "./_shared/cms";
import { readRootSeo, requireLocale, withParam } from "./_shared/listing";
import {
BackLink,
MetaLine,
MetaList,
PageHeader,
PageShell,
TopicChips,
type MetaItem,
} from "./_shared/listing-ui";
import { RichText } from "./_shared/RichText";
import { DETAIL_COPY, NEWS_COPY, NEWS_DETAIL_COPY } from "./_shared/page-copy";
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
/**
* The Arabic translation of this article has its OWN slug, so the hreflang
* alternate cannot be built by swapping the locale prefix - see
* ./_shared/alternates.ts. `localizations` is populated for exactly that.
*/
export const handle = SELF_ALTERNATES_HANDLE;
export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const entry = requireEntity(
await fetchBySlug<NewsEntry>(CMS.newsArticles, slug, {
locale,
populate: {
coverImage: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
authorOrganisation: { fields: ["name", "slug"] },
topics: { fields: ["name", "slug"] },
localizations: LOCALIZATION_POPULATE,
},
}),
);
const date = entry.publishDate ?? entry.publishedAt ?? undefined;
return {
slug: entry.slug,
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
title: entry.title,
excerpt: entry.excerpt ?? undefined,
body: entry.body ?? undefined,
category: entry.category ?? "sharper-news",
organisation: entry.authorOrganisation?.name ?? undefined,
date,
dateIso: toIsoDate(date),
dateLabel: date ? formatDate(date, locale) : undefined,
modifiedIso: toIsoDate(entry.updatedAt),
topics: (entry.topics ?? [])
.filter((topic) => Boolean(topic?.slug && topic?.name))
.map((topic) => ({ label: topic.name, slug: topic.slug })),
cover: toImage(entry.coverImage, "large"),
};
}
/* ---------------------------------------------------------------------------
META
The shell emits a prefix-swapped canonical + hreflang pair for every route;
a DETAIL page overrides it, because a Strapi 5 localisation has its own
slug. `handle = SELF_ALTERNATES_HANDLE` is what tells the shell to stand
down for this route.
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const entry = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
const section = NEWS_COPY[root.locale];
if (!entry) {
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: section.heading,
description: section.metaDescription,
});
}
const sectionLabel =
entry.category === "from-the-network"
? section.networkBlurb
: section.projectBlurb;
const descriptors = buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: entry.title,
description: entry.excerpt ?? sectionLabel,
type: "article",
...(entry.cover ? { image: { url: entry.cover.src, alt: entry.cover.alt } } : {}),
...(entry.dateIso
? {
article: {
publishedTime: entry.dateIso,
...(entry.modifiedIso ? { modifiedTime: entry.modifiedIso } : {}),
...(entry.organisation ? { authors: [entry.organisation] } : {}),
tags: entry.topics.map((topic) => topic.label),
},
}
: {}),
jsonLd: [
buildBreadcrumbJsonLd(root.siteUrl, [
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
{ name: section.heading, pathname: sectionPath("news", root.locale) },
{ name: entry.title, pathname: root.pathname },
]),
...(entry.dateIso
? [
buildArticleJsonLd({
siteUrl: root.siteUrl,
locale: root.locale,
headline: entry.title,
pathname: root.pathname,
datePublished: entry.dateIso,
...(entry.modifiedIso ? { dateModified: entry.modifiedIso } : {}),
...(entry.excerpt ? { description: entry.excerpt } : {}),
...(entry.cover ? { image: entry.cover.src } : {}),
// A network update is authored by the CSO that submitted it.
...(entry.organisation ? { authorName: entry.organisation } : {}),
keywords: entry.topics.map((topic) => topic.label),
}),
]
: []),
],
});
/* The corrected canonical + hreflang pair, built from the counterpart's real
slug rather than from this URL with the prefix swapped. */
return [
...descriptors,
...detailAlternateDescriptors({
siteUrl: root.siteUrl,
section: "news",
locale: root.locale,
slugs: entry.alternates,
}),
];
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function NewsArticleDetail() {
const entry = useLoaderData<typeof loader>();
const { t, locale } = useTranslation();
const listingPath = sectionPath("news", locale);
const detail = NEWS_DETAIL_COPY[locale];
const shared = DETAIL_COPY[locale];
const streamLabel =
entry.category === "from-the-network"
? t("news.tabNetwork")
: t("news.tabProject");
const streamHref = `${listingPath}${withParam(new URLSearchParams(), "tab", entry.category)}`;
const metaItems: MetaItem[] = [
...(entry.dateLabel
? [
{
label: shared.published,
value: <time dateTime={entry.dateIso ?? entry.date}>{entry.dateLabel}</time>,
},
]
: []),
...(entry.organisation
? [{ label: shared.author, value: entry.organisation }]
: []),
];
return (
<PageShell>
<BackLink to={listingPath} label={detail.backToListing} />
<article className="flex flex-col gap-2xl">
<PageHeader eyebrow={streamLabel} heading={entry.title} intro={entry.excerpt}>
<MetaLine
items={[
entry.dateLabel ?? "",
entry.organisation
? t("news.postedBy", { organisation: entry.organisation })
: "",
]}
/>
</PageHeader>
{entry.cover ? (
<img
src={entry.cover.src}
alt={entry.cover.alt}
width={entry.cover.width}
height={entry.cover.height}
decoding="async"
className="w-full rounded-card border border-border-subtle object-cover"
/>
) : null}
<div className="grid gap-2xl lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-lg">
<RichText value={entry.body} />
<div>
<Button as={Link} to={streamHref} variant="secondary" size="sm">
{streamLabel}
</Button>
</div>
</div>
<aside className="flex flex-col gap-lg">
<MetaList heading={detail.aboutThis} items={metaItems} />
<TopicChips
heading={shared.topics}
topics={entry.topics.map((topic) => ({ label: topic.label }))}
/>
</aside>
</div>
</article>
</PageShell>
);
}
+253
View File
@@ -0,0 +1,253 @@
/* ============================================================================
NEWS AND UPDATES - listing
----------------------------------------------------------------------------
/{locale}/news-and-updates
§5 of docs/SHARPER_Website_Draft_Section_EN_AR.docx. Two streams, exactly as
the draft describes them:
Tab 1 SHARP-ER News ?tab=sharper-news official project updates
Tab 2 From the Network ?tab=from-the-network updates submitted by CSOs
granted as part of the consortium,
each card crediting the
organisation
The tab values are the `news-article.category` enumeration in the CMS,
verbatim, so the query string and the Strapi filter cannot drift apart.
The tabs are LINKS, not client-side state: each stream is its own URL, so it
can be shared, bookmarked and crawled, and the server renders exactly what
the address asks for. `aria-current="page"` marks the active one.
========================================================================= */
import { Link, useLocation } from "react-router";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { formatDate, sectionPath, useTranslation, type Locale } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/news-and-updates";
import {
CMS,
NEWS_CATEGORIES,
fetchCollection,
toImage,
type CmsImage,
type NewsCategory,
type NewsEntry,
} from "./_shared/cms";
import {
PAGE_SIZE,
buildPagination,
compactFilters,
parseEnum,
parsePage,
readRootSeo,
requireLocale,
withParam,
} from "./_shared/listing";
import {
EmptyState,
PageHeader,
PageShell,
Pagination,
ResultSummary,
TabLinks,
type TabLink,
} from "./_shared/listing-ui";
import { NEWS_COPY } from "./_shared/page-copy";
/** Tab 1 is the default stream, matching the order of the draft. */
const DEFAULT_TAB: NewsCategory = "sharper-news";
/** `news-article.publishDate` - required in the CMS, so it always sorts. */
const DATE_FIELD = "publishDate";
interface NewsListItem {
readonly id: string;
readonly slug: string;
readonly title: string;
readonly excerpt?: string;
readonly organisation?: string;
readonly dateLabel?: string;
readonly image?: CmsImage;
}
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
export async function loader({ params, request }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const search = new URL(request.url).searchParams;
const page = parsePage(search);
const tab = parseEnum(search, "tab", NEWS_CATEGORIES, DEFAULT_TAB);
const listing = await fetchCollection<NewsEntry>(CMS.newsArticles, {
locale,
filters: compactFilters({ category: { $eq: tab } }),
sort: [`${DATE_FIELD}:desc`, "title:asc"],
populate: {
coverImage: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
// A relation, not a string: without this the byline is simply absent.
authorOrganisation: { fields: ["name", "slug"] },
},
pagination: { page, pageSize: PAGE_SIZE, withCount: true },
});
const entries: NewsListItem[] = listing.entries.map((entry) =>
toListItem(entry, locale),
);
return {
entries,
tab,
pagination: buildPagination(page, PAGE_SIZE, listing.pagination.total),
available: listing.available,
};
}
function toListItem(entry: NewsEntry, locale: Locale): NewsListItem {
const image = toImage(entry.coverImage, "small");
const date = entry.publishDate ?? entry.publishedAt ?? undefined;
const organisation = entry.authorOrganisation?.name;
return {
id: entry.documentId,
slug: entry.slug,
title: entry.title,
...(entry.excerpt ? { excerpt: entry.excerpt } : {}),
...(organisation ? { organisation } : {}),
...(date ? { dateLabel: formatDate(date, locale) } : {}),
...(image ? { image } : {}),
};
}
/* ---------------------------------------------------------------------------
META
The canonical URL and the en/ar/x-default hreflang set are emitted for every
route by the shell (root.tsx `Layout`), so `alternates` stays off here -
passing it from a leaf would emit each of those links twice.
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const copy = NEWS_COPY[root.locale];
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: copy.heading,
description: copy.metaDescription,
// If the CMS could not be reached the page is an empty shell, not an empty
// section - keep it out of the index rather than teaching a crawler that
// SHARP-ER has published nothing.
noIndex: loaderData?.available === false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
{ name: copy.heading, pathname: sectionPath("news", root.locale) },
]),
});
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function NewsAndUpdates({ loaderData }: Route.ComponentProps) {
const { entries, pagination, tab } = loaderData;
const { t, locale } = useTranslation();
const { pathname, search } = useLocation();
const params = new URLSearchParams(search);
const copy = NEWS_COPY[locale];
const empty = new URLSearchParams();
const tabs: TabLink[] = [
{
id: "sharper-news",
label: t("news.tabProject"),
href: `${pathname}${withParam(empty, "tab", "sharper-news")}`,
isCurrent: tab === "sharper-news",
},
{
id: "from-the-network",
label: t("news.tabNetwork"),
href: `${pathname}${withParam(empty, "tab", "from-the-network")}`,
isCurrent: tab === "from-the-network",
},
];
return (
<PageShell>
<PageHeader heading={copy.heading} intro={copy.intro} />
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
<TabLinks label={t("nav.sections.news")} tabs={tabs} />
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
{tab === "from-the-network" ? copy.networkBlurb : copy.projectBlurb}
</p>
<ResultSummary pagination={pagination} t={t} />
{entries.length === 0 ? (
<EmptyState title={copy.emptyTitle} body={copy.emptyBody}>
{tab === "from-the-network" ? (
<Button
as={Link}
to={sectionPath("contact", locale)}
variant="secondary"
size="sm"
>
{t("actions.submitYourUpdate")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{entries.map((entry) => (
<Card.Content
key={entry.id}
as="li"
href={`${sectionPath("news", locale)}/${entry.slug}`}
title={entry.title}
actionLabel={t("actions.readMore")}
eyebrow={
entry.organisation
? t("news.postedBy", { organisation: entry.organisation })
: entry.dateLabel
}
{...(entry.excerpt ? { excerpt: entry.excerpt } : {})}
{...(entry.dateLabel && entry.organisation
? { badge: entry.dateLabel }
: {})}
{...(entry.image ? { image: entry.image } : {})}
/>
))}
</ul>
)}
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
</section>
{/* "Are you a partner organisation? Submit your update ->" - §5 of the
draft. It points at Contact, which is where a submission is handled
until the CMS grows a submission form. */}
<Card.CTA
eyebrow={t("news.tabNetwork")}
headline={t("news.partnerPrompt")}
body={copy.networkBlurb}
action={{
href: sectionPath("contact", locale),
label: t("actions.submitYourUpdate"),
}}
/>
</PageShell>
);
}
+21
View File
@@ -0,0 +1,21 @@
/* ============================================================================
CATCH-ALL 404
----------------------------------------------------------------------------
Owned by the app shell, not by a page agent.
Anything that matches no route lands here and throws, so the localised
"Page not found" screen lives in exactly one place: the root ErrorBoundary.
The locale still resolves correctly, because /ar/does-not-exist keeps its
prefix and `useLocale()` reads the pathname.
========================================================================= */
import { data } from "react-router";
export function loader() {
throw data("Not Found", { status: 404, statusText: "Not Found" });
}
/** Never rendered - the loader always throws. */
export default function NotFound() {
return null;
}
@@ -0,0 +1,297 @@
/* ============================================================================
RESOURCES AND PUBLICATIONS - one publication
----------------------------------------------------------------------------
/{locale}/resources-and-publications/{slug}
Registered in app/routes.ts as "resourcesDetail", a SIBLING of the listing
route under the ":lang" layout, and typed against its generated `./+types/`
module.
404s
A slug that does not exist is a 404, and so is a collection Strapi has not
been given yet - `fetchBySlug` maps Strapi's own 404 to null and rethrows
anything else, so an outage stays a 500 instead of being advertised to a
crawler as "this publication does not exist".
========================================================================= */
import { useLoaderData } from "react-router";
import { Button } from "~/components/ui/Button";
import {
formatDate,
formatFileSize,
sectionPath,
toIsoDate,
translate,
useTranslation,
type TranslationKey,
} from "~/i18n";
import { requireEntity } from "~/lib/strapi";
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/resources-and-publications.$slug";
import {
collectLocalizedSlugs,
detailAlternateDescriptors,
LOCALIZATION_POPULATE,
SELF_ALTERNATES_HANDLE,
} from "./_shared/alternates";
import {
CMS,
fetchBySlug,
taxonomyNames,
toFile,
toImage,
type ResourceEntry,
type ResourceType,
} from "./_shared/cms";
import { readRootSeo, requireLocale, withParam } from "./_shared/listing";
import {
BackLink,
MetaList,
MetaLine,
PageHeader,
PageShell,
TopicChips,
type MetaItem,
} from "./_shared/listing-ui";
import { DETAIL_COPY, RESOURCES_COPY } from "./_shared/page-copy";
const TYPE_LABEL: Readonly<Record<ResourceType, TranslationKey>> = {
"research-perception": "resources.categoryResearch",
"policy-article": "resources.categoryPolicy",
"project-report": "resources.categoryReports",
"visibility-factsheet": "resources.categoryVisibility",
};
/**
* A Strapi 5 localisation carries its own slug, so this page emits its own
* canonical + hreflang instead of letting the shell swap the locale prefix.
*/
export const handle = SELF_ALTERNATES_HANDLE;
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
export async function loader({ params }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const slug = params.slug ?? "";
const entry = requireEntity(
await fetchBySlug<ResourceEntry>(CMS.resources, slug, {
locale,
populate: {
thumbnail: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
file: { fields: ["url", "name", "ext", "mime", "size"] },
partnerOrganisations: { fields: ["name", "slug"] },
topics: { fields: ["name", "slug"] },
localizations: LOCALIZATION_POPULATE,
},
}),
);
const file = toFile(entry.file);
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
const cover = toImage(entry.thumbnail, "large");
return {
locale,
slug: entry.slug,
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
title: entry.title,
summary: entry.description ?? undefined,
typeLabel: entry.resourceType
? translate(locale, TYPE_LABEL[entry.resourceType])
: undefined,
date,
dateIso: toIsoDate(date),
dateLabel: date ? formatDate(date, locale) : undefined,
modifiedIso: toIsoDate(entry.updatedAt),
/** Free text in the CMS, rendered as the editor wrote it. */
authors: entry.authors?.trim() || undefined,
partners: taxonomyNames(entry.partnerOrganisations),
topics: (entry.topics ?? [])
.filter((topic) => Boolean(topic?.slug && topic?.name))
.map((topic) => ({ label: topic.name, slug: topic.slug })),
cover,
file: file
? { ...file, label: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}` }
: undefined,
externalUrl: entry.externalUrl ?? undefined,
};
}
/* ---------------------------------------------------------------------------
META
The shell's prefix-swapped canonical + hreflang is suppressed for this
route by `handle = SELF_ALTERNATES_HANDLE`; the correct pair is appended
below from the counterpart's real slug.
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const entry = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
const fallback = RESOURCES_COPY[root.locale];
if (!entry) {
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: fallback.heading,
description: fallback.metaDescription,
});
}
const description = entry.summary ?? fallback.metaDescription;
const descriptors = buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: entry.title,
description,
type: "article",
...(entry.cover
? { image: { url: entry.cover.src, alt: entry.cover.alt } }
: {}),
...(entry.dateIso
? {
article: {
publishedTime: entry.dateIso,
...(entry.modifiedIso ? { modifiedTime: entry.modifiedIso } : {}),
...(entry.authors ? { authors: [entry.authors] } : {}),
...(entry.typeLabel ? { section: entry.typeLabel } : {}),
tags: entry.topics.map((topic) => topic.label),
},
}
: {}),
jsonLd: [
buildBreadcrumbJsonLd(root.siteUrl, [
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
{ name: fallback.heading, pathname: sectionPath("resources", root.locale) },
{ name: entry.title, pathname: root.pathname },
]),
...(entry.dateIso
? [
buildArticleJsonLd({
siteUrl: root.siteUrl,
locale: root.locale,
headline: entry.title,
pathname: root.pathname,
datePublished: entry.dateIso,
...(entry.modifiedIso ? { dateModified: entry.modifiedIso } : {}),
...(entry.summary ? { description: entry.summary } : {}),
...(entry.cover ? { image: entry.cover.src } : {}),
...(entry.authors ? { authorName: entry.authors } : {}),
...(entry.typeLabel ? { section: entry.typeLabel } : {}),
keywords: entry.topics.map((topic) => topic.label),
}),
]
: []),
],
});
return [
...descriptors,
...detailAlternateDescriptors({
siteUrl: root.siteUrl,
section: "resources",
locale: root.locale,
slugs: entry.alternates,
}),
];
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function ResourceDetail() {
const entry = useLoaderData<typeof loader>();
const { t, locale } = useTranslation();
const listingPath = sectionPath("resources", locale);
const detail = DETAIL_COPY[locale];
const downloadHref = entry.file?.url ?? entry.externalUrl;
const metaItems: MetaItem[] = [
...(entry.dateLabel
? [
{
label: detail.published,
value: <time dateTime={entry.dateIso ?? entry.date}>{entry.dateLabel}</time>,
},
]
: []),
...(entry.authors ? [{ label: detail.author, value: entry.authors }] : []),
...(entry.partners.length > 0
? [{ label: detail.partner, value: entry.partners.join(", ") }]
: []),
];
return (
<PageShell>
<BackLink to={listingPath} label={detail.backToListing} />
<article className="flex flex-col gap-2xl">
<PageHeader
eyebrow={entry.typeLabel}
heading={entry.title}
intro={entry.summary}
>
<MetaLine items={[entry.dateLabel ?? "", entry.authors ?? ""]} />
</PageHeader>
{entry.cover ? (
<img
src={entry.cover.src}
alt={entry.cover.alt}
width={entry.cover.width}
height={entry.cover.height}
decoding="async"
className="w-full rounded-card border border-border-subtle object-cover"
/>
) : null}
<div className="grid gap-2xl lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-lg">
{/* A publication has no rich-text body in the CMS - it IS the
document. `description` is the standfirst above. */}
{downloadHref ? (
<div className="flex flex-wrap items-center gap-md">
<Button
as="a"
href={downloadHref}
variant="primary"
{...(entry.file
? { download: entry.file.name }
: { target: "_blank", rel: "noopener noreferrer" })}
>
{entry.file ? t("actions.download") : detail.openExternal}
</Button>
{entry.file ? (
<span className="body-sm text-text-tertiary">{entry.file.label}</span>
) : null}
</div>
) : null}
</div>
<aside className="flex flex-col gap-lg">
<MetaList heading={detail.aboutThis} items={metaItems} />
<TopicChips
heading={detail.topics}
topics={entry.topics.map((topic) => ({
label: topic.label,
href: `${listingPath}${withParam(new URLSearchParams(), "topic", topic.slug)}`,
}))}
/>
</aside>
</div>
</article>
</PageShell>
);
}
@@ -0,0 +1,457 @@
/* ============================================================================
RESOURCES AND PUBLICATIONS - listing
----------------------------------------------------------------------------
/{locale}/resources-and-publications
§6 of docs/SHARPER_Website_Draft_Section_EN_AR.docx, with the four filters
the ToR mandates - topic, date, author, partner organisation - plus the
type tabs the draft lists and a sort order.
THE AUTHOR FILTER IS NOT A TAXONOMY
`resource.authors` is a plain STRING field in the CMS - there is no author
content type and therefore no /api/authors to read. Its options are the
distinct values the published resources actually carry, which is also why
the filter offers nothing at all while the CMS is empty instead of 404ing.
FILTER STATE LIVES IN THE URL
Every control is a field of one `<form method="get">`, so a filtered view is
an ordinary link: ?topic=access-to-justice&year=2026&sort=oldest. It can be
shared, bookmarked, walked back with the browser's Back button and followed
by a crawler, and it renders identically on the server and the client
because nothing about it is component state.
The canonical URL and the en/ar/x-default hreflang set are emitted for this
route by the app shell (root.tsx `Layout`) from the pathname, which is why
`buildMeta` is called WITHOUT `alternates` here: a leaf `meta` replaces its
parents' wholesale, and passing it would emit each link twice. Every filter
combination therefore canonicalises to the unfiltered listing, which is the
behaviour we want from a facet.
========================================================================= */
import { Link, useLocation } from "react-router";
import {
CardActionHint,
CardEyebrow,
CardLink,
CardMedia,
CardShell,
} from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import {
formatDate,
formatFileSize,
sectionPath,
translate,
useTranslation,
type Locale,
type TranslationKey,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/resources-and-publications";
import {
CMS,
RESOURCE_TYPES,
fetchCollection,
fetchDistinctValues,
fetchFilterOptions,
fetchYearOptions,
taxonomyNames,
toFile,
toImage,
type CmsFile,
type CmsImage,
type ResourceEntry,
type ResourceType,
} from "./_shared/cms";
import {
PAGE_SIZE,
buildPagination,
compactFilters,
enumOptions,
parseExactText,
parseOptionalEnum,
parsePage,
parseSlug,
parseSort,
parseYear,
readRootSeo,
SORT_ORDERS,
requireLocale,
sortFor,
yearFilter,
yearOptions,
type SelectOption,
type SortOrder,
} from "./_shared/listing";
import {
EmptyState,
FilterBar,
FilterSelect,
MetaLine,
PageHeader,
PageShell,
Pagination,
ResultSummary,
} from "./_shared/listing-ui";
import { RESOURCES_COPY } from "./_shared/page-copy";
/**
* The four publication kinds, as `resource.resourceType` publishes them,
* mapped to the message catalogue.
*/
const TYPE_LABEL: Readonly<Record<ResourceType, TranslationKey>> = {
"research-perception": "resources.categoryResearch",
"policy-article": "resources.categoryPolicy",
"project-report": "resources.categoryReports",
"visibility-factsheet": "resources.categoryVisibility",
};
const SORT_LABEL = {
newest: "filters.sortNewest",
oldest: "filters.sortOldest",
} as const satisfies Readonly<Record<SortOrder, TranslationKey>>;
/** The date the listing sorts and filters on. */
const DATE_FIELD = "publicationDate";
/* ---------------------------------------------------------------------------
LOADER DATA
------------------------------------------------------------------------ */
interface ResourceListItem {
readonly id: string;
readonly slug: string;
readonly title: string;
readonly summary?: string;
readonly typeLabel?: string;
readonly date?: string;
readonly dateLabel?: string;
/** Free text from the CMS, rendered as written. */
readonly authors?: string;
readonly partners: readonly string[];
readonly topics: readonly string[];
readonly image?: CmsImage;
readonly file?: CmsFile;
readonly fileLabel?: string;
readonly externalUrl?: string;
}
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
export async function loader({ params, request }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const search = new URL(request.url).searchParams;
const page = parsePage(search);
const type = parseOptionalEnum(search, "type", RESOURCE_TYPES);
const topic = parseSlug(search, "topic");
const author = parseExactText(search, "author");
const partner = parseSlug(search, "partner");
const year = parseYear(search);
const sort = parseSort(search);
const filters = compactFilters({
resourceType: type ? { $eq: type } : undefined,
topics: topic ? { slug: { $eq: topic } } : undefined,
// A string field, so an exact match - not a relation lookup.
authors: author ? { $eq: author } : undefined,
partnerOrganisations: partner ? { slug: { $eq: partner } } : undefined,
[DATE_FIELD]: year ? yearFilter(year) : undefined,
});
// One round trip per facet, all in flight together: the listing itself, the
// three taxonomies behind the ToR filters, and the years actually covered.
const [listing, topicOptions, authorOptions, partnerOptions, years] =
await Promise.all([
fetchCollection<ResourceEntry>(CMS.resources, {
locale,
filters,
sort: sortFor(DATE_FIELD, sort),
populate: {
thumbnail: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
file: { fields: ["url", "name", "ext", "mime", "size"] },
partnerOrganisations: { fields: ["name", "slug"] },
topics: { fields: ["name", "slug"] },
},
pagination: { page, pageSize: PAGE_SIZE, withCount: true },
}),
fetchFilterOptions(CMS.topics, locale),
fetchDistinctValues(CMS.resources, locale, "authors"),
fetchFilterOptions(CMS.partners, locale),
fetchYearOptions(CMS.resources, locale, DATE_FIELD),
]);
const entries: ResourceListItem[] = listing.entries.map((entry) =>
toListItem(entry, locale),
);
return {
entries,
pagination: buildPagination(page, PAGE_SIZE, listing.pagination.total),
available: listing.available,
filters: {
type: type ?? "",
topic: topic ?? "",
author: author ?? "",
partner: partner ?? "",
year: year === undefined ? "" : String(year),
sort,
},
facets: {
types: enumOptions(RESOURCE_TYPES, TYPE_LABEL, locale),
topics: topicOptions,
authors: authorOptions,
partners: partnerOptions,
years: yearOptions(years, locale),
sorts: enumOptions(SORT_ORDERS, SORT_LABEL, locale),
} satisfies Readonly<Record<string, readonly SelectOption[]>>,
};
}
function toListItem(entry: ResourceEntry, locale: Locale): ResourceListItem {
const file = toFile(entry.file);
const image = toImage(entry.thumbnail, "small");
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
const authors = entry.authors?.trim();
return {
id: entry.documentId,
slug: entry.slug,
title: entry.title,
...(entry.description ? { summary: entry.description } : {}),
...(entry.resourceType
? { typeLabel: translate(locale, TYPE_LABEL[entry.resourceType]) }
: {}),
...(date ? { date, dateLabel: formatDate(date, locale) } : {}),
...(authors ? { authors } : {}),
partners: taxonomyNames(entry.partnerOrganisations),
topics: taxonomyNames(entry.topics),
...(image ? { image } : {}),
...(file
? {
file,
fileLabel: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}`,
}
: {}),
...(entry.externalUrl ? { externalUrl: entry.externalUrl } : {}),
};
}
/* ---------------------------------------------------------------------------
META
------------------------------------------------------------------------ */
export function meta({ matches, loaderData }: Route.MetaArgs) {
const root = readRootSeo(matches);
const copy = RESOURCES_COPY[root.locale];
return buildMeta({
locale: root.locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: copy.heading,
description: copy.metaDescription,
// If the CMS could not be reached the page is an empty shell, not an empty
// section - keep it out of the index rather than teaching a crawler that
// SHARP-ER has published nothing.
noIndex: loaderData?.available === false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
{ name: copy.heading, pathname: sectionPath("resources", root.locale) },
]),
});
}
/* ---------------------------------------------------------------------------
PAGE
------------------------------------------------------------------------ */
export default function ResourcesAndPublications({
loaderData,
}: Route.ComponentProps) {
const { entries, pagination, filters, facets } = loaderData;
const { t, locale } = useTranslation();
const { pathname, search } = useLocation();
const params = new URLSearchParams(search);
const copy = RESOURCES_COPY[locale];
const isFiltered =
Boolean(filters.type) ||
Boolean(filters.topic) ||
Boolean(filters.author) ||
Boolean(filters.partner) ||
Boolean(filters.year);
return (
<PageShell>
<PageHeader heading={copy.heading} intro={copy.intro} />
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
<FilterBar
legend={t("filters.heading")}
action={pathname}
applyLabel={t("filters.apply")}
clearLabel={t("filters.clear")}
clearHref={pathname}
showClear={isFiltered || filters.sort !== "newest"}
>
<FilterSelect
name="type"
label={t("filters.category")}
value={filters.type}
options={facets.types}
allLabel={t("filters.all")}
/>
<FilterSelect
name="topic"
label={t("filters.topic")}
value={filters.topic}
options={facets.topics}
allLabel={t("filters.all")}
/>
<FilterSelect
name="author"
label={t("filters.author")}
value={filters.author}
options={facets.authors}
allLabel={t("filters.all")}
/>
<FilterSelect
name="partner"
label={t("filters.partner")}
value={filters.partner}
options={facets.partners}
allLabel={t("filters.all")}
/>
<FilterSelect
name="year"
label={t("filters.date")}
value={filters.year}
options={facets.years}
allLabel={t("filters.all")}
/>
{/* No "All" option: sorting always has a value. */}
<FilterSelect
name="sort"
label={t("filters.sortBy")}
value={filters.sort}
options={facets.sorts}
/>
</FilterBar>
<ResultSummary pagination={pagination} t={t} />
{entries.length === 0 ? (
<EmptyState
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
>
{isFiltered ? (
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="flex flex-col gap-lg">
{entries.map((entry) => (
<ResourceRow
key={entry.id}
entry={entry}
href={`${sectionPath("resources", locale)}/${entry.slug}`}
readLabel={t("actions.viewDetails")}
downloadLabel={t("actions.download")}
/>
))}
</ul>
)}
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
</section>
</PageShell>
);
}
/* ---------------------------------------------------------------------------
ONE RESULT
Composed from the exported card primitives rather than Card.Horizontal:
a publication carries a metadata line (date · author · partner · PDF · size)
and a direct download, neither of which that card has a slot for.
------------------------------------------------------------------------ */
function ResourceRow({
entry,
href,
readLabel,
downloadLabel,
}: {
entry: ResourceListItem;
href: string;
readLabel: string;
downloadLabel: string;
}) {
const downloadHref = entry.file?.url ?? entry.externalUrl;
return (
<CardShell as="li" linked className="flex-col overflow-hidden sm:flex-row">
{entry.image ? (
<CardMedia
image={entry.image}
className="aspect-[6/5] w-full sm:aspect-auto sm:w-60 sm:shrink-0"
/>
) : null}
<div className="flex flex-1 flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
{entry.typeLabel ? <CardEyebrow>{entry.typeLabel}</CardEyebrow> : null}
<h2 className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{entry.title}
</CardLink>
</h2>
<MetaLine
items={[
entry.dateLabel ?? "",
entry.authors ?? "",
entry.partners.join(", "),
entry.fileLabel ?? "",
]}
/>
{entry.summary ? (
<p className="body-sm text-text-secondary line-clamp-3">{entry.summary}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-md">
<CardActionHint label={readLabel} />
{downloadHref ? (
// `sh-card-above` lifts it over the stretched title link, so the
// download stays its own target instead of opening the detail page.
<Button
as="a"
href={downloadHref}
variant="secondary"
size="sm"
className="sh-card-above"
{...(entry.file
? { download: entry.file.name }
: { target: "_blank", rel: "noopener noreferrer" })}
>
{downloadLabel}
</Button>
) : null}
</div>
</div>
</CardShell>
);
}
+212
View File
@@ -0,0 +1,212 @@
/* ============================================================================
WHAT WE DO - /en/what-we-do and /ar/what-we-do
----------------------------------------------------------------------------
Section 2 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: the participatory
approach, the four connected areas of work, the values that guide them and
the four principles the approach rests on.
Overridden field by field by api::what-we-do-page.what-we-do-page; the draft
copy below is what renders until an editor fills that in.
========================================================================= */
import { Card } from "~/components/ui/Card";
import { createTranslator, formatNumber, sectionPath } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/what-we-do";
import { isRichTextBlock, type CmsWhatWeDoPage } from "./_page-kit/cms-types";
import {
list,
loadSingle,
toImage,
toStringList,
} from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
InfoPanel,
PageHero,
PageSection,
Prose,
SectionHeader,
ValueChips,
} from "./_page-kit/sections";
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const page = await loadSingle<CmsWhatWeDoPage>("what-we-do-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
workstreams: { populate: { icon: true } },
principles: true,
blocks: { on: { "blocks.rich-text": { populate: "*" } } },
seo: { populate: { ogImage: true } },
},
});
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
intro: page?.intro ?? null,
richTextBlocks: list(page?.blocks)
.filter(isRichTextBlock)
.map((block) => ({ id: block.id, body: block.body })),
workstreams: list(page?.workstreams).map((workstream) => ({
title: workstream.title,
description: workstream.description ?? "",
icon: toImage(workstream.icon, "thumbnail") ?? null,
})),
principles: list(page?.principles).map((principle) => ({
title: principle.title,
description: principle.description ?? "",
})),
guidingValues: toStringList(page?.guidingValues),
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).whatWeDo;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: loaderData?.seo.title ?? t("nav.sections.whatWeDo"),
description: loaderData?.seo.description ?? copy.lead,
...(loaderData?.seo.image
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
: {}),
noIndex: loaderData?.seo.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{
name: t("nav.sections.whatWeDo"),
pathname: sectionPath("whatWeDo", locale),
},
]),
});
}
export default function WhatWeDo({ loaderData }: Route.ComponentProps) {
const { locale, hero, intro, richTextBlocks } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).whatWeDo;
const workstreams =
loaderData.workstreams.length > 0
? loaderData.workstreams
: copy.workstreams.map((workstream) => ({ ...workstream, icon: null }));
const values =
loaderData.guidingValues.length > 0 ? loaderData.guidingValues : copy.values;
return (
<>
<PageHero
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.aboutAndStrategy")}
lead={hero.subheading ?? copy.lead}
{...(hero.media ? { media: hero.media } : {})}
/>
{/* THE FOUR AREAS OF WORK */}
<PageSection
aria-labelledby="wwd-workstreams-heading"
className="pt-0 md:pt-0"
>
<div className="flex flex-col gap-2xl">
<SectionHeader
id="wwd-workstreams-heading"
title={copy.workstreamsHeading}
lead={intro ? undefined : copy.workstreamsIntro}
/>
{intro ? (
<Prose>
<RichText markdown={intro} />
</Prose>
) : null}
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-2">
{workstreams.map((workstream, index) => (
<li key={workstream.title}>
<InfoPanel
title={workstream.title}
eyebrow={formatNumber(index + 1, locale, {
minimumIntegerDigits: 2,
})}
>
{workstream.description}
</InfoPanel>
</li>
))}
</ul>
<ValueChips values={values} label={copy.valuesLabel} />
</div>
</PageSection>
{/* HOW WE WORK */}
<PageSection aria-labelledby="wwd-principles-heading">
<div className="flex flex-col gap-lg">
<SectionHeader
id="wwd-principles-heading"
title={copy.principlesHeading}
/>
{loaderData.principles.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-2">
{loaderData.principles.map((principle) => (
<li key={principle.title}>
<InfoPanel title={principle.title}>
{principle.description}
</InfoPanel>
</li>
))}
</ul>
) : (
<Prose>
<p className="body-md text-[var(--text-secondary)]">
{copy.principles}
</p>
</Prose>
)}
{richTextBlocks.map((block) => (
<Prose key={block.id}>
<RichText markdown={block.body} />
</Prose>
))}
</div>
</PageSection>
<PageSection>
<Card.CTA
headline={t("nav.sections.activities")}
body={copy.lead}
action={{
href: sectionPath("activities", locale),
label: t("actions.seeActivities"),
}}
/>
</PageSection>
</>
);
}