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,122 @@
---
name: react-router
description: Build applications with React Router in Framework, Data, Declarative, and unstable RSC modes. Use when configuring routes, route modules, loaders, actions, forms, fetchers, navigation, pending UI, SSR/SPA/pre-rendering, middleware, URL params/search params, or React Router upgrades.
license: MIT
---
# React Router
React Router is mode-specific. Before changing an app, identify the mode, load the matching reference, then read the installed docs for the installed package version.
## Identify the Mode
Do not apply Framework/Data patterns to a Declarative app unless you are intentionally migrating modes.
### Framework Mode
Use Framework Mode guidance when you see:
- `@react-router/dev` in dependencies
- `react-router.config.ts`
- `app/routes.ts`
- `app/entry.server.tsx` and/or `app/entry.client.tsx` files
- route modules under `app/routes/`
- route exports like `loader`, `action`, `clientLoader`, `clientAction`, `ErrorBoundary`, `meta`, `links`, or `headers`
- imports from `./+types/...`
- the React Router Vite plugin from `@react-router/dev/vite`
Framework examples usually use the default `app/` directory, but check `react-router.config.ts` for a custom `appDirectory` before assuming exact paths.
Then read `references/framework-mode.md`.
### Data Mode
Use Data Mode guidance when you see:
- `createBrowserRouter`, `createHashRouter`, `createMemoryRouter`, or `createStaticRouter`
- `<RouterProvider router={router}>`
- route objects with properties like `path`, `children`, `loader`, `action`, `Component`, `ErrorBoundary`, or `lazy`
- data APIs without the Framework Vite plugin
Then read `references/data-mode.md`.
### Declarative Mode
Use Declarative Mode guidance when you see:
- `<BrowserRouter>`, `<HashRouter>`, or `<MemoryRouter>`
- `<Routes>` and `<Route>` JSX route configuration
- route components passed with `element={<Component />}`
- no data router, no route module convention, and no loaders/actions
Then read `references/declarative-mode.md`.
### RSC Framework and RSC Data Modes
React Server Components support is unstable and exists in both Framework and Data variants. Use RSC guidance when you see:
- `unstable_reactRouterRSC`
- `@vitejs/plugin-rsc`
- `unstable_RSCRouteConfig`
- RSC entry files such as `entry.rsc`
- `ServerComponent`, `ServerErrorBoundary`, `ServerLayout`, or `ServerHydrateFallback`
- React directives or boundary packages such as `"use client"`, `"server-only"`, or `"client-only"`
For RSC Framework, read both `references/framework-mode.md` and `references/rsc.md`.
For RSC Data, read both `references/data-mode.md` and `references/rsc.md`.
## Use Installed Docs as Source of Truth
React Router ships markdown docs in the package so guidance can match the installed version:
```txt
node_modules/react-router/docs/
```
Key docs paths:
```txt
node_modules/react-router/docs/index.md
node_modules/react-router/docs/start/
node_modules/react-router/docs/how-to/
node_modules/react-router/docs/explanation/
node_modules/react-router/docs/upgrading/
```
When this skill references `react-router/docs/...`, read the matching file under `node_modules/react-router/docs/`. If the installed version does not include local docs, use the repo `docs/` directory when working inside the React Router repository; in a consuming app, fall back to version-matched website docs.
Most docs include a mode marker near the top:
```txt
[MODES: framework, data, declarative]
```
Only apply a doc when its mode marker matches the app mode. If a task spans modes, prefer the section or file that matches the current app.
RSC is documented primarily in:
```txt
node_modules/react-router/docs/how-to/react-server-components.md
```
## Skill References
Load the relevant reference after identifying the mode:
| Reference | Use When |
| -------------------------------- | --------------------------------------------- |
| `references/framework-mode.md` | Framework Mode or RSC Framework base behavior |
| `references/data-mode.md` | Data Mode or RSC Data base behavior |
| `references/declarative-mode.md` | Declarative Mode |
| `references/rsc.md` | Any unstable RSC app |
## Mode Migration Doc Index
If the user explicitly asks to switch modes, read the target mode reference plus the migration-relevant docs:
| Migration | Docs to read |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Declarative → Data | `react-router/docs/start/modes.md`, `react-router/docs/start/data/routing.md`, `react-router/docs/start/data/data-loading.md`, `react-router/docs/start/data/actions.md` |
| Declarative/Data → Framework | `react-router/docs/start/modes.md`, `react-router/docs/start/framework/routing.md`, `react-router/docs/start/framework/route-module.md`, `react-router/docs/how-to/route-module-type-safety.md` |
| Framework SPA/SSR/pre-render changes | `react-router/docs/start/framework/rendering.md`, `react-router/docs/how-to/spa.md`, `react-router/docs/how-to/pre-rendering.md`, `react-router/docs/start/framework/data-loading.md`, `react-router/docs/start/framework/actions.md` |
| Future flags/upgrades | `react-router/docs/upgrading/future.md` and relevant files under `react-router/docs/upgrading/` |
@@ -0,0 +1,165 @@
# Data Mode
Data Mode uses data routers such as `createBrowserRouter` and renders with `<RouterProvider>`. It gives an app route objects, loaders, actions, pending UI, fetchers, and SSR primitives without adopting the Framework Vite plugin or route-module file conventions.
Use this reference after the main skill identifies a Data Mode app.
## Read the Local Docs by Mode
Start with:
```txt
react-router/docs/start/modes.md
react-router/docs/start/data/index.md
```
Then use the Data docs under:
```txt
react-router/docs/start/data/
```
Those files cover installation, route objects, routing, data loading, actions, navigation, pending UI, and testing. For task-specific details, read relevant files in:
```txt
react-router/docs/how-to/
react-router/docs/explanation/
```
Always check the `[MODES: data, ...]` marker in a doc before applying it.
## Data Router Shape
Typical setup:
```tsx
import { createBrowserRouter, RouterProvider } from "react-router";
const router = createBrowserRouter([
{
path: "/",
Component: Root,
loader: rootLoader,
children: [
{ index: true, Component: Home },
{
path: "projects/:projectId",
Component: Project,
loader: projectLoader,
},
],
},
]);
root.render(<RouterProvider router={router} />);
```
Look for route object arrays and APIs such as:
- `createBrowserRouter`
- `createHashRouter`
- `createMemoryRouter`
- `RouterProvider`
- `loader`
- `action`
- `Component`
- `ErrorBoundary`
- `lazy`
- `children`
## Route Objects and Routing
Before editing route configuration, read:
```txt
react-router/docs/start/data/routing.md
react-router/docs/start/data/route-object.md
```
Rules:
- Keep route objects outside render when possible.
- Use nested routes for shared layouts and data boundaries.
- Use index routes for default child content.
- Use dynamic segments and splats according to route-object docs.
- Prefer `Component`/`ErrorBoundary` route object properties in Data Mode examples unless the existing app uses `element` consistently.
## Data and Mutations
Before working on data loading or mutations, read:
```txt
react-router/docs/start/data/data-loading.md
react-router/docs/start/data/actions.md
```
Rules:
- Load route data with route `loader` functions.
- Mutate route data with route `action` functions.
- Prefer loaders/actions over route-level `useEffect` fetching.
- Use `request`, `params`, and returned/throwable Responses as described in the docs.
- Let React Router revalidate after actions unless there is a documented reason to customize revalidation.
Common patterns:
- Validation failure from an action: return `data({ errors, values }, { status: 400 })`, then render errors with `useActionData()` or `fetcher.data`.
- Missing record in a loader: throw `data("Not Found", { status: 404 })` and render the route `ErrorBoundary`.
- Search/filter data: parse `new URL(request.url).searchParams` in the loader so the URL is shareable and bookmarkable.
## Forms, Fetchers, and Pending UI
For forms and pending UI, read:
```txt
react-router/docs/start/data/actions.md
react-router/docs/start/data/pending-ui.md
react-router/docs/how-to/fetchers.md
react-router/docs/explanation/form-vs-fetcher.md
```
Rules of thumb:
- Search/filter form that updates the URL: `<Form method="get">`.
- Mutation that should change URL/history or redirect after completion: `<Form method="post">`.
- Mutation that should keep the user on the same page: `useFetcher` / `<fetcher.Form>`.
- Optimistic UI: derive from `fetcher.formData` or `navigation.formData`.
## Navigation and URL State
Before changing navigation or search params, read:
```txt
react-router/docs/start/data/navigating.md
react-router/docs/how-to/search-params.md
react-router/docs/explanation/location.md
```
Rules:
- Use `<Link>`/`<NavLink>` for user-initiated internal navigation.
- Use `redirect` in loaders/actions when navigation follows data loading or mutations.
- Use `useNavigate` for imperative client-side event navigation.
- Treat URL params as strings and validate/parse them.
- Preserve unrelated search params unless intentionally resetting them.
## SSR in Data Mode
Data Mode SSR is manual and lower-level than Framework Mode. Before implementing or changing SSR, read the Data Mode custom/SSR docs and match existing server abstractions.
Start with:
```txt
react-router/docs/start/data/custom.md
```
Look for APIs like `createStaticHandler`, `createStaticRouter`, `StaticRouterProvider`, and hydration data handling in the current app before changing anything.
## RSC Data
If this Data Mode app uses `unstable_RSCRouteConfig`, RSC route config, or low-level RSC server APIs, also read:
```txt
references/rsc.md
react-router/docs/how-to/react-server-components.md
```
@@ -0,0 +1,123 @@
# Declarative Mode
Declarative Mode is React Router's simplest mode. It uses router components like `<BrowserRouter>` and JSX routes with `<Routes>`/`<Route>`. It does not provide loaders, actions, fetchers, or data-router pending UI.
Use this reference after the main skill identifies a Declarative Mode app.
## Read the Local Docs by Mode
Start with:
```txt
react-router/docs/start/modes.md
react-router/docs/start/declarative/index.md
```
Then use the Declarative docs under:
```txt
react-router/docs/start/declarative/
```
Those files cover installation, routing, navigation, and URL values. For conceptual details, read relevant files in:
```txt
react-router/docs/explanation/
```
Always check the `[MODES: declarative, ...]` marker in a doc before applying it.
## Declarative Router Shape
Typical setup:
```tsx
import { BrowserRouter, Routes, Route } from "react-router";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="about" element={<About />} />
<Route path="dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHome />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
);
}
```
Look for APIs such as:
- `<BrowserRouter>`
- `<HashRouter>`
- `<MemoryRouter>`
- `<Routes>`
- `<Route>`
- `element={<Component />}`
- `useRoutes`
## Routing
Before editing routes, read:
```txt
react-router/docs/start/declarative/routing.md
```
Rules:
- Use `<Routes>` and `<Route>` for route configuration.
- Use nested routes with `<Outlet>` for shared layout.
- Use index routes for default child UI.
- Use route params and splats according to the declarative routing docs.
- Do not add route object loaders/actions to a Declarative router.
## Navigation
Before changing navigation, read:
```txt
react-router/docs/start/declarative/navigating.md
```
Rules:
- Use `<Link>` or `<NavLink>` for user-initiated internal navigation.
- Use `NavLink` when active styling matters.
- Use `useNavigate` for imperative navigation from event handlers or effects.
- Do not use plain `<a href>` for internal navigation unless intentionally forcing a full document navigation.
## URL Values
Before changing params, search params, or location state, read:
```txt
react-router/docs/start/declarative/url-values.md
react-router/docs/explanation/location.md
```
Rules:
- Use `useParams` for dynamic route params.
- Use `useSearchParams` for query string state.
- Use `useLocation` for the current location object and navigation state.
- Validate and parse URL params; they are strings and can be absent.
- Preserve unrelated search params unless intentionally resetting them.
## Mode Boundary
Declarative Mode does not have Data/Framework APIs such as:
- `loader`
- `action`
- `<Form>`
- `useFetcher`
- `useNavigation`
- route module exports
- generated `./+types` route types
If the user asks for route data loading, DB/API-backed data, CRUD, form mutations, validation returned from submissions, revalidation, pending UI, optimistic UI, or fetchers, recommend Data Mode or Framework Mode depending on how much structure they want. Ask before migrating unless they already requested it.
@@ -0,0 +1,213 @@
# Framework Mode
Framework Mode is React Router's full-stack mode. It uses the React Router Vite plugin, route config in `app/routes.ts`, route modules, generated route types, and rendering strategies such as SSR, SPA mode, and pre-rendering.
Use this reference after the main skill identifies a Framework Mode app.
## Read the Local Docs by Mode
Start with:
```txt
react-router/docs/start/modes.md
react-router/docs/start/framework/index.md
```
Then use the Framework docs under:
```txt
react-router/docs/start/framework/
```
Those files cover installation, routing, route modules, data loading, actions, navigation, pending UI, rendering, deploying, and testing. For task-specific details, read relevant files in:
```txt
react-router/docs/how-to/
react-router/docs/explanation/
```
Always check the `[MODES: framework, ...]` marker in a doc before applying it.
## Framework Shape
Examples usually assume the default `appDirectory` of `app`. Check `react-router.config.ts` before assuming exact paths.
Look for these files and conventions:
```txt
react-router.config.ts
app/root.tsx
app/routes.ts
app/routes/**/*.tsx
route modules importing from ./+types/...
```
Typical route module:
```tsx
import type { Route } from "./+types/product";
export async function loader({ params }: Route.LoaderArgs) {
return { product: await getProduct(params.productId) };
}
export default function Product({ loaderData }: Route.ComponentProps) {
return <h1>{loaderData.product.name}</h1>;
}
```
## Route Configuration
Framework apps use `app/routes.ts`. Many apps use file-system routing via `flatRoutes()`, but manual route config is also supported.
Before editing routes, read:
```txt
react-router/docs/start/framework/routing.md
```
If the app uses file-route conventions, read:
```txt
react-router/docs/how-to/file-route-conventions.md
```
## Route Modules
Route modules are the main unit of Framework Mode. Before adding or changing route exports, read:
```txt
react-router/docs/start/framework/route-module.md
```
Common exports include:
| Export | Use |
| --------------------------------- | ------------------------------------------------------------------- |
| `default` | Route component rendered for the match |
| `loader` | Server data loading for SSR/pre-rendering/server data requests |
| `clientLoader` | Browser-only data loading or supplementing server loader data |
| `action` | Server mutation called by `<Form>`, `useSubmit`, or fetchers |
| `clientAction` | Browser-only mutation or client-side wrapper around a server action |
| `ErrorBoundary` | UI for errors thrown by this route's loaders/actions/component |
| `HydrateFallback` | Initial fallback while client loader hydration runs |
| `links` / `meta` | Route document links and metadata |
| `handle` | Arbitrary route metadata consumed via `useMatches` |
| `shouldRevalidate` | Overrides default loader revalidation behavior |
| `middleware` / `clientMiddleware` | Server/client request pipeline hooks when enabled |
Use generated `Route.*` types from `./+types/<route>` for route module args and props.
## Layout and Root Route Rules
- `app/root.tsx` is the root route and should contain global document/app shell concerns.
- Put global providers, app-wide nav, app-wide footer, scripts/meta/links, and document structure in `root.tsx` when appropriate.
- Use nested routes/layout routes for section-specific layouts.
- Do not flatten routes that should share UI or data boundaries.
Useful docs:
```txt
react-router/docs/explanation/special-files.md
react-router/docs/start/framework/routing.md
```
## Data and Mutations
Before working on route data:
```txt
react-router/docs/start/framework/data-loading.md
react-router/docs/start/framework/actions.md
```
Framework rules:
- Load route data with `loader` or `clientLoader`.
- Mutate route data with `action` or `clientAction`.
- Prefer route loaders/actions over ad hoc `useEffect` fetching for route data.
- Use `data()`/Responses and redirects according to the docs.
- Let React Router revalidate after actions unless the docs point you to `shouldRevalidate`.
- In SSR/server data routes, keep Node-only/database code in server-only modules and call it from `loader`/`action`, not from browser-rendered component code.
Common patterns:
- Validation failure from an action: return `data({ errors, values }, { status: 400 })`, then render errors from `Route.ComponentProps["actionData"]` or `fetcher.data`.
- Missing record in a loader: throw `data("Not Found", { status: 404 })` and render the route `ErrorBoundary`.
- Search/filter data: parse the route request URL/search params in the loader so the URL is shareable and bookmarkable.
## Forms, Fetchers, and Pending UI
For forms and pending UI, read:
```txt
react-router/docs/start/framework/actions.md
react-router/docs/start/framework/pending-ui.md
react-router/docs/how-to/fetchers.md
react-router/docs/explanation/form-vs-fetcher.md
```
Rules of thumb:
- Search/filter form that updates the URL: `<Form method="get">`.
- Mutation that should change URL/history or redirect after completion: `<Form method="post">`.
- Mutation that should keep the user on the same page: `useFetcher` / `<fetcher.Form>`.
- Optimistic UI: derive from `fetcher.formData` or `navigation.formData`.
## Type Safety
Before changing generated route types or typed URL behavior, read:
```txt
react-router/docs/how-to/route-module-type-safety.md
react-router/docs/explanation/type-safety.md
```
Rules:
- Import types from `./+types/<route>`.
- Use `Route.LoaderArgs`, `Route.ActionArgs`, `Route.ComponentProps`, etc.
- Use type-only imports where appropriate.
- Do not edit generated `.react-router/types` files.
## Metadata
Before changing `meta`, read:
```txt
react-router/docs/how-to/meta.md
react-router/docs/start/framework/route-module.md
```
Important: `meta` receives `loaderData`; do not use deprecated `data` args.
## Rendering Strategy
Framework Mode can be SSR, SPA, pre-rendered, or mixed depending on config and route behavior. Before changing rendering behavior, read:
```txt
react-router/docs/start/framework/rendering.md
react-router/docs/how-to/spa.md
react-router/docs/how-to/pre-rendering.md
react-router/docs/explanation/hydration.md
```
## Middleware, Sessions, and Auth
Before implementing middleware or auth/session flows, read:
```txt
react-router/docs/how-to/middleware.md
react-router/docs/explanation/sessions-and-cookies.md
```
Middleware and context APIs are version/config sensitive. Check the installed React Router version and the app's `react-router.config.ts` before implementing.
## RSC Framework
If this Framework app uses `unstable_reactRouterRSC` or `@vitejs/plugin-rsc`, also read:
```txt
references/rsc.md
react-router/docs/how-to/react-server-components.md
```
@@ -0,0 +1,90 @@
# React Server Components (RSC)
React Router's RSC support is unstable and exists in two variants:
- **RSC Framework Mode**: Framework Mode with the unstable RSC Vite plugin.
- **RSC Data Mode**: lower-level RSC runtime APIs and manual bundler/server integration.
Use this reference in addition to `framework-mode.md` or `data-mode.md` after the main skill identifies an RSC app.
## Read the Local RSC Docs
Start with:
```txt
react-router/docs/how-to/react-server-components.md
```
Then read the relevant base mode docs:
```txt
react-router/docs/start/framework/
react-router/docs/start/data/
```
RSC docs may describe differences from non-RSC mode rather than repeating every Framework/Data concept, so keep both layers in mind.
## Detect RSC Framework Mode
Look for:
- `unstable_reactRouterRSC` imported from `@react-router/dev/vite`
- `@vitejs/plugin-rsc`
- `vite.config.ts` with `plugins: [reactRouterRSC(), rsc()]`
- Framework route modules plus RSC route exports
- RSC entry files such as `entry.rsc`
RSC Framework Mode uses a different Vite plugin from non-RSC Framework Mode. Do not swap it for the regular `reactRouter()` plugin.
## Detect RSC Data Mode
Look for:
- `unstable_RSCRouteConfig`
- route config passed to lower-level RSC APIs
- APIs such as `unstable_matchRSCServerRequest`, `unstable_routeRSCServerRequest`, `unstable_RSCHydratedRouter`, or `unstable_RSCStaticRouter`
- custom bundler/server setup around RSC
RSC Data Mode is more manual than RSC Framework Mode. Match the app's bundler and server abstractions before changing routes or entries.
## RSC Route Module Differences
In RSC Framework Mode, many normal Framework Mode concepts still apply, but routes can use server component exports.
Important route-module concepts from the RSC docs include:
- `ServerComponent` instead of the usual client `default` component
- `ServerErrorBoundary` paired with `ErrorBoundary`
- `ServerLayout` paired with `Layout`
- `ServerHydrateFallback` paired with `HydrateFallback`
- server-rendered React elements returned from loaders/actions
A route module cannot export both the normal client component and its server component counterpart for the same role. Read the RSC docs before adding these exports.
## Client/Server Boundaries
RSC code must respect React's client/server split:
- Use `"use client"` for components that need hooks, browser APIs, or event handlers.
- Use server-only modules for server data access and secrets.
- In RSC Framework Mode, prefer the `server-only` and `client-only` boundary imports described in the docs.
- Do not assume `.server`/`.client` file naming works the same way in RSC Framework Mode; read the RSC docs before relying on those conventions.
## Data Loading in RSC
RSC changes where data can be loaded:
- Server Components can fetch data directly on the server.
- Loaders/actions may still exist and can have RSC-specific behavior.
- Client components still need client-safe data and cannot directly access server-only modules.
When choosing between a server component fetch, a loader, and a client loader/action, follow the RSC docs and match existing app patterns.
## Stability
RSC APIs are explicitly unstable. Before implementing or refactoring RSC code:
- Check the installed React Router version.
- Check the installed `@vitejs/plugin-rsc` version.
- Read the app's existing RSC entry/config files.
- Prefer minimal changes that match current patterns.
+4
View File
@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
.env
/node_modules/
# React Router
/.react-router/
/build/
+69
View File
@@ -0,0 +1,69 @@
# syntax=docker/dockerfile:1
###############################################################################
# SHARP-ER web - React Router 8 (framework mode, SSR) on Node 22
#
# Adapted from the React Router template Dockerfile. Two things changed:
# * npm -> pnpm, because this repository is a pnpm workspace and there is no
# package-lock.json to `npm ci` from;
# * the build context is the REPOSITORY ROOT, where pnpm-lock.yaml,
# pnpm-workspace.yaml and .npmrc live.
#
# docker build -f apps/web/Dockerfile -t sharper-web .
# docker compose build web # does exactly that
#
# The server reads STRAPI_URL / STRAPI_TOKEN / SITE_URL at RUN time, never at
# build time, so one image is valid for every environment.
###############################################################################
# --- base: the toolchain both stages share ----------------------------------
FROM node:22-bookworm-slim AS base
ENV PNPM_VERSION=9.12.3
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global "pnpm@${PNPM_VERSION}"
WORKDIR /repo
# --- build: install dependencies and produce build/client + build/server -----
FROM base AS build
# Manifests first so the install layer stays cached. Both workspace manifests
# are copied: pnpm checks the lockfile against every project in the workspace
# and treats it as stale if one is missing.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY apps/web/package.json ./apps/web/
COPY apps/cms/package.json ./apps/cms/
# `--filter web...` keeps Strapi out of this image entirely.
# devDependencies are required: vite, @react-router/dev and tailwind build here.
RUN pnpm install --frozen-lockfile --filter web...
COPY apps/web ./apps/web
RUN pnpm --filter web build
# --- runtime ----------------------------------------------------------------
FROM base AS runtime
ENV NODE_ENV=production \
PORT=3000
COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/package.json /repo/pnpm-workspace.yaml /repo/.npmrc ./
COPY --from=build /repo/apps/web ./apps/web
RUN chown -R node:node /repo/apps/web
USER node
WORKDIR /repo/apps/web
EXPOSE 3000
# /en is a real page: it proves the SSR handler renders, not just that a socket
# is open. It stays 200 even when the CMS is unreachable, because every route
# falls back to its bundled draft copy - so this healthcheck tracks the web
# service alone.
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=5 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/en').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# react-router-serve, the production server that ships with the framework.
CMD ["pnpm", "start"]
+122
View File
@@ -0,0 +1,122 @@
# `apps/web` — SHARP-ER front end
React Router 8 in **framework mode** with `ssr: true`, React 19, Vite 8 and Tailwind CSS 4.
It renders the bilingual (EN / AR) SHARP-ER website and reads its content from the Strapi CMS
in `apps/cms`.
Setup, the database, Docker and everything else that spans both applications is in the
[repository README](../../README.md). This file covers the web application only.
## Run it
Always install from the repository root (`pnpm install`) — this is a pnpm workspace with a
hoisted `node_modules`.
```bash
pnpm --filter web dev # http://localhost:5173, HMR (or: pnpm dev:web)
pnpm --filter web build # build/client + build/server
pnpm --filter web start # react-router-serve, http://localhost:3000
pnpm --filter web typecheck # react-router typegen && tsc
```
The server reads three variables from its process environment, all optional in development:
`STRAPI_URL` (default `http://localhost:1337`), `STRAPI_TOKEN` and `SITE_URL` (unset means
the canonical URL is derived from the request).
## Layout
```
app/
├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, <Navigation>, <Footer>
├── routes.ts Route config: "/" redirect, the ":lang" layout, the ten sections
├── routes/
│ ├── *.tsx One module per page, serving BOTH locales
│ ├── _page-kit/ Section/hero/prose primitives, markdown renderer, CMS helpers
│ ├── _shared/ Listing helpers + listing UI (filters, tabs, pagination, empty states)
│ └── _events-opportunities/ Event and opportunity models, forms, .ics generation
├── components/ui/ Button · Input · Card · Navigation · Footer · LanguageSwitcher
├── styles/ Design tokens — see styles/README.md
├── i18n/ Locale config, paths, translator, formatters, messages/{en,ar}.json
└── lib/ strapi.ts · seo.ts · env.server.ts · route-slugs.ts · site-navigation.ts
public/
├── assets/ Logos, EU emblem, partner logos, icons
└── fonts/ Licensed woff2 files — NOT in git, see the root README
```
## URLs
```
/ 302 (Vary: Accept-Language) -> /en or /ar
/:lang home
/:lang/<section> about · what-we-do · activities-and-initiatives · events-calendar ·
applications-and-opportunities · news-and-updates · contact ·
resources-and-publications · knowledge-hub
/:lang/<section>/:slug detail pages (events, opportunities, resources, knowledge, news, activities)
/EN/about 301 -> /en/about
anything else localised 404
```
One route module serves both languages: `params.lang` is validated by the `:lang` layout, so
inside a page it is always `"en" | "ar"`. Add a section module at the path
`SECTION_MODULES` names in `app/routes.ts` and it is wired on the next build — Vite does not
re-evaluate that file when a module appears, so restart the dev server.
## Data
Content is fetched **in loaders, on the server**, through `~/lib/strapi`:
```ts
const { data } = await getMany<Story>("stories", { locale, sort: "publicationDate:desc" });
const story = requireEntity(await getBySlug<Story>("stories", params.slug, { locale }));
```
`getMany` · `getOne` · `getOneOrNull` · `getSingle` · `getBySlug` · `findFirst`, plus
`mediaUrl`, `requireEntity` and `notFound`. The client is server-only; `STRAPI_TOKEN` never
reaches the browser. Listing pages swallow CMS failures and fall back to the approved draft
copy, so the site stays up when Strapi is down — `requireEntity` is for genuine per-slug 404s.
SEO comes from `~/lib/seo` (`buildMeta` plus the JSON-LD builders). `root.tsx` already emits
the canonical link and the `en` / `ar` / `x-default` alternates for every route, so a page's
`meta` must **not** pass `alternates: true`; override the canonical with
`handle.canonicalPath` instead. In `meta`, read the root data with `readRootSeo(matches)` —
React Router 8 exposes `loaderData` on a match, not `data`.
## Styling
Two-tier design tokens: tier 1 is raw values (`app/styles/primitives.css`), tier 2 is meaning
(`semantic.css`, `typography.css`). **Components reference tier-2 tokens only** — no raw hex,
no tier-1 primitive, no raw font sizes. `:root` is the dark surface; `data-surface="light"`
flips a subtree and repaints itself.
Full reference: [`app/styles/README.md`](app/styles/README.md).
## Bilingual and RTL
Import from `~/i18n`: `useTranslation()` in components, `translate(locale, key)` in loaders,
`localizedPath` / `sectionPath` for links, `formatDate` / `formatNumber` for values. UI
strings live in `app/i18n/messages/en.json` and `ar.json` with identical, typechecked keys.
Lay out with **logical properties only** — `ps-` `pe-` `ms-` `me-` `start-` `end-`,
`margin-inline-start`, `text-align: start` — never `pl-` / `pr-` / `left` / `right`. Arabic
then mirrors for free. `rtl:` and `ltr:` variants exist for the rare case that cannot be
expressed logically.
## Docker
`Dockerfile` builds this application for production. Its build context is the **repository
root**, because the pnpm lockfile lives there:
```bash
docker build -f apps/web/Dockerfile -t sharper-web . # from the repository root
docker compose up -d --build web # or through compose
```
It is a pnpm adaptation of the React Router template Dockerfile: install with
`--filter web...`, `react-router build`, then serve `build/server/index.js` with
`react-router-serve` on port 3000, with a health check on `/en`.
## Further reading
- [React Router docs](https://reactrouter.com/) — framework mode, loaders, `meta`, `handle`
- [`../../README.md`](../../README.md) — setting the whole project up, both paths
- [`../../CLAUDE.md`](../../CLAUDE.md) — the house rules in short form
+88
View File
@@ -0,0 +1,88 @@
/* ============================================================================
SHARP-ER - stylesheet entry
----------------------------------------------------------------------------
Load order matters:
1. tailwindcss - preflight + the utility engine
2. primitives.css - TIER 1, raw scales in a @theme block
3. semantic.css - TIER 2, roles that alias tier 1, per surface
4. typography.css - type roles + @font-face + the EN/AR font switch
See ./styles/README.md for the rules components have to follow.
========================================================================= */
@import "tailwindcss";
@import "./styles/primitives.css";
@import "./styles/semantic.css";
@import "./styles/typography.css";
/* ---------------------------------------------------------------------------
DIRECTION VARIANTS
The site is served at /en/* (LTR) and /ar/* (RTL) and lays out with logical
properties, so these are only for the rare case that genuinely cannot be
expressed logically (an asymmetric icon, a background position).
------------------------------------------------------------------------ */
@custom-variant rtl (&:where([dir="rtl"], [dir="rtl"] *));
@custom-variant ltr (&:where([dir="ltr"], [dir="ltr"] *));
@layer base {
/* Every surface paints itself, so <footer data-surface="light"> inside a
dark page is complete on its own. :root defaults to the dark surface. */
html,
body,
[data-surface] {
background-color: var(--surface-page);
color: var(--text-primary);
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
font-family: var(--font-body);
/* Tier-2 only, like everything else: --type-body-base-* is the semantic
alias of the Body/Medium role (16/24) and lives in semantic.css. */
font-size: var(--type-body-base-size);
line-height: var(--type-body-base-leading);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: start;
}
::selection {
background-color: var(--surface-selection);
color: var(--text-primary);
}
:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* Headings inherit the heading family; size comes from a type role utility
so that an <h2> used as a card title can still be body-lg-bold. */
:is(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-heading);
}
a {
color: inherit;
text-decoration: inherit;
}
::placeholder {
color: var(--field-placeholder);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@@ -0,0 +1,62 @@
/* ============================================================================
ARROW ICON - figma `arrow-small-right` 84:4260 / `arrow-small-lift` 140:8078
----------------------------------------------------------------------------
The exported asset is at /assets/icons/arrow-small-right.svg. Its path is
filled with a hard-coded black, so it is applied as a mask over
`currentColor` instead of being dropped in an <img>: that is the only way the
arrow can turn white on a Primary button, dark grey on a Secondary one and
green on a Ghost one while remaining the exact exported bytes.
GEOMETRY, straight from Figma: a 24x24 clipped box (`--control-icon-size`)
with the 12.9996 x 11.414 leaf inset 20.8333% from the left, 25% from the
right and 26.22% top and bottom. The insets are physical on purpose - the
glyph is asymmetric, which app.css names as the one case that cannot be
expressed logically, so mirroring is done by flipping the whole box.
DIRECTION. Figma's left arrow is not a second drawing: node 140:8078 is this
same asset with `-scale-x-100`. So `forward` (the arrow that follows reading
order) is the exported right arrow in LTR and that same box mirrored in RTL,
which is exactly what the Arabic home 85:5029 shows in its hero CTA.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-arrow-icon {
position: relative;
display: inline-block;
flex: none;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
overflow: hidden;
}
.sh-arrow-icon__glyph {
position: absolute;
top: 26.22%;
bottom: 26.22%;
left: 20.8333%;
right: 25%;
background-color: currentColor;
-webkit-mask-image: url("/assets/icons/arrow-small-right.svg");
mask-image: url("/assets/icons/arrow-small-right.svg");
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
/* Mirror the box, not the glyph, so the 1px asymmetry of the inset mirrors
with it - this reproduces node 140:8078 rather than approximating it. */
.sh-arrow-icon[data-direction="backward"] {
transform: scaleX(-1);
}
[dir="rtl"] .sh-arrow-icon[data-direction="forward"] {
transform: scaleX(-1);
}
[dir="rtl"] .sh-arrow-icon[data-direction="backward"] {
transform: none;
}
}
@@ -0,0 +1,32 @@
import "./ArrowIcon.css";
export type ArrowIconDirection = "forward" | "backward";
export type ArrowIconProps = {
/**
* `forward` follows reading order - it points right in LTR and left in RTL,
* which is what the Arabic home hero does. `backward` is the opposite, for
* "previous" affordances such as pagination.
*/
direction?: ArrowIconDirection;
className?: string;
};
/**
* The arrow that rides along with the CTA label ("Discover SHARP-ER ->").
*
* Purely decorative: the label next to it carries the meaning, so the element
* is hidden from assistive technology. It paints in `currentColor`, so it takes
* the colour of whatever button or link it sits in with no props.
*/
export function ArrowIcon({ direction = "forward", className }: ArrowIconProps) {
return (
<span
aria-hidden="true"
data-direction={direction}
className={className ? `sh-arrow-icon ${className}` : "sh-arrow-icon"}
>
<span className="sh-arrow-icon__glyph" />
</span>
);
}
@@ -0,0 +1,247 @@
/* ============================================================================
BUTTON - figma frame 14:487 (Variant x Size x State)
----------------------------------------------------------------------------
Only TIER-2 semantic tokens are referenced here. The `--sh-btn-*` custom
properties are component-local plumbing: each variant block binds them to a
tier-2 token once, and the state rules below read them, so the whole 5x3x3
matrix is expressed as five small blocks instead of forty-five selectors.
Everything lives in @layer components so a Tailwind utility passed through
`className` (`w-full`, `mt-lg`, `text-text-secondary`, ...) still wins.
The layer statement is a defensive no-op when app.css has already declared
the same order, and makes this file correct on its own if it ever loads first.
HEIGHTS. Figma draws the stroke *inside* the frame, so a Secondary button is
the same height as a Primary one. Borders are therefore subtracted from the
padding, which lands every size on its designed box:
sm 6.5*2 + 20 + 1.5*2 = 36 md 10.5*2 + 24 + 1.5*2 = 48
lg 10.5*2 + 28 + 1.5*2 = 52
The 20/24/28 line-heights come from the type role the component puts on the
element (body-sm-bold / body-md-bold / body-lg-bold).
DEVIATIONS FROM FIGMA, deliberate:
* Focus-visible is not drawn in Figma at all. WCAG 2.4.7 requires it, so the
semantic focus ring is applied here; filled variants switch to the inverse
ring colour because a green ring on a green fill is invisible.
* "Ghost / Large / Default" is the one cell in the matrix with no padding
while its own Hover and Disabled cells have it - an authoring slip that
would make the button jump on hover. Padding is kept on all ghost states.
* Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than
Figma's 40% opacity, so the page never shows through a disabled control.
`--control-opacity-disabled` still holds the 0.4 if that is ever wanted.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------
Base - shared by every variant and size.
A one-cell grid stacks the label and the loading spinner without either
transforms or absolute offsets, so it stays correct under dir="rtl".
------------------------------------------------------------------ */
.sh-btn {
display: inline-grid;
grid-template-areas: "sh-btn-stack";
place-items: center;
border-style: solid;
border-width: var(--control-border-width);
border-radius: var(--radius-control);
background-color: var(--sh-btn-bg);
color: var(--sh-btn-fg);
border-color: var(--sh-btn-border);
text-align: center;
text-decoration: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-btn--block {
display: grid;
inline-size: 100%;
}
.sh-btn__content {
grid-area: sh-btn-stack;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--control-gap);
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
/* The 24px box the icon is drawn inside in Figma. Kept on the slot rather
than on the icon so a consumer's own glyph inherits the same geometry. */
.sh-btn__icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
}
/* ---------------------------------------------------------------------
Sizes - Figma: sm 16/8 (space.md/space.xs), md + lg 24/12 (space.lg/sm)
------------------------------------------------------------------ */
.sh-btn[data-size="sm"] {
padding-inline: calc(var(--control-padding-inline-sm) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-sm) - var(--control-border-width));
}
.sh-btn[data-size="md"] {
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
}
.sh-btn[data-size="lg"] {
padding-inline: calc(var(--control-padding-inline-lg) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-lg) - var(--control-border-width));
}
/* ---------------------------------------------------------------------
Variants - the only place a token name appears twice.
------------------------------------------------------------------ */
.sh-btn[data-variant="primary"] {
--sh-btn-bg: var(--interactive-primary-bg);
--sh-btn-bg-hover: var(--interactive-primary-bg-hover);
--sh-btn-bg-active: var(--interactive-primary-bg-active);
--sh-btn-bg-disabled: var(--interactive-primary-bg-disabled);
--sh-btn-fg: var(--interactive-primary-fg);
--sh-btn-fg-hover: var(--interactive-primary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-primary-fg-disabled);
--sh-btn-border: var(--interactive-primary-border);
--sh-btn-border-hover: var(--interactive-primary-border-hover);
--sh-btn-border-disabled: var(--interactive-primary-border-disabled);
}
.sh-btn[data-variant="secondary"] {
--sh-btn-bg: var(--interactive-secondary-bg);
--sh-btn-bg-hover: var(--interactive-secondary-bg-hover);
--sh-btn-bg-active: var(--interactive-secondary-bg-active);
--sh-btn-bg-disabled: var(--interactive-secondary-bg-disabled);
--sh-btn-fg: var(--interactive-secondary-fg);
--sh-btn-fg-hover: var(--interactive-secondary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-secondary-fg-disabled);
--sh-btn-border: var(--interactive-secondary-border);
--sh-btn-border-hover: var(--interactive-secondary-border-hover);
--sh-btn-border-disabled: var(--interactive-secondary-border-disabled);
}
.sh-btn[data-variant="dark"] {
--sh-btn-bg: var(--interactive-dark-bg);
--sh-btn-bg-hover: var(--interactive-dark-bg-hover);
--sh-btn-bg-active: var(--interactive-dark-bg-active);
--sh-btn-bg-disabled: var(--interactive-dark-bg-disabled);
--sh-btn-fg: var(--interactive-dark-fg);
--sh-btn-fg-hover: var(--interactive-dark-fg-hover);
--sh-btn-fg-disabled: var(--interactive-dark-fg-disabled);
--sh-btn-border: var(--interactive-dark-border);
--sh-btn-border-hover: var(--interactive-dark-border-hover);
--sh-btn-border-disabled: var(--interactive-dark-border-disabled);
}
.sh-btn[data-variant="danger"] {
--sh-btn-bg: var(--interactive-danger-bg);
--sh-btn-bg-hover: var(--interactive-danger-bg-hover);
--sh-btn-bg-active: var(--interactive-danger-bg-active);
--sh-btn-bg-disabled: var(--interactive-danger-bg-disabled);
--sh-btn-fg: var(--interactive-danger-fg);
--sh-btn-fg-hover: var(--interactive-danger-fg-hover);
--sh-btn-fg-disabled: var(--interactive-danger-fg-disabled);
--sh-btn-border: var(--interactive-danger-border);
--sh-btn-border-hover: var(--interactive-danger-border-hover);
--sh-btn-border-disabled: var(--interactive-danger-border-disabled);
}
.sh-btn[data-variant="ghost"] {
--sh-btn-bg: var(--interactive-ghost-bg);
--sh-btn-bg-hover: var(--interactive-ghost-bg-hover);
--sh-btn-bg-active: var(--interactive-ghost-bg-active);
--sh-btn-bg-disabled: var(--interactive-ghost-bg-disabled);
--sh-btn-fg: var(--interactive-ghost-fg);
--sh-btn-fg-hover: var(--interactive-ghost-fg-hover);
--sh-btn-fg-disabled: var(--interactive-ghost-fg-disabled);
--sh-btn-border: var(--interactive-ghost-border);
--sh-btn-border-hover: var(--interactive-ghost-border-hover);
--sh-btn-border-disabled: var(--interactive-ghost-border-disabled);
}
/* Figma gives the ghost md/lg cells radius.md while every other cell uses
radius.sm - reproduced rather than normalised. */
.sh-btn[data-variant="ghost"][data-size="md"],
.sh-btn[data-variant="ghost"][data-size="lg"] {
border-radius: var(--radius-control-lg);
}
/* ---------------------------------------------------------------------
States
------------------------------------------------------------------ */
@media (hover: hover) {
.sh-btn:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-hover);
color: var(--sh-btn-fg-hover);
border-color: var(--sh-btn-border-hover);
}
}
/* No `-fg-active` / `-border-active` exists in the token contract, so the
pressed state deepens the fill only. */
.sh-btn:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-active);
}
.sh-btn:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* A green ring on a green (or red, or near-black) fill reads as nothing. */
.sh-btn[data-variant="primary"]:focus-visible,
.sh-btn[data-variant="danger"]:focus-visible,
.sh-btn[data-variant="dark"]:focus-visible {
outline-color: var(--focus-ring-color-inverse);
}
.sh-btn:disabled,
.sh-btn[aria-disabled="true"] {
background-color: var(--sh-btn-bg-disabled);
color: var(--sh-btn-fg-disabled);
border-color: var(--sh-btn-border-disabled);
cursor: not-allowed;
}
/* ---------------------------------------------------------------------
Loading - the label keeps its box (no reflow) and stays in the
accessibility tree, which `visibility: hidden` would not allow.
------------------------------------------------------------------ */
.sh-btn[data-loading="true"] {
cursor: progress;
}
.sh-btn[data-loading="true"] .sh-btn__content {
opacity: 0;
}
.sh-btn__spinner {
grid-area: sh-btn-stack;
inline-size: 1em;
block-size: 1em;
border: var(--control-border-width) solid currentColor;
border-block-start-color: transparent;
border-radius: var(--radius-pill);
/* The contract publishes fast/base/slow only; two slows is the ~640ms a
spinner wants without inventing a duration token. */
animation: sh-btn-spin calc(var(--motion-duration-slow) * 2) linear infinite;
}
}
@keyframes sh-btn-spin {
to {
rotate: 1turn;
}
}
@@ -0,0 +1,151 @@
import type { ComponentPropsWithRef, ElementType, ReactNode, SyntheticEvent } from "react";
import "./Button.css";
export type ButtonVariant = "primary" | "secondary" | "dark" | "danger" | "ghost";
/** Figma calls these Small / Medium / Large; they resolve to 36 / 48 / 52px. */
export type ButtonSize = "sm" | "md" | "lg";
/**
* Each size owns a published type role rather than a raw font-size, and the
* role's line-height is what makes the button land on its designed height.
*/
const TYPE_ROLE: Record<ButtonSize, string> = {
sm: "body-sm-bold",
md: "body-md-bold",
lg: "body-lg-bold",
};
type ButtonOwnProps = {
variant?: ButtonVariant;
size?: ButtonSize;
/** Rendered in the inline-start 24px slot - the left in LTR, the right in RTL. */
iconStart?: ReactNode;
/** Rendered in the inline-end slot. Pass `<ArrowIcon />` for the CTA arrow. */
iconEnd?: ReactNode;
/** Keeps the label's box, overlays a spinner and sets `aria-busy`. */
isLoading?: boolean;
disabled?: boolean;
/** Stretch to the width of the parent (`display: grid` instead of inline). */
fullWidth?: boolean;
className?: string;
children?: ReactNode;
};
export type ButtonProps<E extends ElementType = "button"> = ButtonOwnProps & {
/**
* The element to render. Defaults to `button`; pass react-router's `Link`
* (plus its `to`) for navigation, or `"a"` for an external href. Props of the
* chosen element are type-checked, so `as={Link}` requires `to`.
*/
as?: E;
} & Omit<ComponentPropsWithRef<E>, keyof ButtonOwnProps | "as">;
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
function blockActivation(event: SyntheticEvent): void {
event.preventDefault();
event.stopPropagation();
}
/**
* The SHARP-ER button - figma frame 14:487.
*
* Polymorphic: renders a native `<button>` by default and anything else through
* `as`. `ref` is an ordinary prop in React 19, so it travels with the rest of
* the element props and lands on whichever element `as` selected.
*/
export function Button<E extends ElementType = "button">({
as,
variant = "primary",
size = "md",
iconStart,
iconEnd,
isLoading = false,
disabled = false,
fullWidth = false,
className,
children,
...rest
}: ButtonProps<E>) {
const Component = (as ?? "button") as ElementType;
const isNativeButton = Component === "button";
const isDisabled = disabled || isLoading;
const elementProps = rest as Record<string, unknown>;
/**
* A native button gets the real `disabled` attribute. Anything else (a Link,
* an anchor) has no such attribute, so it is taken out of the tab order and
* its activation is swallowed instead. Both forms carry `aria-disabled`.
*/
const stateProps: Record<string, unknown> = isNativeButton
? {
// Default to a plain button so a button inside a form never submits it
// by accident; pass type="submit" explicitly when that is the intent.
type: (elementProps.type as string | undefined) ?? "button",
disabled: isDisabled,
}
: isDisabled
? { tabIndex: -1, onClick: blockActivation }
: {};
return (
<Component
{...elementProps}
{...stateProps}
data-variant={variant}
data-size={size}
data-loading={isLoading ? "true" : undefined}
aria-busy={isLoading || undefined}
aria-disabled={isDisabled || undefined}
className={cx("sh-btn", TYPE_ROLE[size], fullWidth && "sh-btn--block", className)}
>
<span className="sh-btn__content">
{iconStart ? <span className="sh-btn__icon">{iconStart}</span> : null}
<span className="sh-btn__label">{children}</span>
{iconEnd ? <span className="sh-btn__icon">{iconEnd}</span> : null}
</span>
{isLoading ? <span className="sh-btn__spinner" aria-hidden="true" /> : null}
</Component>
);
}
/* ----------------------------------------------------------------------------
USAGE (kept as a comment - the project has no Storybook)
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
// The home hero CTA, Home EN 253:4799 / Home AR 85:5029.
// The arrow sits inline-end, so RTL moves it to the left and mirrors it.
<Button as={Link} to="/en/about" size="lg" iconEnd={<ArrowIcon />}>
Discover SHARP-ER
</Button>
// Every variant, all three sizes.
<Button variant="primary">Apply</Button>
<Button variant="secondary" size="sm">Read more</Button>
<Button variant="dark" size="lg">Download the report</Button>
<Button variant="danger">Withdraw application</Button>
<Button variant="ghost" iconEnd={<ArrowIcon />}>All news</Button>
// Submitting a form: aria-busy is set and the label keeps its box.
<Button type="submit" isLoading={isSubmitting} fullWidth>Send message</Button>
// Disabled - aria-disabled on both the native and the link form.
<Button disabled>Applications closed</Button>
<Button as={Link} to="/en/events" disabled>Registration closed</Button>
// A "previous" control, mirrored the other way.
<Button variant="ghost" iconStart={<ArrowIcon direction="backward" />}>
Previous
</Button>
// An external link keeps anchor semantics and prop typing.
<Button as="a" href="https://europa.eu" target="_blank" rel="noreferrer">
European Union
</Button>
------------------------------------------------------------------------- */
@@ -0,0 +1,4 @@
export { Button } from "./Button";
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
export { ArrowIcon } from "./ArrowIcon";
export type { ArrowIconDirection, ArrowIconProps } from "./ArrowIcon";
@@ -0,0 +1,97 @@
/* ============================================================================
Card.CTA - Figma 25:802 (720 x 141)
----------------------------------------------------------------------------
A call to action on the accent fill: eyebrow, headline, supporting line and
one outlined action. The action is the only interactive element, so this
card is never stretched over a link.
The panel carries data-surface="dark" because it is a dark green field
whatever the page around it is doing: inside it the focus ring, the hover
veil and every text role resolve for a dark background, on a light page too.
USAGE
-----
<Card.CTA
eyebrow="Ready to join us?"
headline="Empower your local community"
body="Apply now to join the upcoming civil dialogue summit and help draft public policies."
action={{ href: "/en/opportunities", label: "Learn more" }}
/>
// Arabic
<Card.CTA
headline="مكّن مجتمعك المحلي"
action={{ href: "/ar/opportunities", label: "اعرف المزيد" }}
/>
// External application form
<Card.CTA
headline="Call for expression of interest"
action={{ href: "https://forms.example.org/eoi", label: "Apply" }}
/>
========================================================================= */
import { Button } from "../Button";
import { CardLink, cx } from "./CardShell";
import type { CardLinkTarget, CardTitleElement } from "./types";
export interface CardCTAAction extends CardLinkTarget {
label: string;
}
export interface CardCTAProps {
headline: string;
action: CardCTAAction;
/** Small line above the headline ("Ready to join us?"). */
eyebrow?: string;
body?: string;
headlineAs?: CardTitleElement;
className?: string;
}
export function CardCTA({
headline,
action,
eyebrow,
body,
headlineAs = "h2",
className,
}: CardCTAProps) {
const Headline = headlineAs;
const { label, ...destination } = action;
return (
<section
data-surface="dark"
className={cx(
"sh-card relative flex flex-col gap-lg rounded-surface p-10",
"bg-surface-accent text-text-on-accent shadow-[var(--elevation-popover)]",
"sm:flex-row sm:items-center",
className,
)}
>
<div className="flex flex-1 flex-col gap-xs">
{eyebrow ? <p className="label-md">{eyebrow}</p> : null}
<Headline className="heading-h3">{headline}</Headline>
{/* Figma dims this line to 90%; left at full strength because white on
the accent green is already close to the AA floor. */}
{body ? <p className="body-md">{body}</p> : null}
</div>
{/* The outlined action is the project Button, not a look-alike: inside
this dark subtree its secondary variant already resolves to the white
stroke and white label the design asks for. CardLink keeps internal
hrefs routing client-side and external ones a plain anchor. */}
<Button
as={CardLink}
variant="secondary"
className="sh-card-cta-action shrink-0"
{...destination}
>
{label}
</Button>
</section>
);
}
@@ -0,0 +1,114 @@
/* ============================================================================
Card.Content - Figma 25:752 (360 x 372)
----------------------------------------------------------------------------
The news / blog / programme card: image, category badge, eyebrow, title,
excerpt and a "Read more" affordance. The whole card is one link.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
<Card.Content
as="li"
href="/en/news/civic-dialogue"
eyebrow="Programme"
badge="Rule of Law"
title="Strengthening Civic Dialogue"
excerpt="Supporting coordinated efforts among institutions, civil society,
and local actors to foster community resilience."
actionLabel="Read more"
image={{ src: "/media/dialogue.jpg", alt: "" }}
/>
</ul>
// Arabic - the copy is the only thing that changes, RTL mirrors itself.
<Card.Content
href="/ar/news/civic-dialogue"
title="تعزيز الحوار المدني"
actionLabel="اقرأ المزيد"
image={{ src: "/media/dialogue.jpg", alt: "" }}
/>
// Above the fold - skip lazy loading for the lead card only.
<Card.Content … image={{ src: "/media/lead.jpg", alt: "", priority: true }} />
========================================================================= */
import {
CardActionHint,
CardEyebrow,
CardLink,
CardMedia,
CardShell,
cx,
} from "./CardShell";
import type { CardElement, CardImage, CardTitleElement } from "./types";
export interface CardContentProps {
/** Where the whole card goes. */
href: string;
title: string;
/**
* Label of the "Read more" affordance. Required rather than defaulted so an
* Arabic page cannot silently ship the English string.
*/
actionLabel: string;
/** Accent kicker above the title, rendered uppercase. */
eyebrow?: string;
excerpt?: string;
/** Chip over the top-inline-start corner of the image ("Rule of Law"). */
badge?: string;
image?: CardImage;
as?: CardElement;
/** Heading level - match the surrounding page outline. */
titleAs?: CardTitleElement;
className?: string;
}
export function CardContent({
href,
title,
actionLabel,
eyebrow,
excerpt,
badge,
image,
as = "article",
titleAs = "h3",
className,
}: CardContentProps) {
const Title = titleAs;
return (
<CardShell as={as} linked className={cx("flex-col overflow-hidden", className)}>
{image ? (
<CardMedia image={image} className="aspect-[9/5] w-full">
{/* The chip sits on a scrim over the photo, so its label has to stay
light on both surfaces - text-on-accent is the only always-white
role, text-primary would go dark on the light surface. */}
{badge ? (
<span className="absolute start-md top-md rounded-control-lg bg-overlay-scrim px-xs py-2xs label-md text-text-on-accent backdrop-blur-[4px]">
{badge}
</span>
) : null}
</CardMedia>
) : null}
<div className="flex flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
{eyebrow ? <CardEyebrow>{eyebrow}</CardEyebrow> : null}
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{title}
</CardLink>
</Title>
{excerpt ? (
<p className="body-sm text-text-secondary line-clamp-3">{excerpt}</p>
) : null}
</div>
<CardActionHint label={actionLabel} />
</div>
</CardShell>
);
}
@@ -0,0 +1,104 @@
/* ============================================================================
Card.Horizontal - Figma 25:781 (720 x 200)
----------------------------------------------------------------------------
The listing row: a 240px image beside the text, image at the inline start so
it moves to the right in Arabic on its own. Stacks to a single column below
the sm breakpoint. Like Card.Content, the whole row is one link.
USAGE
-----
<ul className="flex flex-col gap-lg">
<Card.Horizontal
as="li"
href="/en/resources/governance-systems"
eyebrow="Dialogue report"
title="Strengthening Governance Systems"
excerpt="Our annual deep-dive reports focus on structural coordination,
bringing diverse multi-stakeholder views into policy discussions."
actionLabel="Read full report"
image={{ src: "/media/roundtable.jpg", alt: "" }}
/>
</ul>
// External resource - rendered as a plain anchor, not a client-side route.
<Card.Horizontal
href="https://example.org/report.pdf"
title="Annual review 2026"
actionLabel="Download"
/>
========================================================================= */
import {
CardActionHint,
CardEyebrow,
CardLink,
CardMedia,
CardShell,
cx,
} from "./CardShell";
import type { CardElement, CardImage, CardTitleElement } from "./types";
export interface CardHorizontalProps {
href: string;
title: string;
/** Label of the "Read full report" affordance - required, so Arabic cannot inherit English. */
actionLabel: string;
eyebrow?: string;
excerpt?: string;
image?: CardImage;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}
export function CardHorizontal({
href,
title,
actionLabel,
eyebrow,
excerpt,
image,
as = "article",
titleAs = "h3",
className,
}: CardHorizontalProps) {
const Title = titleAs;
return (
<CardShell
as={as}
linked
className={cx(
"flex-col overflow-hidden sm:flex-row sm:items-stretch",
className,
)}
>
{/* Stacked: the ratio reserves the height. Side by side: a fixed 240px
column that stretches to whatever the text column needs. */}
{image ? (
<CardMedia
image={image}
className="aspect-[6/5] w-full sm:aspect-auto sm:w-60 sm:shrink-0"
/>
) : null}
<div className="flex flex-1 flex-col justify-between gap-md p-lg">
<div className="flex flex-col gap-xs">
{eyebrow ? <CardEyebrow>{eyebrow}</CardEyebrow> : null}
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{title}
</CardLink>
</Title>
{excerpt ? (
<p className="body-sm text-text-secondary line-clamp-2">{excerpt}</p>
) : null}
</div>
<CardActionHint label={actionLabel} />
</div>
</CardShell>
);
}
@@ -0,0 +1,238 @@
/* ============================================================================
CARD - shared building blocks
----------------------------------------------------------------------------
Every card in this directory is assembled from the pieces below, so the
surface, radius, border, elevation and link behaviour are decided once.
STYLING RULE for this whole directory:
* colour and radius come from the tier-2 Tailwind bridge
(bg-surface-raised, text-text-accent, border-border-subtle,
rounded-surface, ...) - never a hex, never a tier-1 primitive;
* tokens without a bridge utility are spent as arbitrary values,
e.g. shadow-[var(--elevation-card)];
* spacing uses the named scale (p-lg = 24, gap-md = 16) so the Figma
numbers stay readable;
* everything directional uses logical utilities (ps-/pe-/start-/end-)
so the Arabic build mirrors without a second stylesheet.
Nothing here is surface-aware by hand: the same markup is correct on
[data-surface="dark"] and [data-surface="light"] because every colour is a
role the surface re-points.
========================================================================= */
import type { CSSProperties, ReactElement, ReactNode } from "react";
import { Link } from "react-router";
import "./card.css";
import type {
CardElement,
CardIconName,
CardImage,
CardLinkTarget,
} from "./types";
/* ---------------------------------------------------------------------------
LINK
------------------------------------------------------------------------ */
/** Anything with a scheme, a protocol-relative host or a fragment is not a route. */
const NON_ROUTE_HREF = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
/**
* React Router sits in the hoisted root of this workspace, where it resolves
* its React types from the @types/react 18 that Strapi pulls in, so its props
* are declared against a ReactNode that is not ours (no bigint, and a
* ReactPortal that still requires children). Re-declaring the component once
* here keeps every call site below honest instead of casting at each of them.
* Delete this alias when the workspace resolves to a single @types/react.
*/
const RouterLink = Link as unknown as (props: {
to: string;
className?: string;
target?: string;
rel?: string;
children: ReactNode;
}) => ReactElement;
export interface CardLinkProps extends CardLinkTarget {
className?: string;
children: ReactNode;
}
/**
* Routes internal hrefs through React Router so navigation stays client-side,
* and falls back to a plain anchor for external links, mailto: and fragments.
*/
export function CardLink({ href, className, children, ...rest }: CardLinkProps) {
if (NON_ROUTE_HREF.test(href)) {
return (
<a href={href} className={className} {...rest}>
{children}
</a>
);
}
return (
<RouterLink to={href} className={className} {...rest}>
{children}
</RouterLink>
);
}
/* ---------------------------------------------------------------------------
ICON
------------------------------------------------------------------------ */
/** The assets exported from the Figma icon frame, served from public/. */
const CARD_ICON_SRC: Record<CardIconName, string> = {
"arrow-right": "url('/assets/icons/arrow-right.svg')",
"arrow-up": "url('/assets/icons/arrow-up.svg')",
linkedin: "url('/assets/icons/linkedin.svg')",
mail: "url('/assets/icons/mail.svg')",
globe: "url('/assets/icons/globe.svg')",
};
export interface CardIconProps {
name: CardIconName;
/** Designed box in px - 14 for the 14x14 icons, 10 for the trend arrow. */
size: number;
/** Mirror in RTL. True for the horizontal arrow, false for the vertical one. */
directional?: boolean;
className?: string;
}
/**
* Draws an exported SVG as a mask over a currentColor fill, so the icon keeps
* the geometry Figma exported while taking its colour from the token in force.
* Always decorative - the adjacent text carries the meaning.
*/
export function CardIcon({ name, size, directional, className }: CardIconProps) {
return (
<span
aria-hidden="true"
className={cx(
"sh-card-icon",
directional && "sh-card-icon--directional",
className,
)}
style={
{
"--sh-card-icon-src": CARD_ICON_SRC[name],
width: size,
height: size,
} as CSSProperties
}
/>
);
}
/* ---------------------------------------------------------------------------
SHELL
------------------------------------------------------------------------ */
export interface CardShellProps {
/** `li` inside a list, `section` for a landmark, `article` by default. */
as?: CardElement;
/**
* The card contains a stretched link. Adds the hover lift and lets the
* ::after overlay in card.css position against the card.
*/
linked?: boolean;
className?: string;
children: ReactNode;
}
/**
* The surface every card shares: raised fill, hairline border, 12px corner
* (Figma radius/lg) and the card elevation.
*/
export function CardShell({
as = "article",
linked = false,
className,
children,
}: CardShellProps) {
const Tag = as;
return (
<Tag
className={cx(
"sh-card",
linked && "sh-card--linked",
"relative flex bg-surface-raised text-text-primary",
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]",
className,
)}
>
{children}
</Tag>
);
}
/* ---------------------------------------------------------------------------
MEDIA
------------------------------------------------------------------------ */
export interface CardMediaProps {
image: CardImage;
/** Sets the box: an aspect-ratio utility, a fixed size, or both per breakpoint. */
className?: string;
/** Overlays that sit on the image, e.g. the category badge. */
children?: ReactNode;
}
/**
* A media box whose height is decided by the wrapper, never by the file, so
* the layout is stable before the image loads.
*/
export function CardMedia({ image, className, children }: CardMediaProps) {
return (
<div className={cx("sh-card-media relative overflow-hidden", className)}>
<img
src={image.src}
srcSet={image.srcSet}
sizes={image.sizes}
alt={image.alt}
loading={image.priority ? "eager" : "lazy"}
fetchPriority={image.priority ? "high" : undefined}
decoding="async"
className="absolute inset-0 block size-full object-cover"
/>
{children}
</div>
);
}
/* ---------------------------------------------------------------------------
TEXT ATOMS
------------------------------------------------------------------------ */
/** The small accent kicker above a card title ("PROGRAMME", "DIALOGUE REPORT"). */
export function CardEyebrow({ children }: { children: ReactNode }) {
return <p className="label-md uppercase text-text-accent">{children}</p>;
}
/**
* The "Read more ->" affordance. Decorative: the stretched title link already
* carries the destination, so announcing this again would just be noise.
*/
export function CardActionHint({ label }: { label: string }) {
return (
<p
aria-hidden="true"
className="inline-flex items-center gap-xs body-sm-bold text-text-accent"
>
{label}
<CardIcon name="arrow-right" size={14} directional />
</p>
);
}
/* ---------------------------------------------------------------------------
UTIL
------------------------------------------------------------------------ */
/** Joins class names, dropping anything falsy. */
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
@@ -0,0 +1,106 @@
/* ============================================================================
Card.Stat - Figma 25:792 (260 x 166)
----------------------------------------------------------------------------
The figure card used by the home page stats strip: a large accent number,
an optional trend chip, the label it counts, a rule and a caption. Never a
link - a number is not a destination.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-4">
<Card.Stat
as="li"
value="2,450"
label="Participants reached"
caption="Cumulative regional outreach, updated each quarter."
trend={{ label: "12%", direction: "up", description: "up 12% on last quarter" }}
/>
<Card.Stat as="li" value="18" label="Municipalities engaged" />
</ul>
// Arabic - Eastern Arabic numerals belong in the value string itself.
<Card.Stat value="٢٬٤٥٠" label="عدد المشاركين" />
========================================================================= */
import { CardIcon, CardShell, cx } from "./CardShell";
import type { CardElement } from "./types";
export interface CardStatTrend {
/** Visible text, e.g. "12%". */
label: string;
/** Up is the accent fill, down the danger fill. Defaults to up. */
direction?: "up" | "down";
/**
* Spoken instead of the bare figure, e.g. "up 12% on last quarter". The
* arrow is decorative, so without this a screen reader hears only "12%".
*/
description?: string;
}
export interface CardStatProps {
/** Pre-formatted for the locale - the card never formats a number itself. */
value: string;
label: string;
caption?: string;
trend?: CardStatTrend;
as?: CardElement;
className?: string;
}
export function CardStat({
value,
label,
caption,
trend,
as = "article",
className,
}: CardStatProps) {
const isDown = trend?.direction === "down";
return (
<CardShell
as={as}
className={cx("flex-col items-start gap-md p-lg", className)}
>
<div className="flex w-full items-center justify-between gap-sm">
<p className="display-sm tabular-nums text-text-accent">{value}</p>
{trend ? (
/* Figma paints the chip label in text/accent on a background/accent
fill - green on green, invisible. Kept as the accent fill with the
always-white on-accent label, and the danger fill when the figure
is falling. */
<span
className={cx(
"inline-flex shrink-0 items-center gap-2xs rounded-control-lg px-xs py-2xs",
"label-md text-text-on-accent",
isDown ? "bg-surface-danger" : "bg-surface-accent",
)}
>
<CardIcon
name="arrow-up"
size={10}
className={isDown ? "rotate-180" : undefined}
/>
{trend.description ? (
<>
<span aria-hidden="true">{trend.label}</span>
<span className="sr-only">{trend.description}</span>
</>
) : (
trend.label
)}
</span>
) : null}
</div>
<p className="body-md-bold text-text-primary">{label}</p>
<div aria-hidden="true" className="w-full border-t border-border-subtle" />
{caption ? (
<p className="body-sm text-text-secondary">{caption}</p>
) : null}
</CardShell>
);
}
@@ -0,0 +1,140 @@
/* ============================================================================
Card.Team - Figma 25:765 (280 x 295)
----------------------------------------------------------------------------
Portrait in an accent frame, name, role, affiliation and up to three social
links. The social links are real links, so the card itself is only stretched
over a link when `href` is given - and the links are lifted above the
overlay so they stay clickable.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-4">
<Card.Team
as="li"
name="Dr. Helen Vance"
role="Chief Programme Director"
affiliation="SHARP-ER Governance Initiative"
portrait={{ src: "/media/team/helen-vance.jpg", alt: "" }}
socials={[
{ kind: "linkedin", href: "https://www.linkedin.com/in/example" },
{ kind: "mail", href: "mailto:helen.vance@example.org", label: "Email" },
{ kind: "website", href: "https://example.org", label: "Website" },
]}
/>
</ul>
// Optional bio page - the whole card becomes clickable, socials still work.
<Card.Team href="/en/about/helen-vance" name="Dr. Helen Vance" … />
========================================================================= */
import { CardLink, CardMedia, CardShell, CardIcon, cx } from "./CardShell";
import type { CardElement, CardIconName, CardImage, CardTitleElement } from "./types";
export type CardTeamSocialKind = "linkedin" | "mail" | "website";
export interface CardTeamSocial {
kind: CardTeamSocialKind;
href: string;
/**
* Accessible name of the link. Defaults to the platform name; pass a
* translated string on Arabic pages ("البريد الإلكتروني", "الموقع").
*/
label?: string;
}
export interface CardTeamProps {
name: string;
role?: string;
/** Team, unit or initiative the person belongs to. */
affiliation?: string;
portrait?: CardImage;
socials?: CardTeamSocial[];
/** Optional bio page. Given, the whole card links to it. */
href?: string;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}
const SOCIAL_ICON: Record<CardTeamSocialKind, CardIconName> = {
linkedin: "linkedin",
mail: "mail",
website: "globe",
};
const SOCIAL_LABEL: Record<CardTeamSocialKind, string> = {
linkedin: "LinkedIn",
mail: "Email",
website: "Website",
};
export function CardTeam({
name,
role,
affiliation,
portrait,
socials,
href,
as = "article",
titleAs = "h3",
className,
}: CardTeamProps) {
const Title = titleAs;
return (
<CardShell
as={as}
linked={Boolean(href)}
className={cx("flex-col items-center gap-5 p-xl text-center", className)}
>
{/* 96px portrait inside the 4px accent frame, exactly as drawn. */}
{portrait ? (
<CardMedia
image={portrait}
className="size-24 shrink-0 rounded-surface border-4 border-border-accent"
/>
) : null}
<div className="flex w-full flex-col gap-xs">
<Title className="sh-card-title body-md-bold text-text-primary">
{href ? (
<CardLink href={href} className="sh-card-link">
{name}
</CardLink>
) : (
name
)}
</Title>
{role ? <p className="body-sm text-text-secondary">{role}</p> : null}
{affiliation ? (
<p className="body-sm text-text-accent">{affiliation}</p>
) : null}
</div>
{socials && socials.length > 0 ? (
/* sh-card-above keeps these clickable through the stretched-link
overlay when the card as a whole is a link. */
<ul className="sh-card-above flex items-start justify-center gap-sm">
{socials.map((social) => (
<li key={`${social.kind}-${social.href}`}>
{/* Figma strokes these icons in text/accent on a background/accent
chip - green on green, invisible. Painted with the always-white
on-accent role instead. */}
<CardLink
href={social.href}
className="flex size-8 items-center justify-center rounded-surface bg-surface-accent text-text-on-accent"
>
<CardIcon name={SOCIAL_ICON[social.kind]} size={14} />
<span className="sr-only">
{`${social.label ?? SOCIAL_LABEL[social.kind]} - ${name}`}
</span>
</CardLink>
</li>
))}
</ul>
) : null}
</CardShell>
);
}
+129
View File
@@ -0,0 +1,129 @@
/* ============================================================================
CARD - mechanics that cannot be expressed as a utility
----------------------------------------------------------------------------
Everything token-driven lives in the TSX as a tier-2 utility
(bg-surface-raised, text-text-accent, rounded-surface, ...). This file only
holds the three things a utility cannot do:
1. paint an exported SVG asset in the current token colour (mask),
2. the whole-card link overlay + its focus ring,
3. the hover transitions, which need a duration/easing token pair.
Class names are prefixed `sh-card` because this file is imported by the
component rather than by app/app.css, so it is not scoped by anything else.
Everything lives in @layer components so a Tailwind utility passed through
`className` (`rounded-surface`, `text-text-secondary`, ...) still wins.
The layer statement is a defensive no-op when app.css has already declared
the same order, and makes this file correct on its own if it ever loads first.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------------
ICONS
The SVGs exported from Figma (public/assets/icons/*.svg) have the brand
green baked in as stroke="#008764", which would be wrong on the dark surface
and inside the accent chips. Drawing the asset as a mask over a currentColor
fill keeps the exact exported geometry while the colour comes from whichever
semantic token the parent set. Size is set per instance so each icon keeps
its designed box (14px, or 10px for the stat trend arrow).
------------------------------------------------------------------------ */
.sh-card-icon {
display: block;
flex: none;
background-color: currentColor;
-webkit-mask-image: var(--sh-card-icon-src);
mask-image: var(--sh-card-icon-src);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
/* Arrows that mean "forward" point the other way in Arabic. Only the
horizontal arrow mirrors - the stat trend arrow is vertical. */
[dir="rtl"] .sh-card-icon--directional {
transform: scaleX(-1);
}
/* ---------------------------------------------------------------------------
WHOLE-CARD LINK
The anchor around the title stretches over the card with ::after, so the
entire card is clickable while the title stays the accessible name and no
interactive element is nested inside another.
z-index 1 / 2 are structural, not design values: 1 is the overlay, 2 lifts
anything that has to stay clickable above it (the team card's social links).
------------------------------------------------------------------------ */
.sh-card-link::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
}
.sh-card-above {
position: relative;
z-index: 2;
}
/* The ring belongs around the card, not around the inline title text. Where
:has() is unavailable the anchor keeps the global :focus-visible ring from
app.css, so focus is never invisible. */
@supports selector(:has(*)) {
.sh-card-link:focus-visible {
outline: none;
}
.sh-card:has(.sh-card-link:focus-visible) {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
}
/* ---------------------------------------------------------------------------
HOVER / FOCUS AFFORDANCES
------------------------------------------------------------------------ */
.sh-card--linked {
transition:
box-shadow var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard);
}
.sh-card--linked:hover {
border-color: var(--border-default);
box-shadow: var(--elevation-popover);
}
.sh-card--linked:hover .sh-card-title,
.sh-card--linked:has(.sh-card-link:focus-visible) .sh-card-title {
text-decoration: underline;
text-underline-offset: 2px;
}
.sh-card-media img {
transition: transform var(--motion-duration-slow) var(--motion-ease-standard);
}
.sh-card--linked:hover .sh-card-media img {
transform: scale(1.04);
}
/* app.css already neutralises transition durations under reduced motion, but a
transform still lands instantly - drop it entirely. */
@media (prefers-reduced-motion: reduce) {
.sh-card--linked:hover .sh-card-media img {
transform: none;
}
}
/* The CTA sits on the accent fill, where the green focus ring has almost no
contrast. Inside its data-surface="dark" subtree the inverse ring is white. */
.sh-card-cta-action:focus-visible {
outline-color: var(--focus-ring-color-inverse);
}
}
+71
View File
@@ -0,0 +1,71 @@
/* ============================================================================
CARD - Figma frame 25:809
----------------------------------------------------------------------------
Five card types that share one surface, one link mechanism and one icon
strategy. Import the namespace for readable markup:
import { Card } from "~/components/ui/Card";
<Card.Content … /> <Card.Team … /> <Card.Horizontal … />
<Card.Stat … /> <Card.CTA … />
or the components directly when you want the narrowest import:
import { CardStat } from "~/components/ui/Card";
Every card is correct on both surfaces without a prop: colour comes only
from tier-2 roles, which [data-surface="dark"] and [data-surface="light"]
re-point. See CardShell.tsx for the styling rules this directory follows.
========================================================================= */
export { CardContent } from "./CardContent";
export { CardTeam } from "./CardTeam";
export { CardHorizontal } from "./CardHorizontal";
export { CardStat } from "./CardStat";
export { CardCTA } from "./CardCTA";
export {
CardShell,
CardMedia,
CardIcon,
CardLink,
CardEyebrow,
CardActionHint,
} from "./CardShell";
export type { CardContentProps } from "./CardContent";
export type {
CardTeamProps,
CardTeamSocial,
CardTeamSocialKind,
} from "./CardTeam";
export type { CardHorizontalProps } from "./CardHorizontal";
export type { CardStatProps, CardStatTrend } from "./CardStat";
export type { CardCTAProps, CardCTAAction } from "./CardCTA";
export type {
CardShellProps,
CardMediaProps,
CardIconProps,
CardLinkProps,
} from "./CardShell";
export type {
CardElement,
CardIconName,
CardImage,
CardLinkTarget,
CardTitleElement,
} from "./types";
import { CardContent } from "./CardContent";
import { CardTeam } from "./CardTeam";
import { CardHorizontal } from "./CardHorizontal";
import { CardStat } from "./CardStat";
import { CardCTA } from "./CardCTA";
/** The card family, namespaced: Card.Content, Card.Team, Card.Horizontal, Card.Stat, Card.CTA. */
export const Card = {
Content: CardContent,
Team: CardTeam,
Horizontal: CardHorizontal,
Stat: CardStat,
CTA: CardCTA,
} as const;
+42
View File
@@ -0,0 +1,42 @@
/* ============================================================================
CARD - shared prop types
========================================================================= */
/** Elements a card shell is allowed to render as. `li` for grids and lists. */
export type CardElement = "article" | "div" | "li" | "section";
/** Heading level for a card title - pick the one that fits the page outline. */
export type CardTitleElement = "h2" | "h3" | "h4";
/**
* An image in a card.
*
* The card fixes the aspect ratio of every media box, so the space is reserved
* before the file arrives and nothing shifts. `priority` is for the rare card
* that is above the fold; everything else stays lazy.
*/
export interface CardImage {
src: string;
/** Pass "" for a purely decorative image so it is skipped by screen readers. */
alt: string;
srcSet?: string;
sizes?: string;
/** Above the fold: load eagerly at high priority instead of lazily. */
priority?: boolean;
}
/** A destination. Internal paths route client-side, everything else is a plain anchor. */
export interface CardLinkTarget {
/** `/en/news/launch` routes client-side; `https://…`, `mailto:…`, `#…` do not. */
href: string;
target?: string;
rel?: string;
}
/** The five icons the card family uses, each an asset exported from Figma. */
export type CardIconName =
| "arrow-right"
| "arrow-up"
| "linkedin"
| "mail"
| "globe";
@@ -0,0 +1,340 @@
/* ============================================================================
Footer - figma frame 140:7813
----------------------------------------------------------------------------
ONE component for all four published variants:
light EN 140:7812 | dark EN 140:7814 | light AR 140:8713 | dark AR 140:8685
`surface` picks the palette and the logo cut; direction comes from `<html dir>`
and every offset is a logical property, so the Arabic variants are the same
markup mirrored - there is no RTL branch anywhere below.
STYLING: Tailwind utilities for layout and spacing, tier-2 semantic tokens for
everything that carries meaning (`text-[var(--text-primary)]`). No raw hex, no
tier-1 primitive, no component stylesheet.
TWO DELIBERATE DEVIATIONS FROM THE PUBLISHED FRAMES, both noted inline:
1. the two EN frames disagree on the sitemap (140:7812 carries the real IA
but no column headings, 140:7814 carries headings but stale labels) -
this takes the newer IA and the headings;
2. `text/tertiary` (#949492) on white is ~2.9:1, so the small labels use
`--text-secondary` instead. Revert the two lines marked (a11y) for
pixel-exact parity.
========================================================================= */
import { Link } from "react-router";
import type {
FooterCopy,
FooterLink,
FooterPartner,
FooterProps,
FooterSocialLink,
} from "./types";
import {
DEFAULT_FOOTER_COPY,
DEFAULT_FOOTER_PARTNERS,
DEFAULT_FOOTER_SOCIAL_LINKS,
FOOTER_ASSETS,
FOOTER_EU_EMBLEM_SIZE,
FOOTER_WORDMARK_SIZE,
defaultFooterLinkGroups,
} from "./content";
import { FooterSocialIcon } from "./FooterSocialIcon";
const cx = (...parts: readonly (string | false | undefined)[]): string =>
parts.filter(Boolean).join(" ");
function definedOnly<T extends object>(source: Partial<T> | undefined): Partial<T> {
if (!source) return {};
return Object.fromEntries(
Object.entries(source).filter(([, value]) => value !== undefined),
) as Partial<T>;
}
/** Visible focus ring on every interactive element, straight off the focus tokens. */
const FOCUS_RING = cx(
"rounded-[var(--radius-control)]",
"focus-visible:[outline:var(--focus-ring-width)_solid_var(--focus-ring-color)]",
"focus-visible:[outline-offset:var(--focus-ring-offset)]",
);
/** Only `color` - `transition-colors` would also fade `outline-color`, which
makes the focus ring arrive late. */
const TRANSITION =
"transition-[color] duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
/** Body/Medium Bold links, as in figma 380:12385. */
const LINK_CLASS = cx(
FOCUS_RING,
TRANSITION,
"body-md-bold inline-block text-[var(--text-primary)]",
"hover:text-[var(--text-link)] hover:underline underline-offset-4",
);
/** Label/Medium, used for the column headings and the "implemented by" label. */
const LABEL_CLASS = "label-md uppercase";
/**
* Internal links go through the router so the footer navigates client-side;
* anything flagged `external` stays a plain anchor and opens in a new tab.
*/
function FooterNavLink({ link, className }: { link: FooterLink; className: string }) {
if (link.external) {
return (
<a href={link.href} className={className} target="_blank" rel="noopener noreferrer">
{link.label}
</a>
);
}
return (
<Link to={link.href} className={className} prefetch="intent">
{link.label}
</Link>
);
}
export function Footer({
surface = "dark",
locale = "en",
dir,
linkGroups,
socialLinks = DEFAULT_FOOTER_SOCIAL_LINKS,
partners = DEFAULT_FOOTER_PARTNERS,
copy: copyOverrides,
homeHref,
className,
id,
}: FooterProps) {
const isDark = surface === "dark";
// A CMS field that has not been filled in arrives as undefined; spreading it
// would blank the default, so only defined overrides are applied.
const copy: FooterCopy = { ...DEFAULT_FOOTER_COPY[locale], ...definedOnly(copyOverrides) };
const groups = linkGroups ?? defaultFooterLinkGroups(locale);
const home = homeHref ?? `/${locale}`;
return (
<footer
id={id}
dir={dir}
data-surface={surface}
className={cx(
// data-surface paints background-color and color itself (app.css), so
// the footer needs no colour utility of its own.
"w-full px-lg pb-2xl pt-[var(--layout-section-gap)] md:px-xl",
className,
)}
>
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-2xl">
{/* ---- brand + sitemap (figma 140:7684 / 140:8686) ---------------- */}
<div className="flex flex-col gap-2xl lg:flex-row lg:gap-3xl">
{/* --- brand column (figma 140:7685 / 140:8687) ----------------- */}
<div
className={cx(
"flex flex-col gap-md",
// the divider sits on the inline-end of the brand block, which is
// the left edge once <html dir="rtl"> mirrors the row.
"lg:w-[345px] lg:shrink-0 lg:border-e lg:border-[var(--border-subtle)] lg:pe-2xl",
)}
>
<Link to={home} aria-label={copy.homeLabel} className={cx(FOCUS_RING, "w-fit")}>
<img
src={isDark ? FOOTER_ASSETS.wordmarkInverse : FOOTER_ASSETS.wordmark}
alt="SHARP-ER"
width={FOOTER_WORDMARK_SIZE.width}
height={FOOTER_WORDMARK_SIZE.height}
className="block h-[27px] w-[196px]"
loading="lazy"
decoding="async"
/>
</Link>
<p className="body-sm text-[var(--text-secondary)]">{copy.tagline}</p>
<p className="body-sm max-w-[46ch] text-[var(--text-primary)]">
{copy.description}
</p>
{/* --- EU visibility (figma 140:7688 / 238:2325) -------------- */}
<div className="flex items-center gap-2xs pt-2xs">
<img
src={isDark ? FOOTER_ASSETS.euEmblemInverse : FOOTER_ASSETS.euEmblem}
alt={copy.euEmblemAlt}
width={FOOTER_EU_EMBLEM_SIZE.width}
height={FOOTER_EU_EMBLEM_SIZE.height}
className="block h-[32px] w-[48px] shrink-0"
loading="lazy"
decoding="async"
/>
<p className="body-sm-bold max-w-[24ch] text-[var(--text-primary)]">
{copy.euStatement}
</p>
</div>
{socialLinks.length > 0 && <SocialRow copy={copy} links={socialLinks} />}
</div>
{/* --- sitemap columns (figma 380:12402) ------------------------ */}
<nav aria-label={copy.navLabel} className="flex flex-1 flex-col gap-2xl sm:flex-row sm:gap-0">
{groups.map((group, index) => (
<div
key={group.id}
className={cx(
"flex min-w-0 flex-col gap-md sm:flex-1",
// the rule sits on the inline-start of every column but the
// first, so it mirrors with the row under dir="rtl".
index > 0 && "sm:border-s sm:border-[var(--border-subtle)] sm:ps-xl",
index < groups.length - 1 && "sm:pe-xl",
)}
>
{/* (a11y) figma uses text/tertiary; see the header note. */}
<h2 className={cx(LABEL_CLASS, "text-[var(--text-secondary)]")}>
{group.heading}
</h2>
<ul className="flex flex-col gap-sm">
{group.links.map((link: FooterLink, linkIndex: number) => (
<li key={`${link.href}-${linkIndex}`}>
<FooterNavLink link={link} className={LINK_CLASS} />
</li>
))}
</ul>
</div>
))}
</nav>
</div>
{/* ---- implemented-by bar (figma 228:3062 / 140:8710) ------------- */}
{partners.length > 0 && (
<div className="flex flex-col gap-lg border-t border-[var(--border-default)] pt-lg sm:flex-row sm:items-center sm:justify-between">
{/* Figma paints this label background/accent on dark and
text/secondary on light; --text-accent is the readable green. */}
<p
className={cx(
LABEL_CLASS,
"text-[var(--text-secondary)] surface-dark:text-[var(--text-accent)]",
)}
>
{copy.implementedByLabel}
</p>
<ul className="flex flex-wrap items-center gap-lg">
{partners.map((partner) => (
<li key={partner.id} className="flex items-center">
<PartnerMark isDark={isDark} partner={partner} />
</li>
))}
</ul>
</div>
)}
</div>
</footer>
);
}
/* ---------------------------------------------------------------------------
Consortium logo. The three marks are shown together and never on their own
(communication guideline), so this renders one mark of that row - each keeps
its own designed box rather than a shared height rule.
------------------------------------------------------------------------ */
function PartnerMark({ isDark, partner }: { isDark: boolean; partner: FooterPartner }) {
const image = (
<img
src={isDark ? partner.srcInverse : partner.src}
alt={partner.name}
width={partner.width}
height={partner.height}
style={{ width: `${partner.width}px`, height: `${partner.height}px` }}
className="block"
loading="lazy"
decoding="async"
/>
);
if (!partner.href) return image;
return (
<a
href={partner.href}
target="_blank"
rel="noopener noreferrer"
className={cx(FOCUS_RING, "inline-flex")}
>
{image}
</a>
);
}
/* ---------------------------------------------------------------------------
Social row. Not in the published frames - the draft copy asks for it, so it
closes the brand column. The negative inline-start margin cancels the icon
padding so the glyphs optically align with the text above them.
------------------------------------------------------------------------ */
function SocialRow({
copy,
links,
}: {
copy: FooterCopy;
links: readonly FooterSocialLink[];
}) {
return (
<div className="pt-2xs">
<h2 className="sr-only">{copy.socialHeading}</h2>
<ul className="-ms-2xs flex flex-wrap items-center gap-2xs">
{links.map((link) => (
<li key={link.platform}>
<a
href={link.href}
aria-label={link.label}
target="_blank"
rel="noopener noreferrer"
className={cx(
FOCUS_RING,
TRANSITION,
"inline-flex items-center justify-center p-2xs",
"text-[var(--text-secondary)] hover:text-[var(--text-link)]",
)}
>
<FooterSocialIcon
platform={link.platform}
className="h-[var(--control-icon-size)] w-[var(--control-icon-size)]"
/>
</a>
</li>
))}
</ul>
</div>
);
}
export default Footer;
/* ============================================================================
Footer.stories-ish - usage, as a comment only (no Storybook in this project)
----------------------------------------------------------------------------
// 1. Dark English footer - the default, matches figma 140:7814.
// <html lang="en" dir="ltr"> already carries the direction.
<Footer locale="en" />
// 2. Light English footer - figma 140:7812.
<Footer surface="light" locale="en" />
// 3. Arabic, dark. Same markup; the RTL mirroring comes from <html dir="rtl">,
// so nothing is passed for direction inside the real app.
<Footer locale="ar" />
// 4. Light Arabic - figma 140:8713. `dir` is only needed when the footer is
// previewed inside a document that is not in that locale.
<Footer surface="light" locale="ar" dir="rtl" />
// 5. Strapi-driven: every list is a prop, so the CMS payload maps 1:1.
<Footer
surface="dark"
locale="en"
linkGroups={cms.footer.groups}
socialLinks={cms.footer.social}
partners={cms.footer.partners}
copy={{ description: cms.footer.blurb }}
/>
// 6. Sitemap-only footer, no consortium row and no social row.
<Footer partners={[]} socialLinks={[]} />
========================================================================= */
@@ -0,0 +1,43 @@
/* ============================================================================
Footer - social glyphs
----------------------------------------------------------------------------
The Figma icon set (frame 84:4485) only ships the arrow pair, so the brand
marks below are inlined rather than exported. Inline is also what the surface
model needs: the glyph paints with `currentColor`, so one file works on both
the light and the dark footer without a second asset.
========================================================================= */
import type { FooterSocialPlatform } from "./types";
/** Single-path 24x24 brand marks, viewBox `0 0 24 24`, filled with currentColor. */
const GLYPHS: Readonly<Record<FooterSocialPlatform, string>> = {
facebook:
"M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
linkedin:
"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 1 1 0-4.124 2.062 2.062 0 0 1 0 4.124Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z",
youtube:
"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814ZM9.545 15.568V8.432L15.818 12l-6.273 3.568Z",
instagram:
"M12 2.163c3.204 0 3.584.012 4.85.07 1.17.053 1.805.249 2.227.413.56.218.96.478 1.382.9.421.42.681.82.898 1.38.164.423.36 1.058.413 2.228.058 1.266.07 1.646.07 4.85 0 3.204-.012 3.584-.07 4.85-.053 1.17-.249 1.805-.413 2.227a3.72 3.72 0 0 1-.898 1.382c-.422.421-.822.681-1.382.898-.422.164-1.057.36-2.227.413-1.265.058-1.645.07-4.85.07-3.204 0-3.584-.012-4.85-.07-1.17-.053-1.805-.249-2.227-.413a3.72 3.72 0 0 1-1.382-.898 3.72 3.72 0 0 1-.898-1.382c-.164-.422-.36-1.057-.413-2.227-.058-1.266-.07-1.646-.07-4.85 0-3.204.012-3.584.07-4.85.053-1.17.249-1.805.413-2.227a3.72 3.72 0 0 1 .898-1.382 3.72 3.72 0 0 1 1.382-.898c.422-.164 1.057-.36 2.227-.413 1.266-.058 1.646-.07 4.85-.07ZM12 0C8.741 0 8.332.014 7.052.072 5.775.13 4.902.333 4.14.63a5.88 5.88 0 0 0-2.126 1.384A5.88 5.88 0 0 0 .63 4.14C.333 4.902.13 5.775.072 7.052.014 8.332 0 8.741 0 12s.014 3.668.072 4.948c.058 1.277.261 2.15.558 2.912a5.88 5.88 0 0 0 1.384 2.126A5.88 5.88 0 0 0 4.14 23.37c.762.297 1.635.5 2.912.558C8.332 23.986 8.741 24 12 24s3.668-.014 4.948-.072c1.277-.058 2.15-.261 2.912-.558a5.88 5.88 0 0 0 2.126-1.384 5.88 5.88 0 0 0 1.384-2.126c.297-.762.5-1.635.558-2.912.058-1.28.072-1.689.072-4.948s-.014-3.668-.072-4.948c-.058-1.277-.261-2.15-.558-2.912a5.88 5.88 0 0 0-1.384-2.126A5.88 5.88 0 0 0 19.86.63c-.762-.297-1.635-.5-2.912-.558C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm7.846-10.405a1.44 1.44 0 1 1-2.88 0 1.44 1.44 0 0 1 2.88 0Z",
x: "M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
};
export interface FooterSocialIconProps {
readonly platform: FooterSocialPlatform;
readonly className?: string;
}
/** Decorative glyph - the accessible name lives on the wrapping link. */
export function FooterSocialIcon({ platform, className }: FooterSocialIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
focusable="false"
className={className}
>
<path d={GLYPHS[platform]} />
</svg>
);
}
@@ -0,0 +1,202 @@
/* ============================================================================
Footer - typed defaults
----------------------------------------------------------------------------
Draft copy is taken from docs/SHARPER_Website_Draft_Section_EN_AR.docx and the
communication guideline; the section names are the ten-section IA. Everything
here is a default only - Strapi will supply the same shapes later, so nothing
below may be imported by anything except <Footer />'s own prop defaults.
========================================================================= */
import { ROUTE_SLUGS } from "~/lib/route-slugs";
import type {
FooterCopy,
FooterLinkGroup,
FooterLocale,
FooterPartner,
FooterSocialLink,
} from "./types";
/* ---------------------------------------------------------------------------
Assets - exported from Figma (frames 312:4012, 84:4483, 228:2984) and
committed under apps/web/public/assets/. Two cuts of every piece of baked
ink: the plain one for a light surface, `*-inverse` for a dark one.
------------------------------------------------------------------------ */
export const FOOTER_ASSETS = {
/** SHARP-ER wordmark, designed 334x46 (figma 317:5571 / 317:7812). */
wordmark: "/assets/sharp-er-wordmark.png",
wordmarkInverse: "/assets/sharp-er-wordmark-inverse.png",
/** EU emblem, designed 48x32 (figma 22:5358 / 238:2323). */
euEmblem: "/assets/eu-emblem.png",
euEmblemInverse: "/assets/eu-emblem-inverse.png",
} as const;
/** Designed CSS pixels of the wordmark inside the footer brand column. */
export const FOOTER_WORDMARK_SIZE = { width: 196, height: 27 } as const;
/** Designed CSS pixels of the EU emblem (figma "european union", 48x32). */
export const FOOTER_EU_EMBLEM_SIZE = { width: 48, height: 32 } as const;
/* ---------------------------------------------------------------------------
Route slugs
The footer only ever builds `/{locale}/{slug}`, and the slugs come from the
ONE source of truth the route config itself is built from - redeclaring them
here is how the footer ended up linking at five pages that do not exist.
Re-exported under the old name so the barrel keeps its public surface.
------------------------------------------------------------------------ */
export const FOOTER_ROUTE_SLUGS = ROUTE_SLUGS;
const path = (locale: FooterLocale, slug: string): string => `/${locale}/${slug}`;
/* ---------------------------------------------------------------------------
Copy
------------------------------------------------------------------------ */
export const DEFAULT_FOOTER_COPY: Readonly<Record<FooterLocale, FooterCopy>> = {
en: {
tagline: "Shared Action on Rule of Law Progress – Exit Route",
description:
"A nationwide initiative supporting Libya’s civil society, local institutions and communities in advancing justice, accountability and good governance. Running from December 2025 to December 2027.",
euStatement: "This project is funded by the European Union",
euEmblemAlt: "Emblem of the European Union",
implementedByLabel: "Implemented by",
socialHeading: "Follow SHARP-ER",
homeLabel: "SHARP-ER — home",
navLabel: "Site sections",
},
ar: {
tagline: "العمل المشترك لتعزيز سيادة القانون – مسار الخروج",
description:
"مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة. يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027.",
euStatement: "هذا المشروع ممول من الاتحاد الأوروبي",
euEmblemAlt: "شعار الاتحاد الأوروبي",
implementedByLabel: "الجهات المنفّذة",
socialHeading: "تابعوا SHARP-ER",
homeLabel: "SHARP-ER — الصفحة الرئيسية",
navLabel: "أقسام الموقع",
},
};
/* ---------------------------------------------------------------------------
Sitemap columns - the three IA groups. "Home" is not repeated as a link
because the wordmark above the columns already points at it.
------------------------------------------------------------------------ */
const LINK_GROUPS: Readonly<
Record<FooterLocale, readonly FooterLinkGroup[]>
> = {
en: [
{
id: "about-and-strategy",
heading: "About and Strategy",
links: [
{ label: "About", href: path("en", FOOTER_ROUTE_SLUGS.about) },
{ label: "What We Do", href: path("en", FOOTER_ROUTE_SLUGS.whatWeDo) },
{
label: "Activities and Initiatives",
href: path("en", FOOTER_ROUTE_SLUGS.activities),
},
],
},
{
id: "engagement-and-opportunities",
heading: "Engagement and Opportunities",
links: [
{ label: "Events Calendar", href: path("en", FOOTER_ROUTE_SLUGS.events) },
{
label: "Applications and Opportunities",
href: path("en", FOOTER_ROUTE_SLUGS.applications),
},
{ label: "News and Updates", href: path("en", FOOTER_ROUTE_SLUGS.news) },
{ label: "Contact", href: path("en", FOOTER_ROUTE_SLUGS.contact) },
],
},
{
id: "knowledge-and-impact",
heading: "Knowledge and Impact",
links: [
{
label: "Resources and Publications",
href: path("en", FOOTER_ROUTE_SLUGS.resources),
},
{ label: "Knowledge Hub", href: path("en", FOOTER_ROUTE_SLUGS.knowledgeHub) },
],
},
],
ar: [
{
id: "about-and-strategy",
heading: "عن المشروع والاستراتيجية",
links: [
{ label: "عن المشروع", href: path("ar", FOOTER_ROUTE_SLUGS.about) },
{ label: "ماذا نفعل", href: path("ar", FOOTER_ROUTE_SLUGS.whatWeDo) },
{ label: "الأنشطة والمبادرات", href: path("ar", FOOTER_ROUTE_SLUGS.activities) },
],
},
{
id: "engagement-and-opportunities",
heading: "المشاركة والفرص",
links: [
{ label: "التقويم الزمني للفعاليات", href: path("ar", FOOTER_ROUTE_SLUGS.events) },
{ label: "طلبات وفرص المشاركة", href: path("ar", FOOTER_ROUTE_SLUGS.applications) },
{ label: "الأخبار والمستجدات", href: path("ar", FOOTER_ROUTE_SLUGS.news) },
{ label: "تواصل معنا", href: path("ar", FOOTER_ROUTE_SLUGS.contact) },
],
},
{
id: "knowledge-and-impact",
heading: "المعرفة والأثر",
links: [
{ label: "الموارد والمنشورات", href: path("ar", FOOTER_ROUTE_SLUGS.resources) },
{ label: "مركز المعرفة", href: path("ar", FOOTER_ROUTE_SLUGS.knowledgeHub) },
],
},
],
};
/** The three IA sitemap columns for a locale. */
export function defaultFooterLinkGroups(
locale: FooterLocale,
): readonly FooterLinkGroup[] {
return LINK_GROUPS[locale];
}
/* ---------------------------------------------------------------------------
Consortium logos - shown together, never standalone (communication guideline).
Sizes are the designed boxes from figma frame 228:2984.
------------------------------------------------------------------------ */
export const DEFAULT_FOOTER_PARTNERS: readonly FooterPartner[] = [
{
id: "acted",
name: "Acted",
src: "/assets/partner-acted.png",
srcInverse: "/assets/partner-acted-inverse.png",
width: 86,
height: 24,
},
{
id: "uec",
name: "United Efforts Consultancy (UEC)",
src: "/assets/partner-uec.png",
srcInverse: "/assets/partner-uec-inverse.png",
width: 24,
height: 24,
},
{
id: "sbe",
name: "Stichting Bureau Ensemble (SBE)",
src: "/assets/partner-sbe.png",
srcInverse: "/assets/partner-sbe-inverse.png",
width: 43,
height: 24,
},
];
/* ---------------------------------------------------------------------------
Social profiles
DELIBERATELY EMPTY. The draft copy names Facebook as the only confirmed
channel and leaves its URL a placeholder, so there is no handle here that
the project is known to own - and shipping a guessed one from an EU-funded
site would point readers at an account somebody else may control.
Real profiles arrive through the `socialLinks` prop (Strapi); the footer's
`links.length > 0` guard drops the whole row until they do.
------------------------------------------------------------------------ */
export const DEFAULT_FOOTER_SOCIAL_LINKS: readonly FooterSocialLink[] = [];
@@ -0,0 +1,31 @@
/* ============================================================================
Footer - public surface
========================================================================= */
export { Footer, default } from "./Footer";
export { FooterSocialIcon } from "./FooterSocialIcon";
export type { FooterSocialIconProps } from "./FooterSocialIcon";
export {
DEFAULT_FOOTER_COPY,
DEFAULT_FOOTER_PARTNERS,
DEFAULT_FOOTER_SOCIAL_LINKS,
FOOTER_ASSETS,
FOOTER_EU_EMBLEM_SIZE,
FOOTER_ROUTE_SLUGS,
FOOTER_WORDMARK_SIZE,
defaultFooterLinkGroups,
} from "./content";
export type {
FooterCopy,
FooterDirection,
FooterLink,
FooterLinkGroup,
FooterLocale,
FooterPartner,
FooterProps,
FooterSocialLink,
FooterSocialPlatform,
FooterSurface,
} from "./types";
+116
View File
@@ -0,0 +1,116 @@
/* ============================================================================
Footer - public types
----------------------------------------------------------------------------
Everything the footer renders that is not chrome comes in through props, so
the same component can later be driven straight from Strapi. The defaults in
./content.ts are the fallback, not the contract.
========================================================================= */
/** Which tier-2 surface the footer paints itself with. `:root` is the dark one. */
export type FooterSurface = "light" | "dark";
/** The two locales the site is served in. URLs are prefixed on both. */
export type FooterLocale = "en" | "ar";
/** Writing direction. Normally inherited from `<html dir>` - see FooterProps.dir. */
export type FooterDirection = "ltr" | "rtl";
/** Social channels the footer can render a glyph for. */
export type FooterSocialPlatform =
| "facebook"
| "linkedin"
| "youtube"
| "instagram"
| "x";
/** A single sitemap or utility link. */
export interface FooterLink {
/** Visible label, already in the target locale. */
readonly label: string;
/** Absolute path including the locale prefix, e.g. `/en/about`. */
readonly href: string;
/** Opens in a new tab with `rel="noopener noreferrer"` when true. */
readonly external?: boolean;
}
/** One sitemap column: a heading plus its links. */
export interface FooterLinkGroup {
/** Stable key, locale independent (e.g. `about-and-strategy`). */
readonly id: string;
/** Visible column heading, already in the target locale. */
readonly heading: string;
readonly links: readonly FooterLink[];
}
/** A social profile. `label` is the accessible name of the icon link. */
export interface FooterSocialLink {
readonly platform: FooterSocialPlatform;
readonly label: string;
readonly href: string;
}
/**
* A consortium logo in the "implemented by" row. Two sources, because the
* artwork is baked ink: `src` is the dark-ink cut for a light surface and
* `srcInverse` the white cut for a dark surface. `width`/`height` are the
* designed CSS pixels, kept explicit so the row never reflows on load.
*/
export interface FooterPartner {
readonly id: string;
readonly name: string;
readonly src: string;
readonly srcInverse: string;
readonly width: number;
readonly height: number;
readonly href?: string;
}
/** Every string the footer renders that is not a link label. */
export interface FooterCopy {
/** "Shared Action on Rule of Law Progress - Exit Route" */
readonly tagline: string;
/** Short programme description under the wordmark. */
readonly description: string;
/** EU funding statement rendered next to the emblem. */
readonly euStatement: string;
/** Alt text for the EU emblem image. */
readonly euEmblemAlt: string;
/** Label above the consortium logos. */
readonly implementedByLabel: string;
/** Visually hidden heading for the social row. */
readonly socialHeading: string;
/** Accessible name of the wordmark link. */
readonly homeLabel: string;
/** Accessible name of the sitemap `<nav>` and the `<footer>` landmark. */
readonly navLabel: string;
}
export interface FooterProps {
/**
* Which surface to paint. Sets `data-surface`, so the subtree re-resolves
* every tier-2 token and the correct logo cut is chosen. Defaults to `dark`,
* matching the home design.
*/
readonly surface?: FooterSurface;
/** Selects the default copy and link set. Defaults to `en`. */
readonly locale?: FooterLocale;
/**
* Writing direction. Leave unset in the real app - `<html dir>` owns it and
* the footer lays out with logical properties, so it mirrors for free. Pass
* it only when previewing the footer outside a document of that locale.
*/
readonly dir?: FooterDirection;
/** Sitemap columns. Defaults to the ten-section IA for `locale`. */
readonly linkGroups?: readonly FooterLinkGroup[];
/** Social profiles. Pass `[]` to hide the row. */
readonly socialLinks?: readonly FooterSocialLink[];
/** Consortium logos. Pass `[]` to hide the row. */
readonly partners?: readonly FooterPartner[];
/** Overrides for individual strings; anything omitted falls back to `locale`. */
readonly copy?: Partial<FooterCopy>;
/** Target of the wordmark link. Defaults to `/{locale}`. */
readonly homeHref?: string;
/** Extra classes on the `<footer>` element. */
readonly className?: string;
readonly id?: string;
}
+141
View File
@@ -0,0 +1,141 @@
/* ============================================================================
INPUT / TEXTAREA - figma frame 18:212 (Type x State)
----------------------------------------------------------------------------
Only TIER-2 semantic tokens. The label, hint and error colours are driven off
two data attributes on the group (`data-invalid`, `data-disabled`) plus
`:focus-within`, so the whole 2x5 matrix is a handful of rules.
Layout (the column and its gaps) is plain Tailwind spacing utilities in
Input.tsx, and they name a step of the spacing scale (`gap-xs` = 8, `ms-2xs`
= 4) rather than the raw `gap-1.5` / `ms-0.5` multiples of --spacing, which
are tier-1. Everything token-driven lives here.
BOX. As with the button, Figma strokes inside the frame, so the 1.5px border
is subtracted from the 16/12 padding and the outer edge stays where it was
drawn.
DEVIATIONS FROM FIGMA, deliberate:
* Height. Figma hand-sets 44px, which does not agree with its own 12px
padding and 24px Body/Medium line-height. Following the tokens gives 48px,
which is also exactly the Medium button, so a field and a button sitting on
one row line up. Textarea keeps Figma's 120px, reached honestly as
rows={4}: 4*24 + 10.5*2 + 1.5*2.
* Focus. Figma paints a 2px grey (`stroke/hover`) border, which both moves
the box by half a pixel and is not a WCAG-visible focus indicator. The
border instead turns `--field-border-focus` at an unchanged 1.5px and the
semantic focus ring is drawn outside it. The green label on focus is kept.
* Disabled. Figma dims the whole group to 50%. The `--field-*-disabled`
tokens already encode that reading and stay opaque, so no group opacity is
applied and text on a translucent dark surface cannot smear.
* The error caption is Figma's "Caption" role (Regular 12), which the type
scale does not publish. It uses `body-sm` (14/20), the smallest published
body role - more legible for a message a user has to act on.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------
Label, hint, error
------------------------------------------------------------------ */
.sh-field__label {
color: var(--text-primary);
transition: color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-field:focus-within .sh-field__label {
color: var(--text-accent);
}
.sh-field[data-invalid="true"] .sh-field__label,
.sh-field[data-invalid="true"]:focus-within .sh-field__label {
color: var(--text-danger);
}
.sh-field[data-disabled="true"] .sh-field__label {
color: var(--text-disabled);
}
.sh-field__required {
color: var(--text-danger);
}
.sh-field__hint {
color: var(--text-secondary);
}
.sh-field[data-disabled="true"] .sh-field__hint {
color: var(--text-disabled);
}
.sh-field__error {
color: var(--text-danger);
}
/* ---------------------------------------------------------------------
The control itself
------------------------------------------------------------------ */
.sh-field__control {
display: block;
inline-size: 100%;
appearance: none;
background-color: var(--field-bg);
color: var(--field-fg);
border: var(--control-border-width) solid var(--field-border);
border-radius: var(--radius-control-lg);
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
text-align: start;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-field__control::placeholder {
color: var(--field-placeholder);
opacity: 1;
}
@media (hover: hover) {
.sh-field__control:hover:not(:disabled) {
border-color: var(--field-border-hover);
}
}
/* Matched to the hover rule's specificity and placed after it, so focusing
a field the pointer happens to be over still shows the focus border. */
.sh-field .sh-field__control:focus-visible {
border-color: var(--field-border-focus);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
.sh-field[data-invalid="true"] .sh-field__control {
border-color: var(--field-border-error);
}
.sh-field[data-invalid="true"] .sh-field__control:focus-visible {
border-color: var(--field-border-error);
outline-color: var(--field-border-error);
}
/* Last, and specific enough to win over the invalid border - a disabled
field reads as disabled even when it is still holding an error. */
.sh-field .sh-field__control:disabled {
background-color: var(--field-bg-disabled);
color: var(--field-fg-disabled);
border-color: var(--border-subtle);
cursor: not-allowed;
}
.sh-field .sh-field__control:disabled::placeholder {
color: var(--field-fg-disabled);
}
/* Only the block axis resizes - growing a textarea sideways would break the
column it sits in. */
.sh-field__control--textarea {
resize: vertical;
}
}
+246
View File
@@ -0,0 +1,246 @@
import { useId } from "react";
import type { ComponentPropsWithRef, ReactNode } from "react";
import "./Input.css";
/** Everything the label / hint / error scaffolding needs, shared by both controls. */
type FieldShellProps = {
/** Visible, always-present label. Never omit it - a placeholder is not a label. */
label: ReactNode;
/** Helper text under the control. Announced through `aria-describedby`. */
hint?: ReactNode;
/** Validation message. Turns the field red and is announced via `role="alert"`. */
error?: ReactNode;
required?: boolean;
/** Auto-generated from `useId()` when omitted, so SSR and hydration agree. */
id?: string;
/** Applied to the field group (label + control + messages). */
className?: string;
/** Applied to the `<input>` / `<textarea>` itself. */
inputClassName?: string;
};
type NativeInputProps = Omit<
ComponentPropsWithRef<"input">,
keyof FieldShellProps | "aria-invalid"
>;
type NativeTextareaProps = Omit<
ComponentPropsWithRef<"textarea">,
keyof FieldShellProps | "aria-invalid"
>;
export type InputProps = FieldShellProps & NativeInputProps;
export type TextareaProps = FieldShellProps & NativeTextareaProps;
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
type FieldWiring = {
fieldId: string;
hintId: string;
errorId: string;
describedBy: string | undefined;
};
/**
* Stable ids for the control and its two messages, plus the `aria-describedby`
* that points at whichever of them are actually rendered. Any `aria-describedby`
* the caller supplied is preserved and appended rather than overwritten.
*/
function useFieldWiring(
id: string | undefined,
hasHint: boolean,
hasError: boolean,
callerDescribedBy: string | undefined,
): FieldWiring {
const generatedId = useId();
const fieldId = id ?? `sh-field-${generatedId}`;
const hintId = `${fieldId}-hint`;
const errorId = `${fieldId}-error`;
const describedBy =
[hasHint ? hintId : null, hasError ? errorId : null, callerDescribedBy ?? null]
.filter(Boolean)
.join(" ") || undefined;
return { fieldId, hintId, errorId, describedBy };
}
type FieldShellRenderProps = FieldShellProps & {
wiring: FieldWiring;
disabled?: boolean;
children: ReactNode;
};
function FieldShell({
label,
hint,
error,
required = false,
className,
wiring,
disabled,
children,
}: FieldShellRenderProps) {
return (
<div
className={cx("sh-field flex flex-col gap-xs", className)}
data-invalid={error ? "true" : undefined}
data-disabled={disabled ? "true" : undefined}
>
<label className="sh-field__label label-md" htmlFor={wiring.fieldId}>
{label}
{required ? (
<span className="sh-field__required ms-2xs" aria-hidden="true">
*
</span>
) : null}
</label>
{children}
{hint ? (
<p id={wiring.hintId} className="sh-field__hint body-sm">
{hint}
</p>
) : null}
{/* role="alert" so a message that appears after a failed submit is spoken. */}
{error ? (
<p id={wiring.errorId} role="alert" className="sh-field__error body-sm">
{error}
</p>
) : null}
</div>
);
}
/**
* Single-line text field - figma frame 18:212, Type=Text.
*
* The Default / Focused / Filled / Error / Disabled states of the design are all
* driven by the control's own state, so there is no `state` prop to keep in
* sync: pass `error` to get the error state, `disabled` to get the disabled one.
*/
export function Input({
label,
hint,
error,
required = false,
id,
className,
inputClassName,
disabled,
...rest
}: InputProps) {
const wiring = useFieldWiring(id, Boolean(hint), Boolean(error), rest["aria-describedby"]);
return (
<FieldShell
label={label}
hint={hint}
error={error}
required={required}
className={className}
wiring={wiring}
disabled={disabled}
>
<input
{...rest}
id={wiring.fieldId}
disabled={disabled}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={wiring.describedBy}
className={cx("sh-field__control body-md", inputClassName)}
/>
</FieldShell>
);
}
/**
* Multi-line text field - figma frame 18:212, Type=Textarea.
*
* `rows` defaults to 4, which is what makes the box land on the 120px Figma
* drew: 4 lines of 24 + 2 x 10.5 padding + 2 x 1.5 border.
*/
export function Textarea({
label,
hint,
error,
required = false,
id,
className,
inputClassName,
disabled,
rows = 4,
...rest
}: TextareaProps) {
const wiring = useFieldWiring(id, Boolean(hint), Boolean(error), rest["aria-describedby"]);
return (
<FieldShell
label={label}
hint={hint}
error={error}
required={required}
className={className}
wiring={wiring}
disabled={disabled}
>
<textarea
{...rest}
id={wiring.fieldId}
rows={rows}
disabled={disabled}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={wiring.describedBy}
className={cx(
"sh-field__control sh-field__control--textarea body-md",
inputClassName,
)}
/>
</FieldShell>
);
}
/* ----------------------------------------------------------------------------
USAGE (kept as a comment - the project has no Storybook)
import { Input, Textarea } from "~/components/ui/Input";
// Default. The id is generated, the label is wired to it with htmlFor.
<Input label="Full name" placeholder="Enter value..." />
// Required: a red asterisk for sighted users, the native `required`
// attribute (which is what actually gets announced) for everyone else.
<Input label="Email address" type="email" required autoComplete="email" />
// Hint and error together - aria-describedby points at both, in order.
<Input
label="Organisation"
hint="The legal name as it appears on your registration."
error="Enter the organisation's legal name."
defaultValue="United Efforts"
/>
// Disabled.
<Input label="Reference number" defaultValue="SHARP-ER/2026/014" disabled />
// Textarea - rows={4} is the 120px box from the design.
<Textarea label="Message" placeholder="Enter value..." />
<Textarea label="Message" rows={8} error="Tell us how we can help." />
// Contact form, controlled, with a submit button from the Button barrel.
const [email, setEmail] = useState("");
<Input
label="Email address"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
error={touched && !email ? "Enter your email address." : undefined}
/>
------------------------------------------------------------------------- */
@@ -0,0 +1,2 @@
export { Input, Textarea } from "./Input";
export type { InputProps, TextareaProps } from "./Input";
@@ -0,0 +1,121 @@
import { Link } from "react-router";
import {
getCounterpartLocale,
getCounterpartPath,
getDirection,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
type Locale,
} from "./locale-path";
/* Reinforces the global :focus-visible rule in app.css with the same tier-2
focus tokens, so the ring is visible even if a caller overrides `outline`. */
const FOCUS_RING =
"focus-visible:outline-[color:var(--focus-ring-color)] " +
"focus-visible:outline-[length:var(--focus-ring-width)] " +
"focus-visible:outline-offset-[var(--focus-ring-offset)]";
/** Padding presets. Kept as a prop rather than an overridable class, because
two padding utilities on one element are resolved by the order Tailwind
emits them in, not by the order they appear in the class attribute. */
const SIZE_CLASSES = {
sm: "px-sm py-2xs",
md: "min-h-[44px] px-md py-xs",
} as const;
export type LanguageSwitcherSize = keyof typeof SIZE_CLASSES;
export interface LanguageSwitcherProps {
/** The locale the reader is currently on. */
locale: Locale;
/** Current pathname, e.g. "/en/news-and-updates". */
pathname: string;
/** Query string to carry across the switch. Usually `location.search`. */
search?: string;
/** Fragment to carry across the switch. Usually `location.hash`. */
hash?: string;
/**
* Navigate with a full document request instead of a client transition.
* Off by default: the root route re-renders <html lang>/<html dir> on a
* client navigation, so a soft transition already lands on a correct
* document. Turn it on if a page needs the server to re-render first.
*/
reloadDocument?: boolean;
/** "sm" for the desktop bar, "md" for a 44px touch target in the drawer. */
size?: LanguageSwitcherSize;
className?: string;
}
/**
* Compact language toggle: shows the two-letter code of the OTHER language
* ("AR" while reading English, "EN" while reading Arabic) and links to the
* same page under that locale, preserving the rest of the path.
*
* The Figma navigation has no language switcher - a gap recorded in
* docs/SHARP-ER_Website Recommendations.docx - so this is designed to sit in
* the existing bar: label-md on a pill outline, matching the EU badge height.
*/
export function LanguageSwitcher({
locale,
pathname,
search,
hash,
reloadDocument = false,
size = "sm",
className = "",
}: LanguageSwitcherProps) {
const target = getCounterpartLocale(locale);
const href = getCounterpartPath(pathname, locale, { search, hash });
return (
<Link
to={href}
reloadDocument={reloadDocument}
hrefLang={target}
lang={target}
dir={getDirection(target)}
aria-label={LOCALE_ENDONYM[target]}
title={LOCALE_ENDONYM[target]}
data-language-switcher={target}
className={[
"label-md inline-flex shrink-0 items-center justify-center rounded-pill",
"border border-[var(--border-default)]",
SIZE_CLASSES[size],
"text-[var(--text-primary)] no-underline",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)]",
"hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]",
FOCUS_RING,
className,
]
.filter(Boolean)
.join(" ")}
>
<span aria-hidden="true">{LOCALE_SHORT_LABEL[target]}</span>
</Link>
);
}
export default LanguageSwitcher;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// Reading English at /en/news-and-updates?page=2 -> links to
// /ar/news-and-updates?page=2 and renders the pill "AR".
<LanguageSwitcher locale="en" pathname="/en/news-and-updates" search="?page=2" />
// Reading Arabic -> renders "EN", links to /en/...
<LanguageSwitcher locale="ar" pathname="/ar/contact" />
// Inside the mobile drawer, where a 44px touch target is wanted:
<LanguageSwitcher locale={locale} pathname={pathname} size="md" />
// Force a document navigation (server re-renders <html lang>/<html dir>):
<LanguageSwitcher locale={locale} pathname={pathname} reloadDocument />
// Wired from a route, using the router's own location:
const { pathname, search, hash } = useLocation();
<LanguageSwitcher locale={locale} pathname={pathname} search={search} hash={hash} />
------------------------------------------------------------------------ */
@@ -0,0 +1,26 @@
export { LanguageSwitcher, default } from "./LanguageSwitcher";
export type {
LanguageSwitcherProps,
LanguageSwitcherSize,
} from "./LanguageSwitcher";
export {
buildLocalizedHref,
DEFAULT_LOCALE,
getCounterpartLocale,
getCounterpartPath,
getDirection,
getLocaleFromPathname,
isCurrentPath,
isLocale,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
LOCALES,
localizePath,
stripLocale,
} from "./locale-path";
export type {
CounterpartPathOptions,
Direction,
Locale,
} from "./locale-path";
@@ -0,0 +1,140 @@
/* ============================================================================
LOCALE + PATH HELPERS
----------------------------------------------------------------------------
SHARP-ER is served with an EXPLICIT locale prefix on both languages:
/en/... and /ar/.... Bare "/" is redirected by the server on Accept-Language;
there is no ?lang= query parameter anywhere in the product.
These are pure functions on purpose - they run identically during SSR and in
the browser, and they are the single source of truth for "what is the other
language's URL for the page I am on".
========================================================================= */
export const LOCALES = ["en", "ar"] as const;
export type Locale = (typeof LOCALES)[number];
export type Direction = "ltr" | "rtl";
export const DEFAULT_LOCALE: Locale = "en";
/** Locale -> the value that belongs in <html dir>. */
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
en: "ltr",
ar: "rtl",
};
/** Locale -> its endonym, used as the accessible name of the switcher link. */
export const LOCALE_ENDONYM: Record<Locale, string> = {
en: "English",
ar: "العربية",
};
/** Locale -> the two-letter badge the switcher shows. */
export const LOCALE_SHORT_LABEL: Record<Locale, string> = {
en: "EN",
ar: "AR",
};
export function isLocale(value: unknown): value is Locale {
return (
typeof value === "string" && (LOCALES as readonly string[]).includes(value)
);
}
export function getDirection(locale: Locale): Direction {
return DIRECTION_BY_LOCALE[locale];
}
/** The language a reader on `locale` can switch to. Two locales, so it flips. */
export function getCounterpartLocale(locale: Locale): Locale {
return locale === "en" ? "ar" : "en";
}
/**
* The locale prefix of a pathname, or null when there is none.
* "/ar/contact" -> "ar" "/about" -> null "/" -> null
*/
export function getLocaleFromPathname(pathname: string): Locale | null {
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
return isLocale(first) ? first : null;
}
/**
* The pathname with its locale prefix removed, always leading-slashed.
* "/ar/news/2026" -> "/news/2026" "/en" -> "/" "/news" -> "/news"
*/
export function stripLocale(pathname: string): string {
const locale = getLocaleFromPathname(pathname);
const withoutLeadingSlashes = pathname.replace(/^\/+/, "");
const rest = locale
? withoutLeadingSlashes.slice(locale.length)
: withoutLeadingSlashes;
if (rest === "" || rest === "/") return "/";
return rest.startsWith("/") ? rest : `/${rest}`;
}
/**
* The same page under `locale`. Safe to call with an already-prefixed path, an
* unprefixed path, or a bare "/" - the locale prefix is normalised either way.
* ("/en/contact", "ar") -> "/ar/contact"
* ("/contact", "ar") -> "/ar/contact"
* ("/", "en") -> "/en"
*/
export function localizePath(pathname: string, locale: Locale): string {
const rest = stripLocale(pathname);
return rest === "/" ? `/${locale}` : `/${locale}${rest}`;
}
export interface CounterpartPathOptions {
/** Query string to carry over, with or without the leading "?". */
search?: string;
/** Fragment to carry over, with or without the leading "#". */
hash?: string;
}
/**
* The counterpart-language URL for the current location: same page, other
* locale, query string and fragment preserved.
*/
export function getCounterpartPath(
pathname: string,
locale: Locale,
options: CounterpartPathOptions = {},
): string {
return buildLocalizedHref(
pathname,
getCounterpartLocale(locale),
options,
);
}
/** localizePath() plus the preserved search/hash. */
export function buildLocalizedHref(
pathname: string,
locale: Locale,
{ search = "", hash = "" }: CounterpartPathOptions = {},
): string {
const normalisedSearch =
search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : "";
const normalisedHash =
hash && hash !== "#" ? (hash.startsWith("#") ? hash : `#${hash}`) : "";
return `${localizePath(pathname, locale)}${normalisedSearch}${normalisedHash}`;
}
/**
* True when `href` addresses the page currently at `pathname`. Trailing
* slashes and an absent locale prefix on either side are tolerated so a link
* built as "/en/about" still matches a location of "/en/about/".
*/
export function isCurrentPath(href: string, pathname: string): boolean {
return normalizeForCompare(href) === normalizeForCompare(pathname);
}
function normalizeForCompare(value: string): string {
const pathOnly = value.split(/[?#]/)[0] ?? value;
const withoutTrailingSlash = pathOnly.replace(/\/+$/, "");
return withoutTrailingSlash === "" ? "/" : withoutTrailingSlash.toLowerCase();
}
@@ -0,0 +1,50 @@
import { Link } from "react-router";
import { cx, FOCUS_RING } from "./styles";
import type { NavSurface } from "./types";
/* Exported from Figma logo/sharp-er (317:5571) and logo/sharp-er-dark
(317:7812). Intrinsic 334x46; the navigation draws it at 100px wide, which
is the designed size in every navigation frame (desktop and mobile). */
const LOGO_SRC: Record<NavSurface, string> = {
light: "/assets/sharp-er-logo.png",
dark: "/assets/sharp-er-logo-dark.png",
};
const LOGO_INTRINSIC_WIDTH = 334;
const LOGO_INTRINSIC_HEIGHT = 46;
export interface BrandProps {
/** Locale-prefixed home path, e.g. "/en". */
href: string;
/** Accessible name of the link; the image itself is decorative. */
label: string;
surface: NavSurface;
className?: string;
}
/** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */
export function Brand({ href, label, surface, className }: BrandProps) {
return (
<Link
to={href}
aria-label={label}
className={cx(
"inline-flex shrink-0 items-center rounded-control",
FOCUS_RING,
className,
)}
>
<img
src={LOGO_SRC[surface]}
alt=""
width={LOGO_INTRINSIC_WIDTH}
height={LOGO_INTRINSIC_HEIGHT}
loading="eager"
decoding="async"
draggable={false}
className="block h-auto w-[100px]"
/>
</Link>
);
}
@@ -0,0 +1,441 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type FocusEvent as ReactFocusEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Link } from "react-router";
import { isCurrentPath, type Direction } from "../LanguageSwitcher";
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
import { isNavGroup, type NavItem, type NavigationStrings } from "./types";
/* The trigger row and the column row MUST use the same gap: the columns are
width-matched to their triggers, so an identical gap is what makes every
column line up under its own heading in both directions. */
const ROW_GAP = "gap-2xl";
/** Grace period before a pointer leaving the bar closes the mega menu. */
const CLOSE_DELAY_MS = 160;
export interface DesktopNavProps {
items: readonly NavItem[];
/** Current pathname, for aria-current="page". */
pathname: string;
strings: NavigationStrings;
/** Reading direction, so the arrow keys mirror in Arabic. */
direction: Direction;
className?: string;
}
type ColumnPosition = "first" | "last";
/**
* Desktop navigation with the Figma mega menu (317:6063): opening reveals every
* group's children at once, each column sitting directly under its own trigger.
*
* Opens on hover and on keyboard focus, closes on Escape, on outside click and
* when focus leaves the bar. Tab is deliberately untouched, so focus is never
* trapped; the arrow keys add direct movement on top of it.
*/
export function DesktopNav({
items,
pathname,
strings,
direction,
className,
}: DesktopNavProps) {
const [open, setOpen] = useState(false);
const navRef = useRef<HTMLElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingFocus = useRef<{ groupId: string; position: ColumnPosition } | null>(
null,
);
const rawId = useId();
const baseId = `sharper-nav-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const triggerId = (groupId: string) => `${baseId}-${groupId}-trigger`;
const panelId = (groupId: string) => `${baseId}-${groupId}-panel`;
const groupIds = items.filter(isNavGroup).map((item) => item.id);
const clearCloseTimer = useCallback(() => {
if (closeTimer.current !== null) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const openMega = useCallback(() => {
clearCloseTimer();
setOpen(true);
}, [clearCloseTimer]);
const closeMega = useCallback(() => {
clearCloseTimer();
pendingFocus.current = null;
setOpen(false);
}, [clearCloseTimer]);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
}, [clearCloseTimer]);
useEffect(() => clearCloseTimer, [clearCloseTimer]);
/* Outside click. pointerdown rather than click so the menu is already gone
by the time the click lands on whatever is underneath. */
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!navRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
/* A keyboard request to open and step into a column can only be honoured
once the column has actually rendered. */
useEffect(() => {
if (!open) return;
const target = pendingFocus.current;
pendingFocus.current = null;
if (target) focusInColumn(target.groupId, target.position);
}, [open]);
const columnLinks = useCallback((groupId: string): HTMLElement[] => {
const nav = navRef.current;
if (!nav) return [];
return Array.from(
nav.querySelectorAll<HTMLElement>("[data-nav-sublink]"),
).filter(
(link) =>
link.closest<HTMLElement>("[data-nav-column]")?.dataset.navColumn ===
groupId,
);
}, []);
const topLevelControls = useCallback((): HTMLElement[] => {
const nav = navRef.current;
if (!nav) return [];
return Array.from(nav.querySelectorAll<HTMLElement>("[data-nav-top]"));
}, []);
const triggerFor = useCallback(
(groupId: string): HTMLElement | undefined =>
topLevelControls().find((el) => el.dataset.navTrigger === groupId),
[topLevelControls],
);
const focusInColumn = useCallback(
(groupId: string, position: ColumnPosition): boolean => {
const links = columnLinks(groupId);
const target = position === "first" ? links[0] : links[links.length - 1];
target?.focus();
return Boolean(target);
},
[columnLinks],
);
/** Open (if needed) and put focus on the first/last link of `groupId`. */
const enterColumn = useCallback(
(groupId: string, position: ColumnPosition) => {
if (open) {
focusInColumn(groupId, position);
return;
}
pendingFocus.current = { groupId, position };
openMega();
},
[focusInColumn, open, openMega],
);
const forwardKey = direction === "rtl" ? "ArrowLeft" : "ArrowRight";
const backwardKey = direction === "rtl" ? "ArrowRight" : "ArrowLeft";
const moveTopLevel = (from: HTMLElement, delta: number) => {
const controls = topLevelControls();
const index = controls.indexOf(from);
if (index === -1 || controls.length === 0) return;
const next = controls[(index + delta + controls.length) % controls.length];
next?.focus();
};
const handleTriggerKeyDown = (
event: ReactKeyboardEvent<HTMLButtonElement>,
groupId: string,
) => {
switch (event.key) {
case "Enter":
case " ":
case "Spacebar":
event.preventDefault();
if (open) closeMega();
else enterColumn(groupId, "first");
break;
case "ArrowDown":
event.preventDefault();
enterColumn(groupId, "first");
break;
case "ArrowUp":
event.preventDefault();
enterColumn(groupId, "last");
break;
case forwardKey:
event.preventDefault();
moveTopLevel(event.currentTarget, 1);
break;
case backwardKey:
event.preventDefault();
moveTopLevel(event.currentTarget, -1);
break;
case "Home":
event.preventDefault();
topLevelControls()[0]?.focus();
break;
case "End": {
event.preventDefault();
const controls = topLevelControls();
controls[controls.length - 1]?.focus();
break;
}
default:
break;
}
};
const handleSubLinkKeyDown = (
event: ReactKeyboardEvent<HTMLAnchorElement>,
groupId: string,
) => {
const links = columnLinks(groupId);
const index = links.indexOf(event.currentTarget);
const groupIndex = groupIds.indexOf(groupId);
const step = (delta: number) => {
if (links.length === 0) return;
links[(index + delta + links.length) % links.length]?.focus();
};
const jumpColumn = (delta: number) => {
if (groupIds.length === 0) return;
const nextId =
groupIds[(groupIndex + delta + groupIds.length) % groupIds.length];
if (nextId) focusInColumn(nextId, "first");
};
switch (event.key) {
case "ArrowDown":
event.preventDefault();
step(1);
break;
case "ArrowUp":
event.preventDefault();
if (index === 0) triggerFor(groupId)?.focus();
else step(-1);
break;
case forwardKey:
event.preventDefault();
jumpColumn(1);
break;
case backwardKey:
event.preventDefault();
jumpColumn(-1);
break;
case "Home":
event.preventDefault();
links[0]?.focus();
break;
case "End":
event.preventDefault();
links[links.length - 1]?.focus();
break;
default:
break;
}
};
/* Escape is handled once, on the bar, so it works from a trigger and from
inside a column. Focus goes back to the trigger that owns the column. */
const handleNavKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape" || !open) return;
event.preventDefault();
const active = document.activeElement as HTMLElement | null;
const groupId =
active?.closest<HTMLElement>("[data-nav-column]")?.dataset.navColumn ??
active?.dataset.navTrigger;
closeMega();
if (groupId) triggerFor(groupId)?.focus();
};
/* React's onBlur is focusout, so it fires for descendants too. */
const handleNavBlur = (event: ReactFocusEvent<HTMLElement>) => {
const next = event.relatedTarget as Node | null;
if (!next || !navRef.current?.contains(next)) closeMega();
};
const handleItemPointerEnter = (
event: ReactPointerEvent<HTMLLIElement>,
isGroup: boolean,
) => {
if (event.pointerType !== "mouse") return;
if (isGroup) openMega();
else closeMega();
};
const handleNavPointerLeave = (event: ReactPointerEvent<HTMLElement>) => {
if (event.pointerType !== "mouse") return;
scheduleClose();
};
return (
<nav
ref={navRef}
aria-label={strings.primaryNavLabel}
data-mega-open={open ? "true" : "false"}
className={cx("relative", className)}
onPointerEnter={clearCloseTimer}
onPointerLeave={handleNavPointerLeave}
onKeyDown={handleNavKeyDown}
onBlur={handleNavBlur}
>
<ul className={cx("flex items-center", ROW_GAP)}>
{items.map((item) => {
const group = isNavGroup(item);
const hasCurrentChild =
group && item.children.some((child) => isCurrentPath(child.href, pathname));
return (
<li
key={item.id}
className="flex"
onPointerEnter={(event) => handleItemPointerEnter(event, group)}
>
{group ? (
<button
type="button"
id={triggerId(item.id)}
data-nav-top=""
data-nav-trigger={item.id}
data-current={hasCurrentChild ? "true" : undefined}
aria-expanded={open}
aria-controls={panelId(item.id)}
onFocus={openMega}
onClick={() => (open ? closeMega() : openMega())}
onKeyDown={(event) => handleTriggerKeyDown(event, item.id)}
className={cx(
"body-md-bold cursor-pointer whitespace-nowrap pb-[2px]",
"text-start text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
"data-[current=true]:text-[var(--text-accent)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
{item.label}
</button>
) : (
<Link
to={item.href}
data-nav-top=""
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
onFocus={closeMega}
className={cx(
"body-md-bold whitespace-nowrap pb-[2px] no-underline",
"text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
"aria-[current=page]:text-[var(--text-accent)]",
"aria-[current=page]:underline aria-[current=page]:decoration-2",
"aria-[current=page]:underline-offset-4",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
{item.label}
</Link>
)}
</li>
);
})}
</ul>
{/* Mega panel. Anchored to the bar's inline start so the columns share the
trigger row's origin and gap; `start-0` mirrors on its own in Arabic. */}
<div hidden={!open} className="absolute start-0 top-full w-full">
{/* One full-bleed backdrop for all columns, so the surface and its
bottom edge are a single unbroken rectangle. The header clips the
horizontal bleed with overflow-x-clip, which leaves the vertical
overflow visible. */}
<div
aria-hidden="true"
className={cx(
"absolute inset-y-0 start-[-50vw] end-[-50vw]",
"border-b border-[var(--border-subtle)] bg-[var(--surface-page)]",
)}
/>
<div className={cx("relative flex pb-lg", ROW_GAP)}>
{items.map((item) => (
<div
key={item.id}
data-nav-column={item.id}
className="flex flex-col"
>
{/* Width setter: an invisible copy of the trigger label in the
same type role, so this column is exactly as wide as its
trigger. It is h-0, so it adds no height. */}
<span
aria-hidden="true"
className="body-md-bold invisible block h-0 overflow-hidden whitespace-nowrap"
>
{item.label}
</span>
{isNavGroup(item) && (
/* w-0 keeps the list out of the column's max-content width;
min-w-full then stretches it back, so long child labels wrap
inside the trigger's width exactly as they do in Figma. */
<ul
id={panelId(item.id)}
aria-labelledby={triggerId(item.id)}
className="flex w-0 min-w-full flex-col gap-sm pt-md"
>
{item.children.map((child) => (
<li key={child.id}>
<Link
to={child.href}
data-nav-sublink=""
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
className={cx(
"body-md block no-underline",
"text-[var(--text-secondary)]",
"hover:text-[var(--text-primary)]",
"aria-[current=page]:text-[var(--text-accent)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
{child.label}
</Link>
</li>
))}
</ul>
)}
</div>
))}
</div>
</div>
</nav>
);
}
@@ -0,0 +1,73 @@
import { getDirection, type Locale } from "../LanguageSwitcher";
import { cx } from "./styles";
import type { NavSurface } from "./types";
/* Exported from Figma assets/eu-en (224:11275) and assets/eu-en - darkBg
(238:2325). The emblem is drawn at the prescribed 48x32 on both surfaces;
the source files are downscaled copies of the Figma exports. */
const EMBLEM_SRC: Record<NavSurface, string> = {
light: "/assets/eu-flag.png",
dark: "/assets/eu-flag-dark.png",
};
const EMBLEM_INTRINSIC_WIDTH = 192;
const EMBLEM_INTRINSIC_HEIGHT = 128;
export interface EuBadgeProps {
/** "Funded by the European Union", already in the reader's language. */
statement: string;
/** Alt text for the emblem. Empty when the statement is rendered beside it. */
emblemAlt?: string;
/** Language of `statement` - drives the font switch and the text direction. */
locale: Locale;
surface: NavSurface;
className?: string;
}
/**
* The mandatory EU visibility lockup: emblem + funding statement.
*
* The lockup itself is pinned to `dir="ltr"` so the emblem always leads, which
* is how the Arabic navigation frame (334:526) draws it; only the statement
* carries the reader's own direction. The bar's own mirroring still moves the
* whole badge from the top right (English) to the top left (Arabic).
*
* DEVIATION FROM FIGMA: the design sets the statement in EU reflex blue
* (hex 0f2a8e). A raw hex is not permitted in a component here, and there is
* no semantic token for a third-party brand colour, so the statement uses
* --text-primary. It stays legible on both surfaces and the emblem still
* carries the EU colours. Add a tier-2 token if the blue has to come back.
*/
export function EuBadge({
statement,
emblemAlt = "",
locale,
surface,
className,
}: EuBadgeProps) {
return (
<div
dir="ltr"
className={cx("flex shrink-0 items-center gap-2xs", className)}
>
<img
src={EMBLEM_SRC[surface]}
alt={emblemAlt}
width={EMBLEM_INTRINSIC_WIDTH}
height={EMBLEM_INTRINSIC_HEIGHT}
loading="eager"
decoding="async"
draggable={false}
className="block h-[32px] w-[48px] shrink-0"
/>
<span
lang={locale}
dir={getDirection(locale)}
className="label-md block w-[96px] text-[var(--text-primary)]"
>
{statement}
</span>
</div>
);
}
@@ -0,0 +1,33 @@
import { cx } from "./styles";
/* The Figma menu icon (20:224) is three 20x2 rectangles in a 24x24 box with a
5px gap - not an exported asset, so it is drawn with the same geometry here.
`open` folds the outer two into a cross and fades the middle one, which is
what the drawer's close control needs. */
const BAR = "block h-[2px] w-[20px] rounded-pill bg-[var(--text-primary)]";
const BAR_MOTION =
"transition-[transform,opacity] duration-[var(--motion-duration-fast)] " +
"ease-[var(--motion-ease-standard)]";
export interface MenuBarsProps {
/** true renders the cross (close), false the hamburger. */
open?: boolean;
className?: string;
}
export function MenuBars({ open = false, className }: MenuBarsProps) {
return (
<span
aria-hidden="true"
className={cx(
"flex size-lg flex-col items-center justify-center gap-[5px]",
className,
)}
>
<span className={cx(BAR, BAR_MOTION, open && "translate-y-[7px] rotate-45")} />
<span className={cx(BAR, BAR_MOTION, open && "opacity-0")} />
<span className={cx(BAR, BAR_MOTION, open && "-translate-y-[7px] -rotate-45")} />
</span>
);
}
@@ -0,0 +1,344 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { Link } from "react-router";
import { LanguageSwitcher, isCurrentPath, type Locale } from "../LanguageSwitcher";
import { Brand } from "./Brand";
import { EuBadge } from "./EuBadge";
import { MenuBars } from "./MenuBars";
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
import { isNavGroup, type NavItem, type NavigationStrings, type NavSurface } from "./types";
/** Must stay in step with the `xl:` variants below (Tailwind xl = 80rem). */
const DESKTOP_MEDIA_QUERY = "(min-width: 80rem)";
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
function focusablesIn(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0,
);
}
export interface MobileNavProps {
items: readonly NavItem[];
pathname: string;
search?: string;
hash?: string;
locale: Locale;
strings: NavigationStrings;
surface: NavSurface;
/** Locale-prefixed home path for the logo inside the drawer. */
homeHref: string;
showLanguageSwitcher: boolean;
className?: string;
}
/**
* Hamburger trigger plus the full-screen drawer it opens.
*
* While the drawer is open, focus is trapped inside it, everything behind it is
* `inert` (so assistive technology cannot browse the page under an aria-modal
* dialog either), the page behind cannot scroll, Escape closes it, and focus
* returns to the hamburger on close.
*/
export function MobileNav({
items,
pathname,
search,
hash,
locale,
strings,
surface,
homeHref,
showLanguageSwitcher,
className,
}: MobileNavProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const drawerRef = useRef<HTMLDivElement>(null);
const wasOpen = useRef(false);
const rawId = useId();
const baseId = `sharper-drawer-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const drawerId = `${baseId}-panel`;
const headingId = (groupId: string) => `${baseId}-${groupId}-heading`;
const close = useCallback(() => setOpen(false), []);
/* Focus in on open, and back to the hamburger on close. */
useEffect(() => {
if (open) {
wasOpen.current = true;
closeRef.current?.focus();
} else if (wasOpen.current) {
wasOpen.current = false;
triggerRef.current?.focus();
}
}, [open]);
/* The drawer is aria-modal, so the rest of the page has to be inert as well -
otherwise a screen reader still browses the header, main and footer behind
it. Rather than naming those landmarks (the drawer is itself rendered
inside the header, so inerting the header would inert the drawer), every
ancestor up to <body> has its OTHER children inerted. `inert` is set as an
attribute so no DOM-lib typing is needed, and an element that was already
inert is skipped so the restore never clears someone else's flag.
This runs after the focus effect above on purpose: focus is already inside
the drawer by the time the element it came from goes inert. */
useEffect(() => {
const drawer = drawerRef.current;
if (!open || !drawer) return;
const inerted: Element[] = [];
let node: Element | null = drawer;
while (node !== null) {
const parent: HTMLElement | null = node.parentElement;
if (parent === null) break;
const siblings: Element[] = Array.from(parent.children);
for (const sibling of siblings) {
if (sibling === node || sibling.hasAttribute("inert")) continue;
sibling.setAttribute("inert", "");
inerted.push(sibling);
}
node = parent === document.body ? null : parent;
}
return () => {
for (const element of inerted) element.removeAttribute("inert");
};
}, [open]);
/* Body scroll lock. The scrollbar's width is handed back as inline-end
padding so the page behind does not jump sideways when it disappears. */
useEffect(() => {
if (!open) return;
const { body, documentElement } = document;
const previousOverflow = body.style.overflow;
const previousPadding = body.style.paddingInlineEnd;
const scrollbar = window.innerWidth - documentElement.clientWidth;
body.style.overflow = "hidden";
if (scrollbar > 0) body.style.paddingInlineEnd = `${scrollbar}px`;
return () => {
body.style.overflow = previousOverflow;
body.style.paddingInlineEnd = previousPadding;
};
}, [open]);
/* Keeps focus inside the drawer even when something moves it programmatically
(the Tab handler below covers ordinary keyboard travel). */
useEffect(() => {
if (!open) return;
const handleFocusIn = (event: FocusEvent) => {
const drawer = drawerRef.current;
if (drawer && !drawer.contains(event.target as Node)) {
focusablesIn(drawer)[0]?.focus();
}
};
document.addEventListener("focusin", handleFocusIn);
return () => document.removeEventListener("focusin", handleFocusIn);
}, [open]);
/* The drawer is desktop-hidden; close it rather than stranding a locked body
if the viewport grows past the breakpoint while it is open. */
useEffect(() => {
if (!open || typeof window.matchMedia !== "function") return;
const query = window.matchMedia(DESKTOP_MEDIA_QUERY);
if (query.matches) {
setOpen(false);
return;
}
const handleChange = (event: MediaQueryListEvent) => {
if (event.matches) setOpen(false);
};
query.addEventListener("change", handleChange);
return () => query.removeEventListener("change", handleChange);
}, [open]);
const handleDrawerKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
const drawer = drawerRef.current;
if (!drawer) return;
const stops = focusablesIn(drawer);
if (stops.length === 0) {
event.preventDefault();
return;
}
const first = stops[0];
const last = stops[stops.length - 1];
const active = document.activeElement;
const inside = drawer.contains(active);
if (event.shiftKey && (active === first || !inside)) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && (active === last || !inside)) {
event.preventDefault();
first?.focus();
}
};
const linkClasses = cx(
"block rounded-control py-2xs no-underline",
"hover:text-[var(--text-accent)]",
"aria-[current=page]:text-[var(--text-accent)]",
HOVER_TRANSITION,
FOCUS_RING,
);
return (
<>
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-controls={drawerId}
aria-label={open ? strings.closeMenu : strings.openMenu}
onClick={() => setOpen((value) => !value)}
className={cx(
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control xl:hidden",
FOCUS_RING,
className,
)}
>
<MenuBars />
</button>
<div
id={drawerId}
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={strings.primaryNavLabel}
hidden={!open}
data-surface={surface}
onKeyDown={handleDrawerKeyDown}
className={cx(
"fixed inset-0 z-[var(--layer-modal)] flex flex-col",
"bg-[var(--surface-page)] xl:hidden",
)}
>
<div className="flex min-h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
<button
ref={closeRef}
type="button"
aria-label={strings.closeMenu}
onClick={close}
className={cx(
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
FOCUS_RING,
)}
>
<MenuBars open />
</button>
</div>
<nav
aria-label={strings.primaryNavLabel}
className="flex-1 overflow-y-auto px-[var(--layout-gutter)] pt-md pb-2xl"
>
<ul className="flex flex-col gap-xl">
{items.map((item) =>
isNavGroup(item) ? (
<li key={item.id}>
<h2
id={headingId(item.id)}
className="body-md-bold text-[var(--text-primary)]"
>
{item.label}
</h2>
<ul
aria-labelledby={headingId(item.id)}
className="mt-sm flex flex-col gap-2xs border-s border-[var(--border-subtle)] ps-md"
>
{item.children.map((child) => (
<li key={child.id}>
<Link
to={child.href}
onClick={close}
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md text-[var(--text-secondary)]",
)}
>
{child.label}
</Link>
</li>
))}
</ul>
</li>
) : (
<li key={item.id}>
<Link
to={item.href}
onClick={close}
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md-bold text-[var(--text-primary)]",
)}
>
{item.label}
</Link>
</li>
),
)}
</ul>
</nav>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-md border-t border-[var(--border-subtle)] px-[var(--layout-gutter)] py-lg">
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/>
{showLanguageSwitcher && (
<LanguageSwitcher
locale={locale}
pathname={pathname}
search={search}
hash={hash}
size="md"
/>
)}
</div>
</div>
</>
);
}
@@ -0,0 +1,211 @@
import { useMemo } from "react";
import {
buildLocalizedHref,
getDirection,
LanguageSwitcher,
type Locale,
} from "../LanguageSwitcher";
import { Brand } from "./Brand";
import { DesktopNav } from "./DesktopNav";
import { EuBadge } from "./EuBadge";
import { MobileNav } from "./MobileNav";
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
import { cx } from "./styles";
import type { NavItem, NavigationStrings, NavSurface } from "./types";
export interface NavigationProps {
/** Reading locale. Drives the copy, the default hrefs and the arrow keys. */
locale?: Locale;
/** Current pathname, e.g. "/ar/contact". Used for aria-current="page". */
pathname: string;
/** Query string, carried across a language switch. */
search?: string;
/** Fragment, carried across a language switch. */
hash?: string;
/**
* The navigation tree. The app shell feeds this from the Strapi
* site-settings single type; omitting it renders the typed default IA.
*/
items?: readonly NavItem[];
/** Which tier-2 surface the bar paints. `:root` is dark, so dark is default. */
surface?: NavSurface;
/** Override any of the navigation's own strings (e.g. from the CMS). */
strings?: Partial<NavigationStrings>;
/** Where the logo links. Defaults to the locale home, "/en" or "/ar". */
homeHref?: string;
showLanguageSwitcher?: boolean;
/**
* Show the EU badge in the compact bar below xl as well. ON by default:
* donor visibility is non-negotiable, and the Figma mobile frame (20:220),
* which is logo + hamburger only, would leave the badge out of the header on
* every phone, tablet and 1280px-narrow laptop. Below xl the lockup renders
* at a reduced size rather than being dropped. Pass `false` only for a
* surface where the funding statement is guaranteed elsewhere on screen.
*/
showEuBadgeOnMobileBar?: boolean;
/** Stick the bar to the top of the viewport. */
sticky?: boolean;
className?: string;
}
/**
* The SHARP-ER site navigation - Figma frame 20:236.
*
* Logo at the inline start, the mandatory "Funded by the European Union" badge
* at the inline end, and the mega-menu groups between them. Everything is laid
* out with logical properties, so the Arabic bar mirrors (logo right, EU badge
* left) from `<html dir="rtl">` alone - there is no separate Arabic component.
*
* Below the xl breakpoint the bar collapses to logo + hamburger and the whole
* tree moves into a full-screen drawer.
*/
export function Navigation({
locale = "en",
pathname,
search,
hash,
items,
surface = "dark",
strings: stringOverrides,
homeHref,
showLanguageSwitcher = true,
showEuBadgeOnMobileBar = true,
sticky = true,
className,
}: NavigationProps) {
const navItems = useMemo(
() => items ?? getDefaultNavItems(locale),
[items, locale],
);
const strings = useMemo(
() => getNavigationStrings(locale, stringOverrides),
[locale, stringOverrides],
);
const resolvedHomeHref = homeHref ?? buildLocalizedHref("/", locale);
const direction = getDirection(locale);
return (
<header
data-surface={surface}
/* overflow-x-clip contains the mega panel's full-bleed backdrop without
clipping the panel itself, which overflows vertically. */
className={cx(
"w-full overflow-x-clip border-b border-[var(--border-subtle)]",
sticky && "sticky top-0 z-[var(--layer-header)]",
className,
)}
>
<div
className={cx(
"mx-auto flex w-full max-w-[var(--layout-content-max)] items-center justify-between",
/* The compact bar carries wordmark + EU lockup + hamburger on a
320px screen, so it runs a tighter gap than the desktop bar. */
"min-h-[64px] gap-xs px-[var(--layout-gutter)]",
"xl:min-h-[72px] xl:gap-lg xl:px-2xl",
)}
>
<Brand
href={resolvedHomeHref}
label={strings.homeLinkLabel}
surface={surface}
/>
<DesktopNav
items={navItems}
pathname={pathname}
strings={strings}
direction={direction}
className="hidden xl:block"
/>
<div className="flex items-center gap-xs xl:gap-md">
{/* Desktop-only cluster. Visibility lives on this wrapper rather than
on the children: two display utilities on one element are resolved
by the order Tailwind emits them, not by class-attribute order. */}
<div className="hidden items-center gap-md xl:flex">
{showLanguageSwitcher && (
<LanguageSwitcher
locale={locale}
pathname={pathname}
search={search}
hash={hash}
/>
)}
{!showEuBadgeOnMobileBar && (
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/>
)}
</div>
{showEuBadgeOnMobileBar && (
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/* Below xl the lockup is scaled down - emblem 36x24 instead of
48x32, statement wrapped in a narrower box - so it still fits
beside the wordmark and the hamburger at 320px. It is scaled,
never hidden: the statement stays at Label/Medium, i.e. the
same size it is everywhere else, so it remains legible. */
className="max-xl:[&>img]:h-[24px] max-xl:[&>img]:w-[36px] max-xl:[&>span]:w-[80px]"
/>
)}
<MobileNav
items={navItems}
pathname={pathname}
search={search}
hash={hash}
locale={locale}
strings={strings}
surface={surface}
homeHref={resolvedHomeHref}
showLanguageSwitcher={showLanguageSwitcher}
/>
</div>
</div>
</header>
);
}
export default Navigation;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// Dark bar on the dark home page, default IA, English:
<Navigation locale="en" pathname="/en" />
// Arabic. The bar mirrors from <html dir="rtl"> - no extra props, no
// separate component - and the arrow keys mirror with it.
<Navigation locale="ar" pathname="/ar/contact" />
// Light bar on an interior page:
<Navigation locale="en" pathname="/en/about" surface="light" />
// Fed from Strapi site-settings, with the CMS owning the labels and hrefs:
<Navigation
locale={locale}
pathname={pathname}
items={siteSettings.navigation}
strings={{ euStatement: siteSettings.euFundingStatement }}
/>
// Wired from a route, using the router's own location so a language switch
// keeps the query string and fragment:
const { pathname, search, hash } = useLocation();
<Navigation locale={locale} pathname={pathname} search={search} hash={hash} />
// The EU badge is in the compact bar by default. Opt out only where the
// funding statement is guaranteed to be visible elsewhere on the screen:
<Navigation locale="en" pathname="/en" showEuBadgeOnMobileBar={false} />
------------------------------------------------------------------------ */
@@ -0,0 +1,31 @@
export { Navigation, default } from "./Navigation";
export type { NavigationProps } from "./Navigation";
export { Brand } from "./Brand";
export type { BrandProps } from "./Brand";
export { DesktopNav } from "./DesktopNav";
export type { DesktopNavProps } from "./DesktopNav";
export { EuBadge } from "./EuBadge";
export type { EuBadgeProps } from "./EuBadge";
export { MenuBars } from "./MenuBars";
export type { MenuBarsProps } from "./MenuBars";
export { MobileNav } from "./MobileNav";
export type { MobileNavProps } from "./MobileNav";
export {
getDefaultNavItems,
getNavigationStrings,
NAV_PATHS,
NAVIGATION_STRINGS,
} from "./nav-items";
export type { NavPathKey } from "./nav-items";
export { isNavGroup } from "./types";
export type {
NavGroupItem,
NavigationStrings,
NavigationStringsByLocale,
NavItem,
NavLinkItem,
NavSurface,
} from "./types";
@@ -0,0 +1,153 @@
/* ============================================================================
DEFAULT INFORMATION ARCHITECTURE
----------------------------------------------------------------------------
The ten top-level destinations from the Figma navigation (frame 20:236) and
ToR 3.2. The app shell normally feeds `items` to <Navigation /> from the
Strapi site-settings single type; this module is the typed fallback that
renders when Strapi has not been wired yet.
It does NOT own the slugs: ~/lib/route-slugs is the one table the route
config is built from, and `NAV_PATHS` below is constrained to it.
Arabic copy is taken verbatim from the Arabic mega-menu frame (334:639).
========================================================================= */
import type { RouteSlugKey, RouteSlugs } from "~/lib/route-slugs";
import { buildLocalizedHref, type Locale } from "../LanguageSwitcher";
import type { NavItem, NavigationStrings, NavigationStringsByLocale } from "./types";
/**
* Unprefixed route paths. Both locales share the Latin slugs; the locale lives
* in the prefix (/en/contact, /ar/contact), never in the slug.
*
* The `satisfies` clause pins every entry to the slug `ROUTE_SLUGS` registers
* the route under - home's empty slug yielding "/" - so this table cannot
* drift away from the URLs the app actually serves, and cannot gain or lose a
* section without ~/lib/route-slugs gaining or losing one first.
*/
export const NAV_PATHS = {
home: "/",
about: "/about",
whatWeDo: "/what-we-do",
activities: "/activities-and-initiatives",
events: "/events-calendar",
applications: "/applications-and-opportunities",
news: "/news-and-updates",
contact: "/contact",
resources: "/resources-and-publications",
knowledgeHub: "/knowledge-hub",
} as const satisfies { readonly [K in RouteSlugKey]: `/${RouteSlugs[K]}` };
export type NavPathKey = keyof typeof NAV_PATHS;
type LabelKey =
| NavPathKey
| "aboutAndStrategy"
| "engagementAndOpportunities"
| "knowledgeAndImpact";
const LABELS: Record<Locale, Record<LabelKey, string>> = {
en: {
home: "Home",
aboutAndStrategy: "About and Strategy",
about: "About",
whatWeDo: "What We Do",
activities: "Activities and Initiatives",
engagementAndOpportunities: "Engagement and Opportunities",
events: "Events Calendar",
applications: "Applications and Opportunities",
news: "News and Updates",
contact: "Contact",
knowledgeAndImpact: "Knowledge and Impact",
resources: "Resources and Publications",
knowledgeHub: "Knowledge Hub",
},
ar: {
home: "الرئيسية",
aboutAndStrategy: "عن المشروع والاستراتيجية",
about: "عن المشروع",
whatWeDo: "ماذا نفعل",
activities: "الأنشطة والمبادرات",
engagementAndOpportunities: "المشاركة والفرص",
events: "تقويم الفعاليات",
applications: "التطبيقات والفرص",
news: "الأخبار والمستجدات",
contact: "اتصل بنا",
knowledgeAndImpact: "المعرفة والتأثير",
resources: "الموارد والمنشورات",
knowledgeHub: "مركز المعرفة",
},
};
/**
* The default navigation for a locale, with every href already prefixed.
* Returns a fresh array so a caller may safely sort or splice it.
*/
export function getDefaultNavItems(locale: Locale): NavItem[] {
const label = LABELS[locale];
const href = (key: NavPathKey) => buildLocalizedHref(NAV_PATHS[key], locale);
return [
{ id: "home", label: label.home, href: href("home") },
{
id: "about-and-strategy",
label: label.aboutAndStrategy,
children: [
{ id: "about", label: label.about, href: href("about") },
{ id: "what-we-do", label: label.whatWeDo, href: href("whatWeDo") },
{ id: "activities", label: label.activities, href: href("activities") },
],
},
{
id: "engagement-and-opportunities",
label: label.engagementAndOpportunities,
children: [
{ id: "events", label: label.events, href: href("events") },
{ id: "applications", label: label.applications, href: href("applications") },
{ id: "news", label: label.news, href: href("news") },
{ id: "contact", label: label.contact, href: href("contact") },
],
},
{
id: "knowledge-and-impact",
label: label.knowledgeAndImpact,
children: [
{ id: "resources", label: label.resources, href: href("resources") },
{ id: "knowledge-hub", label: label.knowledgeHub, href: href("knowledgeHub") },
],
},
];
}
/**
* EU visibility is mandatory on every page. "Funded by the European Union" is
* the prescribed wording; the Figma text layer reads "European nation", which
* is a typo in the design file and is corrected here.
*/
export const NAVIGATION_STRINGS: NavigationStringsByLocale = {
en: {
primaryNavLabel: "Main navigation",
openMenu: "Open menu",
closeMenu: "Close menu",
homeLinkLabel: "SHARP-ER home",
euStatement: "Funded by the European Union",
euEmblemAlt: "",
},
ar: {
primaryNavLabel: "التنقل الرئيسي",
openMenu: "فتح القائمة",
closeMenu: "إغلاق القائمة",
homeLinkLabel: "الصفحة الرئيسية لمشروع SHARP-ER",
euStatement: "بتمويل من الاتحاد الأوروبي",
euEmblemAlt: "",
},
};
export function getNavigationStrings(
locale: Locale,
overrides?: Partial<NavigationStrings>,
): NavigationStrings {
return { ...NAVIGATION_STRINGS[locale], ...overrides };
}
@@ -0,0 +1,19 @@
/**
* Shared class fragments for the navigation.
*
* app.css already paints a token-driven ring on every `:focus-visible`; these
* classes restate it with the same tier-2 tokens so the ring survives any local
* `outline` override and is visible in the component source.
*/
export const FOCUS_RING =
"focus-visible:outline-[color:var(--focus-ring-color)] " +
"focus-visible:outline-[length:var(--focus-ring-width)] " +
"focus-visible:outline-offset-[var(--focus-ring-offset)]";
/** Colour/opacity transitions, driven by the motion tokens. */
export const HOVER_TRANSITION =
"transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
@@ -0,0 +1,53 @@
import type { Locale } from "../LanguageSwitcher";
/** A leaf destination in the navigation. `href` is already locale-prefixed. */
export interface NavLinkItem {
/** Stable identifier - React key and the base of the panel's DOM id. */
id: string;
label: string;
/** Locale-prefixed path, e.g. "/en/contact". */
href: string;
children?: undefined;
}
/**
* A mega-menu group. Groups are disclosures rather than destinations: the
* Figma navigation draws the group title as plain text with no landing page,
* so the trigger is a <button> and only the children navigate.
*/
export interface NavGroupItem {
id: string;
label: string;
children: readonly NavLinkItem[];
}
export type NavItem = NavLinkItem | NavGroupItem;
export function isNavGroup(item: NavItem): item is NavGroupItem {
const children = (item as NavGroupItem).children;
return Array.isArray(children) && children.length > 0;
}
/** Which tier-2 surface the bar paints itself with. `:root` is the dark one. */
export type NavSurface = "dark" | "light";
/** Every non-link string the navigation renders or announces. */
export interface NavigationStrings {
/** Accessible name of the <nav> landmark and of the mobile dialog. */
primaryNavLabel: string;
/** Accessible name of the hamburger button. */
openMenu: string;
/** Accessible name of the drawer's close button. */
closeMenu: string;
/** Accessible name of the logo link. */
homeLinkLabel: string;
/** The mandatory EU funding statement. */
euStatement: string;
/**
* Alt text for the EU emblem. Empty by default: the statement sits next to
* the emblem as real text, so describing the image would double it up.
*/
euEmblemAlt: string;
}
export type NavigationStringsByLocale = Record<Locale, NavigationStrings>;
+122
View File
@@ -0,0 +1,122 @@
/* ============================================================================
SERVER ENTRY
----------------------------------------------------------------------------
The stock React Router streaming entry plus two SHARP-ER additions:
- `Content-Language` is stamped from the URL's locale prefix, so the
header agrees with <html lang> that root.tsx renders. `Vary` is NOT set
globally: every page URL carries its own locale, so only the bare "/"
redirect varies on Accept-Language and it sets the header itself.
- `handleError` logs server errors without the noise of client aborts.
========================================================================= */
import { PassThrough } from "node:stream";
import type { EntryContext, RouterContextProvider } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import { DEFAULT_LOCALE, getLocaleFromPathname } from "~/i18n/config";
export const streamTimeout = 5_000;
/** The locale a URL addresses, for the `Content-Language` response header. */
function contentLanguage(request: Request): string {
try {
return getLocaleFromPathname(new URL(request.url).pathname) ?? DEFAULT_LOCALE;
} catch {
return DEFAULT_LOCALE;
}
}
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: RouterContextProvider,
) {
// https://httpwg.org/specs/rfc9110.html#HEAD
if (request.method.toUpperCase() === "HEAD") {
return new Response(null, {
status: responseStatusCode,
headers: responseHeaders,
});
}
return new Promise((resolve, reject) => {
let shellRendered = false;
let userAgent = request.headers.get("user-agent");
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
let readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
() => abort(),
streamTimeout + 1000,
);
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough({
final(callback) {
// Clear the timeout to prevent retaining the closure and memory leak
clearTimeout(timeoutId);
timeoutId = undefined;
callback();
},
});
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set("Content-Language", contentLanguage(request));
pipe(body);
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
});
}
/**
* Central server-side error logging. A request the client walked away from is
* not an application error, so it is not logged as one.
*/
export function handleError(
error: unknown,
{ request }: { request: Request },
): void {
if (request.signal.aborted) return;
console.error(`[sharp-er] ${request.method} ${request.url}`, error);
}
+136
View File
@@ -0,0 +1,136 @@
/* ============================================================================
I18N CONFIG
----------------------------------------------------------------------------
SHARP-ER is served with an EXPLICIT locale prefix on BOTH languages:
/en/... and /ar/.... Bare "/" is redirected by the server on Accept-Language.
There is no ?lang= query parameter anywhere in the product.
The locale primitives themselves live in the LanguageSwitcher's pure
`locale-path` module - it is imported by <Navigation />, <LanguageSwitcher />
and <Footer />, so re-exporting from there (rather than declaring a second
`Locale` union) keeps one type across the whole app. A relative import is
used because this module is reachable from `app/routes.ts`, which the dev
plugin evaluates outside the app's normal alias resolution.
========================================================================= */
import {
DEFAULT_LOCALE,
getDirection,
getLocaleFromPathname,
isLocale,
LOCALES,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
type Direction,
type Locale,
} from "../components/ui/LanguageSwitcher/locale-path";
export type { Direction, Locale };
export {
DEFAULT_LOCALE,
getLocaleFromPathname,
isLocale,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
};
/** Every locale the site is published in, in preference order. */
export const SUPPORTED_LOCALES = LOCALES;
/** `<html dir>` for a locale. Alias of the component library's helper. */
export const dirFor = getDirection;
/**
* The BCP-47 tag handed to `Intl.*` and to `<html lang>`'s consumers.
* Libyan Arabic content is written in MSA, and the draft copy uses Western
* (latn) digits throughout, so number formatting stays latn - see ./format.ts.
*/
export const INTL_LOCALE: Readonly<Record<Locale, string>> = {
en: "en-GB",
ar: "ar-LY",
};
/** Open Graph `og:locale` values. */
export const OG_LOCALE: Readonly<Record<Locale, string>> = {
en: "en_GB",
ar: "ar_LY",
};
/* ---------------------------------------------------------------------------
ACCEPT-LANGUAGE NEGOTIATION
Only ever used for bare "/" - once the reader is on a prefixed URL the URL
wins, so a shared /ar/... link never silently flips to English.
------------------------------------------------------------------------ */
/** One parsed entry of an `Accept-Language` header. */
export interface LanguageRange {
readonly tag: string;
readonly quality: number;
}
/**
* Parse an `Accept-Language` header into ranges sorted by descending quality.
* Malformed entries are dropped rather than throwing - this runs on a header
* an arbitrary client controls.
*/
export function parseAcceptLanguage(
header: string | null | undefined,
): LanguageRange[] {
if (!header) return [];
return header
.split(",")
.map((part, index): LanguageRange | null => {
const [rawTag, ...parameters] = part.trim().split(";");
const tag = rawTag?.trim().toLowerCase();
if (!tag) return null;
const qParameter = parameters
.map((parameter) => parameter.trim())
.find((parameter) => parameter.startsWith("q="));
const parsed = qParameter ? Number.parseFloat(qParameter.slice(2)) : 1;
const quality = Number.isFinite(parsed) ? parsed : 0;
// Index keeps the header's own order stable inside one quality band.
return { tag, quality: quality - index * 1e-6 };
})
.filter((range): range is LanguageRange => range !== null && range.quality > 0)
.sort((a, b) => b.quality - a.quality);
}
/**
* The best supported locale for an `Accept-Language` header.
* "ar-LY,ar;q=0.9,en;q=0.8" -> "ar"; "fr-FR,fr;q=0.9" -> the default locale.
*/
export function negotiateLocale(
header: string | null | undefined,
fallback: Locale = DEFAULT_LOCALE,
): Locale {
for (const { tag } of parseAcceptLanguage(header)) {
if (tag === "*") return fallback;
const base = tag.split("-")[0];
if (isLocale(base)) return base;
}
return fallback;
}
/** `negotiateLocale()` applied to a Fetch `Request`'s headers. */
export function negotiateLocaleFromRequest(
request: Request,
fallback: Locale = DEFAULT_LOCALE,
): Locale {
return negotiateLocale(request.headers.get("accept-language"), fallback);
}
/**
* The locale a URL addresses. Every page URL carries its prefix, so this is
* the authoritative resolver used by the root layout and by `useLocale()`.
*/
export function localeFromRequest(
request: Request,
fallback: Locale = DEFAULT_LOCALE,
): Locale {
return getLocaleFromPathname(new URL(request.url).pathname) ?? fallback;
}
+103
View File
@@ -0,0 +1,103 @@
/* ============================================================================
LOCALE-AWARE FORMATTING
----------------------------------------------------------------------------
Thin `Intl` wrappers so a route never hard-codes a date pattern. Arabic uses
ar-LY with the `latn` numbering system: the approved draft copy writes
Arabic dates and figures in Western digits ("30 يونيو", "2025"), so Eastern
Arabic numerals would contradict the source content.
========================================================================= */
import { INTL_LOCALE, type Locale } from "./config";
const NUMBERING_SYSTEM = "latn";
/** The BCP-47 tag (with numbering system) handed to `Intl`. */
export function intlLocale(locale: Locale): string {
return `${INTL_LOCALE[locale]}-u-nu-${NUMBERING_SYSTEM}`;
}
function toDate(value: Date | string | number): Date | null {
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export type DateStyle = "full" | "long" | "medium" | "short";
/** "12 March 2026" / "12 مارس 2026". Returns "" for an unparseable input. */
export function formatDate(
value: Date | string | number | null | undefined,
locale: Locale,
options: Intl.DateTimeFormatOptions = { dateStyle: "long" },
): string {
if (value === null || value === undefined) return "";
const date = toDate(value);
if (!date) return "";
return new Intl.DateTimeFormat(intlLocale(locale), options).format(date);
}
/** Date plus time of day, for an event card or an event detail page. */
export function formatDateTime(
value: Date | string | number | null | undefined,
locale: Locale,
options: Intl.DateTimeFormatOptions = {
dateStyle: "long",
timeStyle: "short",
},
): string {
return formatDate(value, locale, options);
}
/** "12–14 March 2026". Falls back to a single date when `end` is missing. */
export function formatDateRange(
start: Date | string | number,
end: Date | string | number | null | undefined,
locale: Locale,
options: Intl.DateTimeFormatOptions = { dateStyle: "long" },
): string {
const from = toDate(start);
if (!from) return "";
const to = end === null || end === undefined ? null : toDate(end);
if (!to || from.getTime() === to.getTime()) {
return formatDate(from, locale, options);
}
return new Intl.DateTimeFormat(intlLocale(locale), options).formatRange(
from,
to,
);
}
/** The machine-readable value for `<time dateTime>` - always ISO, never localised. */
export function toIsoDate(
value: Date | string | number | null | undefined,
): string | undefined {
if (value === null || value === undefined) return undefined;
return toDate(value)?.toISOString();
}
export function formatNumber(
value: number,
locale: Locale,
options?: Intl.NumberFormatOptions,
): string {
return new Intl.NumberFormat(intlLocale(locale), options).format(value);
}
/** "1.2 MB" - used by the resources list for a download's file size. */
export function formatFileSize(bytes: number, locale: Locale): string {
const units = ["B", "KB", "MB", "GB"] as const;
let size = Math.max(0, bytes);
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
const formatted = formatNumber(size, locale, {
maximumFractionDigits: unit === 0 ? 0 : 1,
});
return `${formatted} ${units[unit]}`;
}
+111
View File
@@ -0,0 +1,111 @@
/* ============================================================================
I18N HOOKS
----------------------------------------------------------------------------
The locale is a property of the URL, never of a cookie or of client state,
so every hook here is derived from the router - which means SSR and the
first client render agree by construction and hydration never mismatches.
`useParams().lang` is the fast path inside a page route. The root layout and
the root ErrorBoundary sit ABOVE the ":lang" segment, where that param does
not exist, so the pathname is the fallback and both are correct.
========================================================================= */
import { useMemo } from "react";
import { useLocation, useMatches, useParams } from "react-router";
import {
DEFAULT_LOCALE,
dirFor,
getLocaleFromPathname,
isLocale,
type Direction,
type Locale,
} from "./config";
import { alternatePath, localizedPath, sectionPath } from "./paths";
import type { RouteSlugKey } from "./paths";
import {
createTranslator,
getMessages,
type Messages,
type Translator,
} from "./t";
/** The locale of the page being rendered. */
export function useLocale(): Locale {
const params = useParams();
const { pathname } = useLocation();
const lang = params.lang;
return useMemo(() => {
if (isLocale(lang)) return lang;
return getLocaleFromPathname(pathname) ?? DEFAULT_LOCALE;
}, [lang, pathname]);
}
/** "ltr" for /en/*, "rtl" for /ar/*. Mirrors `<html dir>`. */
export function useDirection(): Direction {
return dirFor(useLocale());
}
export interface Translation {
readonly locale: Locale;
readonly dir: Direction;
/** `t("nav.sections.about")`, `t("pagination.page", { page, pageCount })`. */
readonly t: Translator;
/** The raw catalogue, for the rare case a whole namespace is needed. */
readonly messages: Messages;
/** `/{locale}` prefixed URL for an unprefixed path. */
readonly localizedPath: (path: string) => string;
/** The locale-prefixed URL of one of the ten sections. */
readonly sectionPath: (key: RouteSlugKey) => string;
}
/** Everything a component needs to render in the current language. */
export function useTranslation(): Translation {
const locale = useLocale();
return useMemo(
() => ({
locale,
dir: dirFor(locale),
t: createTranslator(locale),
messages: getMessages(locale),
localizedPath: (path: string) => localizedPath(path, locale),
sectionPath: (key: RouteSlugKey) => sectionPath(key, locale),
}),
[locale],
);
}
/**
* The counterpart-language URL for the current location, query string and
* fragment preserved. This is what the header's language switch points at.
*/
export function useAlternatePath(targetLocale: Locale): string {
const { pathname, search, hash } = useLocation();
return useMemo(
() => alternatePath(pathname, targetLocale, { search, hash }),
[pathname, search, hash, targetLocale],
);
}
/**
* Read a value off the deepest route `handle` that declares it. Used by the
* shell to let a page opt into a light navigation or footer surface.
*/
export function useRouteHandleValue<T>(
read: (handle: Record<string, unknown>) => T | undefined,
): T | undefined {
const matches = useMatches();
for (let index = matches.length - 1; index >= 0; index -= 1) {
const handle = matches[index]?.handle;
if (handle && typeof handle === "object") {
const value = read(handle as Record<string, unknown>);
if (value !== undefined) return value;
}
}
return undefined;
}
+78
View File
@@ -0,0 +1,78 @@
/* ============================================================================
I18N - public surface
----------------------------------------------------------------------------
Import everything from "~/i18n". The sub-modules are implementation detail,
except `~/i18n/config` which loaders and `app/routes.ts` may import directly
to stay clear of anything that pulls in React.
========================================================================= */
export {
DEFAULT_LOCALE,
dirFor,
getLocaleFromPathname,
INTL_LOCALE,
isLocale,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
localeFromRequest,
negotiateLocale,
negotiateLocaleFromRequest,
OG_LOCALE,
parseAcceptLanguage,
SUPPORTED_LOCALES,
} from "./config";
export type { Direction, LanguageRange, Locale } from "./config";
export {
createTranslator,
getMessages,
interpolate,
translate,
} from "./t";
export type {
Messages,
MessageKey,
TranslationKey,
TranslationParams,
Translator,
} from "./t";
export {
alternatePath,
buildLocalizedHref,
getCounterpartLocale,
isCurrentPath,
localeAlternates,
localizedPath,
ROUTE_SECTION_GROUPS,
ROUTE_SLUGS,
ROUTE_SLUG_KEYS,
sectionPath,
sectionSlugPath,
stripLocale,
} from "./paths";
export type {
CounterpartPathOptions,
RouteSectionGroupId,
RouteSlugKey,
} from "./paths";
export {
formatDate,
formatDateRange,
formatDateTime,
formatFileSize,
formatNumber,
intlLocale,
toIsoDate,
} from "./format";
export type { DateStyle } from "./format";
export {
useAlternatePath,
useDirection,
useLocale,
useRouteHandleValue,
useTranslation,
} from "./hooks";
export type { Translation } from "./hooks";
+169
View File
@@ -0,0 +1,169 @@
{
"site": {
"name": "SHARP-ER",
"fullName": "العمل المشترك لتعزيز سيادة القانون – مسار الخروج",
"tagline": "نحو تعزيز سيادة القانون، معًا",
"description": "مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة. يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027.",
"metaTitle": "SHARP-ER — العمل المشترك لتعزيز سيادة القانون – مسار الخروج",
"euStatement": "بتمويل من الاتحاد الأوروبي",
"euEmblemAlt": "شعار الاتحاد الأوروبي",
"implementedBy": "الجهات المنفّذة",
"followUs": "تابعوا SHARP-ER"
},
"nav": {
"primaryLabel": "التنقل الرئيسي",
"footerLabel": "أقسام الموقع",
"openMenu": "فتح القائمة",
"closeMenu": "إغلاق القائمة",
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
"skipToContent": "تخطَّ إلى المحتوى",
"sections": {
"home": "الرئيسية",
"aboutAndStrategy": "عن المشروع والاستراتيجية",
"about": "عن المشروع",
"whatWeDo": "ماذا نفعل",
"activities": "الأنشطة والمبادرات",
"engagementAndOpportunities": "المشاركة والفرص",
"events": "تقويم الفعاليات",
"applications": "طلبات وفرص المشاركة",
"news": "الأخبار والمستجدات",
"contact": "اتصل بنا",
"knowledgeAndImpact": "المعرفة والتأثير",
"resources": "الموارد والمنشورات",
"knowledgeHub": "مركز المعرفة"
}
},
"language": {
"label": "اللغة",
"switchTo": "التبديل إلى {language}",
"en": "English",
"ar": "العربية"
},
"actions": {
"readMore": "اقرأ المزيد",
"learnMore": "اعرف المزيد",
"viewDetails": "عرض التفاصيل",
"viewAll": "عرض الكل",
"download": "تحميل",
"register": "سجّل الآن",
"apply": "قدّم طلبك",
"submit": "إرسال",
"cancel": "إلغاء",
"close": "إغلاق",
"share": "مشاركة",
"print": "طباعة",
"backToHome": "العودة إلى الصفحة الرئيسية",
"backToTop": "العودة إلى الأعلى",
"readRecap": "اقرأ الملخص",
"browseHub": "تصفّح المركز",
"seeActivities": "اطّلع على أنشطتنا",
"learnWhatWeDo": "تعرّف على عملنا",
"seeCurrentCalls": "اطّلع على الدعوات الحالية",
"applyToJoin": "قدّم طلب الانضمام",
"viewUpcomingSessions": "اطّلع على الجلسات القادمة",
"submitYourUpdate": "شاركونا مستجداتكم",
"addToCalendar": "أضف إلى التقويم",
"tryAgain": "حاول مرة أخرى"
},
"filters": {
"heading": "تصفية",
"all": "الكل",
"upcoming": "القادمة",
"ongoing": "جارية",
"completed": "منتهية",
"region": "المنطقة",
"topic": "الموضوع",
"date": "التاريخ",
"author": "الكاتب",
"partner": "المنظمة الشريكة",
"category": "التصنيف",
"year": "السنة",
"clear": "مسح عوامل التصفية",
"apply": "تطبيق عوامل التصفية",
"resultCount": "{count} نتيجة",
"noResults": "لا توجد نتائج.",
"noResultsHint": "جرّب إزالة أحد عوامل التصفية أو البحث عن شيء آخر.",
"sortBy": "الترتيب حسب",
"sortNewest": "الأحدث أولًا",
"sortOldest": "الأقدم أولًا"
},
"search": {
"label": "بحث",
"placeholder": "ابحث في موقع SHARP-ER",
"submit": "بحث",
"clear": "مسح البحث",
"resultsFor": "نتائج البحث عن ”{query}“"
},
"pagination": {
"label": "ترقيم الصفحات",
"previous": "السابق",
"next": "التالي",
"page": "صفحة {page} من {pageCount}",
"goToPage": "الانتقال إلى صفحة {page}",
"showing": "عرض {from}–{to} من {total}"
},
"events": {
"upcoming": "الفعاليات القادمة",
"past": "الفعاليات السابقة",
"dateTime": "التاريخ والوقت",
"location": "الصيغة/الموقع",
"online": "عبر الإنترنت",
"inPerson": "حضوري",
"registrationStatus": "حالة التسجيل",
"statusOpen": "مفتوح",
"statusClosed": "مغلق",
"statusFull": "مكتمل",
"register": "تسجيل",
"joinWaitlist": "الانضمام إلى قائمة الانتظار",
"confirmationTitle": "تم تسجيلك بنجاح",
"confirmationBody": "شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.",
"noEvents": "لا توجد فعاليات لعرضها بعد."
},
"news": {
"tabProject": "أخبار SHARP-ER",
"tabNetwork": "من الشبكة",
"publishedOn": "نُشر بتاريخ {date}",
"postedBy": "نشرته {organisation}",
"partnerPrompt": "هل أنتم منظمة شريكة؟"
},
"resources": {
"fileMeta": "{type} · {size}",
"categoryResearch": "دراسات بحثية واستطلاعية",
"categoryPolicy": "مقالات سياساتية",
"categoryReports": "تقارير المشروع",
"categoryVisibility": "مواد الرؤية وأوراق الحقائق"
},
"opportunities": {
"statusOpen": "مفتوحة",
"statusClosed": "مغلقة",
"deadline": "الموعد النهائي: {date}",
"eligibility": "معايير الأهلية"
},
"form": {
"name": "الاسم",
"organisation": "المنظمة",
"email": "البريد الإلكتروني",
"subject": "الموضوع",
"message": "الرسالة",
"phone": "رقم الهاتف",
"optional": "اختياري",
"required": "مطلوب",
"send": "إرسال",
"sending": "جارٍ الإرسال…",
"sent": "تم إرسال الرسالة",
"errorRequired": "هذا الحقل مطلوب.",
"errorEmail": "أدخل بريدًا إلكترونيًا صالحًا.",
"errorConsent": "يرجى تأكيد الموافقة لإتمام التسجيل.",
"errorGeneric": "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
"successTitle": "شكرًا لك",
"successBody": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن."
},
"errors": {
"statusLabel": "خطأ {status}",
"notFoundTitle": "الصفحة غير موجودة",
"notFoundBody": "الصفحة التي تبحث عنها غير موجودة أو تم نقلها.",
"genericTitle": "حدث خطأ ما",
"genericBody": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى بعد قليل.",
"detailsLabel": "تفاصيل تقنية"
}
}
+169
View File
@@ -0,0 +1,169 @@
{
"site": {
"name": "SHARP-ER",
"fullName": "Shared Action on Rule of Law Progress – Exit Route",
"tagline": "Strengthening the Rule of Law, Together",
"description": "A nationwide initiative supporting Libya’s civil society, local institutions and communities in advancing justice, accountability and good governance. Running from December 2025 to December 2027.",
"metaTitle": "SHARP-ER — Shared Action on Rule of Law Progress – Exit Route",
"euStatement": "Funded by the European Union",
"euEmblemAlt": "Emblem of the European Union",
"implementedBy": "Implemented by",
"followUs": "Follow SHARP-ER"
},
"nav": {
"primaryLabel": "Main navigation",
"footerLabel": "Site sections",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"homeLinkLabel": "SHARP-ER home",
"skipToContent": "Skip to content",
"sections": {
"home": "Home",
"aboutAndStrategy": "About and Strategy",
"about": "About",
"whatWeDo": "What We Do",
"activities": "Activities and Initiatives",
"engagementAndOpportunities": "Engagement and Opportunities",
"events": "Events Calendar",
"applications": "Applications and Opportunities",
"news": "News and Updates",
"contact": "Contact",
"knowledgeAndImpact": "Knowledge and Impact",
"resources": "Resources and Publications",
"knowledgeHub": "Knowledge Hub"
}
},
"language": {
"label": "Language",
"switchTo": "Switch to {language}",
"en": "English",
"ar": "العربية"
},
"actions": {
"readMore": "Read more",
"learnMore": "Learn more",
"viewDetails": "View details",
"viewAll": "View all",
"download": "Download",
"register": "Register",
"apply": "Apply",
"submit": "Submit",
"cancel": "Cancel",
"close": "Close",
"share": "Share",
"print": "Print",
"backToHome": "Back to home",
"backToTop": "Back to top",
"readRecap": "Read recap",
"browseHub": "Browse the hub",
"seeActivities": "See our activities",
"learnWhatWeDo": "Learn what we do",
"seeCurrentCalls": "See current calls",
"applyToJoin": "Apply to join",
"viewUpcomingSessions": "View upcoming sessions",
"submitYourUpdate": "Submit your update",
"addToCalendar": "Add to calendar",
"tryAgain": "Try again"
},
"filters": {
"heading": "Filter",
"all": "All",
"upcoming": "Upcoming",
"ongoing": "Ongoing",
"completed": "Completed",
"region": "Region",
"topic": "Topic",
"date": "Date",
"author": "Author",
"partner": "Partner organisation",
"category": "Category",
"year": "Year",
"clear": "Clear filters",
"apply": "Apply filters",
"resultCount": "{count} results",
"noResults": "No results found.",
"noResultsHint": "Try removing a filter or searching for something else.",
"sortBy": "Sort by",
"sortNewest": "Newest first",
"sortOldest": "Oldest first"
},
"search": {
"label": "Search",
"placeholder": "Search SHARP-ER",
"submit": "Search",
"clear": "Clear search",
"resultsFor": "Results for “{query}”"
},
"pagination": {
"label": "Pagination",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {pageCount}",
"goToPage": "Go to page {page}",
"showing": "Showing {from}–{to} of {total}"
},
"events": {
"upcoming": "Upcoming events",
"past": "Past events",
"dateTime": "Date and time",
"location": "Format and location",
"online": "Online",
"inPerson": "In person",
"registrationStatus": "Registration status",
"statusOpen": "Open",
"statusClosed": "Closed",
"statusFull": "Full",
"register": "Register",
"joinWaitlist": "Join the waitlist",
"confirmationTitle": "You’re registered.",
"confirmationBody": "Thank you — your spot for {event} on {date} is confirmed. We’ll send a reminder and any joining details to the email you provided.",
"noEvents": "There are no events to show yet."
},
"news": {
"tabProject": "SHARP-ER News",
"tabNetwork": "From the Network",
"publishedOn": "Published {date}",
"postedBy": "Posted by {organisation}",
"partnerPrompt": "Are you a partner organisation?"
},
"resources": {
"fileMeta": "{type} · {size}",
"categoryResearch": "Research and Perception Studies",
"categoryPolicy": "Policy Articles",
"categoryReports": "Project Reports",
"categoryVisibility": "Visibility and Fact Sheets"
},
"opportunities": {
"statusOpen": "Open",
"statusClosed": "Closed",
"deadline": "Deadline: {date}",
"eligibility": "Eligibility"
},
"form": {
"name": "Name",
"organisation": "Organisation",
"email": "Email",
"subject": "Subject",
"message": "Message",
"phone": "Phone",
"optional": "optional",
"required": "required",
"send": "Send",
"sending": "Sending…",
"sent": "Message sent",
"errorRequired": "This field is required.",
"errorEmail": "Enter a valid email address.",
"errorConsent": "Please confirm this to complete your registration.",
"errorGeneric": "Something went wrong. Please try again.",
"successTitle": "Thank you",
"successBody": "Your message has been sent. We will get back to you as soon as we can."
},
"errors": {
"statusLabel": "Error {status}",
"notFoundTitle": "Page not found",
"notFoundBody": "The page you are looking for does not exist, or it has moved.",
"genericTitle": "Something went wrong",
"genericBody": "An unexpected error occurred. Please try again in a moment.",
"detailsLabel": "Technical details"
}
}
+80
View File
@@ -0,0 +1,80 @@
/* ============================================================================
LOCALISED PATHS
----------------------------------------------------------------------------
Thin, typed wrappers over the pure helpers the component library already
ships, plus the section-level helpers the route modules need. Pure and
import-light: safe in a loader, in a `meta` function and during SSR.
========================================================================= */
import {
buildLocalizedHref,
getCounterpartLocale,
getLocaleFromPathname,
isCurrentPath,
localizePath,
stripLocale,
type CounterpartPathOptions,
type Locale,
} from "../components/ui/LanguageSwitcher/locale-path";
import {
ROUTE_SECTION_GROUPS,
ROUTE_SLUGS,
ROUTE_SLUG_KEYS,
sectionSlugPath,
type RouteSectionGroupId,
type RouteSlugKey,
} from "../lib/route-slugs";
export {
buildLocalizedHref,
getCounterpartLocale,
getLocaleFromPathname,
isCurrentPath,
ROUTE_SECTION_GROUPS,
ROUTE_SLUGS,
ROUTE_SLUG_KEYS,
sectionSlugPath,
stripLocale,
};
export type { CounterpartPathOptions, RouteSectionGroupId, RouteSlugKey };
/**
* The locale-prefixed URL for a path. Accepts an already-prefixed path, an
* unprefixed one, or a bare "/".
* ("/contact", "ar") -> "/ar/contact" ("/en/contact", "ar") -> "/ar/contact"
*/
export function localizedPath(path: string, locale: Locale): string {
return localizePath(path, locale);
}
/**
* The same page in the other language, query string and fragment preserved.
* This is what the header's language switch links to.
* ("/en/news-and-updates?page=2", "ar") -> "/ar/news-and-updates?page=2"
*/
export function alternatePath(
pathname: string,
targetLocale: Locale,
options: CounterpartPathOptions = {},
): string {
return buildLocalizedHref(pathname, targetLocale, options);
}
/** The locale-prefixed URL of one of the ten sections. */
export function sectionPath(key: RouteSlugKey, locale: Locale): string {
return localizePath(sectionSlugPath(key), locale);
}
/**
* Both language URLs for the page currently at `pathname`, in the order
* `SUPPORTED_LOCALES` declares. Used to emit hreflang alternates.
*/
export function localeAlternates(
pathname: string,
options: CounterpartPathOptions = {},
): { locale: Locale; path: string }[] {
return (["en", "ar"] as const).map((locale) => ({
locale,
path: buildLocalizedHref(pathname, locale, options),
}));
}
+118
View File
@@ -0,0 +1,118 @@
/* ============================================================================
TRANSLATOR
----------------------------------------------------------------------------
~2 KB of dependency-free lookup: a dotted key path into the JSON catalogue
plus {placeholder} interpolation. No i18next, no ICU MessageFormat - the
catalogues hold UI chrome only, page prose comes from Strapi.
English is the reference catalogue: `Messages` is derived from en.json, and
ar.json is typechecked against it, so a key added on one side and forgotten
on the other is a compile error rather than a blank label in production.
========================================================================= */
import en from "./messages/en.json";
import ar from "./messages/ar.json";
import { DEFAULT_LOCALE, type Locale } from "./config";
/** The shape of every catalogue. Derived from the English source of truth. */
export type Messages = typeof en;
/** Anything that may be interpolated into a message. */
export type TranslationParams = Readonly<
Record<string, string | number | undefined>
>;
/**
* Every valid dotted key path in the catalogue, e.g. "nav.sections.about".
* Keys that address a nested object rather than a string are not assignable,
* so `t("nav")` does not typecheck.
*/
export type MessageKey<T> = T extends string
? never
: {
[K in Extract<keyof T, string>]: T[K] extends string
? K
: `${K}.${MessageKey<T[K]>}`;
}[Extract<keyof T, string>];
export type TranslationKey = MessageKey<Messages>;
/** A bound translator. Stable per locale - see `useTranslation()`. */
export type Translator = (
key: TranslationKey,
params?: TranslationParams,
) => string;
/* The `Messages` annotation is the parity check: ar.json must supply every
key en.json declares, with a string in every leaf position. */
const arabicMessages: Messages = ar;
const CATALOGUES: Readonly<Record<Locale, Messages>> = {
en,
ar: arabicMessages,
};
/** The whole catalogue for a locale - handy for spreading into a component. */
export function getMessages(locale: Locale): Messages {
return CATALOGUES[locale] ?? CATALOGUES[DEFAULT_LOCALE];
}
/** Walk a dotted path. Returns undefined for a missing or non-string leaf. */
function lookup(catalogue: Messages, key: string): string | undefined {
let current: unknown = catalogue;
for (const segment of key.split(".")) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[segment];
}
return typeof current === "string" ? current : undefined;
}
const PLACEHOLDER = /\{(\w+)\}/g;
/**
* Replace every `{name}` with `params.name`. An unknown or undefined
* placeholder is left in place rather than replaced with "undefined", so a
* missing value is visible in review instead of silently shipping.
*/
export function interpolate(
template: string,
params?: TranslationParams,
): string {
if (!params) return template;
return template.replace(PLACEHOLDER, (match, name: string) => {
const value = params[name];
return value === undefined ? match : String(value);
});
}
/**
* Translate one key. Falls back to the English string when a locale is
* missing it, and finally to the key itself so a page never renders "".
*/
export function translate(
locale: Locale,
key: TranslationKey,
params?: TranslationParams,
): string {
const template =
lookup(getMessages(locale), key) ??
lookup(CATALOGUES[DEFAULT_LOCALE], key);
if (template === undefined) {
if (import.meta.env?.DEV) {
console.warn(`[i18n] missing translation for "${key}" (${locale})`);
}
return key;
}
return interpolate(template, params);
}
/** A translator bound to one locale. Cheap - safe to call in a `useMemo`. */
export function createTranslator(locale: Locale): Translator {
return (key, params) => translate(locale, key, params);
}
+135
View File
@@ -0,0 +1,135 @@
/* ============================================================================
SERVER ENVIRONMENT
----------------------------------------------------------------------------
The `.server` suffix is enforced by the React Router Vite plugin: this
module is stripped from the browser bundle, and importing it from component
code fails the build rather than leaking STRAPI_TOKEN to the client.
Validation is lazy and memoised. Reading it at module load would crash the
whole server on a typo in one variable; reading it on first use lets the
error surface as an ordinary 500 with every problem listed at once.
========================================================================= */
export type NodeEnv = "development" | "production" | "test";
export interface ServerEnv {
readonly NODE_ENV: NodeEnv;
/** Strapi REST origin, no trailing slash. Default http://localhost:1337 */
readonly STRAPI_URL: string;
/** Optional API token. Sent as a bearer token when present. */
readonly STRAPI_TOKEN?: string;
/** Public origin of this site, no trailing slash. Used for canonical URLs. */
readonly SITE_URL: string;
readonly isProduction: boolean;
readonly isDevelopment: boolean;
}
/** Thrown when one or more variables are missing or malformed. */
export class EnvError extends Error {
override readonly name = "EnvError";
readonly issues: readonly string[];
constructor(issues: readonly string[]) {
super(`Invalid server environment:\n - ${issues.join("\n - ")}`);
this.issues = issues;
}
}
const DEFAULTS = {
STRAPI_URL: "http://localhost:1337",
SITE_URL: "https://sharper.ly",
} as const;
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function readOrigin(
name: string,
raw: string | undefined,
fallback: string,
issues: string[],
): string {
const value = raw?.trim() || fallback;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
issues.push(`${name} must be an http(s) URL, received "${value}"`);
return trimTrailingSlash(fallback);
}
return trimTrailingSlash(value);
} catch {
issues.push(`${name} must be an absolute URL, received "${value}"`);
return trimTrailingSlash(fallback);
}
}
function readNodeEnv(raw: string | undefined): NodeEnv {
return raw === "production" || raw === "test" ? raw : "development";
}
let cached: ServerEnv | undefined;
/** The validated environment. Memoised after the first successful read. */
export function getServerEnv(): ServerEnv {
if (cached) return cached;
const source = process.env;
const issues: string[] = [];
const NODE_ENV = readNodeEnv(source.NODE_ENV);
const STRAPI_URL = readOrigin(
"STRAPI_URL",
source.STRAPI_URL,
DEFAULTS.STRAPI_URL,
issues,
);
const SITE_URL = readOrigin(
"SITE_URL",
source.SITE_URL,
DEFAULTS.SITE_URL,
issues,
);
const token = source.STRAPI_TOKEN?.trim();
if (issues.length > 0) throw new EnvError(issues);
cached = {
NODE_ENV,
STRAPI_URL,
SITE_URL,
...(token ? { STRAPI_TOKEN: token } : {}),
isProduction: NODE_ENV === "production",
isDevelopment: NODE_ENV === "development",
};
return cached;
}
/** Drop the memoised value. Only useful in tests. */
export function resetServerEnv(): void {
cached = undefined;
}
/**
* The origin canonical URLs and hreflang alternates are built from.
*
* SITE_URL wins when it is set to something other than the default, so a
* preview deployment behind a proxy still emits its own canonical. Otherwise
* the incoming request is trusted, honouring the standard forwarding headers.
*/
export function getSiteUrl(request: Request): string {
const { SITE_URL } = getServerEnv();
if (process.env.SITE_URL?.trim()) return SITE_URL;
const url = new URL(request.url);
const forwardedHost =
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
const forwardedProto = request.headers.get("x-forwarded-proto");
const protocol = forwardedProto?.split(",")[0]?.trim() ?? url.protocol.replace(":", "");
const host = forwardedHost?.split(",")[0]?.trim() ?? url.host;
return trimTrailingSlash(`${protocol}://${host}`);
}
+59
View File
@@ -0,0 +1,59 @@
/* ============================================================================
ROUTE HANDLE
----------------------------------------------------------------------------
The shell reads a few presentation decisions off the deepest route's
`handle`, so a page can flip the header or footer to the light surface
without the root layout knowing anything about that page.
export const handle = { navSurface: "light" } satisfies RouteHandle;
========================================================================= */
/** Which tier-2 surface a shell element paints. `:root` is the dark one. */
export type ShellSurface = "dark" | "light";
export interface RouteHandle {
/** Surface for the sticky header. Default "dark". */
readonly navSurface?: ShellSurface;
/** Surface for the footer. Default "dark". */
readonly footerSurface?: ShellSurface;
/**
* Override the canonical pathname the shell emits for this route - e.g. a
* paginated listing that should point page 2 at page 1. Locale-prefixed.
*/
readonly canonicalPath?: string;
/**
* "This page emits its own canonical + hreflang - do not add the shell's
* prefix-swapped pair." Set by detail routes, whose Arabic counterpart has
* its own Strapi slug, so swapping the locale prefix alone points the
* alternate at a URL that does not exist.
*/
readonly selfAlternates?: boolean;
/** Anything a route wants to hang off its own handle. */
readonly [key: string]: unknown;
}
function readSurface(value: unknown): ShellSurface | undefined {
return value === "dark" || value === "light" ? value : undefined;
}
export function readNavSurface(handle: Record<string, unknown>): ShellSurface | undefined {
return readSurface(handle.navSurface);
}
export function readFooterSurface(
handle: Record<string, unknown>,
): ShellSurface | undefined {
return readSurface(handle.footerSurface);
}
export function readCanonicalPath(
handle: Record<string, unknown>,
): string | undefined {
return typeof handle.canonicalPath === "string" ? handle.canonicalPath : undefined;
}
export function readSelfAlternates(
handle: Record<string, unknown>,
): boolean | undefined {
return handle.selfAlternates === true ? true : undefined;
}
+75
View File
@@ -0,0 +1,75 @@
/* ============================================================================
ROUTE SLUGS - the single source of truth for the site's ten sections
----------------------------------------------------------------------------
This module has ZERO imports on purpose: it is loaded by `app/routes.ts`,
which the React Router dev plugin evaluates in Node while building the route
config, long before the React tree exists.
This table is the source of truth for the site's URLs, and the consumers
are checked against it rather than repeating it: the footer sitemap imports
`ROUTE_SLUGS` directly, and `NAV_PATHS` in
~/components/ui/Navigation/nav-items.ts is constrained by `RouteSlugs`, so
a slug changed here and forgotten there is a type error, not a dead link.
Both locales share the Latin slug; the locale lives in the URL prefix
(/en/contact, /ar/contact) and never in the slug itself.
========================================================================= */
/**
* Slug for every top-level section, WITHOUT a leading slash so it can be
* handed straight to `route()` in the route config. `home` is the index of the
* `:lang` layout and therefore has no slug of its own.
*/
export const ROUTE_SLUGS = {
home: "",
about: "about",
whatWeDo: "what-we-do",
activities: "activities-and-initiatives",
events: "events-calendar",
applications: "applications-and-opportunities",
news: "news-and-updates",
contact: "contact",
resources: "resources-and-publications",
knowledgeHub: "knowledge-hub",
} as const;
export type RouteSlugKey = keyof typeof ROUTE_SLUGS;
/**
* The table's own shape, so a consumer can be typed against the exact slugs
* without importing the value and taking on this module's Node-time role.
*/
export type RouteSlugs = typeof ROUTE_SLUGS;
/** The ten section keys in navigation order. */
export const ROUTE_SLUG_KEYS = Object.keys(ROUTE_SLUGS) as readonly RouteSlugKey[];
/**
* The unprefixed pathname of a section, e.g. "/" or "/what-we-do".
* Use `sectionPath()` from ~/i18n when you want the locale-prefixed URL.
*/
export function sectionSlugPath(key: RouteSlugKey): string {
const slug = ROUTE_SLUGS[key];
return slug === "" ? "/" : `/${slug}`;
}
/** The IA groups the navigation and the footer sitemap both render. */
export const ROUTE_SECTION_GROUPS = [
{
id: "about-and-strategy",
children: ["about", "whatWeDo", "activities"],
},
{
id: "engagement-and-opportunities",
children: ["events", "applications", "news", "contact"],
},
{
id: "knowledge-and-impact",
children: ["resources", "knowledgeHub"],
},
] as const satisfies readonly {
id: string;
children: readonly RouteSlugKey[];
}[];
export type RouteSectionGroupId = (typeof ROUTE_SECTION_GROUPS)[number]["id"];
+71
View File
@@ -0,0 +1,71 @@
/* ============================================================================
SAFE HREF
----------------------------------------------------------------------------
Every URL the site renders into an `href` comes out of Strapi, which means
it is typed by an editor account rather than by this codebase. An `href` is
NOT inert: `javascript:...`, `data:text/html,...` and `vbscript:...` all
execute on click, so a single compromised or careless editor account is an
XSS vector unless the scheme is checked here, at the point of rendering.
`safeHref` is that check, and it is the ONLY thing allowed to decide whether
a CMS-supplied URL becomes a link. It returns the cleaned URL when it is
safe to put in an `href`, and `undefined` when it is not - and `undefined`
must be rendered as plain text, never as an `<a>` with a missing or empty
`href`.
WHAT PASSES
- a fragment (`#section`) or a relative/root-relative path (`/en/about`,
`about`, `?page=2`) - no scheme, so nothing can be smuggled
- an absolute URL whose protocol is http, https, mailto or tel
WHAT IS STRIPPED FIRST
Browsers ignore control characters and whitespace inside a scheme, so
a newline- or tab-split `java<LF>script:alert(1)` navigates exactly like
`javascript:alert(1)`. Those characters are normalised away BEFORE the
scheme is read, so this check sees what the browser will see.
========================================================================= */
/**
* Characters a browser drops (or treats as insignificant) while parsing a URL:
* C0/C1 controls, tab and newlines, plus the zero-width and bidi marks that can
* be pasted invisibly into a CMS field.
*/
const IGNORED_CHARS =
/[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028\u2029\u202A-\u202E\uFEFF]/g;
/** A leading `scheme:`, per RFC 3986. */
const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
/** The only protocols an editor-supplied absolute URL may use. */
const ALLOWED_PROTOCOLS: ReadonlySet<string> = new Set([
"http:",
"https:",
"mailto:",
"tel:",
]);
/**
* The URL to render, or `undefined` when the value must not become a link.
*
* @param url anything a CMS field might hold - unknown on purpose, because a
* JSON body is not typed until it has been checked.
*/
export function safeHref(url: unknown): string | undefined {
if (typeof url !== "string") return undefined;
const value = url.replace(IGNORED_CHARS, "").trim();
if (value === "") return undefined;
// No scheme means a fragment, a query or a path: there is nothing to execute.
if (!HAS_SCHEME.test(value)) return value;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
// A scheme that does not parse is not a URL anyone should be sent to.
return undefined;
}
return ALLOWED_PROTOCOLS.has(parsed.protocol) ? value : undefined;
}
+566
View File
@@ -0,0 +1,566 @@
/* ============================================================================
SEO
----------------------------------------------------------------------------
One function builds the whole `meta` descriptor array for a page: title,
description, robots, canonical, the en/ar/x-default hreflang set, Open Graph,
Twitter, and any JSON-LD the route wants to attach.
Pure and client-safe: `meta` also runs in the browser on a client-side
navigation, so nothing here may touch `process.env`. The origin arrives as
`siteUrl`, which the root loader resolves once per request via
`getSiteUrl(request)` from ~/lib/env.server.
========================================================================= */
import type { MetaDescriptor } from "react-router";
import {
INTL_LOCALE,
OG_LOCALE,
SUPPORTED_LOCALES,
type Locale,
} from "~/i18n/config";
import { getCounterpartLocale, localizedPath, stripLocale } from "~/i18n/paths";
import { getMessages } from "~/i18n/t";
export const SITE_NAME = "SHARP-ER";
/** Separator between the page title and the site name. */
const TITLE_SEPARATOR = " | ";
/** A JSON-LD node. Kept structural - schema.org has no useful TS type. */
export type JsonLd = Readonly<Record<string, unknown>>;
export interface SeoImage {
/** Absolute URL, or a path rooted at the site (e.g. "/assets/og.png"). */
readonly url: string;
readonly alt?: string;
readonly width?: number;
readonly height?: number;
/** MIME type, e.g. "image/png". */
readonly type?: string;
}
export interface SeoArticle {
readonly publishedTime?: string;
readonly modifiedTime?: string;
readonly authors?: readonly string[];
readonly section?: string;
readonly tags?: readonly string[];
}
export interface SeoInput {
readonly locale: Locale;
/** Pathname of the page INCLUDING its locale prefix, e.g. "/ar/contact". */
readonly pathname: string;
/** Site origin without a trailing slash, from the root loader. */
readonly siteUrl: string;
/** Page title without the site name - it is appended for you. */
readonly title: string;
readonly description?: string;
readonly image?: SeoImage;
readonly type?: "website" | "article";
/** Emit `robots: noindex, nofollow`. Use on search and filter result pages. */
readonly noIndex?: boolean;
readonly keywords?: readonly string[];
readonly article?: SeoArticle;
/** Append " | SHARP-ER" to the title. Default true. */
readonly appendSiteName?: boolean;
/**
* Also emit `<link rel="canonical">` and the hreflang set from THIS page's
* meta. Default false, and it should normally stay false: the app shell
* emits both for every route from `root.tsx`'s `Layout`, so a page that
* writes its own `meta` cannot accidentally drop them. Turn it on only when
* rendering a document outside that shell.
*/
readonly alternates?: boolean;
/** JSON-LD to embed, from the builders below or hand-written. */
readonly jsonLd?: JsonLd | readonly JsonLd[];
}
/* ---------------------------------------------------------------------------
URLS
------------------------------------------------------------------------ */
const ABSOLUTE_URL = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i;
/** Join a site origin and a path into an absolute URL. */
export function absoluteUrl(siteUrl: string, path: string): string {
if (ABSOLUTE_URL.test(path)) return path;
return `${siteUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
/** The canonical URL of a page. Trailing slashes are normalised away. */
export function canonicalUrl(siteUrl: string, pathname: string): string {
const path = pathname.replace(/\/+$/, "") || "/";
return absoluteUrl(siteUrl, path);
}
export interface LocaleAlternate {
readonly locale: Locale;
readonly hrefLang: string;
readonly href: string;
}
/**
* The hreflang set for a page: one entry per locale plus `x-default`, which
* points at the English URL because English is the default locale that bare
* "/" negotiates towards.
*/
export function alternateUrls(
siteUrl: string,
pathname: string,
): LocaleAlternate[] {
const unprefixed = stripLocale(pathname);
const alternates: LocaleAlternate[] = SUPPORTED_LOCALES.map((locale) => ({
locale,
hrefLang: locale,
href: canonicalUrl(siteUrl, localizedPath(unprefixed, locale)),
}));
return [
...alternates,
{
locale: "en",
hrefLang: "x-default",
href: canonicalUrl(siteUrl, localizedPath(unprefixed, "en")),
},
];
}
/* ---------------------------------------------------------------------------
META
------------------------------------------------------------------------ */
function formatTitle(input: SeoInput): string {
const { title, appendSiteName = true } = input;
if (!appendSiteName) return title;
if (title === SITE_NAME || title.includes(SITE_NAME)) return title;
return `${title}${TITLE_SEPARATOR}${SITE_NAME}`;
}
/**
* The full `meta` descriptor array for a page.
*
* export function meta({ matches }: Route.MetaArgs) {
* const root = matches[0].data;
* return buildMeta({
* locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl,
* title: "Contact", description: "...",
* });
* }
*/
export function buildMeta(input: SeoInput): MetaDescriptor[] {
const {
locale,
pathname,
siteUrl,
description,
image,
type = "website",
noIndex = false,
keywords,
article,
alternates = false,
jsonLd,
} = input;
const messages = getMessages(locale);
const title = formatTitle(input);
const url = canonicalUrl(siteUrl, pathname);
const descriptionText = description ?? messages.site.description;
const counterpart = getCounterpartLocale(locale);
const descriptors: MetaDescriptor[] = [
{ title },
{ name: "description", content: descriptionText },
];
if (keywords && keywords.length > 0) {
descriptors.push({ name: "keywords", content: keywords.join(", ") });
}
descriptors.push({
name: "robots",
content: noIndex ? "noindex, nofollow" : "index, follow",
});
if (alternates) {
descriptors.push({ tagName: "link", rel: "canonical", href: url });
for (const alternate of alternateUrls(siteUrl, pathname)) {
descriptors.push({
tagName: "link",
rel: "alternate",
hrefLang: alternate.hrefLang,
href: alternate.href,
});
}
}
descriptors.push(
{ property: "og:type", content: type },
{ property: "og:site_name", content: SITE_NAME },
{ property: "og:title", content: title },
{ property: "og:description", content: descriptionText },
{ property: "og:url", content: url },
{ property: "og:locale", content: OG_LOCALE[locale] },
{ property: "og:locale:alternate", content: OG_LOCALE[counterpart] },
);
if (image) {
const imageUrl = absoluteUrl(siteUrl, image.url);
descriptors.push({ property: "og:image", content: imageUrl });
if (image.alt) {
descriptors.push({ property: "og:image:alt", content: image.alt });
}
if (image.width) {
descriptors.push({
property: "og:image:width",
content: String(image.width),
});
}
if (image.height) {
descriptors.push({
property: "og:image:height",
content: String(image.height),
});
}
if (image.type) {
descriptors.push({ property: "og:image:type", content: image.type });
}
}
if (type === "article" && article) {
if (article.publishedTime) {
descriptors.push({
property: "article:published_time",
content: article.publishedTime,
});
}
if (article.modifiedTime) {
descriptors.push({
property: "article:modified_time",
content: article.modifiedTime,
});
}
if (article.section) {
descriptors.push({
property: "article:section",
content: article.section,
});
}
for (const author of article.authors ?? []) {
descriptors.push({ property: "article:author", content: author });
}
for (const tag of article.tags ?? []) {
descriptors.push({ property: "article:tag", content: tag });
}
}
descriptors.push(
{
name: "twitter:card",
content: image ? "summary_large_image" : "summary",
},
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: descriptionText },
);
if (image) {
descriptors.push({
name: "twitter:image",
content: absoluteUrl(siteUrl, image.url),
});
if (image.alt) {
descriptors.push({ name: "twitter:image:alt", content: image.alt });
}
}
for (const node of toArray(jsonLd)) {
descriptors.push({ "script:ld+json": node });
}
return descriptors;
}
function toArray(value: JsonLd | readonly JsonLd[] | undefined): JsonLd[] {
if (!value) return [];
return Array.isArray(value) ? [...value] : [value as JsonLd];
}
/* ---------------------------------------------------------------------------
JSON-LD BUILDERS
Facts are taken from the ToR and the approved draft copy: EU-funded,
December 2025 - December 2027, nationwide Libya, Acted (lead) with UEC and
SBE. Nothing here is speculative.
------------------------------------------------------------------------ */
const SCHEMA_CONTEXT = "https://schema.org";
export const CONSORTIUM_PARTNERS = [
{ name: "Acted", role: "Lead implementing partner" },
{ name: "United Efforts Consultancy", role: "Consortium partner" },
{ name: "Stichting Bureau Ensemble", role: "Consortium partner" },
] as const;
export interface OrganizationJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
/** Public profiles, e.g. the project's Facebook page. */
readonly sameAs?: readonly string[];
readonly email?: string;
}
/** Organization JSON-LD for the site itself. Emit it once, from the home page. */
export function buildOrganizationJsonLd({
siteUrl,
locale,
sameAs,
email,
}: OrganizationJsonLdInput): JsonLd {
const messages = getMessages(locale);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Organization",
name: messages.site.name,
alternateName: messages.site.fullName,
description: messages.site.description,
url: canonicalUrl(siteUrl, localizedPath("/", locale)),
logo: absoluteUrl(siteUrl, "/assets/sharp-er-logo.png"),
inLanguage: INTL_LOCALE[locale],
areaServed: { "@type": "Country", name: "Libya" },
funder: { "@type": "Organization", name: "European Union" },
member: CONSORTIUM_PARTNERS.map((partner) => ({
"@type": "Organization",
name: partner.name,
})),
...(email ? { email } : {}),
...(sameAs && sameAs.length > 0 ? { sameAs: [...sameAs] } : {}),
};
}
export interface WebSiteJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
/** Path of the site search route, e.g. "/en/search". Omit to skip the action. */
readonly searchPath?: string;
}
export function buildWebSiteJsonLd({
siteUrl,
locale,
searchPath,
}: WebSiteJsonLdInput): JsonLd {
const messages = getMessages(locale);
return {
"@context": SCHEMA_CONTEXT,
"@type": "WebSite",
name: messages.site.name,
url: canonicalUrl(siteUrl, localizedPath("/", locale)),
inLanguage: INTL_LOCALE[locale],
...(searchPath
? {
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${absoluteUrl(siteUrl, searchPath)}?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
}
: {}),
};
}
export interface EventJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
readonly name: string;
/** Path of the event page, including the locale prefix. */
readonly pathname: string;
/** ISO 8601. */
readonly startDate: string;
readonly endDate?: string;
readonly description?: string;
readonly image?: string;
/** Venue name, e.g. "Tripoli". Omit for an online-only event. */
readonly locationName?: string;
readonly addressLocality?: string;
readonly addressCountry?: string;
/** Joining URL for an online or hybrid event. */
readonly onlineUrl?: string;
readonly isOnline?: boolean;
readonly registrationUrl?: string;
readonly status?: "scheduled" | "cancelled" | "postponed" | "rescheduled";
readonly organizerName?: string;
}
const EVENT_STATUS: Readonly<Record<NonNullable<EventJsonLdInput["status"]>, string>> = {
scheduled: "https://schema.org/EventScheduled",
cancelled: "https://schema.org/EventCancelled",
postponed: "https://schema.org/EventPostponed",
rescheduled: "https://schema.org/EventRescheduled",
};
/** Event JSON-LD for an event detail page or an events listing. */
export function buildEventJsonLd(input: EventJsonLdInput): JsonLd {
const {
siteUrl,
locale,
name,
pathname,
startDate,
endDate,
description,
image,
locationName,
addressLocality,
addressCountry = "LY",
onlineUrl,
isOnline = false,
registrationUrl,
status = "scheduled",
organizerName = SITE_NAME,
} = input;
const hasPhysicalLocation = Boolean(locationName ?? addressLocality);
const attendanceMode = isOnline
? hasPhysicalLocation
? "https://schema.org/MixedEventAttendanceMode"
: "https://schema.org/OnlineEventAttendanceMode"
: "https://schema.org/OfflineEventAttendanceMode";
const physicalLocation = hasPhysicalLocation
? {
"@type": "Place",
name: locationName ?? addressLocality,
address: {
"@type": "PostalAddress",
...(addressLocality ? { addressLocality } : {}),
addressCountry,
},
}
: undefined;
const virtualLocation = onlineUrl
? { "@type": "VirtualLocation", url: onlineUrl }
: undefined;
const locations = [physicalLocation, virtualLocation].filter(Boolean);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Event",
name,
startDate,
...(endDate ? { endDate } : {}),
...(description ? { description } : {}),
...(image ? { image: absoluteUrl(siteUrl, image) } : {}),
url: canonicalUrl(siteUrl, pathname),
inLanguage: INTL_LOCALE[locale],
eventAttendanceMode: attendanceMode,
eventStatus: EVENT_STATUS[status],
...(locations.length === 1
? { location: locations[0] }
: locations.length > 1
? { location: locations }
: {}),
organizer: { "@type": "Organization", name: organizerName },
...(registrationUrl
? {
offers: {
"@type": "Offer",
url: registrationUrl,
price: "0",
priceCurrency: "LYD",
availability: "https://schema.org/InStock",
},
}
: {}),
};
}
export interface ArticleJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
readonly headline: string;
/** Path of the article, including the locale prefix. */
readonly pathname: string;
readonly datePublished: string;
readonly dateModified?: string;
readonly description?: string;
readonly image?: string;
readonly authorName?: string;
readonly section?: string;
readonly keywords?: readonly string[];
}
/** Article JSON-LD for a news item, a publication or a knowledge-hub page. */
export function buildArticleJsonLd(input: ArticleJsonLdInput): JsonLd {
const {
siteUrl,
locale,
headline,
pathname,
datePublished,
dateModified,
description,
image,
authorName,
section,
keywords,
} = input;
const url = canonicalUrl(siteUrl, pathname);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Article",
headline,
...(description ? { description } : {}),
...(image ? { image: absoluteUrl(siteUrl, image) } : {}),
datePublished,
dateModified: dateModified ?? datePublished,
inLanguage: INTL_LOCALE[locale],
url,
mainEntityOfPage: { "@type": "WebPage", "@id": url },
author: { "@type": "Organization", name: authorName ?? SITE_NAME },
publisher: {
"@type": "Organization",
name: SITE_NAME,
logo: {
"@type": "ImageObject",
url: absoluteUrl(siteUrl, "/assets/sharp-er-logo.png"),
},
},
...(section ? { articleSection: section } : {}),
...(keywords && keywords.length > 0 ? { keywords: keywords.join(", ") } : {}),
};
}
export interface BreadcrumbItem {
readonly name: string;
/** Path including the locale prefix. */
readonly pathname: string;
}
/** BreadcrumbList JSON-LD. Pass the trail from the home page to this page. */
export function buildBreadcrumbJsonLd(
siteUrl: string,
items: readonly BreadcrumbItem[],
): JsonLd {
return {
"@context": SCHEMA_CONTEXT,
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: canonicalUrl(siteUrl, item.pathname),
})),
};
}
+79
View File
@@ -0,0 +1,79 @@
/* ============================================================================
SITE NAVIGATION DATA
----------------------------------------------------------------------------
Builds the <Navigation /> tree and the <Footer /> sitemap from ONE source:
`ROUTE_SLUGS` (which `app/routes.ts` also builds the route table from) plus
the `nav.sections.*` labels in the message catalogues. Slugs, routes, header
links and footer links therefore cannot drift apart.
<Footer />'s own defaults use a shorter slug set (/en/events, /en/news, ...);
the shell passes these groups explicitly so both bars point at the routes
that actually exist.
When the CMS is wired, replace these two functions with a Strapi
site-settings query - the shapes they return are exactly what both
components accept as props.
========================================================================= */
import type { FooterLinkGroup } from "~/components/ui/Footer";
import type { NavItem } from "~/components/ui/Navigation";
import type { Locale } from "~/i18n/config";
import { ROUTE_SECTION_GROUPS, sectionPath, type RouteSlugKey } from "~/i18n/paths";
import { getMessages } from "~/i18n/t";
/** Section labels for a locale, keyed the same way as `ROUTE_SLUGS`. */
function sectionLabels(locale: Locale) {
return getMessages(locale).nav.sections;
}
/** Heading for one of the three IA groups. */
function groupHeading(locale: Locale, groupId: string): string {
const labels = sectionLabels(locale);
switch (groupId) {
case "about-and-strategy":
return labels.aboutAndStrategy;
case "engagement-and-opportunities":
return labels.engagementAndOpportunities;
default:
return labels.knowledgeAndImpact;
}
}
/**
* The header navigation: Home as a link, then the three groups as
* disclosures. Every href is already locale-prefixed.
*/
export function buildNavItems(locale: Locale): NavItem[] {
const labels = sectionLabels(locale);
return [
{ id: "home", label: labels.home, href: sectionPath("home", locale) },
...ROUTE_SECTION_GROUPS.map((group) => ({
id: group.id,
label: groupHeading(locale, group.id),
children: group.children.map((key: RouteSlugKey) => ({
id: key,
label: labels[key],
href: sectionPath(key, locale),
})),
})),
];
}
/**
* The footer sitemap: the same three groups, without Home - the wordmark
* above the columns already links there.
*/
export function buildFooterLinkGroups(locale: Locale): FooterLinkGroup[] {
const labels = sectionLabels(locale);
return ROUTE_SECTION_GROUPS.map((group) => ({
id: group.id,
heading: groupHeading(locale, group.id),
links: group.children.map((key: RouteSlugKey) => ({
label: labels[key],
href: sectionPath(key, locale),
})),
}));
}
+700
View File
@@ -0,0 +1,700 @@
/* ============================================================================
STRAPI 5 REST CLIENT
----------------------------------------------------------------------------
SERVER ONLY. This module imports ~/lib/env.server, which the React Router
Vite plugin refuses to include in the browser bundle - so calling any of
these from component code is a build error, not a leaked API token. Call
them from `loader` / `action` only.
Strapi 5 returns FLAT entities: `{ data: { id, documentId, title, ... },
meta }` rather than v4's `{ data: { id, attributes: { ... } } }`, and a
record is addressed by its `documentId`, not by `id`. The normaliser below
still unwraps the v4 shapes, because a plugin route or a hand-written
controller can hand back the old envelope.
========================================================================= */
import { data } from "react-router";
import { getServerEnv } from "./env.server";
/* ---------------------------------------------------------------------------
RESPONSE SHAPES
------------------------------------------------------------------------ */
/** Fields Strapi 5 puts on every entity. Extend it with the content type. */
export interface StrapiEntity {
readonly id: number;
readonly documentId: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly publishedAt: string | null;
readonly locale?: string;
}
export interface StrapiPagination {
readonly page: number;
readonly pageSize: number;
readonly pageCount: number;
readonly total: number;
}
export interface StrapiMeta {
readonly pagination?: StrapiPagination;
readonly [key: string]: unknown;
}
/** A single-entity or single-type response. */
export interface StrapiSingleResponse<T> {
readonly data: T;
readonly meta: StrapiMeta;
}
/** A collection response, with `pagination` lifted out of `meta` for ease. */
export interface StrapiCollectionResponse<T> {
readonly data: T[];
readonly meta: StrapiMeta;
readonly pagination?: StrapiPagination;
}
/** An upload-plugin media entity, as returned inside a populated field. */
export interface StrapiMedia {
readonly id: number;
readonly documentId?: string;
readonly name: string;
readonly alternativeText: string | null;
readonly caption: string | null;
readonly width: number | null;
readonly height: number | null;
readonly ext: string | null;
readonly mime: string;
readonly size: number;
readonly url: string;
readonly previewUrl: string | null;
readonly formats: Readonly<Record<string, StrapiMediaFormat>> | null;
}
export interface StrapiMediaFormat {
readonly name: string;
readonly ext: string;
readonly mime: string;
readonly width: number;
readonly height: number;
readonly size: number;
readonly url: string;
}
export type StrapiMediaFormatName =
| "thumbnail"
| "small"
| "medium"
| "large";
/* ---------------------------------------------------------------------------
QUERY
------------------------------------------------------------------------ */
export type StrapiSort = string | readonly string[];
/** `"*"`, a field list, or Strapi's nested populate object. */
export type StrapiPopulate =
| "*"
| readonly string[]
| { readonly [key: string]: unknown };
export interface StrapiPaginationQuery {
readonly page?: number;
readonly pageSize?: number;
readonly start?: number;
readonly limit?: number;
readonly withCount?: boolean;
}
export interface StrapiQuery {
/** Content locale. Pass the site locale so /ar/* reads Arabic entries. */
readonly locale?: string;
readonly populate?: StrapiPopulate;
/** Nested filter object, e.g. `{ slug: { $eq: "about" } }`. */
readonly filters?: Readonly<Record<string, unknown>>;
readonly sort?: StrapiSort;
readonly fields?: readonly string[];
readonly pagination?: StrapiPaginationQuery;
/** Strapi 5 draft & publish. Defaults to the API default (published). */
readonly status?: "published" | "draft";
/** Anything else Strapi or a plugin accepts, serialised verbatim. */
readonly params?: Readonly<Record<string, unknown>>;
}
export interface StrapiRequestInit {
/** Abort the request after this many ms. Default 10000. */
readonly timeoutMs?: number;
/** Forwarded to `fetch`, e.g. to chain a route's own AbortSignal. */
readonly signal?: AbortSignal;
readonly headers?: Readonly<Record<string, string>>;
/** Standard `fetch` caching hint, honoured by the runtime's fetch. */
readonly cache?: RequestCache;
}
/* ---------------------------------------------------------------------------
ERRORS
------------------------------------------------------------------------ */
/** Strapi's own error envelope, when the response body carries one. */
export interface StrapiErrorBody {
readonly status?: number;
readonly name?: string;
readonly message?: string;
readonly details?: unknown;
}
/** Any non-2xx response, or a transport/timeout failure. */
export class StrapiError extends Error {
override readonly name = "StrapiError";
/** HTTP status, or 0 when the request never completed. */
readonly status: number;
readonly statusText: string;
readonly url: string;
readonly body: StrapiErrorBody | undefined;
constructor(init: {
message: string;
status: number;
statusText?: string;
url: string;
body?: StrapiErrorBody;
cause?: unknown;
}) {
super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
this.status = init.status;
this.statusText = init.statusText ?? "";
this.url = init.url;
this.body = init.body;
}
}
export function isStrapiError(value: unknown): value is StrapiError {
return value instanceof StrapiError;
}
/** True for a 404 from Strapi - i.e. "the entity is not there". */
export function isStrapiNotFound(value: unknown): boolean {
return isStrapiError(value) && value.status === 404;
}
/**
* Throw a React Router 404 so the root ErrorBoundary renders the localised
* "Page not found" page. `isRouteErrorResponse(error)` sees it as a 404.
*/
export function notFound(message = "Not Found"): never {
throw data(message, { status: 404, statusText: "Not Found" });
}
/**
* Turn "the CMS returned nothing" into a 404 inside a loader:
* const event = requireEntity(await getBySlug<Event>("events", params.slug));
*/
export function requireEntity<T>(
entity: T | null | undefined,
message = "Not Found",
): T {
if (entity === null || entity === undefined) notFound(message);
return entity;
}
/* ---------------------------------------------------------------------------
QUERY SERIALISATION
Strapi speaks `qs` bracket syntax - filters[slug][$eq]=about,
populate[cover][fields][0]=url, sort[0]=date:desc - so the serialiser walks
the object rather than pulling in the `qs` package.
------------------------------------------------------------------------ */
function appendDeep(
search: URLSearchParams,
prefix: string,
value: unknown,
): void {
if (value === undefined || value === null) return;
if (value instanceof Date) {
search.append(prefix, value.toISOString());
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
appendDeep(search, `${prefix}[${index}]`, item);
});
return;
}
if (typeof value === "object") {
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
appendDeep(search, `${prefix}[${key}]`, nested);
}
return;
}
search.append(prefix, String(value));
}
/** Serialise a `StrapiQuery` into Strapi's `qs` bracket syntax. */
export function stringifyStrapiQuery(query: StrapiQuery = {}): string {
const search = new URLSearchParams();
const { params, ...rest } = query;
for (const [key, value] of Object.entries(rest)) {
appendDeep(search, key, value);
}
if (params) {
for (const [key, value] of Object.entries(params)) {
appendDeep(search, key, value);
}
}
// Strapi accepts these unescaped, and a query is far easier to read in a
// log as `filters[date][$gte]=2026-01-01` than as `%5Bdate%5D%5B%24gte%5D`.
return search
.toString()
.replace(/%5B/g, "[")
.replace(/%5D/g, "]")
.replace(/%24/g, "$")
.replace(/%3A/g, ":");
}
/* ---------------------------------------------------------------------------
NORMALISATION
------------------------------------------------------------------------ */
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Flatten a v4-style `{ id, attributes }` entity and unwrap a v4 relation
* envelope `{ data, meta? }`. Strapi 5 payloads pass through untouched: they
* match neither signature. Both checks are deliberately narrow so a content
* field genuinely called `data` is not mistaken for an envelope.
*/
export function normalizeStrapiValue<T>(value: unknown): T {
if (Array.isArray(value)) {
return value.map((item) => normalizeStrapiValue(item)) as T;
}
if (!isPlainObject(value)) return value as T;
const keys = Object.keys(value);
const isRelationEnvelope =
"data" in value &&
keys.length <= 2 &&
keys.every((key) => key === "data" || key === "meta");
if (isRelationEnvelope) {
return normalizeStrapiValue(value.data) as T;
}
const isV4Entity =
typeof value.id === "number" && isPlainObject(value.attributes);
if (isV4Entity) {
return {
id: value.id,
...(normalizeStrapiValue(value.attributes) as Record<string, unknown>),
} as T;
}
const result: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
result[key] = normalizeStrapiValue(nested);
}
return result as T;
}
/* ---------------------------------------------------------------------------
MEDIA
------------------------------------------------------------------------ */
const ABSOLUTE_URL = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i;
/**
* Absolutise a Strapi media URL. Local uploads come back as "/uploads/x.jpg";
* an S3/Cloudinary provider already returns an absolute URL, which is passed
* through untouched.
*/
export function mediaUrl(
url: string | null | undefined,
baseUrl?: string,
): string | undefined {
if (!url) return undefined;
if (ABSOLUTE_URL.test(url)) return url;
const origin = baseUrl ?? getServerEnv().STRAPI_URL;
return `${origin.replace(/\/+$/, "")}/${url.replace(/^\/+/, "")}`;
}
/** The absolute URL of one responsive format, falling back to the original. */
export function mediaFormatUrl(
media: StrapiMedia | null | undefined,
format: StrapiMediaFormatName,
baseUrl?: string,
): string | undefined {
if (!media) return undefined;
return mediaUrl(media.formats?.[format]?.url ?? media.url, baseUrl);
}
/** `alternativeText` when the CMS has one, otherwise "" (decorative). */
export function mediaAlt(media: StrapiMedia | null | undefined): string {
return media?.alternativeText ?? "";
}
/* ---------------------------------------------------------------------------
CLIENT
------------------------------------------------------------------------ */
export interface StrapiClientOptions {
/** Overrides STRAPI_URL. No trailing slash required. */
readonly baseUrl?: string;
/** Overrides STRAPI_TOKEN. */
readonly token?: string;
/** Injectable for tests. */
readonly fetch?: typeof fetch;
/** Default request timeout in ms. Default 10000. */
readonly timeoutMs?: number;
}
export interface StrapiClient {
readonly baseUrl: string;
/** GET /api/{resource} - a collection type. */
getMany<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>>;
/** GET /api/{resource}/{documentId} - one entity of a collection type. */
getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T>;
/** As `getOne`, but a 404 from Strapi resolves to null. */
getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** GET /api/{resource} - a single type (about-page, site-settings, ...). */
getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** The first match for a filter, or null. */
findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** The entry whose `slug` field equals `slug`, or null. */
getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** Escape hatch: any path under the Strapi origin, normalised and typed. */
request<T>(
path: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T>;
}
const DEFAULT_TIMEOUT_MS = 10_000;
function buildUrl(baseUrl: string, resource: string, query?: StrapiQuery): string {
const path = resource.replace(/^\/+/, "");
const prefixed = path.startsWith("api/") ? path : `api/${path}`;
const qs = stringifyStrapiQuery(query);
return `${baseUrl}/${prefixed}${qs ? `?${qs}` : ""}`;
}
async function readErrorBody(response: Response): Promise<StrapiErrorBody | undefined> {
try {
const payload: unknown = await response.json();
if (isPlainObject(payload) && isPlainObject(payload.error)) {
return payload.error as StrapiErrorBody;
}
return undefined;
} catch {
return undefined;
}
}
export function createStrapiClient(
options: StrapiClientOptions = {},
): StrapiClient {
// Only read the environment when the caller has not supplied both values,
// so a test can build a client without STRAPI_URL / STRAPI_TOKEN being set.
const fullyConfigured =
options.baseUrl !== undefined && options.token !== undefined;
const env = fullyConfigured ? undefined : getServerEnv();
const baseUrl = (options.baseUrl ?? env?.STRAPI_URL ?? "").replace(/\/+$/, "");
const token = options.token ?? env?.STRAPI_TOKEN;
const doFetch = options.fetch ?? fetch;
const defaultTimeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
async function rawRequest<T>(
resource: string,
query?: StrapiQuery,
init: StrapiRequestInit = {},
): Promise<T> {
const url = buildUrl(baseUrl, resource, query);
const headers: Record<string, string> = {
accept: "application/json",
...init.headers,
};
if (token) headers.authorization = `Bearer ${token}`;
const timeoutSignal = AbortSignal.timeout(init.timeoutMs ?? defaultTimeout);
const signal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal;
let response: Response;
try {
response = await doFetch(url, {
method: "GET",
headers,
signal,
...(init.cache ? { cache: init.cache } : {}),
});
} catch (cause) {
const aborted = cause instanceof Error && cause.name === "TimeoutError";
throw new StrapiError({
message: aborted
? `Strapi request timed out after ${init.timeoutMs ?? defaultTimeout}ms: ${url}`
: `Strapi request failed: ${url}`,
status: 0,
url,
cause,
});
}
if (!response.ok) {
const body = await readErrorBody(response);
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 {
return (await response.json()) as T;
} catch (cause) {
throw new StrapiError({
message: `Strapi returned a non-JSON body for ${url}`,
status: response.status,
statusText: response.statusText,
url,
cause,
});
}
}
async function envelope<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<{ data: unknown; meta: StrapiMeta }> {
const payload = await rawRequest<{ data?: unknown; meta?: StrapiMeta }>(
resource,
query,
init,
);
return {
data: payload?.data,
meta: (payload?.meta ?? {}) as StrapiMeta,
};
}
const client: StrapiClient = {
baseUrl,
async getMany<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>> {
const { data: payload, meta } = await envelope(resource, query, init);
const entries = Array.isArray(payload) ? payload : [];
return {
data: entries.map((entry) => normalizeStrapiValue<T>(entry)),
meta,
pagination: meta.pagination,
};
},
async getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
const { data: payload } = await envelope(
`${resource}/${encodeURIComponent(documentId)}`,
query,
init,
);
return normalizeStrapiValue<T>(payload);
},
async getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
try {
return await client.getOne<T>(resource, documentId, query, init);
} catch (error) {
if (isStrapiNotFound(error)) return null;
throw error;
}
},
async getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
try {
const { data: payload } = await envelope(resource, query, init);
if (payload === null || payload === undefined) return null;
return normalizeStrapiValue<T>(payload);
} catch (error) {
if (isStrapiNotFound(error)) return null;
throw error;
}
},
async findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
const result = await client.getMany<T>(
resource,
{
...query,
pagination: { ...query?.pagination, page: 1, pageSize: 1 },
},
init,
);
return result.data[0] ?? null;
},
async getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return client.findFirst<T>(
resource,
{
...query,
filters: { ...query?.filters, slug: { $eq: slug } },
},
init,
);
},
async request<T>(
path: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
const payload = await rawRequest<unknown>(path, query, init);
return normalizeStrapiValue<T>(payload);
},
};
return client;
}
let defaultClient: StrapiClient | undefined;
/** The process-wide client, built from STRAPI_URL / STRAPI_TOKEN on first use. */
export function getStrapiClient(): StrapiClient {
defaultClient ??= createStrapiClient();
return defaultClient;
}
/** Drop the memoised default client. Only useful in tests. */
export function resetStrapiClient(): void {
defaultClient = undefined;
}
/* Convenience bindings so a loader reads as `getMany<Event>("events", { ... })`. */
export function getMany<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>> {
return getStrapiClient().getMany<T>(resource, query, init);
}
export function getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
return getStrapiClient().getOne<T>(resource, documentId, query, init);
}
export function getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getOneOrNull<T>(resource, documentId, query, init);
}
export function getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getSingle<T>(resource, query, init);
}
export function findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().findFirst<T>(resource, query, init);
}
export function getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getBySlug<T>(resource, slug, query, init);
}
+266
View File
@@ -0,0 +1,266 @@
/* ============================================================================
ROOT ROUTE - the document and the application shell
----------------------------------------------------------------------------
`Layout` renders the whole document, which is why <html lang> and <html dir>
are correct in the FIRST SSR byte: the locale is read from the URL, not from
a cookie, a context provider or an effect, so the server and the first
client render cannot disagree.
The header and the footer live here rather than in the ":lang" layout so the
ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
navigation and the mandatory EU visibility still present.
========================================================================= */
import { useMemo, type ReactNode } from "react";
import {
isRouteErrorResponse,
Link,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLocation,
useRouteLoaderData,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Footer } from "~/components/ui/Footer";
import { Navigation } from "~/components/ui/Navigation";
import { useLocale, useRouteHandleValue } from "~/i18n";
import { DEFAULT_LOCALE, dirFor, type Locale } from "~/i18n/config";
import { localizedPath } from "~/i18n/paths";
import { createTranslator, getMessages } from "~/i18n/t";
import { getSiteUrl } from "~/lib/env.server";
import {
readCanonicalPath,
readFooterSurface,
readNavSurface,
readSelfAlternates,
} from "~/lib/route-handle";
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
/**
* Last-resort origin for the canonical and hreflang links. The root loader
* normally supplies the real one (`getSiteUrl`), but when IT is what threw,
* the ErrorBoundary still renders through `Layout` with no loader data - and
* a page with no canonical at all is worse than one pointing at the public
* production origin. Mirrors DEFAULTS.SITE_URL in ~/lib/env.server, which
* cannot be imported here because `Layout` also runs in the browser.
*/
const FALLBACK_SITE_URL = "https://sharper.ly";
/**
* The faces each language actually renders in - every file listed here exists
* in public/fonts/. Only the two weights above the fold are preloaded per
* locale; Montserrat Arabic Medium is left to load normally.
*/
const FONT_PRELOADS: Readonly<Record<Locale, readonly string[]>> = {
en: ["/fonts/Panton-Regular.woff2", "/fonts/Panton-Bold.woff2"],
ar: [
"/fonts/Montserrat-Arabic-Regular.woff2",
"/fonts/Montserrat-Arabic-Bold.woff2",
],
};
/**
* Resolved once per request. Every route can read the origin and the locale
* off this loader via `matches[0].data` in its own `meta`.
*/
export function loader({ request }: Route.LoaderArgs) {
const { pathname } = new URL(request.url);
const locale = pathnameLocale(pathname);
return {
locale,
dir: dirFor(locale),
pathname,
siteUrl: getSiteUrl(request),
};
}
function pathnameLocale(pathname: string): Locale {
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
}
/**
* Site-level defaults. A page's own `meta` replaces this array wholesale, so
* this is what renders for a route that declares none - and, because `error`
* is passed here when a boundary catches, it is also the error page's meta.
*/
export function meta({ loaderData, error }: Route.MetaArgs) {
const locale = loaderData?.locale ?? DEFAULT_LOCALE;
const messages = getMessages(locale);
const t = createTranslator(locale);
const isError = error !== undefined;
const isNotFound = isRouteErrorResponse(error) && error.status === 404;
return buildMeta({
locale,
pathname: loaderData?.pathname ?? localizedPath("/", locale),
siteUrl: loaderData?.siteUrl ?? FALLBACK_SITE_URL,
title: isNotFound
? t("errors.notFoundTitle")
: isError
? t("errors.genericTitle")
: messages.site.metaTitle,
description: isError ? undefined : messages.site.description,
appendSiteName: isError,
// An error page must never enter the index, whatever its status code.
noIndex: isError,
});
}
export function Layout({ children }: { children: ReactNode }) {
const locale = useLocale();
const dir = dirFor(locale);
const { pathname, search, hash } = useLocation();
const t = useMemo(() => createTranslator(locale), [locale]);
const navItems = useMemo(() => buildNavItems(locale), [locale]);
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
const navSurface = useRouteHandleValue(readNavSurface) ?? "dark";
const footerSurface = useRouteHandleValue(readFooterSurface) ?? "dark";
/* Canonical and hreflang are emitted HERE, not from a route's `meta`: a leaf
`meta` replaces its parents' wholesale, so a page that writes its own
would silently drop them. `handle.canonicalPath` is the escape hatch. */
const rootData = useRouteLoaderData<typeof loader>("root");
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
/* A detail route emits its own pair from the counterpart's real Strapi slug
(see routes/_shared/alternates.ts). Emitting the shell's prefix-swapped
set as well would ship TWO <link rel="canonical"> tags, and the second
would point at an Arabic URL that does not exist. */
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
return (
<html lang={locale} dir={dir} data-surface="dark">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="any" />
{!ownsAlternates && (
<>
<link rel="canonical" href={canonicalUrl(siteUrl, canonicalPath)} />
{alternateUrls(siteUrl, canonicalPath).map((alternate) => (
<link
key={alternate.hrefLang}
rel="alternate"
hrefLang={alternate.hrefLang}
href={alternate.href}
/>
))}
</>
)}
{FONT_PRELOADS[locale].map((href) => (
<link
key={href}
rel="preload"
as="font"
type="font/woff2"
href={href}
crossOrigin="anonymous"
/>
))}
<Meta />
<Links />
</head>
<body className="flex min-h-dvh flex-col">
<a
href="#main-content"
className="sr-only body-sm-bold focus:not-sr-only focus:absolute focus:top-md focus:start-md focus:z-[var(--layer-header)] focus:rounded-[var(--radius-control)] focus:bg-[var(--surface-accent)] focus:px-md focus:py-sm focus:text-[var(--text-on-accent)]"
>
{t("nav.skipToContent")}
</a>
<Navigation
locale={locale}
pathname={pathname}
search={search}
hash={hash}
items={navItems}
surface={navSurface}
/>
<main id="main-content" className="flex-1">
{children}
</main>
<Footer
locale={locale}
surface={footerSurface}
linkGroups={footerLinkGroups}
/>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
/**
* The site's 404 and 500 page. Localised like any other page, and rendered
* inside `Layout`, so <html lang>/<html dir>, the navigation and the footer
* are all still correct.
*/
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const locale = useLocale();
const t = useMemo(() => createTranslator(locale), [locale]);
const routeError = isRouteErrorResponse(error) ? error : null;
const status = routeError?.status ?? 500;
const isNotFound = status === 404;
const title = isNotFound
? t("errors.notFoundTitle")
: t("errors.genericTitle");
const body = isNotFound ? t("errors.notFoundBody") : t("errors.genericBody");
// Stack traces are development-only: a production reader gets the message,
// never the server's internals.
const stack =
import.meta.env.DEV && error instanceof Error ? error.stack : undefined;
return (
<div className="mx-auto flex w-full max-w-[var(--layout-prose-max)] flex-col items-start gap-lg px-[var(--layout-gutter)] py-4xl">
<p className="label-md text-[var(--text-tertiary)]">
{t("errors.statusLabel", { status })}
</p>
<h1 className="display-sm text-[var(--text-primary)]">{title}</h1>
<p className="body-lg text-[var(--text-secondary)]">{body}</p>
<Button
as={Link}
to={localizedPath("/", locale)}
variant="primary"
iconEnd={<ArrowIcon />}
>
{t("actions.backToHome")}
</Button>
{stack ? (
<details className="w-full">
<summary className="body-sm-bold cursor-pointer text-[var(--text-tertiary)]">
{t("errors.detailsLabel")}
</summary>
<pre className="mt-md w-full overflow-x-auto rounded-[var(--radius-card)] bg-[var(--surface-raised)] p-md text-[var(--text-secondary)]">
<code>{stack}</code>
</pre>
</details>
) : null}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
/* ============================================================================
ROUTE CONFIG
----------------------------------------------------------------------------
URL shape - an explicit locale prefix on BOTH languages, never a ?lang=:
/ -> 302 to /en or /ar (Accept-Language)
/:lang -> home
/:lang/<section> -> one of the ten top-level sections
/:lang/<section>/<slug> -> one entry of a CMS-backed section
/:lang/events-calendar/<slug>.ics -> that event as an RFC 5545 file
anything else -> 404
ONE set of route modules serves both languages: the ":lang" layout wraps
every page, so a page module is written once and rendered for /en/about and
/ar/about alike. An unknown ":lang" is rejected by that layout's loader, and
any other unmatched URL falls through to the catch-all, which renders the
root ErrorBoundary's localised 404.
LISTING AND DETAIL ARE SIBLINGS, NOT PARENT AND CHILD
-----------------------------------------------------
A detail route is registered as a SIBLING of its listing route - both are
children of the ":lang" layout - and deliberately not as a child of the
listing. React Router only renders a child where its parent renders an
<Outlet/>, and a listing module renders a page, not an outlet: nesting would
either hide the detail page entirely or paint it underneath the whole index
(its filters, its cards, its pagination). "locale-layout.tsx" is the only
module in this tree that renders an <Outlet/>, so it is the only legitimate
parent. The shared URL prefix is expressed in the path string
("events-calendar/:slug"), which costs nothing and keeps each page's route a
leaf that owns its whole screen.
":slug.ics" DOES NOT SHADOW ":slug"
-----------------------------------
Both patterns match /en/events-calendar/kickoff.ics, so ranking - not array
order - has to decide. React Router scores a partial dynamic segment
(":slug.ics") above a whole-segment one (":slug"), so the resource route
wins the .ics URL and the page route keeps every other slug. The .ics module
exports only a `loader`, which is what makes it a resource route: its
Response is handed to the browser verbatim and no parent loader - not even
the ":lang" layout's - runs, which is why that module validates the locale
segment itself.
ROUTE IDS
---------
"root" | "routes/locale-redirect" | "locale" | "routes/not-found", one per
section named after its key: "home", "about", "whatWeDo", "activities",
"events", "applications", "news", "contact", "resources", "knowledgeHub" -
and one per detail route, the section key plus "Detail" ("newsDetail",
"eventsDetail", ...), plus "eventsIcs". So `useRouteLoaderData("locale")`
reads the ":lang" layout's data anywhere.
ADDING A SECTION PAGE
---------------------
Add the section to `ROUTE_SLUGS`, name its module in `SECTION_MODULES` (and
in `DETAIL_MODULES` if entries can be drilled into), and write the file. The
tables below are declared, not discovered: every module named here is
registered unconditionally, so a path that does not resolve fails the build
instead of quietly falling through to the 404 route. Vite does not
re-evaluate this file when a route module appears, so restart `pnpm dev`
after adding one.
========================================================================= */
import {
index,
route,
type RouteConfig,
type RouteConfigEntry,
} from "@react-router/dev/routes";
import { ROUTE_SLUGS, type RouteSlugKey } from "./lib/route-slugs";
/**
* Section -> route module. The file name is always the slug, so the URL and
* the module that serves it can be read off each other.
*/
const SECTION_MODULES = {
home: "routes/home.tsx",
about: "routes/about.tsx",
whatWeDo: "routes/what-we-do.tsx",
activities: "routes/activities-and-initiatives.tsx",
events: "routes/events-calendar.tsx",
applications: "routes/applications-and-opportunities.tsx",
news: "routes/news-and-updates.tsx",
contact: "routes/contact.tsx",
resources: "routes/resources-and-publications.tsx",
knowledgeHub: "routes/knowledge-hub.tsx",
} as const satisfies Record<RouteSlugKey, string>;
/**
* Section -> the module that renders ONE entry of it, at
* `<section slug>/:slug`. Only the six CMS-backed sections have one; "about",
* "what-we-do", "contact" and the home page are single pages with nothing to
* drill into.
*/
const DETAIL_MODULES = {
activities: "routes/activities-and-initiatives.$slug.tsx",
events: "routes/events-calendar.$slug.tsx",
applications: "routes/applications-and-opportunities.$slug.tsx",
news: "routes/news-and-updates.$slug.tsx",
resources: "routes/resources-and-publications.$slug.tsx",
knowledgeHub: "routes/knowledge-hub.$slug.tsx",
} as const satisfies Partial<Record<RouteSlugKey, string>>;
/**
* Resource routes: a module with a `loader` and no component, whose Response
* is served as-is. `path` is relative to the ":lang" layout, like every other
* entry here.
*/
const RESOURCE_ROUTES = [
{
id: "eventsIcs",
path: `${ROUTE_SLUGS.events}/:slug.ics`,
file: "routes/events-calendar.$slug.ics.ts",
},
] as const;
const detailKeys = Object.keys(DETAIL_MODULES) as (keyof typeof DETAIL_MODULES)[];
/**
* Every page under ":lang": the ten sections, then the six detail routes, then
* the .ics resource route. Flat on purpose - see "LISTING AND DETAIL ARE
* SIBLINGS" above.
*/
const localeChildRoutes: RouteConfigEntry[] = [
...(Object.keys(SECTION_MODULES) as RouteSlugKey[]).map((key) =>
key === "home"
? index(SECTION_MODULES.home, { id: "home" })
: route(ROUTE_SLUGS[key], SECTION_MODULES[key], { id: key }),
),
...detailKeys.map((key) =>
route(`${ROUTE_SLUGS[key]}/:slug`, DETAIL_MODULES[key], {
id: `${key}Detail`,
}),
),
...RESOURCE_ROUTES.map((entry) =>
route(entry.path, entry.file, { id: entry.id }),
),
];
export default [
// Bare "/" is not a page: it negotiates and redirects.
index("routes/locale-redirect.tsx"),
// Every page, in both languages, through one shared route module.
route(
":lang",
"routes/locale-layout.tsx",
{ id: "locale" },
localeChildRoutes,
),
// Unknown locale segments are handled by the layout's loader; every other
// unmatched URL lands here.
route("*", "routes/not-found.tsx"),
] satisfies RouteConfig;
@@ -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 />;
}

Some files were not shown because too many files have changed in this diff Show More