Compare commits

..
Author SHA1 Message Date
Illusion 60dd92e4b0 feat: email templates in CMS 2026-09-24 14:20:24 +02:00
Illusion fb097d22d9 fix: remove event gallery 2026-09-24 12:12:05 +02:00
Illusion eba419bfc2 fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode 2026-09-24 11:51:24 +02:00
Illusion e5bce55bf7 feat: event registration emails and reminders (reminders untested) 2026-09-24 00:55:47 +02:00
Illusion 1879a206ad feat: homepage animations; Arabic as default locale 2026-09-24 00:22:40 +02:00
Illusion 2ff5cf47cf more changes
- Restore the SHARP-ER in the homepage hero section
- Match the design for the 3-image section in the homepage
2026-09-23 23:36:36 +02:00
Illusion cc2b426119 more style changes
- Removed the sheen over the homepage hero image
- Animated the SHARP-ER logo in the navbar
- More padding on the bottom of the hero section in the homepage
2026-09-23 23:22:57 +02:00
Illusion e930318f14 fix: less spacing in home hero 2026-09-23 22:57:46 +02:00
Illusion 93b2fcb2ae feat: animate the logo in the navbar 2026-09-23 22:47:04 +02:00
Illusion c0daacf1ba more fixes
- Drop the globe icon in the language switcher
- Only show RightArrow on nav menuitem hover
- Fix EU logo colors in light mode
2026-09-23 22:33:58 +02:00
Illusion 2663d7eac6 feat: theme toggle 2026-09-23 21:57:24 +02:00
Illusion 9db3602dea fix(home): strip color from white to black 2026-09-23 20:35:55 +02:00
Illusion 0d7db83fd2 fix: set the allowed origins 2026-09-23 20:35:36 +02:00
Illusion 801d99b745 more fixes for mail 2026-09-22 12:11:55 +02:00
Illusion 70f6f92f00 set up mail plugin and environment 2026-09-22 10:24:03 +02:00
IllusionandClaude Opus 5 2e920a96e7 Land the third design drop, seed the CMS, and retire the figures band
Three pieces of one session's work. They are in one commit because home.tsx
and _page-kit/copy.ts carry hunks from all three and splitting them by hand
would have meant committing intermediate states of those two files that were
never built or served.

THE THIRD DESIGN DROP

Only Home.dc.html and SiteFooter.dc.html differ from the second drop; the
other nine page drafts are byte-identical to what already ships.

The home page stops opening on a launch announcement dated to one day in
June. New headline, lead and primary call to action ("See open calls"), and
the launch band becomes "A nationwide project, delivered locally" -
launchEyebrow/Heading/Lead renamed approach*, LaunchShots renamed
GalleryShots, since the band is no longer about an event. Its Arabic reuses
the About page's already-approved wording for the coverage line and the body
heading; the new lead and headline are ar-draft like the rest of the Arabic
home copy and still want client sign-off.

Five motion classes in _page-kit/motion.css, tokens only, each with its RTL
form where the property has no logical one: sh-wipe (the headline uncovered
from the inline start), sh-pan (the hero picture on a view timeline), sh-zoom
(a picture closing in inside its frame), sh-rule-top (an accent rule drawn
across a hovered tile) and sh-marquee (the logo wall). sh-wipe fills
`backwards`, not `both`, so the clip is gone at rest - inset(0) would shave a
descender or an Arabic mark off a tight heading line - and it is removed
outright under prefers-reduced-motion, because a clip does not fade a
headline, it hides it.

The marquee carries each logo twice and slides exactly half the track, so its
spacing is a margin on the tile rather than a gap on the row: with a row gap
the seam lands half a gap short. The second half is aria-hidden with no link,
so the wall is neither read out nor tabbed through twice; it pauses on hover
and focus (WCAG 2.2.2) and unwraps to a plain row under reduced motion. Below
eight partners the wall stays a grid, because a half-track narrower than its
frame drifts a short row across empty space instead of looping.

SEEDING THE CMS

`pnpm seed` writes content through the document service. It opens no port, so
it runs happily beside `pnpm dev:cms`, and every write is matched on a slug
and written over the same document, so a second run changes nothing but the
fields the seed owns.

src/seed/approved.ts is transcribed from docs/ in both languages and is safe
to publish: site settings, the three consortium members, the topic taxonomy,
the five activity streams with their implementing partners, the launch
article in full, and the four pages. What docs/ withholds is not written -
no statistics, no project email, no Facebook URL, no consortium addresses,
all of which the source writes as bracketed placeholders. Two fields are
judgement calls and say so at the line: activityStatus, which the schema
requires and the source never states, and the launch article's year, which is
derived - the source says "Tuesday, June 30" and within December 2025 to
December 2027 that is 2026 and no other year.

src/seed/demo.ts is invented and kept apart three ways: opt-in behind --demo,
refused outright when NODE_ENV=production, and tagged in the data itself with
a demo- slug prefix that shows in every URL and is how --demo --clean finds
them again, with nothing remembered between runs.

Two things the Strapi 5 document service does not do by itself, both of which
leave Arabic rows behind that the next run then collides with on the unique
slug: delete({ documentId }) with no locale removes the DEFAULT locale only,
not the document, and findMany({ locale }) answers for one locale, so a
document whose English half has gone is invisible to a search in English. The
first --clean left six Arabic orphans and failed the next run partway.
deleteMatching now pools documentIds across both locales and deletes with
locale: '*'.

Knex aborts its pending pool acquisitions on teardown, and each abort rejects
a promise nothing awaits, so it arrives as an unhandled rejection rather than
as an error a try/catch would see. scripts/seed.js absorbs exactly that, for
the length of the shutdown only: a seed that worked must not report itself as
a seed that failed.

THE FIGURES BAND, AND THE BAND THAT REPLACED IT

The third draft deletes the figures band, so shared.stat and blocks.stats-grid
are deleted with it, home-page.stats is dropped, and blocks.stats-grid comes
out of all six dynamic zones. On the web side that takes CmsStat, StatsRow,
CountUpValue, StatStrip and Card.Stat - the last two were already dead, but
they existed only to draw figures. No approved document carries a number, so
there is now nowhere for one to be typed in, which is the point.

The "nationwide project, delivered locally" band was half editable and half
not: its pictures came from an image-gallery block down the Blocks dynamic
zone, and its words came from bundled fallback copy with no CMS field at all.
The new page.band-intro component (eyebrow, heading, lead, images) on
home-page.approach puts the whole band in one place in the admin. The page
still falls back to the bundled copy when the fields are empty, and still
reads an image-gallery block after the component's own images, so a gallery
built in Blocks keeps rendering.

VERIFIED

pnpm typecheck and both builds pass. Both rule greps are clean: no raw hex or
tier-1 token in components/ or routes/, no physical-direction utility in
apps/web/app.

Against a seeded CMS on the built server: / redirects 302 with
Vary: Accept-Language, and all ten sections plus the detail routes return 200
in both locales with lang/dir correct in the first bytes. The home page draws
the marquee from twelve partners, the hero pan, the three hovered card rules
and the status pill, and carries no figures band.

The seed is idempotent - two identical --demo runs give identical counts,
balanced across locales - and a clean/re-seed cycle leaves nothing behind.
The approach band was proved editable end to end: heading, eyebrow, lead and
images changed through the CMS all came through on /en while /ar stayed on
its own Arabic, then the seeded copy was restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:16:53 +02:00
IllusionandClaude Opus 5 2ed3a17914 Give the web server its own .env, where PORT means its port
The previous commit made `pnpm --filter web start` load the repository-root
.env, which was the wrong file: Strapi reads a bare PORT as well
(apps/cms/config/server.ts uses env.int('PORT', 1337)), so one value there
would have moved the CMS onto the website's port the moment anyone exported
it - and the root .env is docker compose's file, whose WEB_PORT is a
published host port, not a listen port.

The web app now loads apps/web/.env, the way Strapi already loads
apps/cms/.env. PORT and HOST are per-application because they mean something
different to each server. apps/web/.env.example documents them together with
SITE_URL, STRAPI_URL and STRAPI_TOKEN, and warns against the VITE_ prefix,
which would inline a value into the client bundle - STRAPI_TOKEN lives in
that file.

Still no default port: with PORT unset, react-router-serve keeps its own
behaviour of taking the first free port from 3000 upwards. A real
environment variable wins over the file, so a systemd unit or a container
that injects PORT is unaffected.

.dockerignore now excludes .env at every depth, not only at the root.
`COPY apps/web ./apps/web` would otherwise have baked a developer's
apps/web/.env - STRAPI_TOKEN included - into the image.

Verified against the existing build: PORT=8140 HOST=127.0.0.1 in
apps/web/.env listens on 127.0.0.1:8140, and PORT=8141 in the environment
overrides the file while HOST still comes from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:45:29 +02:00
IllusionandClaude Opus 5 e27e31bfa5 Bind the production web server to a chosen port
react-router-serve only honours a port it is given: with PORT unset it calls
getAvailablePort(3000) and takes the first free port from 3000 upwards
(@react-router/serve cli.js). Nothing in the manual path set PORT, so a
busy 3000 silently moved the site to 3001, 3002 and so on, and a reverse
proxy configured against one upstream stopped matching. Docker never showed
this because apps/web/Dockerfile and docker-compose.yml both set PORT=3000.

The web start script now always passes a port - PORT if exported, else
WEB_PORT from .env, else 3000 - so the manual path is as deterministic as
the containerised one and .env's WEB_PORT finally reaches the process that
binds. HOST is deliberately left undefaulted: react-router-serve reads it
directly, unset means every interface (what the container needs) and
127.0.0.1 restricts the socket to a proxy on the same host.

Verified against the existing build: PORT=8123 listens on *:8123, and
WEB_PORT=8124 HOST=127.0.0.1 listens on 127.0.0.1:8124 and serves /en with
a 200 while the CMS is down.

.env.example documents both variables, and the deployment section of
README.md gains a systemd unit, since nothing sources .env there, and a
warning that WEB_PORT is the published host port under Docker but the
listen port in manual mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:33:02 +02:00
IllusionandClaude Opus 5 2c51c35fe8 Fix the mobile navigation drawer, which painted a 64px strip
The bar carries the drafts' translucent ground with
`backdrop-filter: blur(14px)`, and a filtered element becomes the containing
block for every position:fixed descendant (CSS Filter Effects 1 s.6.1). The
drawer was rendered inside that header, so its `fixed inset-0` resolved
against the 64px bar rather than the viewport. Measured on the built page at
390x844, the panel came out 390x64: a strip of drawer background at the top,
the menu spilling over the hero with nothing painted behind it, and the page
underneath scroll-locked. The header is also a stacking context
(z-index: var(--layer-header)), so an aria-modal dialog nested inside it
could never rise above anything painted above the bar.

The drawer is now portalled to <body>, which resolves the containing block
and the stacking context at once, and is where a modal dialog belongs
regardless. The portal mounts one commit after hydration so the first client
render still matches the server's; nothing is lost, because the trigger is a
button with an onClick - without JavaScript the drawer could not be opened
at all - and every link in it already ships in the server-rendered
DesktopNav markup.

The inert walk benefits too. It inerts each ancestor's OTHER children up to
<body>, which previously could not reach the header without inerting the
drawer inside it; from the portal, body's other children are exactly the
header, main and footer.

Also gives the hamburger and its close twin a 44x44 hit area. They were
24x24, under the WCAG 2.5.5 target size; a logical negative inline-end
margin keeps the glyph on the same page gutter the wordmark opposite it
uses, so it still mirrors in Arabic.

Verified in a headless browser against the production build at 390x844: the
panel fills the viewport in both locales and mirrors under dir="rtl", focus
moves to Close on open and back to the trigger on Escape, the scroll lock
and the inert flags are set and cleared, and the desktop nav is unchanged -
three group triggers and a working dropdown at 1440px, the hamburger at
1279px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:14:29 +02:00
IllusionandClaude Opus 5 19fab4a783 Match the shipped styling to the Claude Design drafts
An audit of all twelve draft files against the build turned up a set of
mismatches, most of them in the tier-2 token layer rather than in any one
component. Tier 1 is untouched throughout - only the meanings moved.

SHAPE. The drafts draw three corners, not five: 2px for a tag, thumbnail,
flag or checkbox, 4px for anything with a box, and fully round for a filter
chip or status pill. Nothing in any draft is 8px or 12px. --radius-card and
--radius-surface therefore come down to radius/sm, a --radius-chip is added
for the 2px tier, and --radius-control-lg - a quirk of the Figma Button
frame's ghost cells - is retired along with its four consumers.

ELEVATION. A shadow is a fraction of the ground it falls on, and the ramp
was tuned for a white page: on the drafts' near-black ground a 12-28% black
is invisible. Tier 1 gains the two deep steps the drafts actually draw
(--shadow-dark-lg/xl), and --elevation-card/-popover/-modal move into the
per-surface blocks so the dark surface can take them while the light one
keeps the soft ramp. A resting card now has no shadow at all - the drafts
separate with a hairline - and the black is spent on what floats.

CARDS. The hover was a white-24% hairline and a faint glow; the drafts turn
the hairline accent green, lift the card 6px and land a deep black under it.
The lift is pointer-only and is dropped under prefers-reduced-motion.

GHOST IS A TEXT LINK. The drafts never fill a ghost control - every
"All updates ->" is plain text that turns green while the label-to-arrow gap
opens. The tokens carry no fill in any state and the variant opts out of the
3px lift, which also retires the WCAG workaround the old Figma hover forced
(green label on a green fill, 1.40:1).

NAVIGATION. A nav label rests in text/secondary and brightens to primary;
the current section stays primary and is marked by the accent rule, not by
green text.

HERO STRIPE. The middle block was painted near-black against a comment
claiming the redesign had dropped white; the drafts paint it #F8F9FA. Back
to background/inverse, and to the drafts' 390 : 495 : 363 split.

styles/README.md is updated to match, including a contrast table that still
described --surface-raised as the retired #2e2e2c.

Not changed, because each is a decision rather than a defect: the drafts set
every eyebrow, tag and meta line in IBM Plex Mono at 10-11px with 1.2-1.4px
tracking (a third typeface, and no Arabic counterpart); draft headings are
weight 800, which needs a font file we do not have; and the EU badge
statement stays at --text-primary, since the visibility lockup is
contractual.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:14:15 +02:00
121 changed files with 6977 additions and 1790 deletions
+6 -4
View File
@@ -14,10 +14,12 @@ apps/cms/.strapi
apps/cms/.tmp
apps/cms/.cache
# Secrets. The containers receive their environment from docker compose.
.env
.env.*
!.env.example
# Secrets. The containers receive their environment from docker compose, so no
# .env belongs in an image - including the per-application ones (apps/web/.env,
# apps/cms/.env), which COPY apps/web / COPY apps/cms would otherwise pick up.
**/.env
**/.env.*
!**/.env.example
# Licensed fonts are mounted or copied in at deploy time, never baked into a
# published image. See the FONTS section of README.md.
+17 -13
View File
@@ -7,12 +7,13 @@
# WHO READS THIS FILE
# * docker compose - every ${VARIABLE} in docker-compose.yml is read from
# the .env file sitting next to it (this one).
# * apps/web - the React Router server reads STRAPI_URL / STRAPI_TOKEN
# / SITE_URL from its process environment. In manual mode
# nothing loads this file for you, so export it first:
# set -a; . ./.env; set +a
# The defaults below are also the code defaults, so a
# plain `pnpm dev` works without exporting anything.
# * apps/web - NOT this file. In manual mode the web app reads
# apps/web/.env (cp apps/web/.env.example apps/web/.env),
# which is where PORT, HOST, SITE_URL, STRAPI_URL and
# STRAPI_TOKEN belong. Under Docker the values below are
# injected into the container instead. The defaults are
# also the code defaults, so a plain `pnpm dev` works
# with no .env at all.
# * apps/cms - Strapi does NOT read this file. In manual mode it reads
# apps/cms/.env (cp apps/cms/.env.example apps/cms/.env).
# Under Docker the values below are injected into the
@@ -63,12 +64,18 @@ ENCRYPTION_KEY=
# ---------------------------------------------------------------------------
# PORTS
# Host ports the two applications are published on in Docker mode, and the
# ports they listen on in manual mode.
# Host ports the two applications are published on in Docker mode. The ports
# they LISTEN on in manual mode live in each application's own .env
# (apps/web/.env, apps/cms/.env), because both servers read a bare PORT.
# ---------------------------------------------------------------------------
# React Router server (production build / Docker). `pnpm dev:web` uses 5173.
# Docker mode - host port the web container is published on; inside the
# container it always listens on 3000, so WEB_PORT=8080 means 8080:3000.
# Manual mode has its own PORT in apps/web/.env - never put a bare PORT in
# THIS file: both servers would read it and Strapi would land on the website's
# port. `pnpm dev:web` uses 5173 either way.
WEB_PORT=3000
# Strapi API + admin panel.
CMS_PORT=1337
@@ -79,7 +86,7 @@ CMS_PUBLIC_URL=http://localhost:1337
# ---------------------------------------------------------------------------
# WEB APP
# WEB APP - Docker mode. In manual mode these live in apps/web/.env.
# ---------------------------------------------------------------------------
# Public origin of the site. Used for canonical URLs, hreflang alternates and
@@ -87,9 +94,6 @@ CMS_PUBLIC_URL=http://localhost:1337
# Leave it unset in local development and the request's own host is used.
SITE_URL=http://localhost:3000
# Where the server-side loaders fetch content from, MANUAL MODE.
STRAPI_URL=http://localhost:1337
# Where they fetch from inside Docker, where "cms" is the compose service name.
# NOTE: this origin is also prefixed onto uploaded media URLs, which the
# browser has to be able to resolve too - see "Images from the CMS" in the
+10 -2
View File
@@ -25,7 +25,7 @@ apps/web React Router 8 (framework mode, ssr: true) · React 19 · Vite 8 · T
Route ids: root · locale · home · about · whatWeDo · activities · events ·
applications · news · contact · resources · knowledgeHub (+ *Detail)
apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (en default, ar) · draft & publish
apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (ar default, en) · draft & publish
src/api/** content types (schema.json is the contract, commit it)
src/bootstrap/** runs on every boot: creates locales, grants Public permissions
(find/findOne on content; create-only on submission types)
@@ -53,7 +53,12 @@ unreachable — the site must never 500 because Strapi is down.
The site ships RTL Arabic; physical directions silently break it.
Check: `grep -rnE "\b(pl|pr|ml|mr|left|right)-[0-9]" apps/web/app` → nothing meaningful.
3. **Surfaces, not a theme.** `:root` *is* the dark surface; `data-surface="light"` flips a
subtree and repaints itself. Do not add a global theme toggle or a `dark:` variant.
subtree and repaints itself. Never add a second theme system or a `dark:` variant. **The
one exception, accepted deliberately:** the nav's theme toggle flips *which* surface `<html>`
stands on (dark default ↔ light), persisted via the `sharper-theme` cookie read in the root
loader and set by the root action — SSR-rendered so the first byte and first paint always
match. The mechanism stays surfaces-only: the toggle just re-roots them. `--surface-nav`
must keep a value in BOTH surface blocks.
4. **Figma is READ ONLY.** Only `get_design_context`, `get_screenshot`, `get_metadata`,
`get_variable_defs`. Never call anything that writes (`use_figma`, `generate_figma_design`,
`create_new_file`, `upload_assets`, shaders, plugins, …). Before the first
@@ -61,6 +66,8 @@ unreachable — the site must never 500 because Strapi is down.
`skillNames: "resource:figma-design-to-code"`. File key `yvKHp3Ha2rQmv9kt9tCC7j`.
5. **Never invent statistics, dates, names or quotes.** If a number is not in `docs/` or in
the CMS, it does not go on the page. Leave the slot empty or omit the component.
The same rule governs the seed: anything invented belongs in `src/seed/demo.ts`, never
in `approved.ts`, and `pnpm seed` without `--demo` must stay publishable.
6. **`docs/` is read-only source material.** Never edit, move or "clean up" those files.
7. **Both locales, always.** New UI text goes into `app/i18n/messages/en.json` *and*
`ar.json` with identical keys (the `Messages` type enforces it). A component that hard-codes
@@ -86,6 +93,7 @@ unreachable — the site must never 500 because Strapi is down.
| HTML shell, canonical, hreflang | `apps/web/app/root.tsx` |
| Content types | `apps/cms/src/api/<name>/content-types/<name>/schema.json` |
| Boot-time locale + permission setup | `apps/cms/src/bootstrap/` |
| CMS seed content (`pnpm seed`) | `apps/cms/src/seed/` — `approved.ts` is from `docs/`; `demo.ts` is invented, opt-in behind `--demo`, every slug prefixed `demo-` |
| CORS, upload rules, i18n locales | `apps/cms/config/{middlewares.ts,plugins.ts}` |
## Conventions that bite
+126 -13
View File
@@ -26,6 +26,7 @@ publications — all of it in English and Arabic, with the Arabic side laid out
- [Path A — manual setup](#path-a--manual-setup)
- [Path B — Docker](#path-b--docker)
- [Available scripts](#available-scripts)
- [Seeding content](#seeding-content)
- [Design tokens](#design-tokens)
- [Bilingual EN / AR](#bilingual-en--ar)
- [Adding a content type, end to end](#adding-a-content-type-end-to-end)
@@ -195,12 +196,17 @@ sudo -u postgres psql -c "CREATE ROLE sharper WITH LOGIN PASSWORD 'a-strong-pass
### 4. Environment files
Two files, with different jobs:
Three files, with different jobs:
| File | Read by | Required in manual mode |
| --- | --- | --- |
| `apps/cms/.env` | Strapi (it loads its own `.env`) | **yes** |
| `.env` (root) | `docker compose`, and optionally the web server | no — the code defaults already point at `localhost` |
| `apps/web/.env` | `pnpm --filter web start` (it loads its own `.env`) | no — but it is where `PORT` goes |
| `.env` (root) | `docker compose` only | no |
Each application reads its **own** `.env`, and the root one belongs to `docker compose`.
That split is not cosmetic: `PORT` and `HOST` mean something different to each server, and a
single shared value would put Strapi on the website's port.
```bash
# CMS: copy the fully documented template, then fill in the six secrets
@@ -222,17 +228,19 @@ overwrites a value that is already set unless you pass `--force`, so it is safe
Without `--write` it just prints a block you can paste. By hand:
`openssl rand -base64 32`, once per key (`APP_KEYS` takes at least two, comma separated).
The web application reads `STRAPI_URL`, `STRAPI_TOKEN` and `SITE_URL` from its **process
environment**. Nothing loads the root `.env` for you in manual mode, and you do not need it:
`STRAPI_URL` already defaults to `http://localhost:1337`, and with `SITE_URL` unset the
canonical URL is derived from the incoming request. To override them anyway:
The web application needs nothing to start: `STRAPI_URL` already defaults to
`http://localhost:1337`, and with `SITE_URL` unset the canonical URL is derived from the
incoming request. Copy its template when you want to override those, or to pin the
production port:
```bash
cp .env.example .env
set -a; . ./.env; set +a # export it into the current shell
pnpm dev
cp apps/web/.env.example apps/web/.env # PORT, HOST, SITE_URL, STRAPI_URL, STRAPI_TOKEN
```
`pnpm --filter web start` loads that file. `pnpm dev:web` does not — the dev server is Vite
on 5173 — so export anything you need for development, or put it in the file for the
production server and pass it inline while developing.
### 5. Run both applications
```bash
@@ -250,7 +258,7 @@ The first `pnpm dev:cms` takes a minute: Strapi creates its tables, then the boo
reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on
every boot; it
- creates the `en` (default) and `ar` locales if they are missing, and
- creates the `ar` (default) and `en` locales if they are missing, and
- grants the **Public** role `find` / `findOne` on the content types the site reads, and
`create` — and only `create` — on the three submission types (event registrations,
applications, contact messages), so nobody can read other people's personal data back out.
@@ -461,7 +469,8 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
| `pnpm dev:cms` | `strapi develop` (auto-reloads on schema changes) → `http://localhost:1337` |
| `pnpm build` | builds the CMS, then the web app (that order: the web build has no CMS dependency, but a broken CMS build should fail first) |
| `pnpm start` | runs both production builds |
| `pnpm typecheck` | the web app's `react-router typegen && tsc` (the CMS has no `typecheck` script — its TypeScript is checked by `strapi build`) |
| `pnpm seed` | writes the approved content into the CMS — see [Seeding content](#seeding-content) |
| `pnpm typecheck` | `tsc` in both applications, preceded in the web app by `react-router typegen` |
**`apps/web`** — run with `pnpm --filter web <script>`:
@@ -469,7 +478,7 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
| --- | --- |
| `dev` | `react-router dev` — SSR dev server with HMR |
| `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) |
| `start` | `react-router-serve ./build/server/index.js` — the production server, port 3000 |
| `start` | the production server — `react-router-serve ./build/server/index.js`, started with `node --env-file-if-exists=.env` so `apps/web/.env` can set `PORT` |
| `typecheck` | regenerates `+types/*` route types, then `tsc` |
**`apps/cms`** — run with `pnpm --filter cms <script>`:
@@ -479,6 +488,7 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
| `develop` / `dev` | `strapi develop` — the only mode in which the Content-Type Builder can write schemas |
| `build` | builds the admin panel and compiles the TypeScript server into `dist/` |
| `start` | `strapi start` — production mode; the Content-Type Builder is disabled |
| `seed` | writes the CMS content — `pnpm --filter cms seed -- --demo` to include the fixtures |
| `console` | a REPL with `strapi` in scope, for one-off data fixes |
| `strapi` | the CLI itself, e.g. `pnpm --filter cms exec strapi admin:create-user` |
| `upgrade` / `upgrade:dry` | `@strapi/upgrade` codemods for a minor/major version bump |
@@ -493,6 +503,73 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
---
## Seeding content
A fresh database has an empty CMS, so every page renders its fallback copy and
every listing renders its empty state. `pnpm seed` fills it in. Both
applications can stay running while it does — it talks to PostgreSQL directly
and never opens a port of its own.
```bash
pnpm seed # the approved content
pnpm seed -- --demo # the same, plus fixtures to look at
pnpm seed -- --demo --clean # remove those fixtures again
```
> The `--` is pnpm's, not the seed's. Running it from `apps/cms` (`pnpm seed
> --demo`) needs no separator.
**It is safe to run twice.** Every write is matched on a slug and written over
the same document, so a second run changes nothing but the fields the seed
owns — your own edits to other fields survive. It deletes nothing except on
`--demo --clean`. It uploads nothing: where a page already has a picture, the
existing file is read back and written out again with the new copy.
### The approved set — `apps/cms/src/seed/approved.ts`
Transcribed from `docs/`, in both languages, and safe to publish: the site
settings, the three consortium members, the topic taxonomy, the five activity
streams, the launch article, and the About, What We Do, Contact and Home
pages.
What `docs/` does not supply is not written. There are no statistics anywhere
(the source marks every number "TBD — must not be published until confirmed"),
no project email, no Facebook URL and no consortium addresses, because the
source writes all of those as bracketed placeholders. Two fields are judgement
calls and say so at the line: every activity's `activityStatus`, which the
schema requires and the source never states, and the launch article's year,
which is derived — the source says "Tuesday, June 30", and within the project's
own December 2025 – December 2027 window that is 2026 and no other year.
### The demo set — `apps/cms/src/seed/demo.ts`
Invented content: twelve network organisations, six articles, six events, four
opportunities, six resources, six knowledge items, eight questions and five
regions, across the calendar and across every enum value, so the listings,
filters, detail pages and the home page's logo marquee have something in them.
It is kept apart from real content three ways:
1. **Opt-in.** `pnpm seed` never writes it.
2. **Refused in production.** With `NODE_ENV=production` the seed exits with an
error rather than writing a fixture, whatever flags it is given.
3. **Tagged in the data.** Every demo slug starts `demo-`, so it is visible in
every URL — `/en/news-and-updates/demo-…` — and `--demo --clean` finds them
again by that prefix alone, with nothing remembered between runs.
### After seeding
```bash
curl -s "http://localhost:1337/api/news-articles?locale=ar" | head -c 200
```
Then the home page at `http://localhost:5173/en` and `/ar`: the hero carries the
CMS headline, the newsroom band has cards, and — with `--demo` — the network
logo wall becomes the marquee, which needs at least eight partners before it
stops being a grid.
---
## Design tokens
Two tiers, so re-branding is a one-line edit in one file.
@@ -766,18 +843,50 @@ pnpm install --frozen-lockfile
pnpm build # cms (admin panel + dist/) then web (build/client + build/server)
pnpm --filter cms start # strapi start :1337
pnpm --filter web start # react-router-serve :3000
pnpm --filter web start # react-router-serve :$PORT (see below)
```
Both are ordinary long-running Node processes. Run them under systemd, PM2 or the
containers in `docker-compose.yml`, behind a reverse proxy (nginx, Caddy) that terminates
TLS and forwards to 3000 and 1337.
#### Choosing the web server's port
`react-router-serve` binds `PORT` when it is set, and otherwise takes the first **free** port
from 3000 upwards — so a busy 3000 moves the site to 3001, 3002, … with no error, and the
proxy's upstream stops matching. Nothing in the repository defaults it for you.
`pnpm --filter web start` runs under `node --env-file-if-exists=.env`, so `apps/web/.env` is
enough:
```bash
echo 'PORT=8080' >> apps/web/.env
pnpm --filter web start # :8080
```
It has to be that file and not the root `.env`: Strapi reads a bare `PORT` too, so a shared
one would move the CMS onto the website's port. `apps/web/.env` is read by the web server
alone, exactly as `apps/cms/.env` is read by Strapi alone.
A real environment variable wins over the file, so `PORT=9090 pnpm --filter web start` and a
systemd unit's `Environment=PORT=8080` both override it. `HOST` works the same way — unset it
listens on every interface, `HOST=127.0.0.1` only accepts the reverse proxy on the same host.
Under Docker none of this applies: `PORT=3000` is set in the image and in
`docker-compose.yml`, `WEB_PORT` in the root `.env` is the **host** port the container is
published on (`WEB_PORT=8080` gives `8080:3000`), and `.dockerignore` keeps every `.env` out
of the build context.
Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly`), not
`http://127.0.0.1:8080` — canonical URLs, `hreflang` and JSON-LD are built from it.
### Production environment
| Variable | Where | Value |
| --- | --- | --- |
| `NODE_ENV` | both | `production` |
| `PORT` | web | the port `react-router-serve` binds, e.g. `8080`. Unset, it takes the first free port from 3000 upwards |
| `HOST` | web | optional; `127.0.0.1` to accept only the local reverse proxy, unset to listen on every interface |
| `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it |
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
@@ -787,6 +896,10 @@ TLS and forwards to 3000 and 1337.
| `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
| `APP_KEYS` and the other five secrets | cms | **different values from staging**, generated with `./scripts/generate-secrets.sh` |
For per-visitor submission limits, the edge proxy must overwrite `X-Forwarded-For` with
the verified client address (not append to a value supplied by the client). The web server
relays that header to Strapi, which trusts it only when `IS_PROXIED=true`.
Uploaded media is written to `apps/cms/public/uploads` by the default provider, so give the
CMS a persistent volume — or switch to S3, which `apps/cms/config/plugins.ts` documents step
by step (remember to add the bucket host to `img-src` / `media-src` in
+18 -1
View File
@@ -19,13 +19,15 @@ PORT=1337
# Public URL of the API, used to build absolute media/admin URLs.
# Leave commented out in local development; set it behind a proxy or a custom
# domain, e.g. PUBLIC_URL=https://cms.sharper.ly
# PUBLIC_URL=
PUBLIC_URL=https://cms.sharper.ly
# true when Strapi sits behind nginx / a load balancer, so X-Forwarded-* headers
# are trusted for the client IP and protocol.
IS_PROXIED=false
# Log verbosity: fatal | error | warn | info | http | debug | trace
LOG_LEVEL=info
# Enable the Strapi cron runner (config/server.ts -> cron.enabled).
# This also turns on the hourly event-reminder e-mails (see src/lib/event-mail).
# Needs SMTP below to be real - with no provider configured the job logs errors.
CRON_ENABLED=false
# Include relations in webhook payloads. Off by default: heavier payloads.
WEBHOOKS_POPULATE_RELATIONS=false
@@ -143,6 +145,21 @@ UPLOAD_MAX_FILE_SIZE=52428800
# Cache-Control max-age (ms) for files served by the local upload provider.
UPLOAD_LOCAL_MAX_AGE=300000
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-username
SMTP_PASSWORD=your-password
DEFAULT_FROM=no-reply@yourdomain.com
DEFAULT_REPLY_TO=support@yourdomain.com
# The values above are also the from/reply-to for the registration
# confirmation, event-reminder and application-acknowledgement e-mails
# (src/lib/event-mail.ts, src/lib/application-mail.ts).
#
# The subject and body of those e-mails are editable in the CMS (the
# email-settings single type); the SHARP-ER / EU / partner logo header needs
# PUBLIC_URL (above) to build absolute image URLs - without it the mail falls
# back to a text wordmark.
# Production only - switching the upload provider to S3. Uncomment these after
# adding @strapi/provider-upload-aws-s3 and editing the upload block in
# config/plugins.ts (the switch is documented there step by step).
+22 -4
View File
@@ -37,7 +37,7 @@ const deniedTypes = [
/**
* The locales the site ships with.
*
* `en` is the default (LTR) and `ar` is the right-to-left translation. The web
* `ar` is the default (RTL) and `en` is the left-to-right translation. The web
* app mirrors these codes in its URL prefixes (`/en/...`, `/ar/...`), so the
* codes here are part of the public contract - do not rename them.
*
@@ -47,8 +47,8 @@ const deniedTypes = [
* here plus a restart.
*/
const locales = [
{ code: 'en', name: 'English (en)', isDefault: true },
{ code: 'ar', name: 'Arabic (ar)' },
{ code: 'ar', name: 'Arabic (ar)', isDefault: true },
{ code: 'en', name: 'English (en)' },
];
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
@@ -59,7 +59,7 @@ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin =>
i18n: {
enabled: true,
config: {
defaultLocale: 'en',
defaultLocale: 'ar',
locales,
},
},
@@ -138,6 +138,24 @@ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin =>
},
},
},
email: {
config: {
provider: "@strapi/provider-email-nodemailer",
providerOptions: {
host: env("SMTP_HOST", "mail.sharper.ly"),
port: env("SMTP_PORT", 587),
secure: true,
auth: {
user: env("SMTP_USERNAME"),
pass: env("SMTP_PASSWORD"),
},
},
settings: {
defaultFrom: "SHARP-ER <noreply@sharper.ly>",
defaultReplyTo: "United Efforts <info@unitedefforts.ngo>"
}
},
}
});
export default config;
+2
View File
@@ -8,6 +8,7 @@
"console": "strapi console",
"deploy": "strapi deploy",
"dev": "strapi develop",
"seed": "node --env-file-if-exists=.env ./scripts/seed.js",
"develop": "strapi develop",
"start": "strapi start",
"strapi": "strapi",
@@ -19,6 +20,7 @@
"@strapi/database": "5.53.0",
"@strapi/plugin-cloud": "5.53.0",
"@strapi/plugin-users-permissions": "5.53.0",
"@strapi/provider-email-nodemailer": "^5.54.0",
"@strapi/strapi": "5.53.0",
"pg": "^8.23.0",
"react": "^18.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+75
View File
@@ -0,0 +1,75 @@
/* ============================================================================
SEED RUNNER
----------------------------------------------------------------------------
Boots Strapi far enough to use the document service, runs src/seed, and
shuts it down again. It does NOT start a server: nothing listens on a port,
so it is safe to run while `pnpm dev:cms` is up - both talk to the same
PostgreSQL database.
`compileStrapi()` is Strapi's own TypeScript build step. It compiles
src/** (the seed included) into dist/, which is why this file is plain
CommonJS JavaScript and everything it calls is TypeScript.
Usage:
pnpm seed
pnpm seed --demo
pnpm seed --demo --clean
========================================================================= */
'use strict';
const { compileStrapi, createStrapi } = require('@strapi/strapi');
async function main() {
const appContext = await compileStrapi();
const app = await createStrapi(appContext).load();
/* Required from dist, not from src: the file below is TypeScript, and what
`compileStrapi()` just produced is the JavaScript for it. */
const { runSeed, parseSeedOptions } = require('../dist/src/seed');
try {
await runSeed(app, parseSeedOptions(process.argv.slice(2)));
} finally {
await shutdown(app);
}
}
/**
* Shut Strapi down without letting the shutdown fail the run.
*
* Knex tears its connection pool down by aborting every acquisition still
* pending on it, and each abort rejects a promise that nothing is waiting on -
* so it arrives as an unhandled rejection reading "aborted" rather than as an
* error this function could simply catch. Everything the seed wrote is
* committed long before this point, so those are absorbed for the length of
* the shutdown and nothing else is: a seed that worked must not report itself
* as a seed that failed, and a real error must still say so.
*/
async function shutdown(app) {
const absorbAbort = (reason) => {
const message = reason instanceof Error ? reason.message : String(reason);
if (message !== 'aborted') {
console.warn(`[seed] while shutting down: ${message}`);
}
};
process.on('unhandledRejection', absorbAbort);
try {
await app.destroy();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[seed] Strapi did not shut down cleanly (${message}). The data is written.`);
} finally {
process.off('unhandledRejection', absorbAbort);
}
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error instanceof Error ? error.message : error);
if (error instanceof Error && error.stack) console.error(error.stack);
process.exit(1);
});
@@ -39,7 +39,6 @@
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
@@ -58,7 +58,6 @@
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
@@ -13,7 +13,7 @@
"attributes": {
"opportunity": {
"type": "relation",
"relation": "oneToOne",
"relation": "manyToOne",
"target": "api::opportunity.opportunity"
},
"applicantName": {
@@ -1,7 +1,68 @@
/**
* application service
*
* `create` is overridden to fire the acknowledgement e-mail *after* the row is
* written, mirroring the event-registration service. The send is deliberately
* not awaited: a slow or failing SMTP call must never turn the application
* POST into a 502. The mail helper stamps `acknowledgementSentAt` only when
* the provider accepted the mail, and swallows and logs its own errors.
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::application.application');
import { sendApplicationAcknowledgement } from '../../../lib/application-mail';
const APPLICATIONS_UID = 'api::application.application';
const LOG_PREFIX = '[application]';
/**
* The `opportunity` value a client may have sent is a documentId (what the
* REST API uses for relations), or a legacy numeric id. The Get Involved form
* sends none at all - an application is not always filed against a call.
*/
const readOpportunityReference = (value: unknown): { documentId?: string; id?: number } | undefined => {
if (typeof value === 'string' && value.length > 0) {
return { documentId: value };
}
if (typeof value === 'number' && Number.isInteger(value)) {
return { id: value };
}
return undefined;
};
const readCreatedResult = (result: unknown): { documentId?: unknown; email?: unknown } =>
(typeof result === 'object' && result !== null ? result : {}) as {
documentId?: unknown;
email?: unknown;
};
export default factories.createCoreService(APPLICATIONS_UID, ({ strapi }) => ({
async create(params: { data?: { opportunity?: unknown } } & Record<string, unknown>) {
const result = await super.create(params);
const { documentId, email } = readCreatedResult(result);
if (typeof documentId === 'string' && documentId.length > 0) {
if (typeof email === 'string' && email.length > 0) {
void sendApplicationAcknowledgement({
strapi,
to: email,
applicationDocumentId: documentId,
opportunityReference: readOpportunityReference(params.data?.opportunity),
});
} else {
strapi.log.warn(
`${LOG_PREFIX} no email on the created application; acknowledgement skipped.`,
);
}
} else {
strapi.log.warn(
`${LOG_PREFIX} no document id on the created application; acknowledgement skipped.`,
);
}
return result;
},
}));
@@ -0,0 +1,31 @@
{
"kind": "singleType",
"collectionName": "email_settings",
"info": {
"singularName": "email-settings",
"pluralName": "email-settings-list",
"displayName": "Email Settings",
"description": "The outbound email templates (registration confirmation, waitlist, reminder, application acknowledgement). Markdown bodies become HTML at send time. The values seeded here on first boot are also the defaults the mails fall back to when the row is missing. Read by the mail jobs only; not exposed over the public API."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"templates": {
"type": "component",
"repeatable": true,
"component": "email.template",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -13,7 +13,7 @@
"attributes": {
"event": {
"type": "relation",
"relation": "oneToOne",
"relation": "manyToOne",
"target": "api::event.event"
},
"fullName": {
@@ -21,15 +21,15 @@
* `curl`, and the count it depends on - how many places are already taken - is
* not something a client can be trusted to report.
*
* The count is read and the row is written without a transaction, so two
* registrations arriving in the same instant can both see the last free place.
* Overbooking by the number of concurrent requests is the accepted trade-off
* for a public form; staff see the overflow in the admin panel.
* A transaction locks the event row while the confirmed count and registration
* write run, so concurrent requests cannot claim the same final place.
*/
import type { Core } from '@strapi/strapi';
import { errors } from '@strapi/utils';
import { sendRegistrationConfirmation } from '../../../lib/event-mail';
const CLIENT_FIELDS = [
'event',
'fullName',
@@ -50,6 +50,7 @@ type EventCapacity = {
capacity: number | null;
waitlistEnabled: boolean | null;
registrationStatus: string | null;
registrationDeadline: string | Date | null;
};
/**
@@ -89,9 +90,9 @@ const findEvent = async (
? { documentId: reference.documentId }
: { id: reference.id };
return (await strapi.db.query(EVENT_UID).findOne({
where,
select: ['documentId', 'capacity', 'waitlistEnabled', 'registrationStatus'],
return (await strapi.documents(EVENT_UID).findFirst({
filters: where,
status: 'published',
})) as EventCapacity | null;
};
@@ -121,13 +122,22 @@ const resolveRegistrationState = async (
const event = await findEvent(strapi, readEventReference(eventValue));
if (!event) {
// Either no event was given or the id does not resolve. There is no
// capacity to weigh, and nothing to confirm a place on.
return 'waitlisted';
throw new errors.ApplicationError('A valid published event is required.');
}
if (event.registrationStatus === 'closed') {
throw new errors.ApplicationError('Registration for this event is closed.');
if (
event.registrationStatus !== 'open' &&
event.registrationStatus !== 'full' &&
event.registrationStatus !== 'waitlist'
) {
throw new errors.ApplicationError('Registration for this event is not open.');
}
if (event.registrationDeadline) {
const deadline = new Date(event.registrationDeadline).getTime();
if (!Number.isFinite(deadline) || Date.now() >= deadline) {
throw new errors.ApplicationError('The registration deadline has passed.');
}
}
const capacity =
@@ -168,13 +178,45 @@ const sanitizeSubmission: Core.MiddlewareFactory =
}
}
data.registrationState = await resolveRegistrationState(strapi, data.event);
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
await strapi.db.transaction(async ({ trx }) => {
const event = await findEvent(strapi, readEventReference(data.event));
if (!event) {
throw new errors.ApplicationError('A valid published event is required.');
}
request.body = { data };
await trx('events')
.select('id')
.where({ document_id: event.documentId })
.orderBy('id')
.forUpdate()
.first();
await next();
data.registrationState = await resolveRegistrationState(strapi, event.documentId);
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
request.body = { data };
await next();
});
// Send the confirmation only after the transaction above has committed:
// the mail helper reads the event, template and branding back from the
// database, and running those reads inside the transaction would bind them
// to a transaction that is about to be committed, which knex rejects
// ("Transaction query already complete"). Dispatched without awaiting so a
// slow SMTP call can never turn the registration POST into a 502.
if (typeof data.email === 'string' && data.email.length > 0) {
void sendRegistrationConfirmation({
strapi,
to: data.email,
state: data.registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed',
eventReference: readEventReference(data.event),
});
} else {
strapi.log.warn(
'no email on the registration request; confirmation e-mail skipped.',
);
}
};
export default sanitizeSubmission;
@@ -1,7 +1,15 @@
/**
* event-registration service
*
* Plain default `create` service. The registration row is written inside the
* transaction opened by the sanitize-submission middleware, and that middleware
* dispatches the confirmation e-mail *after* the transaction has committed.
* It must not be dispatched from here: a send fired inside the request
* transaction would bind the mail helper's own reads (event, template,
* branding) to a transaction that is about to be committed, which knex
* rejects with "Transaction query already complete".
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::event-registration.event-registration');
export default factories.createCoreService('api::event-registration.event-registration');
@@ -199,19 +199,6 @@
}
}
},
"gallery": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"recapUrl": {
"type": "string",
"pluginOptions": {
@@ -26,10 +26,10 @@
}
}
},
"stats": {
"approach": {
"type": "component",
"repeatable": true,
"component": "shared.stat",
"repeatable": false,
"component": "page.band-intro",
"pluginOptions": {
"i18n": {
"localized": true
@@ -41,7 +41,6 @@
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
@@ -58,7 +58,6 @@
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
@@ -58,7 +58,6 @@
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
@@ -0,0 +1,26 @@
{
"kind": "singleType",
"collectionName": "reminder_settings",
"info": {
"singularName": "reminder-settings",
"pluralName": "reminder-settings-list",
"displayName": "Reminder Settings",
"description": "Operational settings for the event reminder emails: whether they are enabled and how long before an event starts the reminder is sent. Read by the cron job only; not exposed over the public API."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"enabled": {
"type": "boolean",
"required": true,
"default": true
},
"leadHours": {
"type": "integer",
"required": true,
"default": 48,
"min": 0
}
}
}
@@ -67,7 +67,6 @@
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
+2 -2
View File
@@ -22,8 +22,8 @@ export type LocaleDefinition = {
* is that config file.
*/
export const FALLBACK_LOCALES: LocaleDefinition[] = [
{ code: 'en', name: 'English (en)', isDefault: true },
{ code: 'ar', name: 'Arabic (ar)' },
{ code: 'ar', name: 'Arabic (ar)', isDefault: true },
{ code: 'en', name: 'English (en)' },
];
/** `type` of the users-permissions role that unauthenticated requests use. */
+58
View File
@@ -0,0 +1,58 @@
import type { Core } from '@strapi/strapi';
import {
DEFAULT_EMAIL_TEMPLATES,
EMAIL_TEMPLATE_KEYS,
type EmailTemplateKey,
} from '../lib/email-template-defaults';
import { LOG_PREFIX } from './constants';
const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings';
/**
* The `email.template` component payload for one locale of one template.
* `enabled` stays on so the shipped wording is the live configuration, keeping
* code defaults and CMS state in agreement on a fresh database.
*/
const templateRow = (key: EmailTemplateKey, locale: 'en' | 'ar') => ({
key,
enabled: true,
subject: DEFAULT_EMAIL_TEMPLATES[key][locale].subject,
body: DEFAULT_EMAIL_TEMPLATES[key][locale].body,
});
/**
* Create the `email-settings` single type if the database does not have one
* yet, so the four email templates exist and are editable in the admin panel
* after the very first boot — carrying the shipped default copy. Never
* overwrites an existing row, so editor customisation survives every restart.
*/
export const ensureEmailSettings = async (strapi: Core.Strapi): Promise<void> => {
const existing = await strapi
.documents(EMAIL_SETTINGS_UID)
.findFirst({});
if (existing) {
return;
}
const templatesFor = (locale: 'en' | 'ar') =>
EMAIL_TEMPLATE_KEYS.map((key) => templateRow(key, locale));
/* Arabic first — it is the default locale and the one a single type is
created on — then the English translation is written onto the same
document, which is how Strapi 5 models one document in two locales. */
const created = await strapi.documents(EMAIL_SETTINGS_UID).create({
locale: 'ar',
data: { templates: templatesFor('ar') },
});
await strapi.documents(EMAIL_SETTINGS_UID).update({
documentId: created.documentId,
locale: 'en',
data: { templates: templatesFor('en') },
});
strapi.log.info(
`${LOG_PREFIX} created email-settings single type with the four default email templates.`
);
};
+6
View File
@@ -2,12 +2,16 @@ import type { Core } from '@strapi/strapi';
import { revokePublicAuthActions } from './auth-permissions';
import { LOG_PREFIX } from './constants';
import { ensureEmailSettings } from './email-settings';
import { ensureLocales } from './locales';
import { ensurePublicPermissions } from './public-permissions';
import { ensureReminderSettings } from './reminder-settings';
export { revokePublicAuthActions } from './auth-permissions';
export { ensureEmailSettings } from './email-settings';
export { ensureLocales } from './locales';
export { ensurePublicPermissions } from './public-permissions';
export { ensureReminderSettings } from './reminder-settings';
/**
* Boot-time reconciliation, run from `src/index.ts`.
@@ -26,6 +30,8 @@ export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise
await ensureLocales(strapi);
await ensurePublicPermissions(strapi);
await revokePublicAuthActions(strapi);
await ensureReminderSettings(strapi);
await ensureEmailSettings(strapi);
strapi.log.debug(`${LOG_PREFIX} bootstrap complete.`);
};
+19 -16
View File
@@ -35,11 +35,9 @@ const desiredActionsFor = (controllerName: string): string[] =>
* Grant the Public role read access to the public content (collection types and
* single types) and create access to the three submission content types.
*
* Only missing permissions are inserted, and permissions granted by hand in the
* admin panel are never removed - so this is safe to run on every boot. A read
* grant on a submission type is reported as a warning rather than deleted, so
* that an unexpected grant is visible without the boot silently undoing an
* administrator's decision.
* Missing public-content permissions are inserted. Submission types are kept
* create-only: any other public grant is revoked on boot because those entries
* contain personal data.
*/
export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void> => {
if (!strapi.plugins['users-permissions']) {
@@ -63,7 +61,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.getActions();
const desiredActions = new Set<string>();
const forbiddenReadActions = new Set<string>();
const forbiddenSubmissionActions = new Set<string>();
for (const [namespace, { controllers }] of Object.entries(actionMap)) {
// Only the project's own APIs; plugin permissions are managed by Strapi.
@@ -80,11 +78,8 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
if (wantedActions.includes(action)) {
desiredActions.add(permission);
} else if (
PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName) &&
PUBLIC_READ_ACTIONS.includes(action)
) {
forbiddenReadActions.add(permission);
} else if (PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName)) {
forbiddenSubmissionActions.add(permission);
}
}
}
@@ -94,7 +89,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.query('plugin::users-permissions.permission')
.findMany({
where: { role: { id: publicRole.id } },
select: ['action'],
select: ['id', 'action'],
});
const existingActions = new Set(existingPermissions.map(({ action }) => action));
@@ -112,12 +107,20 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
);
}
const leakedActions = [...forbiddenReadActions].filter((action) => existingActions.has(action));
const forbiddenPermissions = existingPermissions.filter(({ action }) =>
forbiddenSubmissionActions.has(action)
);
if (forbiddenPermissions.length > 0) {
await strapi.db.query('plugin::users-permissions.permission').deleteMany({
where: { id: { $in: forbiddenPermissions.map(({ id }) => id) } },
});
if (leakedActions.length > 0) {
strapi.log.warn(
`${LOG_PREFIX} the Public role can read submission data: ${leakedActions.join(', ')}. ` +
'Revoke these in Settings > Roles > Public unless this is intentional.'
`${LOG_PREFIX} revoked forbidden Public submission permission(s): ${forbiddenPermissions
.map(({ action }) => action)
.sort()
.join(', ')}.`
);
}
};
@@ -0,0 +1,40 @@
import type { Core } from '@strapi/strapi';
import { LOG_PREFIX } from './constants';
const REMINDER_SETTINGS_UID = 'api::reminder-settings.reminder-settings';
/**
* The values the reminder cron job falls back to when no `reminder-settings`
* single type exists yet (first boot on an empty database). The admin panel
* owns these values from then on; this is only the bootstrap default.
*/
export const DEFAULT_REMINDER_SETTINGS = {
enabled: true,
leadHours: 48,
} as const;
/**
* Create the `reminder-settings` single type if the database does not have one
* yet, so the values the reminder cron job depends on exist and are editable in
* the admin panel after the very first boot. Never overwrites an existing row.
*/
export const ensureReminderSettings = async (strapi: Core.Strapi): Promise<void> => {
const existing = await strapi
.documents(REMINDER_SETTINGS_UID)
.findFirst({});
if (existing) {
return;
}
await strapi.documents(REMINDER_SETTINGS_UID).create({
data: { ...DEFAULT_REMINDER_SETTINGS },
});
strapi.log.info(
`${LOG_PREFIX} created reminder-settings single type with defaults ` +
`(enabled=${DEFAULT_REMINDER_SETTINGS.enabled}, ` +
`leadHours=${DEFAULT_REMINDER_SETTINGS.leadHours}).`
);
};
@@ -1,19 +0,0 @@
{
"collectionName": "components_blocks_stats_grids",
"info": {
"displayName": "Stats Grid",
"icon": "chartBubble",
"description": "A grid of headline figures."
},
"options": {},
"attributes": {
"stats": {
"type": "component",
"repeatable": true,
"component": "shared.stat"
},
"note": {
"type": "text"
}
}
}
@@ -0,0 +1,35 @@
{
"collectionName": "components_email_templates",
"info": {
"displayName": "Email Template",
"icon": "envelop",
"description": "One outbound email template: a subject plus a markdown body. The body is converted to HTML at send time. Placeholders: {event}, {date} and {call}. A placeholder whose value is missing collapses together with the word before it, so \"for {call}\" (and \"على {call}\") reads cleanly when there is no call."
},
"options": {},
"attributes": {
"key": {
"type": "enumeration",
"enum": [
"confirmation",
"waitlist",
"event-reminder",
"application-acknowledgement"
],
"required": true
},
"enabled": {
"type": "boolean",
"default": true,
"description": "Off reverts this email to the shipped default copy."
},
"subject": {
"type": "string",
"required": true,
"maxLength": 250
},
"body": {
"type": "richtext",
"required": true
}
}
}
@@ -0,0 +1,28 @@
{
"collectionName": "components_page_band_intros",
"info": {
"displayName": "Band intro",
"icon": "layer",
"description": "The heading, lead and pictures of a page band, in one place so an editor is not hunting for half of it in the Blocks zone."
},
"options": {},
"attributes": {
"eyebrow": {
"type": "string"
},
"heading": {
"type": "string"
},
"lead": {
"type": "text"
},
"images": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images"
]
}
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"collectionName": "components_shared_stats",
"info": {
"displayName": "Stat",
"icon": "chartBubble",
"description": "A single headline figure, e.g. 64 / CSO Partners."
},
"options": {},
"attributes": {
"value": {
"type": "string",
"required": true
},
"label": {
"type": "string",
"required": true
}
}
}
+20
View File
@@ -1,6 +1,8 @@
import type { Core } from '@strapi/strapi';
import { runBootstrap } from './bootstrap';
import { runEventReminders } from './lib/event-reminders';
import { runWaitlistPromotion } from './lib/waitlist-promotion';
export default {
/**
@@ -21,5 +23,23 @@ export default {
*/
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
await runBootstrap({ strapi });
// The cron provider always runs its own `start()`, so `strapi.cron.add()`
// here schedules immediately. Respect the explicit opt-out in
// `server.cron.enabled` (env `CRON_ENABLED`): without SMTP configured the
// hourly job would just fail and log, so it stays inert by default.
if (strapi.config.get('server.cron.enabled', false)) {
strapi.cron.add({
'send-event-reminders': {
task: runEventReminders,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' },
},
'run-waitlist-promotion': {
task: runWaitlistPromotion,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' },
},
});
strapi.log.info('[index] event reminder + waitlist promotion cron jobs registered.');
}
},
};
+127
View File
@@ -0,0 +1,127 @@
/**
* application-mail — bilingual acknowledgement e-mail for applications.
*
* The web copy promises every application is "acknowledged automatically by
* email" (apps/web/app/routes/_events-opportunities/copy.ts:
* `apply.acknowledgement` / `apply.receivedBody`), and the content type models
* that with `application.acknowledgementSentAt`. This module is the half that
* delivers: fired fire-and-forget from the application service's `create`
* override, mirroring the event-registration confirmation, and stamping
* `acknowledgementSentAt` ONLY after the mail was accepted, so a failure is
* retried by nothing today but is visible in the admin panel as a null.
*
* The (Get Involved) section form can submit an application with no
* opportunity relation. The mail then drops the call name and stays a plain
* acknowledgement, so the promise holds for both entry points.
*
* COPY IS CMS-DRIVEN like the event mails: subject and body come from the
* `email-settings` single type's `application-acknowledgement` template, whose
* shipped default is the approved wording. The call name fills the `{call}`
* placeholder; when there is no call name the placeholder and its preceding
* word collapse, leaving the no-call variant automatically.
*/
import type { Core } from '@strapi/strapi';
import {
readEmailBranding,
resolveEmailTemplate,
} from './email-templates';
import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
const APPLICATION_UID = 'api::application.application';
const LOG_PREFIX = '[application-mail]';
/** Identifier a public form may have sent for the opportunity relation. */
type OpportunityReference = { documentId?: string; id?: number } | undefined;
/** An opportunity looked up in both locales; missing locales are allowed. */
type BilingualOpportunity = { en?: { title?: string | null }; ar?: { title?: string | null } };
/**
* Fetch an opportunity in both locales. Only published entries are ever
* fetched; either a documentId or a legacy numeric id is accepted, mirroring
* the event-registration middleware. Returns an empty record when there is no
* usable reference (the Get Involved form sends none).
*/
export async function fetchBilingualOpportunity(
strapi: Core.Strapi,
reference: OpportunityReference,
): Promise<BilingualOpportunity> {
const filters = reference?.documentId
? { documentId: reference.documentId }
: reference?.id !== undefined
? { id: reference.id }
: null;
if (!filters) return {};
const [en, ar] = await Promise.all([
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'en' }),
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'ar' }),
]);
return { en: en ?? undefined, ar: ar ?? undefined };
}
/**
* Build and send the acknowledgement for an application, and stamp
* `acknowledgementSentAt` when the provider accepted it. Best-effort and
* never awaited by the request path: a slow or failing SMTP call must not make
* the application POST fail.
*
* The `{call}` placeholder is filled from the opportunity's title in each
* locale; an empty title collapses "for {call}" / "على {call}" out of the
* body, which is how a no-opportunity application still reads cleanly.
*/
export async function sendApplicationAcknowledgement({
strapi,
to,
applicationDocumentId,
opportunityReference,
}: {
strapi: Core.Strapi;
to: string;
applicationDocumentId: string;
opportunityReference: OpportunityReference;
}): Promise<void> {
try {
const [opportunity, template, branding] = await Promise.all([
fetchBilingualOpportunity(strapi, opportunityReference),
resolveEmailTemplate(strapi, 'application-acknowledgement'),
readEmailBranding(strapi),
]);
const callEn = opportunity.en?.title || undefined;
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
const mail = buildBilingualMail({
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { call: callAr ?? '' },
en: { call: callEn ?? '' },
},
branding,
});
const ok = await trySendMail(strapi, { to, ...mail });
if (!ok) {
return;
}
await strapi
.documents(APPLICATION_UID)
.update({
documentId: applicationDocumentId,
data: { acknowledgementSentAt: new Date().toISOString() },
});
} catch (error) {
strapi.log.error(`${LOG_PREFIX} acknowledgement failed: ${String(error)}`);
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* email-template-defaults — the shipped subject and body for every email the
* CMS sends.
*
* These are the "default for each email template": first-boot writes them into
* the `email-settings` single type (src/bootstrap/email-settings.ts) so editors
* start from a working, editable baseline, and they remain the fallback in code
* when the single type is missing, unreadable or a template is switched off —
* which is what keeps the mails sending even if nothing was ever configured.
*
* The copy is the wording the mail modules hard-coded before this file existed,
* which itself mirrors the approved web copy (apps/web i18n messages and
* _events-opportunities/copy.ts). Nothing here is invented.
*
* BODIES ARE MARKDOWN. Plain paragraphs convert unchanged; the `{event}`,
* `{date}` and `{call}` placeholders are filled after conversion. A placeholder
* whose value is empty collapses together with the word before it, so the call
* clause of the acknowledgement ("for {call}" / "على {call}") reads cleanly even
* when the submission carries no opportunity.
*/
export const EMAIL_TEMPLATE_KEYS = [
'confirmation',
'waitlist',
'event-reminder',
'application-acknowledgement',
] as const;
export type EmailTemplateKey = (typeof EMAIL_TEMPLATE_KEYS)[number];
/** One locale's worth of a template. */
export type LocalisedEmailTemplate = {
subject: string;
/** Markdown; converted to HTML at send time. */
body: string;
};
/**
* The two approved acknowledgement wordings, kept for their exact strings:
* the with-call variant is the shipped default and the no-call variant is what
* the placeholder collapse reproduces when there is no opportunity.
*/
const ACKNOWLEDGEMENT_WITH_CALL = {
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application for {call}. Our team will review it and follow up by email.',
} as const;
/**
* The plain acknowledgement (no call), reproduced verbatim so editors can
* compare against the collapsed form when the placeholder is empty.
*/
const ACKNOWLEDGEMENT_WITHOUT_CALL = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application. Our team will review it and follow up by email.',
} as const;
export const ACKNOWLEDGEMENT_WITHOUT_CALL_COPY = ACKNOWLEDGEMENT_WITHOUT_CALL;
export const DEFAULT_EMAIL_TEMPLATES: Readonly<
Record<EmailTemplateKey, { ar: LocalisedEmailTemplate; en: LocalisedEmailTemplate }>
> = {
confirmation: {
ar: {
subject: 'تم تأكيد تسجيلك',
body: 'شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.',
},
en: {
subject: 'Registration confirmed',
body: 'Thank you — your spot for {event} on {date} is confirmed. We’ll send a reminder and any joining details to the email you provided.',
},
},
waitlist: {
ar: {
subject: 'تمت إضافتك إلى قائمة الانتظار',
body: 'شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.',
},
en: {
subject: "You're on the waitlist",
body: '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.',
},
},
'event-reminder': {
ar: {
subject: 'تذكير: {event}',
body: 'هذا تذكير بأنك مسجّل في {event} بتاريخ {date}. تبدأ الفعالية في الوقت المُشار إليه أدناه.',
},
en: {
subject: 'Reminder: {event}',
body: 'This is a reminder that you are registered for {event} on {date}. A summary is below.',
},
},
'application-acknowledgement': {
ar: {
subject: 'تم استلام الطلب',
body: ACKNOWLEDGEMENT_WITH_CALL.ar,
},
en: {
subject: 'Application received',
body: ACKNOWLEDGEMENT_WITH_CALL.en,
},
},
};
+295
View File
@@ -0,0 +1,295 @@
/**
* email-templates — resolve the CMS-configured copy and branding the outbound
* mails are built from.
*
* The `email-settings` single type (created at boot with the shipped defaults)
* holds one subject + markdown body per template key, per locale. These
* resolvers turn that editable state into exactly the values an email needs:
*
* resolveEmailTemplate the subject/body pair for a key, per locale, each
* locale falling back to its own code default when
* the row is missing, switched off or unreadable.
* readEmailBranding the logos for the mail: the SHARP-ER project logo,
* the EU badge and the consortium partner logos,
* preferring the media configured on `site-settings`
* and falling back to the brand assets bundled with
* the CMS (public/branding), so the mail brands itself
* the same way the site does even before anyone
* uploads media. Logo images need an absolute URL, so
* they resolve against `server.url` (PUBLIC_URL, with
* the local server as the development fallback); with
* neither available the mail falls back to the site
* name as a text wordmark.
* fillPlaceholders the {event} / {date} / {call} substitution, shared
* by both mail modules. An empty value removes the
* placeholder together with the word before it, so
* "for {call}" / "على {call}" collapse cleanly when
* there is no call (see the component description).
*/
import type { Core } from '@strapi/strapi';
import {
DEFAULT_EMAIL_TEMPLATES,
type EmailTemplateKey,
type LocalisedEmailTemplate,
} from './email-template-defaults';
const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings';
const SITE_SETTINGS_UID = 'api::site-settings.site-settings';
const LOG_PREFIX = '[email-templates]';
/* ---------------------------------------------------------------------------
TEMPLATES
------------------------------------------------------------------------ */
type MailTemplateSettings = {
templates?: unknown;
};
/**
* Find the row for `key` on a `email-settings` document read for one locale.
* Returns `undefined` when there is no such row, it is switched off, or its
* fields are unusable — the caller then falls back to that locale's default.
*/
function templateRowFor(
settings: unknown,
key: EmailTemplateKey
): LocalisedEmailTemplate | undefined {
if (!settings || typeof settings !== 'object') return undefined;
const templates = (settings as MailTemplateSettings).templates;
if (!Array.isArray(templates)) return undefined;
for (const row of templates) {
if (!row || typeof row !== 'object') continue;
const candidate = row as {
key?: unknown;
enabled?: unknown;
subject?: unknown;
body?: unknown;
};
if (candidate.key !== key) continue;
if (candidate.enabled === false) return undefined;
if (typeof candidate.subject === 'string' && typeof candidate.body === 'string') {
return { subject: candidate.subject, body: candidate.body };
}
return undefined;
}
return undefined;
}
/** The per-locale subject/body for one template key, resolved from the CMS. */
export type ResolvedEmailTemplate = {
ar: LocalisedEmailTemplate;
en: LocalisedEmailTemplate;
};
/**
* The subject and body for one template key, per locale. Each locale prefers
* its own CMS row and falls back to that locale's shipped default when the
* row is missing, disabled or the single type is unreadable — so a broken or
* empty `email-settings` never stops a mail from going out.
*/
export async function resolveEmailTemplate(
strapi: Core.Strapi,
key: EmailTemplateKey
): Promise<ResolvedEmailTemplate> {
const defaults = DEFAULT_EMAIL_TEMPLATES[key];
const resolved: ResolvedEmailTemplate = {
ar: { subject: defaults.ar.subject, body: defaults.ar.body },
en: { subject: defaults.en.subject, body: defaults.en.body },
};
try {
const [arSettings, enSettings] = await Promise.all([
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'ar' }),
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'en' }),
]);
const arRow = templateRowFor(arSettings, key);
const enRow = templateRowFor(enSettings, key);
if (arRow) resolved.ar = arRow;
if (enRow) resolved.en = enRow;
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read email-settings for "${key}", using defaults: ${String(error)}`
);
}
return resolved;
}
/* ---------------------------------------------------------------------------
BRANDING (SHARP-ER + EU + consortium logos)
------------------------------------------------------------------------ */
/**
* The `site-settings` media shapes, read with a loose internal eye so the
* population details of the findFirst populate above cannot break the mail.
*/
type RawMedia = { url?: unknown } | null | undefined;
type RawPartner = { name?: unknown; logo?: RawMedia } | undefined;
/** A partner that will appear in the mail's logo block. */
export type EmailPartner = { name: string; logo: string | null };
/** Everything the mail chrome needs, resolved to absolute image URLs. */
export type EmailBranding = {
siteName: string;
projectLogo: string | null;
euBadge: string | null;
partners: EmailPartner[];
};
const EMPTY_BRANDING: EmailBranding = {
siteName: 'SHARP-ER',
projectLogo: null,
euBadge: null,
partners: [],
};
/**
* The brand assets shipped with the CMS, copied from the site's own
* `apps/web/public/assets` into `public/branding`. They are the fallback when
* `site-settings` has no logo uploaded yet, so every mail still carries the
* SHARP-ER wordmark, the EU emblem and the consortium logos (Acted, UEC, SBE —
* always together, never one partner alone). `site-settings` media still wins
* when an editor uploads it.
*/
const BUNDLED_BRANDING = {
projectLogo: '/branding/sharp-er-wordmark.png',
euBadge: '/branding/eu-emblem.png',
partners: [
{ name: 'Acted', logo: '/branding/partner-acted.png' },
{ name: 'United Efforts Consultancy (UEC)', logo: '/branding/partner-uec.png' },
{ name: 'Stichting Bureau Ensemble (SBE)', logo: '/branding/partner-sbe.png' },
],
} as const;
/**
* The absolute origin the brand images are resolved against: `server.url`
* (PUBLIC_URL) when configured, the local server in development, and `null`
* otherwise. Mail clients need absolute URLs or the logos do not load; on
* production behind a custom domain, leaving PUBLIC_URL unset drops the
* images and degrades the mail to the plain text wordmark.
*/
const emailBase = (strapi: Core.Strapi): string | null => {
const configured = strapi.config.get('server.url') as string | undefined;
if (configured && configured.trim().length > 0) return configured;
if ((process.env.NODE_ENV ?? 'development') !== 'production') {
return `http://localhost:${strapi.config.get('server.port') ?? 1337}`;
}
return null;
};
/** Resolve a media URL to an absolute one, or null when that is impossible. */
const absoluteMediaUrl = (base: string | undefined, url: unknown): string | null => {
if (typeof url !== 'string' || url.length === 0) return null;
try {
return new URL(url, base).href;
} catch {
return null;
}
};
/**
* Read the logos for the mail chrome: the SHARP-ER project logo, the EU
* funding badge and the consortium partner logos (Acted, UEC, SBE). It prefers
* the `site-settings` media and falls back to the brand assets bundled with the
* CMS (public/branding) when nothing is uploaded, so the mail only degrades to
* a text wordmark when there is no absolute base URL to resolve images
* against. The badge and the partner logos always render as one unit — EU
* visibility is contractual and a partner logo never appears on its own.
*/
export async function readEmailBranding(strapi: Core.Strapi): Promise<EmailBranding> {
let siteName = EMPTY_BRANDING.siteName;
try {
const settings = await strapi.documents(SITE_SETTINGS_UID).findFirst({
populate: {
logo: true,
euBadge: true,
consortiumLogos: { populate: { logo: true } },
},
});
if (settings && typeof settings.siteName === 'string') {
siteName = settings.siteName;
}
const base = emailBase(strapi);
if (!base) {
strapi.log.warn(
`${LOG_PREFIX} no absolute base URL (PUBLIC_URL) and not in development; e-mails will use the text wordmark and omit logos.`
);
return { siteName, projectLogo: null, euBadge: null, partners: [] };
}
const mediaUrl = (file: RawMedia): string | null =>
absoluteMediaUrl(base, file?.url);
const bundledUrl = (assetPath: string): string | null =>
absoluteMediaUrl(base, assetPath);
const toPartner = (partner: unknown): EmailPartner => {
const p = partner as RawPartner | undefined;
return {
name: typeof p?.name === 'string' ? p.name : '',
logo: mediaUrl(p?.logo),
};
};
const uploadedPartners: EmailPartner[] = Array.isArray(settings?.consortiumLogos)
? (settings.consortiumLogos as unknown[]).map(toPartner)
: [];
const partners: EmailPartner[] =
uploadedPartners.length > 0
? uploadedPartners
: BUNDLED_BRANDING.partners.map((partner) => ({
name: partner.name,
logo: bundledUrl(partner.logo),
}));
return {
siteName,
projectLogo: mediaUrl(settings?.logo) ?? bundledUrl(BUNDLED_BRANDING.projectLogo),
euBadge: mediaUrl(settings?.euBadge) ?? bundledUrl(BUNDLED_BRANDING.euBadge),
partners,
};
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read site-settings branding, emails will use the text wordmark: ${String(error)}`
);
return EMPTY_BRANDING;
}
}
/* ---------------------------------------------------------------------------
PLACEHOLDERS
------------------------------------------------------------------------ */
/** Literal values for {event} / {date} / {call}, per locale, unescaped. */
export type PlaceholderValues = Readonly<Record<string, string>>;
/**
* Fill `{name}` placeholders in a template string. A value of `''` removes
* the placeholder together with the word directly in front of it, so "for
* {call}" (or "على {call}") collapses to nothing instead of leaving a
* dangling connector. Non-empty values are spliced with `split`/`join`, so a
* value containing `$` or backslashes is inserted literally.
*/
export function fillPlaceholders(value: string, vars: PlaceholderValues): string {
let out = value;
for (const name of Object.keys(vars)) {
const replacement = vars[name];
const token = `{${name}}`;
if (replacement === '') {
out = out.replace(new RegExp(`\\S+\\s*\\{${name}\\}`, 'g'), '');
} else {
out = out.split(token).join(replacement);
}
}
return out;
}
+478
View File
@@ -0,0 +1,478 @@
/**
* event-mail — bilingual e-mail building and sending for event registrations.
*
* The CMS sends two kinds of e-mails, both bilingual with Arabic first:
*
* confirmation the moment a registration is created (confirmed or
* waitlisted) — fire-and-forget from the write path, never
* awaited, so a slow or failing SMTP call can never make the
* registration request fail.
* reminder sent by the cron job to confirmed registrations who have not
* been reminded yet, for events starting within the configured
* lead time.
*
* The Arabic block always comes first and is marked dir="rtl"; the English
* block follows inside the same message. Subjects are bilingual too.
*
* COPY IS CMS-DRIVEN. The subject and body for each mail come from the
* `email-settings` single type — markdown bodies, converted to HTML at send
* time (see lib/markdown.ts) — with the shipped approved copy as the fallback
* (see lib/email-template-defaults.ts). This module owns the chrome around
* that copy: the event lookups, the {event} / {date} placeholders, the venue
* or join-online line, and the logo header/footer (SHARP-ER logo, EU badge
* and the consortium partner logos, read from `site-settings`).
*/
import type { Core } from '@strapi/strapi';
import {
readEmailBranding,
resolveEmailTemplate,
fillPlaceholders,
type EmailBranding,
type PlaceholderValues,
} from './email-templates';
import { mdToHtml, mdToText, escapeHtml } from './markdown';
const EVENT_UID = 'api::event.event';
export type RegistrationState = 'confirmed' | 'waitlisted';
type Locale = 'en' | 'ar';
/** Non-localised fields are shared by both locale rows of an event. */
type EventRow = {
documentId: string;
title?: string | null;
/** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */
startDateTime?: string | Date | null;
timezone?: string | null;
format?: 'in-person' | 'online' | 'hybrid' | null;
venue?: string | null;
onlineUrl?: string | null;
};
/** An event looked up in both locales; missing locales are allowed. */
type BilingualEvent = { en?: EventRow; ar?: EventRow };
/** Identifier a public form may have sent for the event relation. */
type EventReference = { documentId?: string; id?: number } | undefined;
const EMAIL_TAG = {
en: 'en-GB-u-nu-latn',
ar: 'ar-LY-u-nu-latn',
} as const;
const DEFAULT_TIMEZONE = 'Africa/Tripoli';
/**
* Date plus time of day, mirroring the web's `formatDateTime`: Arabic uses the
* latn numbering system, so digits never switch to Eastern Arabic numerals.
*/
function formatEventDateTime(
value: string | Date | null | undefined,
locale: Locale,
timeZone: string,
): string {
if (!value) return '';
const date = typeof value === 'string' ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat(EMAIL_TAG[locale], {
dateStyle: 'long',
timeStyle: 'short',
timeZone,
}).format(date);
}
/* ---------------------------------------------------------------------------
LOCATION LINE
------------------------------------------------------------------------ */
const LOCATION_LABEL = {
ar: 'الموقع',
en: 'Location',
} as const;
const JOIN_LABEL = {
ar: 'الانضمام عبر الإنترنت',
en: 'Join online',
} as const;
/**
* Extra line with the venue or the online link, when the event has one. Each
* locale's line uses that locale's row, falling back to the other row when a
* localized field is empty on one side.
*/
function locationLineFor(event: BilingualEvent): { ar: string; en: string } | undefined {
const format = event.en?.format;
const onlineUrl = event.en?.onlineUrl || event.ar?.onlineUrl;
const venue = {
en: event.en?.venue || event.ar?.venue || '',
ar: event.ar?.venue || event.en?.venue || '',
};
const isOnline = format === 'online' || (format === 'hybrid' && !venue.en);
if (isOnline && onlineUrl) {
return {
ar: `${JOIN_LABEL.ar}: ${onlineUrl}`,
en: `${JOIN_LABEL.en}: ${onlineUrl}`,
};
}
if (venue.en || venue.ar) {
return {
ar: `${LOCATION_LABEL.ar}: ${venue.ar}`,
en: `${LOCATION_LABEL.en}: ${venue.en}`,
};
}
return undefined;
}
/* ---------------------------------------------------------------------------
EU VISIBILITY FOOTER (approved wording)
------------------------------------------------------------------------ */
const FOOTER = {
ar: 'SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج — يُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي.',
en: 'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union.',
} as const;
/* ---------------------------------------------------------------------------
MARKUP
------------------------------------------------------------------------ */
const BASE_FONT =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;";
/**
* The logo chrome shared by every mail: the SHARP-ER project logo at the top
* (the EU badge beside it), and the consortium partner logos under the footer
* text. EU visibility is contractual, so the badge and the partner logos are
* rendered as one unit straight from `site-settings`. Without an absolute
* image URL the header degrades to the site name as a text wordmark.
*/
const renderBrandHeader = (branding?: EmailBranding): string => {
if (!branding) return '';
const logo = branding.projectLogo
? `<img src="${branding.projectLogo}" alt="${escapeHtml(branding.siteName)}" style="height:40px; width:auto; display:block;" />`
: `<strong style="font-size:18px; color:#222;">${escapeHtml(branding.siteName)}</strong>`;
const badge = branding.euBadge
? `<img src="${branding.euBadge}" alt="${escapeHtml('Funded by the European Union')}" style="height:36px; width:auto; display:block;" />`
: '';
if (!badge) {
return `<div style="margin-bottom:28px; ${BASE_FONT}">${logo}</div>`;
}
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px; ${BASE_FONT}">
<tr>
<td style="padding:0; vertical-align:middle;">${logo}</td>
<td align="right" style="padding:0; vertical-align:middle;">${badge}</td>
</tr>
</table>`;
};
const renderPartnerLogos = (branding?: EmailBranding): string => {
const logos = (branding?.partners ?? []).filter((partner) => partner.logo);
if (logos.length === 0) return '';
const images = logos
.map(
(partner) =>
`<img src="${partner.logo}" alt="${escapeHtml(partner.name)}" title="${escapeHtml(partner.name)}" style="height:28px; width:auto; max-width:160px; margin:0 16px 8px 0; display:inline-block; vertical-align:middle;" />`
)
.join('');
return `
<div style="margin-top:24px; padding-top:16px; border-top:1px solid #ddd; ${BASE_FONT}">
${images}
</div>`;
};
const locationHtml = (line?: string): string =>
line
? `<p style="margin:12px 0 0; font-size:14px; color:#444; ${BASE_FONT}">${escapeHtml(line)}</p>`
: '';
/**
* One bilingual message: the Arabic block first (rtl), then English, then the
* EU-visibility footer and partner logos. Inline styles keep the layout
* working in mail clients that strip <head> style sheets.
*
* The markdown bodies are converted to HTML here, and the `{event}` /
* `{date}` / `{call}` placeholders are filled AFTER conversion — with
* HTML-escaped values in the HTML part and the literal values in the plain
* part — so nothing a title or URL contains can be read as markdown.
*/
export function buildBilingualMail(opts: {
subjectAr: string;
subjectEn: string;
bodyAr: string;
bodyEn: string;
/** Literal {event} / {date} / {call} values, per locale, unescaped. */
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
locationLine?: { ar: string; en: string };
branding?: EmailBranding;
}): { subject: string; text: string; html: string } {
const { subjectAr, subjectEn, bodyAr, bodyEn, vars, locationLine, branding } = opts;
const varsAr = vars?.ar ?? {};
const varsEn = vars?.en ?? {};
const escaped = (values: PlaceholderValues): PlaceholderValues =>
Object.fromEntries(
Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
);
const html = `
${renderBrandHeader(branding)}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;">
<div dir="rtl" lang="ar">
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
${locationHtml(locationLine?.ar)}
</div>
<hr style="border:none; border-top:1px solid #ddd; margin:24px 0;" />
<div dir="ltr" lang="en">
${fillPlaceholders(mdToHtml(bodyEn), escaped(varsEn))}
${locationHtml(locationLine?.en)}
</div>
</td></tr></table>
<p style="margin-top:32px; font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
${FOOTER.ar}
</p>
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
${FOOTER.en}
</p>
${renderPartnerLogos(branding)}`;
const text = [
fillPlaceholders(mdToText(bodyAr), varsAr),
locationLine?.ar ?? '',
'',
fillPlaceholders(mdToText(bodyEn), varsEn),
locationLine?.en ?? '',
'',
FOOTER.ar,
FOOTER.en,
]
.filter((line) => line.length > 0)
.join('\n');
return {
subject: `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`,
text,
html,
};
}
/* ---------------------------------------------------------------------------
EVENT LOOKUP
------------------------------------------------------------------------ */
/**
* Fetch an event in both locales. Only published events are ever fetched: the
* documents API defaults to `status: published` when none is given. Either a
* documentId (what the REST API uses for relations) or a legacy numeric id is
* accepted, mirroring the sanitize-submission middleware.
*/
export async function fetchBilingualEvent(
strapi: Core.Strapi,
reference: EventReference,
): Promise<BilingualEvent> {
const filters = reference?.documentId
? { documentId: reference.documentId }
: reference?.id !== undefined
? { id: reference.id }
: null;
if (!filters) return {};
const [en, ar] = await Promise.all([
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'en' }),
strapi.documents(EVENT_UID).findFirst({ filters, locale: 'ar' }),
]);
return { en: en ?? undefined, ar: ar ?? undefined };
}
/* ---------------------------------------------------------------------------
SENDING
------------------------------------------------------------------------ */
type MailOptions = {
to: string;
subject: string;
text: string;
html?: string;
};
type EmailServiceShape = { send: (options: MailOptions) => Promise<unknown> };
/**
* Send one e-mail through the nodemailer provider. Throws on failure — callers
* decide whether that should surface (reminders: caught per registration) or
* be swallowed (confirmation: never awaited).
*/
export async function sendMail(
strapi: Core.Strapi,
options: MailOptions,
): Promise<void> {
const service = strapi.plugin('email').service<EmailServiceShape>('email');
await service.send(options);
}
/** A guarded send that logs instead of throwing. Returns true when it worked. */
export async function trySendMail(
strapi: Core.Strapi,
options: MailOptions,
): Promise<boolean> {
try {
await sendMail(strapi, options);
return true;
} catch (error) {
strapi.log.error(
`[event-mail] failed to send to ${options.to}: ${String(error)}`,
);
return false;
}
}
/* ---------------------------------------------------------------------------
CONFIRMATION
------------------------------------------------------------------------ */
/**
* Build and send the confirmation (or waitlist) e-mail for a registration.
* Best-effort: logs failures, never throws into the request path.
*
* Returns true when the mail was accepted by the provider. The waitlist
* promotion cron uses this to only flip a registration to `confirmed` AFTER
* the "your place is now confirmed" mail went out (at-least-once, like the
* reminder job).
*/
export async function sendRegistrationConfirmation({
strapi,
to,
state,
eventReference,
}: {
strapi: Core.Strapi;
to: string;
state: RegistrationState;
eventReference: EventReference;
}): Promise<boolean> {
try {
const isWaitlist = state === 'waitlisted';
const [event, template, branding] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'),
readEmailBranding(strapi),
]);
if (!event.en && !event.ar) {
strapi.log.warn(
'[event-mail] confirmation skipped: no published event for the registration.',
);
return false;
}
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
const titleEn = event.en?.title || event.ar?.title || '';
const titleAr = event.ar?.title || event.en?.title || '';
const dateEn = formatEventDateTime(
event.en?.startDateTime,
'en',
timeZone,
);
const dateAr = formatEventDateTime(
event.ar?.startDateTime ?? event.en?.startDateTime,
'ar',
timeZone,
);
const mail = buildBilingualMail({
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { event: titleAr, date: dateAr },
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
branding,
});
return trySendMail(strapi, { to, ...mail });
} catch (error) {
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
return false;
}
}
/* ---------------------------------------------------------------------------
REMINDER
------------------------------------------------------------------------ */
/** Build and send the reminder e-mail for a single confirmed registration. */
export async function sendEventReminderMail({
strapi,
to,
eventReference,
}: {
strapi: Core.Strapi;
to: string;
eventReference: EventReference;
}): Promise<boolean> {
try {
const [event, template, branding] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, 'event-reminder'),
readEmailBranding(strapi),
]);
if (!event.en && !event.ar) {
strapi.log.warn(
'[event-mail] reminder skipped: no published event for the registration.',
);
return false;
}
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
const titleEn = event.en?.title || event.ar?.title || '';
const titleAr = event.ar?.title || event.en?.title || '';
const dateEn = formatEventDateTime(event.en?.startDateTime, 'en', timeZone);
const dateAr = formatEventDateTime(
event.ar?.startDateTime ?? event.en?.startDateTime,
'ar',
timeZone,
);
const mail = buildBilingualMail({
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { event: titleAr, date: dateAr },
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
branding,
});
return trySendMail(strapi, { to, ...mail });
} catch (error) {
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
return false;
}
}
+159
View File
@@ -0,0 +1,159 @@
/**
* event-reminders — the hourly cron task that sends reminder e-mails to
* confirmed registrations whose event starts within the configured lead time.
*
* Semantics are at-least-once: `reminderSentAt` is written ONLY after the
* e-mail sent successfully, so a failure on one run is retried on the next.
* One failing registration never stops the batch — every registration is
* wrapped in its own try/catch.
*
* Registered from src/index.ts only when `server.cron.enabled` is true.
*/
import type { Core } from '@strapi/strapi';
import { sendEventReminderMail } from './event-mail';
const REGISTRATION_UID = 'api::event-registration.event-registration';
const REMINDER_SETTINGS_UID = 'api::reminder-settings.reminder-settings';
const LOG_PREFIX = '[event-reminders]';
const DEFAULT_LEAD_HOURS = 48;
const DEFAULT_REMINDER_ENABLED = true;
const PAGE_SIZE = 50;
type ReminderSettings = {
enabled: boolean;
leadHours: number;
};
/**
* Read the dashboard-controlled settings, falling back to the seed defaults
* when the single type is missing or its values are empty.
*/
async function readReminderSettings(
strapi: Core.Strapi,
): Promise<ReminderSettings> {
try {
const settings = await strapi
.documents(REMINDER_SETTINGS_UID)
.findFirst({});
if (!settings) {
return { enabled: DEFAULT_REMINDER_ENABLED, leadHours: DEFAULT_LEAD_HOURS };
}
const leadHours =
typeof settings.leadHours === 'number' && settings.leadHours >= 0
? settings.leadHours
: DEFAULT_LEAD_HOURS;
return {
enabled: settings.enabled !== false,
leadHours,
};
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read reminder settings, using defaults: ${String(error)}`,
);
return { enabled: DEFAULT_REMINDER_ENABLED, leadHours: DEFAULT_LEAD_HOURS };
}
}
export async function runEventReminders({
strapi,
}: {
strapi: Core.Strapi;
}): Promise<void> {
const settings = await readReminderSettings(strapi);
if (!settings.enabled) {
strapi.log.debug(`${LOG_PREFIX} disabled in reminder settings, skipping.`);
return;
}
const now = new Date();
const horizon = new Date(now.getTime() + settings.leadHours * 3_600_000);
strapi.log.info(
`${LOG_PREFIX} running for events starting between ${now.toISOString()} and ${horizon.toISOString()}.`,
);
let page = 1;
let sent = 0;
let failed = 0;
while (true) {
const registrations = await strapi
.documents(REGISTRATION_UID)
.findMany({
filters: {
registrationState: { $eq: 'confirmed' },
reminderSentAt: { $null: true },
event: {
startDateTime: {
$gte: now.toISOString(),
$lte: horizon.toISOString(),
},
},
},
populate: { event: true },
pagination: { page, pageSize: PAGE_SIZE },
});
for (const registration of registrations) {
try {
const event = registration.event as
| { documentId?: string; id?: number }
| null
| undefined;
const reference = event?.documentId
? { documentId: event.documentId }
: event?.id !== undefined
? { id: event.id }
: undefined;
if (!event || !reference) {
strapi.log.warn(
`${LOG_PREFIX} registration ${registration.documentId} has no event, skipping.`,
);
continue;
}
const ok = await sendEventReminderMail({
strapi,
to: registration.email as string,
eventReference: reference,
});
if (!ok) {
failed += 1;
continue;
}
await strapi
.documents(REGISTRATION_UID)
.update({
documentId: registration.documentId as string,
data: { reminderSentAt: new Date().toISOString() },
});
sent += 1;
} catch (error) {
failed += 1;
strapi.log.error(
`${LOG_PREFIX} failed for registration ${registration.documentId}: ${String(error)}`,
);
}
}
if (registrations.length < PAGE_SIZE) break;
page += 1;
}
strapi.log.info(
`${LOG_PREFIX} finished: ${sent} reminder(s) sent, ${failed} failed.`,
);
}
+254
View File
@@ -0,0 +1,254 @@
/**
* markdown — a tiny markdown-to-HTML renderer for email bodies.
*
* Email templates are markdown in the CMS (components/email/template.json).
* This converts the subset the editors actually use — headings, paragraphs,
* bullet and numbered lists, blockquotes, plus inline bold / italic / code /
* links — into the inline-styled HTML email clients expect.
*
* There is deliberately NO generic HTML passthrough and no markdown
* dependency: the source is HTML-escaped FIRST, so anything that is not
* recognised converts to visible text rather than markup (the same position
* the web app's rich-text renderer takes). Links are the one exception — an
* `href` is emitted only when its scheme is http, https or mailto; anything
* else renders as its label, as plain text.
*
* `mdToText` renders the same blocks as plain text, so the message's plain
* part stays in step with the HTML part.
*/
const ESCAPE = /[&<>"']/g;
const ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
/** Escape text so the rest of this module can treat it as inert data. */
export function escapeHtml(value: string): string {
return value.replace(ESCAPE, (ch) => ESCAPE_MAP[ch]!);
}
/* ---------------------------------------------------------------------------
INLINE
------------------------------------------------------------------------ */
/** `**bold**`, `*italic*`, `` `code` ``, `[label](/href)`. */
const INLINE_PATTERN =
/(\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\))/g;
const LINK_PATTERN = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/;
/** Schemes an email may link to. Everything else renders as its label. */
const SAFE_LINK = /^(?:https?:|mailto:)/i;
/**
* Render the inline markup in one (already HTML-escaped) line into an HTML
* fragment. `href` values are not re-escaped here because the source was
* escaped up-front.
*/
function inlineToHtml(text: string): string {
return text
.split(INLINE_PATTERN)
.filter((part) => part !== '' && part !== undefined)
.map((part) => {
if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
return `<strong>${part.slice(2, -2)}</strong>`;
}
if (part.startsWith('__') && part.endsWith('__') && part.length > 4) {
return `<strong>${part.slice(2, -2)}</strong>`;
}
if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
return `<em>${part.slice(1, -1)}</em>`;
}
if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
return `<code style="font-family:Menlo,Consolas,monospace; font-size:0.9em; background:#f4f4f4; padding:1px 4px; border-radius:3px;">${part.slice(1, -1)}</code>`;
}
const link = LINK_PATTERN.exec(part);
if (link) {
const [, label, target] = link;
if (!SAFE_LINK.test(target)) return label;
return `<a href="${target}" style="color:#008764; text-decoration:underline;">${label}</a>`;
}
return part;
})
.join('');
}
/** Strip inline markup instead of turning it into tags (for the text part). */
function inlineToText(text: string): string {
return text
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/\*([^*\n]+)\*/g, '$1')
.replace(/`([^`\n]+)`/g, '$1')
.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '$1');
}
/* ---------------------------------------------------------------------------
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 blocks, mirroring the web app's
* `parseMarkdown` (apps/web/app/routes/_page-kit/rich-text.tsx).
*/
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 mail owns no h1, so CMS headings start at h2 and never pass 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;
}
/* ---------------------------------------------------------------------------
RENDERERS
------------------------------------------------------------------------ */
/**
* Convert an email template's markdown body to HTML. The input is escaped
* before parsing, so unrecognised syntax (or any raw HTML) surfaces as text.
*/
export function mdToHtml(source: string): string {
const blocks = parseMarkdown(source);
return blocks
.map((block) => {
switch (block.kind) {
case 'heading': {
const tag = `h${block.level}`;
const size =
block.level === 2 ? '20px' : block.level === 3 ? '17px' : '15px';
return `<${tag} style="margin:0 0 8px; font-size:${size}; line-height:1.35; color:#222;">${inlineToHtml(block.text)}</${tag}>`;
}
case 'quote': {
const text = inlineToHtml(block.text);
return `<blockquote style="margin:0 0 12px; padding:0 0 0 12px; border-left:2px solid #008764; color:#444;">${text}</blockquote>`;
}
case 'list': {
const tag = block.ordered ? 'ol' : 'ul';
const listStyle = block.ordered ? 'decimal' : 'disc';
const items = block.items
.map((item) => `<li style="margin:0 0 4px;">${inlineToHtml(item)}</li>`)
.join('');
return `<${tag} style="margin:0 0 12px; padding:0 0 0 18px; list-style:${listStyle};">${items}</${tag}>`;
}
default:
return `<p style="margin:0 0 12px; line-height:1.6;">${inlineToHtml(block.text)}</p>`;
}
})
.join('');
}
/**
* Convert an email template's markdown body to plain text, for the message's
* `text` part. Paragraphs are separated by one blank line, list items by "- "
* / "N. ", blockquotes just drop their marker.
*/
export function mdToText(source: string): string {
const blocks = parseMarkdown(source);
return blocks
.map((block) => {
switch (block.kind) {
case 'heading':
return block.text;
case 'quote':
return inlineToText(block.text);
case 'list':
return block.items
.map((item, index) =>
block.ordered
? `${index + 1}. ${inlineToText(item)}`
: `- ${inlineToText(item)}`
)
.join('\n');
default:
return inlineToText(block.text);
}
})
.join('\n\n');
}
+203
View File
@@ -0,0 +1,203 @@
/**
* waitlist-promotion — the cron task that honours the "we will email you if a
* place becomes available" promise.
*
* Registrations are stamped `confirmed` OR `waitlisted` at signup
* (event-registration/middlewares/sanitize-submission.ts). Nothing ever moved
* a `waitlisted` row to `confirmed` when a seat freed up, so the waitlist was
* a queue nobody drained. This job does it, first-come-first-served:
*
* 1. find every event that still has `waitlisted` registrations;
* 2. for each, lock the event row (the same serialisation the registration
* middleware uses, so a concurrent POST cannot race the count) and
* compute how many seats are left below capacity;
* 3. promote the OLDEST waitlisted registrations (`submittedAt` asc) into
* those seats, sending the standard confirmation mail first and flipping
* the row to `confirmed` ONLY after the mail was accepted.
*
* Semantics are at-least-once, like the reminder job: a row whose mail failed
* stays `waitlisted` and is retried on the next run. A waitlisted row with no
* email cannot be told anything, so it is left as-is for staff to deal with.
*
* Registered from src/index.ts only when `server.cron.enabled` is true.
*/
import type { Core } from '@strapi/strapi';
import { sendRegistrationConfirmation } from './event-mail';
const EVENT_UID = 'api::event.event';
const REGISTRATION_UID = 'api::event-registration.event-registration';
const LOG_PREFIX = '[waitlist-promotion]';
const PAGE_SIZE = 50;
/** The subset of the event that decides how many seats can be promoted into. */
type EventCapacity = {
documentId: string;
capacity: number | null;
};
/** How many places on this event are already taken, mirroring the middleware. */
const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise<number> =>
strapi.db.query(REGISTRATION_UID).count({
where: {
event: { documentId },
registrationState: 'confirmed',
},
});
/**
* Promote the oldest waiting registrants of ONE event into its free seats.
*
* The lock mirrors `sanitize-submission`: registering and promoting both
* serialize on the event row, so a concurrent request cannot steal a seat the
* promotion just counted. Count and updates run inside the same transaction.
*/
async function promoteEvent(
strapi: Core.Strapi,
eventDocumentId: string,
): Promise<{ promoted: number; failed: number }> {
return strapi.db.transaction(async ({ trx }) => {
await trx('events')
.select('id')
.where({ document_id: eventDocumentId })
.orderBy('id')
.forUpdate()
.first();
const event = (await strapi.documents(EVENT_UID).findFirst({
filters: { documentId: eventDocumentId },
status: 'published',
})) as EventCapacity | null;
if (!event) {
return { promoted: 0, failed: 0 };
}
const capacity =
typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null;
// No finite capacity means there is never a seat to free up.
if (capacity === null) {
return { promoted: 0, failed: 0 };
}
const taken = await countConfirmed(strapi, eventDocumentId);
let slots = capacity - taken;
if (slots <= 0) {
return { promoted: 0, failed: 0 };
}
// Oldest waiters first; the page size is a safety bound, the loop stops
// at `slots` anyway.
const registrations = await strapi
.documents(REGISTRATION_UID)
.findMany({
filters: {
event: { documentId: eventDocumentId },
registrationState: { $eq: 'waitlisted' },
},
sort: 'submittedAt:asc',
pagination: { page: 1, pageSize: PAGE_SIZE },
});
let promoted = 0;
let failed = 0;
for (const registration of registrations) {
if (slots <= 0) break;
const email = (registration as { email?: unknown }).email;
if (typeof email !== 'string' || email.length === 0) {
strapi.log.warn(
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
);
continue;
}
const ok = await sendRegistrationConfirmation({
strapi,
to: email,
state: 'confirmed',
eventReference: { documentId: eventDocumentId },
});
if (!ok) {
failed += 1;
continue;
}
await strapi
.documents(REGISTRATION_UID)
.update({
documentId: registration.documentId as string,
data: { registrationState: 'confirmed' },
});
promoted += 1;
slots -= 1;
}
return { promoted, failed };
});
}
/**
* Run one pass over every event that still has waitlisted registrations.
* One failing event never stops the batch; totals are logged.
*/
export async function runWaitlistPromotion({
strapi,
}: {
strapi: Core.Strapi;
}): Promise<void> {
const eventIds = new Set<string>();
let page = 1;
while (true) {
const registrations = await strapi
.documents(REGISTRATION_UID)
.findMany({
filters: { registrationState: { $eq: 'waitlisted' } },
populate: { event: true },
pagination: { page, pageSize: PAGE_SIZE },
});
for (const registration of registrations) {
const event = (registration as { event?: { documentId?: unknown } }).event;
if (typeof event?.documentId === 'string' && event.documentId.length > 0) {
eventIds.add(event.documentId);
}
}
if (registrations.length < PAGE_SIZE) break;
page += 1;
}
if (eventIds.size === 0) {
strapi.log.info(`${LOG_PREFIX} no events with a waitlist, skipping.`);
return;
}
let promotedTotal = 0;
let failedTotal = 0;
for (const eventId of eventIds) {
try {
const { promoted, failed } = await promoteEvent(strapi, eventId);
promotedTotal += promoted;
failedTotal += failed;
} catch (error) {
failedTotal += 1;
strapi.log.error(
`${LOG_PREFIX} failed for event ${eventId}: ${String(error)}`,
);
}
}
strapi.log.info(
`${LOG_PREFIX} finished: ${promotedTotal} waitlisted registration(s) promoted, ${failedTotal} mail(s) failed (retried next run).`,
);
}
+706
View File
@@ -0,0 +1,706 @@
/* ============================================================================
SEED - APPROVED CONTENT
----------------------------------------------------------------------------
Everything in this file is transcribed from docs/, in both languages, and
nothing in it is invented. That is the whole point of keeping it apart from
demo.ts: this content is safe to publish.
SOURCES
docs/SHARPER_Website_Draft_Section_EN_AR.docx sections 1-9, EN and AR
columns, which is why no
Arabic string here is a
machine translation
docs/SHARPER Home Page .docx the launch article, EN/AR
docs/UEC_SHARPER_Website_TOR V3.docx the consortium and the
project's purpose
WHAT docs/ DOES NOT SUPPLY, AND IS THEREFORE NOT HERE
* Figures. Every number in the draft is bracketed and marked "TBD ... must
not be published until confirmed". There is no figures band on the site
and no `stats` component in the CMS - both were removed rather than left
as an empty invitation to fill one in.
* The project email. The draft writes it "[project email - e.g.
info@sharper.ly]", an example rather than an address, so
`contact-page.generalEmail` is left empty.
* The Facebook URL and the SHARP-legacy link. Both are bracketed
placeholders, and `shared.link` requires an href, so neither link is
written at all rather than written to nowhere.
* Consortium postal addresses - "[addresses/contacts TBD]".
* Any CSO partner, event, resource or question. The draft's examples are
all written "[CSO Name]", "[Date]", "[Report title]". Those live in
demo.ts and are opt-in.
THE TWO JUDGEMENT CALLS, BOTH FLAGGED AT THEIR FIELD
* `activityStatus` is required by the schema and the draft states no
status per activity stream.
* The launch article's year is derived, not given.
========================================================================= */
import type { Localised, SeedData } from './upsert';
/** Slug -> content, for the collection types the approved set writes. */
export type SeedEntries = ReadonlyArray<{
readonly slug: string;
readonly data: Localised<SeedData>;
}>;
/* ---------------------------------------------------------------------------
SITE SETTINGS
------------------------------------------------------------------------ */
export const SITE_SETTINGS: Localised<SeedData> = {
en: {
siteName: 'SHARP-ER',
tagline: 'Shared Action on Rule of Law Progress – Exit Route',
euFundingStatement: 'Funded by the European Union',
implementedByLabel: 'Implemented by',
},
ar: {
siteName: 'SHARP-ER',
tagline: 'العمل المشترك لتعزيز سيادة القانون – مسار الخروج',
euFundingStatement: 'بتمويل من الاتحاد الأوروبي',
implementedByLabel: 'ينفّذه',
},
};
/* ---------------------------------------------------------------------------
THE CONSORTIUM
----------------------------------------------------------------------------
Draft section 1 writes each member as "<name> - <role> - <deliverable>".
The Latin names are kept in the Arabic column exactly as the draft keeps
them. No website is seeded: the draft names none.
------------------------------------------------------------------------ */
export const CONSORTIUM: SeedEntries = [
{
slug: 'acted',
data: {
en: {
name: 'Acted',
slug: 'acted',
partnerRole: 'consortium-lead',
description:
'Lead implementing partner — overall project management and civil society sub-projects.',
},
ar: {
name: 'Acted',
slug: 'acted',
partnerRole: 'consortium-lead',
description: 'الشريك المنفّذ الرئيسي — الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني.',
},
},
},
{
slug: 'united-efforts-consultancy',
data: {
en: {
name: 'United Efforts Consultancy (UEC)',
slug: 'united-efforts-consultancy',
partnerRole: 'consortium-partner',
description: 'Network strengthening, dialogue, and peer-learning.',
},
ar: {
name: 'United Efforts Consultancy (UEC)',
slug: 'united-efforts-consultancy',
partnerRole: 'consortium-partner',
description: 'تعزيز الشبكة، والحوار، والتعلّم بين الأقران.',
},
},
},
{
slug: 'stichting-bureau-ensemble',
data: {
en: {
name: 'Stichting Bureau Ensemble (SBE)',
slug: 'stichting-bureau-ensemble',
partnerRole: 'consortium-partner',
description: 'Research, perception data, and knowledge publications.',
},
ar: {
name: 'Stichting Bureau Ensemble (SBE)',
slug: 'stichting-bureau-ensemble',
partnerRole: 'consortium-partner',
description: 'البحث، وبيانات الاستطلاع، والمنشورات المعرفية.',
},
},
},
];
/** Slugs, so later steps can wire relations without repeating the strings. */
export const PARTNER_SLUGS = {
acted: 'acted',
uec: 'united-efforts-consultancy',
sbe: 'stichting-bureau-ensemble',
} as const;
/* ---------------------------------------------------------------------------
TOPICS
----------------------------------------------------------------------------
The taxonomy the site filters by. Each name and description is one of the
draft's own area headings - the three pillars of section 1 and two of the
five activity streams of section 3 - so the vocabulary is the project's,
not one this seed made up.
------------------------------------------------------------------------ */
export const TOPICS: SeedEntries = [
{
slug: 'rule-of-law',
data: {
en: {
name: 'Rule of Law',
slug: 'rule-of-law',
description:
'Supporting local institutions to improve access to justice and transparency.',
},
ar: {
name: 'سيادة القانون',
slug: 'rule-of-law',
description: 'دعم المؤسسات المحلية لتحسين الوصول إلى العدالة وتعزيز الشفافية.',
},
},
},
{
slug: 'civil-society',
data: {
en: {
name: 'Civil Society',
slug: 'civil-society',
description:
'Building the capacity of CSOs to strengthen their role in oversight and accountability.',
},
ar: {
name: 'المجتمع المدني',
slug: 'civil-society',
description: 'بناء قدرات منظمات المجتمع المدني لتعزيز دورها في الرقابة والمساءلة.',
},
},
},
{
slug: 'local-governance',
data: {
en: {
name: 'Local Governance',
slug: 'local-governance',
description:
'Enhancing collaboration between municipalities and local actors to improve service delivery.',
},
ar: {
name: 'الحوكمة المحلية',
slug: 'local-governance',
description: 'تعزيز التعاون بين البلديات والجهات الفاعلة المحلية لتحسين تقديم الخدمات.',
},
},
},
{
slug: 'dialogue',
data: {
en: {
name: 'Dialogue',
slug: 'dialogue',
description:
'SUQ dialogues and Civil Society Law Sessions that bring civic and government actors to the same table.',
},
ar: {
name: 'الحوار',
slug: 'dialogue',
description: 'حوارات SUQ وجلسات قانون المجتمع المدني التي تجمع الفاعلين المدنيين والحكوميين حول طاولة واحدة.',
},
},
},
{
slug: 'human-security',
data: {
en: {
name: 'Human Security',
slug: 'human-security',
description: 'Sub-projects designed to foster human security at the community level.',
},
ar: {
name: 'الأمن البشري',
slug: 'human-security',
description: 'مشاريع فرعية مصمَّمة لتعزيز الأمن البشري على المستوى المجتمعي.',
},
},
},
];
export const TOPIC_SLUGS = {
ruleOfLaw: 'rule-of-law',
civilSociety: 'civil-society',
localGovernance: 'local-governance',
dialogue: 'dialogue',
humanSecurity: 'human-security',
} as const;
/* ---------------------------------------------------------------------------
ACTIVITIES - draft section 3
----------------------------------------------------------------------------
Five streams, and the draft names all five along with the partner that
implements each. They map one-to-one onto the `activityStream` enum, which
is where that enum came from.
`activityStatus` is the one field with no source. The schema requires it and
the draft states no status per stream, so every activity is seeded
`ongoing`: the draft heads the section "here's where the project stands
today" and describes all five in the present tense. It is a default for
editors to correct, not a claim from docs/.
------------------------------------------------------------------------ */
const activity = (
slug: string,
stream: string,
partners: readonly string[],
topics: readonly string[],
en: { title: string; summary: string },
ar: { title: string; summary: string }
) => ({
slug,
partners,
topics,
data: {
en: {
title: en.title,
slug,
summary: en.summary,
activityStream: stream,
activityStatus: 'ongoing',
},
ar: {
title: ar.title,
slug,
summary: ar.summary,
activityStream: stream,
activityStatus: 'ongoing',
},
} satisfies Localised<SeedData>,
});
export const ACTIVITIES = [
activity(
'the-sharp-network',
'sharp-network',
[PARTNER_SLUGS.uec],
[TOPIC_SLUGS.civilSociety],
{
title: 'The SHARP Network',
summary:
'Regional meetings, international events, and peer-to-peer learning that keep the civil society network connected.',
},
{
title: 'شبكة SHARP',
summary: 'اجتماعات إقليمية وفعاليات دولية وتعلّم بين الأقران تُبقي شبكة المجتمع المدني على تواصل.',
}
),
activity(
'cso-sub-projects-and-policy-to-rights',
'cso-subprojects-p2r',
[PARTNER_SLUGS.acted],
[TOPIC_SLUGS.civilSociety, TOPIC_SLUGS.ruleOfLaw],
{
title: 'CSO Sub-Projects & Policy-to-Rights',
summary:
'Collective sub-projects selected through open Calls for Expression of Interest, fostering cross-country cooperation, plus Policy-to-Rights (P2R) projects.',
},
{
title: 'المشاريع الفرعية لمنظمات المجتمع المدني ومشاريع P2R',
summary:
'مشاريع فرعية جماعية تُختار عبر دعوات مفتوحة للتعبير عن الاهتمام لتعزيز التعاون العابر للمناطق، إضافة إلى مشاريع “من السياسة إلى الحقوق”.',
}
),
activity(
'suq-dialogues-and-civil-society-law-sessions',
'suq-dialogues',
[PARTNER_SLUGS.uec, PARTNER_SLUGS.acted],
[TOPIC_SLUGS.dialogue, TOPIC_SLUGS.ruleOfLaw],
{
title: 'SUQ Dialogues & Civil Society Law Sessions',
summary:
'Search, Understand, Question (SUQ) dialogues driven by local needs, dialogue-facilitation training, and joint sessions with the Commission on Civil Society (CCS).',
},
{
title: 'حوارات SUQ وجلسات قانون المجتمع المدني',
summary:
'حوارات “ابحث، افهم، اسأل” (SUQ) مدفوعة باحتياجات محلية، وتدريب على تيسير الحوار، وجلسات مشتركة مع لجنة المجتمع المدني (CCS).',
}
),
activity(
'human-security-initiatives',
'human-security',
[PARTNER_SLUGS.uec],
[TOPIC_SLUGS.humanSecurity],
{
title: 'Human Security Initiatives',
summary:
'16 individual sub-projects designed to foster human security at the community level.',
},
{
title: 'مبادرات الأمن البشري',
summary: '16 مشروعًا فرديًا فرعيًا مصمَّمًا لتعزيز الأمن البشري على المستوى المجتمعي.',
}
),
activity(
'research-and-perception-data',
'research',
[PARTNER_SLUGS.sbe],
[TOPIC_SLUGS.localGovernance],
{
title: 'Research & Perception Data',
summary: 'Nationwide perception-based data collection, analysis, and dissemination.',
},
{
title: 'البحث وبيانات الاستطلاع',
summary: 'جمع بيانات استطلاعية على المستوى الوطني وتحليلها ونشرها.',
}
),
] as const;
/* ---------------------------------------------------------------------------
THE LAUNCH ARTICLE - docs/SHARPER Home Page .docx
----------------------------------------------------------------------------
The one piece of real news the project has handed over, written out in full
in both languages.
THE DATE: the document says "Tuesday, June 30" and gives no year. Within the
project's own window - December 2025 to December 2027 - 30 June falls on a
Tuesday in 2026 and in no other year, so 2026-06-30 is derived from the
source rather than chosen.
The source's closing line ("Visit the SHARP-ER website and follow our social
media channels...") is left out: it addresses a reader on social media and
asks them to come to this site, which is not something to print on it.
No `authorOrganisation`: the document attributes the article to no one, and
a byline is not a thing to guess at.
------------------------------------------------------------------------ */
const LAUNCH_SLUG = 'sharp-er-project-officially-launches-in-libya';
export const LAUNCH_ARTICLE = {
slug: LAUNCH_SLUG,
topics: [TOPIC_SLUGS.civilSociety, TOPIC_SLUGS.ruleOfLaw],
data: {
en: {
title: 'SHARP-ER Project Officially Launches in Libya',
slug: LAUNCH_SLUG,
category: 'sharper-news',
publishDate: '2026-06-30',
featured: true,
excerpt:
'The launch brought together the European Union Delegation, three ministries, civil society organisations, partners and stakeholders from across Libya, and opened the Expression of Interest.',
body: [
'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.',
'This project is funded by the European Union in Libya.',
].join('\n\n'),
},
ar: {
title: 'انطلاق مشروع SHARP-ER رسميًا في ليبيا',
slug: LAUNCH_SLUG,
category: 'sharper-news',
publishDate: '2026-06-30',
featured: true,
excerpt:
'جمعت فعالية الإطلاق بعثة الاتحاد الأوروبي وثلاث وزارات ومنظمات المجتمع المدني والشركاء وأصحاب المصلحة من مختلف أنحاء ليبيا، وشهدت فتح باب التعبير عن الاهتمام.',
body: [
'انطلقت يوم الثلاثاء 30 يونيو رسميًا فعاليات مشروع SHARP-ER خلال حدث الإطلاق الذي جمع ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، إلى جانب منظمات المجتمع المدني والشركاء والجهات الفاعلة من مختلف أنحاء ليبيا.',
'وشكّل الحدث بداية مهمة للمشروع، حيث تم إطلاق الموقع الإلكتروني الرسمي، والإعلان عن فتح باب التعبير عن الاهتمام (EOI)، إلى جانب استعراض أهداف المشروع والفرص والأنشطة القادمة. وتركّز هذه الأنشطة على تعزيز التعاون والشراكات وبناء القدرات على المستوى المحلي.',
'نتوجه بالشكر إلى جميع المشاركين على حضورهم ومساهماتهم القيّمة. ونتطلع إلى العمل معًا خلال المرحلة المقبلة لدعم المبادرات المحلية وتحقيق أثر مستدام في مختلف أنحاء ليبيا.',
'المشروع ممول من الاتحاد الأوروبي في ليبيا.',
].join('\n\n'),
},
} satisfies Localised<SeedData>,
};
/* ---------------------------------------------------------------------------
PAGES
----------------------------------------------------------------------------
The single types. Each hero, body and component list below is the draft's
own wording for that section; the home page's hero is the Claude Design
draft's, which is the same content the web app bundles as its fallback, so
the CMS and the fallback say the same thing rather than drifting apart.
------------------------------------------------------------------------ */
export const HOME_PAGE: Localised<SeedData> = {
en: {
hero: {
/* No `eyebrow`. The design draws a status pill reading "Now open -
Funding calls for Libyan organisations"; whether a call is open on any
given day is a fact about the project, not about the design, so the
pill stays unwritten until an editor writes it. */
headline: 'Advancing Justice and Accountability, Led by Libyan Communities',
subheading:
'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — works with civil society organisations, municipalities, and community leaders across Libya to strengthen access to justice, accountability, and local governance. Funded by the European Union, running to December 2027.',
},
/* The band under the hero. Seeded so an editor opening Home Page finds the
words already in a field they can change, rather than wondering where
the page is getting them from - the web app still falls back to the
bundled draft copy if this is ever emptied. `images` is left alone: the
seed uploads nothing. */
approach: {
eyebrow: 'Nationwide, Libya',
heading: 'A nationwide project, delivered locally',
lead: 'Local organisations lead the design and delivery of the work. SHARP-ER partners with Libyan civil society, municipalities, and community leaders — through funded sub-projects, training, and open dialogue between communities and the institutions that serve them.',
},
partnersHeadline: 'The consortium',
seo: {
metaTitle: 'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route',
metaDescription:
'A nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.',
},
},
ar: {
hero: {
headline: 'تعزيز العدالة والمساءلة بقيادة المجتمعات الليبية',
subheading:
'يعمل SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج — مع منظمات المجتمع المدني والبلديات وقادة المجتمعات في مختلف أنحاء ليبيا على تعزيز الوصول إلى العدالة والمساءلة والحوكمة المحلية. بتمويل من الاتحاد الأوروبي، ويستمر حتى ديسمبر 2027.',
},
approach: {
eyebrow: 'على مستوى ليبيا بأكملها',
heading: 'مشروع وطني يُنفَّذ محليًا',
lead: 'تتولّى المنظمات المحلية تصميم العمل وتنفيذه. ويعمل SHARP-ER بالشراكة مع المجتمع المدني الليبي والبلديات وقادة المجتمعات، من خلال المشاريع الفرعية المموَّلة، والتدريب، والحوار المفتوح بين المجتمعات والمؤسسات التي تخدمها.',
},
partnersHeadline: 'الائتلاف المنفّذ',
seo: {
metaTitle: 'SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج',
metaDescription:
'مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات في ليبيا في تعزيز العدالة والمساءلة والحوكمة الرشيدة.',
},
},
};
/**
* The About page. `consortium` is built in index.ts, where the partner
* documentIds are known; everything else is here.
*/
export const ABOUT_PAGE: Localised<SeedData> = {
en: {
hero: {
eyebrow: 'About the project',
headline: 'Strengthening the Rule of Law, Together',
subheading:
'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.',
].join('\n\n'),
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.',
},
],
legacyHeadline: 'SHARP I and II (2019–2024)',
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.',
/* No `legacyLink`: the draft marks the target "[link: read about the SHARP
legacy]" and gives no URL, and `shared.link` requires an href. */
seo: {
metaTitle: 'About SHARP-ER',
metaDescription:
'SHARP-ER is implemented by Acted with United Efforts Consultancy and Stichting Bureau Ensemble, and funded by the European Union, from December 2025 to December 2027.',
},
},
ar: {
hero: {
eyebrow: 'عن المشروع',
headline: 'نحو تعزيز سيادة القانون، معًا',
subheading:
'SHARP-ER (العمل المشترك لتعزيز سيادة القانون – مسار الخروج) هو مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة.',
},
body: [
'يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027، ويُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي. يعمل المشروع على مستوى ليبيا بأكملها، بالتعاون مع منظمات المجتمع المدني والبلديات والقيادات المجتمعية، من أجل تحسين الوصول إلى العدالة، وبناء قدرات المجتمع المدني على مساءلة المؤسسات، وتحسين الحوكمة المحلية وتقديم الخدمات.',
].join('\n\n'),
pillars: [
{
title: 'تعزيز سيادة القانون',
description: 'دعم المؤسسات المحلية لتحسين الوصول إلى العدالة وتعزيز الشفافية.',
},
{
title: 'تمكين المجتمع المدني',
description: 'بناء قدرات منظمات المجتمع المدني لتعزيز دورها في الرقابة والمساءلة.',
},
{
title: 'دعم الحوكمة المحلية',
description: 'تعزيز التعاون بين البلديات والجهات الفاعلة المحلية لتحسين تقديم الخدمات.',
},
],
legacyHeadline: 'مشروعا SHARP الأول والثاني (2019–2024)',
legacyBody:
'يبني SHARP-ER على الأساس الذي أرساه مشروعا SHARP الأول والثاني (2019–2024)، حاملًا معه شبكة الشركاء من المجتمع المدني وممارسة الحوار إلى مرحلة جديدة تركّز على الاستدامة ومسار الخروج.',
seo: {
metaTitle: 'عن مشروع SHARP-ER',
metaDescription:
'يُنفَّذ SHARP-ER من قِبل Acted بالشراكة مع United Efforts Consultancy وStichting Bureau Ensemble، وبتمويل من الاتحاد الأوروبي، من ديسمبر 2025 إلى ديسمبر 2027.',
},
},
};
/** The role chip and its line on the About page's consortium cards. */
export const CONSORTIUM_ROLES: Readonly<
Record<string, Localised<{ role: string; roleDescription: string }>>
> = {
[PARTNER_SLUGS.acted]: {
en: {
role: 'Lead implementing partner',
roleDescription: 'Overall project management and civil society sub-projects.',
},
ar: {
role: 'الشريك المنفّذ الرئيسي',
roleDescription: 'الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني.',
},
},
[PARTNER_SLUGS.uec]: {
en: {
role: 'Consortium partner',
roleDescription: 'Network strengthening, dialogue, and peer-learning.',
},
ar: {
role: 'شريك في الائتلاف',
roleDescription: 'تعزيز الشبكة، والحوار، والتعلّم بين الأقران.',
},
},
[PARTNER_SLUGS.sbe]: {
en: {
role: 'Consortium partner',
roleDescription: 'Research, perception data, and knowledge publications.',
},
ar: {
role: 'شريك في الائتلاف',
roleDescription: 'البحث، وبيانات الاستطلاع، والمنشورات المعرفية.',
},
},
};
export const WHAT_WE_DO_PAGE: Localised<SeedData> = {
en: {
hero: {
headline: 'A Participatory Approach to Justice and Governance',
subheading: 'We work directly with civil society, municipalities, and communities — not around them.',
},
intro: '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.',
},
],
/* The four clauses of the draft's "Our approach rests on four
principles..." sentence, and nothing else: the design gives each tile an
explanatory line that appears in no approved document. */
principles: [
{ title: 'Working with local actors' },
{ title: 'Promoting sustainable — not one-off — solutions' },
{ title: 'Connecting civil society with institutions' },
{ title: 'Supporting gradual, realistic institutional reform' },
],
guidingValues: [
'Dialogue',
'Community Leadership',
'Learning',
'Inclusion',
'Responsibility & Accountability',
],
seo: {
metaTitle: 'What We Do — SHARP-ER',
metaDescription:
'Capacity building, institutional support, community initiatives, and dialogue and coordination: the four connected areas of work behind SHARP-ER.',
},
},
ar: {
hero: {
headline: 'نهج تشاركي في العدالة والحوكمة',
subheading: 'نعمل مباشرة مع المجتمع المدني والبلديات والمجتمعات المحلية، لا من حولها.',
},
intro: 'يقوم عمل SHARP-ER على أربعة محاور مترابطة.',
workstreams: [
{
title: 'بناء القدرات',
description:
'تدريب منظمات المجتمع المدني والجهات المحلية على الحوكمة والعدالة والإدارة، وتزويد شبكة SHARP بالتعلّم بين الأقران.',
},
{
title: 'الدعم المؤسسي',
description: 'تعزيز الأنظمة والإجراءات وقنوات الحوار التي تربط المؤسسات المحلية بالمجتمعات التي تخدمها.',
},
{
title: 'المبادرات المجتمعية',
description:
'تمويل ودعم مشاريع فرعية تقودها منظمات المجتمع المدني، ومشاريع “من السياسة إلى الحقوق” (P2R)، ومبادرات الأمن البشري التي يصممها الفاعلون المحليون ويقودونها.',
},
{
title: 'الحوار والتنسيق',
description:
'تنظيم حوارات SUQ (ابحث، افهم، اسأل) وجلسات قانون المجتمع المدني التي تجمع الفاعلين المدنيين والحكوميين حول طاولة واحدة.',
},
],
principles: [
{ title: 'العمل مع الفاعلين المحليين' },
{ title: 'تعزيز الحلول المستدامة لا الآنية' },
{ title: 'الربط بين المجتمع المدني والمؤسسات' },
{ title: 'دعم إصلاح مؤسسي تدريجي وواقعي' },
],
guidingValues: ['الحوار', 'القيادة المجتمعية', 'التعلّم', 'الشمول', 'المساءلة والمسؤولية'],
seo: {
metaTitle: 'ماذا نفعل — SHARP-ER',
metaDescription:
'بناء القدرات، والدعم المؤسسي، والمبادرات المجتمعية، والحوار والتنسيق: المحاور الأربعة المترابطة التي يقوم عليها عمل SHARP-ER.',
},
},
};
export const CONTACT_PAGE: Localised<SeedData> = {
en: {
hero: {
headline: 'Get in Touch',
subheading: 'Questions about SHARP-ER, a partnership, or a specific activity? Reach out.',
},
intro: 'Questions about SHARP-ER, a partnership, or a specific activity? Reach out.',
websiteLabel: 'sharper.ly',
/* No `generalEmail`, no `socialLinks`, no `consortiumContacts`: the draft
writes all three as bracketed placeholders. */
seo: {
metaTitle: 'Contact SHARP-ER',
metaDescription: 'Get in touch with the SHARP-ER consortium about the project, a partnership, or a specific activity.',
},
},
ar: {
hero: {
headline: 'تواصل معنا',
subheading: 'لديك استفسار حول SHARP-ER، أو شراكة محتملة، أو نشاط معيّن؟ راسلنا.',
},
intro: 'لديك استفسار حول SHARP-ER، أو شراكة محتملة، أو نشاط معيّن؟ راسلنا.',
websiteLabel: 'sharper.ly',
seo: {
metaTitle: 'تواصل مع SHARP-ER',
metaDescription: 'تواصل مع ائتلاف SHARP-ER بشأن المشروع، أو شراكة محتملة، أو نشاط معيّن.',
},
},
};
+925
View File
@@ -0,0 +1,925 @@
/* ============================================================================
SEED - DEMO FIXTURES
----------------------------------------------------------------------------
INVENTED CONTENT. Nothing in this file comes from docs/ and nothing in it is
true. It exists so the listings, filters, cards, empty states and the logo
marquee can be looked at with something in them, on a development machine.
HOW IT IS KEPT APART FROM REAL CONTENT
1. Opt-in. `pnpm seed` never writes it; only `pnpm seed --demo` does.
2. Refused in production. The seed will not write demo content when
NODE_ENV is production (see index.ts), whatever flags it is given.
3. Tagged in the data itself. Every slug starts `demo-`, so every demo
URL says so - /en/news-and-updates/demo-... - and `--demo --clean`
finds them again by that prefix alone, with nothing remembered between
runs. The FAQ type has no slug, so its demo entries are matched by
their exact questions instead.
The names are deliberately generic ("Coastal Justice Forum") rather than
plausible-sounding Libyan organisations: an invented name that could be
mistaken for a real partner is the thing to avoid. Cities are real places,
used only as locations.
DATES are computed from the moment the seed runs, so a freshly seeded site
always has events in the past, in progress and still to come.
========================================================================= */
import type { Localised, SeedData } from './upsert';
/** Every demo slug starts with this, and that is how `--clean` finds them. */
export const DEMO_PREFIX = 'demo-';
export interface DemoEntry {
readonly slug: string;
readonly topics?: readonly string[];
readonly regions?: readonly string[];
readonly partners?: readonly string[];
readonly data: Localised<SeedData>;
}
const DAY = 24 * 60 * 60 * 1000;
/** An ISO date `days` from the reference point, date only. */
const onDay = (from: Date, days: number): string =>
new Date(from.getTime() + days * DAY).toISOString().slice(0, 10);
/** The same, with a time of day, for the datetime fields. */
const atHour = (from: Date, days: number, hour: number): string => {
const when = new Date(from.getTime() + days * DAY);
when.setUTCHours(hour, 0, 0, 0);
return when.toISOString();
};
/* ---------------------------------------------------------------------------
REGIONS
------------------------------------------------------------------------ */
export const DEMO_REGIONS: readonly DemoEntry[] = [
['tripoli', 'Tripoli', 'طرابلس'],
['benghazi', 'Benghazi', 'بنغازي'],
['misrata', 'Misrata', 'مصراتة'],
['sebha', 'Sebha', 'سبها'],
['zawiya', 'Zawiya', 'الزاوية'],
].map(([key, en, ar]) => {
const slug = `${DEMO_PREFIX}${key}`;
return {
slug,
data: {
en: { name: en as string, slug },
ar: { name: ar as string, slug },
},
};
});
export const DEMO_REGION_SLUGS = DEMO_REGIONS.map((region) => region.slug);
/* ---------------------------------------------------------------------------
THE CIVIL SOCIETY NETWORK
----------------------------------------------------------------------------
Twelve organisations, which is what the home page's logo wall needs to
become a marquee rather than a grid (it wants at least eight). None of them
has a logo: an invented organisation with an invented mark would be a step
too far, and the tile's no-logo state - its name set as text - is a state
the design draws anyway.
------------------------------------------------------------------------ */
const csoPartner = (
key: string,
en: { name: string; description: string },
ar: { name: string; description: string },
role: 'cso-partner' | 'institution' = 'cso-partner'
): DemoEntry => {
const slug = `${DEMO_PREFIX}${key}`;
return {
slug,
data: {
en: { name: en.name, slug, description: en.description, partnerRole: role, country: 'Libya' },
ar: { name: ar.name, slug, description: ar.description, partnerRole: role, country: 'ليبيا' },
},
};
};
export const DEMO_PARTNERS: readonly DemoEntry[] = [
csoPartner(
'coastal-justice-forum',
{ name: 'Coastal Justice Forum', description: 'Legal awareness sessions for residents of the coastal municipalities.' },
{ name: 'منتدى العدالة الساحلي', description: 'جلسات توعية قانونية لسكان البلديات الساحلية.' }
),
csoPartner(
'southern-governance-network',
{ name: 'Southern Governance Network', description: 'Bringing municipal councils and residents together in the south.' },
{ name: 'شبكة الحوكمة الجنوبية', description: 'تقريب المجالس البلدية من السكان في الجنوب.' }
),
csoPartner(
'open-municipality-initiative',
{ name: 'Open Municipality Initiative', description: 'Publishing municipal budgets and service plans in plain language.' },
{ name: 'مبادرة البلدية المفتوحة', description: 'نشر ميزانيات البلديات وخطط الخدمات بلغة مبسّطة.' }
),
csoPartner(
'womens-participation-centre',
{ name: 'Women’s Participation Centre', description: 'Supporting women’s participation in local decision-making.' },
{ name: 'مركز المشاركة النسائية', description: 'دعم مشاركة النساء في صنع القرار المحلي.' }
),
csoPartner(
'youth-civic-lab',
{ name: 'Youth Civic Lab', description: 'Civic education and volunteering programmes for young people.' },
{ name: 'مختبر الشباب المدني', description: 'برامج تربية مدنية وتطوّع للشباب.' }
),
csoPartner(
'community-mediation-group',
{ name: 'Community Mediation Group', description: 'Mediation between neighbourhoods and local service providers.' },
{ name: 'مجموعة الوساطة المجتمعية', description: 'الوساطة بين الأحياء ومقدّمي الخدمات المحليين.' }
),
csoPartner(
'legal-aid-collective',
{ name: 'Legal Aid Collective', description: 'Free first-line legal advice and referral.' },
{ name: 'تجمّع المساعدة القانونية', description: 'استشارات قانونية أولية مجانية وإحالة.' }
),
csoPartner(
'transparency-watch-libya',
{ name: 'Transparency Watch', description: 'Monitoring public procurement at municipal level.' },
{ name: 'مرصد الشفافية', description: 'مراقبة المشتريات العامة على مستوى البلديات.' }
),
csoPartner(
'desert-communities-association',
{ name: 'Desert Communities Association', description: 'Human-security initiatives in remote communities.' },
{ name: 'جمعية مجتمعات الصحراء', description: 'مبادرات الأمن البشري في المجتمعات النائية.' }
),
csoPartner(
'civic-media-workshop',
{ name: 'Civic Media Workshop', description: 'Training citizen reporters to cover local governance.' },
{ name: 'ورشة الإعلام المدني', description: 'تدريب المراسلين المواطنين على تغطية الحوكمة المحلية.' }
),
csoPartner(
'municipal-council-of-tripoli-centre',
{ name: 'Municipal Council — Tripoli Centre', description: 'Local institution partnering on service-delivery dialogue.' },
{ name: 'المجلس البلدي — وسط طرابلس', description: 'مؤسسة محلية شريكة في حوار تقديم الخدمات.' },
'institution'
),
csoPartner(
'municipal-council-of-benghazi',
{ name: 'Municipal Council — Benghazi', description: 'Local institution partnering on transparency and planning.' },
{ name: 'المجلس البلدي — بنغازي', description: 'مؤسسة محلية شريكة في الشفافية والتخطيط.' },
'institution'
),
];
export const DEMO_PARTNER_SLUGS = DEMO_PARTNERS.map((partner) => partner.slug);
/* ---------------------------------------------------------------------------
FAQs
----------------------------------------------------------------------------
No slug on this content type, so `--clean` matches these questions exactly.
Change a question here and the old one stops being cleanable - which is the
point: an edited question is an editor's question, not the seed's.
------------------------------------------------------------------------ */
const faq = (order: number, en: [string, string], ar: [string, string]): Localised<SeedData> => ({
en: { question: en[0], answer: en[1], order },
ar: { question: ar[0], answer: ar[1], order },
});
export const DEMO_FAQS: readonly Localised<SeedData>[] = [
faq(
1,
['Who can apply to a SHARP-ER call?', 'Calls are open to civil society organisations registered in Libya. Each call lists its own eligibility criteria on its page.'],
['من يمكنه التقديم على دعوات SHARP-ER؟', 'الدعوات مفتوحة لمنظمات المجتمع المدني المسجّلة في ليبيا. وتُدرج كل دعوة شروط الأهلية الخاصة بها على صفحتها.']
),
faq(
2,
['In which languages can I apply?', 'Applications are accepted in Arabic and in English. Neither is preferred over the other.'],
['بأي لغة يمكنني التقديم؟', 'تُقبل الطلبات باللغتين العربية والإنجليزية، ولا تُفضَّل إحداهما على الأخرى.']
),
faq(
3,
['What is a SUQ dialogue?', 'SUQ stands for Search, Understand, Question. It is a facilitated session that brings residents together with the institutions that serve them.'],
['ما هو حوار SUQ؟', '«SUQ» اختصار لـ «ابحث، افهم، اسأل». وهي جلسة ميسَّرة تجمع السكان بالمؤسسات التي تخدمهم.']
),
faq(
4,
['Do I need to be a member of the SHARP network to take part?', 'No. Events and calls are open beyond the network, though some opportunities are reserved for member organisations and say so.'],
['هل يجب أن أكون عضوًا في شبكة SHARP للمشاركة؟', 'لا. الفعاليات والدعوات مفتوحة خارج الشبكة، وإن كانت بعض الفرص مخصّصة للمنظمات الأعضاء وتوضّح ذلك.']
),
faq(
5,
['Is there a cost to attend an event?', 'No. Every SHARP-ER event is free to attend.'],
['هل هناك رسوم لحضور الفعاليات؟', 'لا. حضور جميع فعاليات SHARP-ER مجاني.']
),
faq(
6,
['How is SHARP-ER funded?', 'SHARP-ER is funded by the European Union and implemented by a consortium of Acted, United Efforts Consultancy and Stichting Bureau Ensemble.'],
['كيف يُموَّل مشروع SHARP-ER؟', 'يُموَّل SHARP-ER من الاتحاد الأوروبي، وينفّذه ائتلاف يضم Acted وUnited Efforts Consultancy وStichting Bureau Ensemble.']
),
faq(
7,
['Can my organisation publish an update on this site?', 'Partner organisations in the network can submit updates, which appear in the newsroom under "From the Network" with the organisation credited.'],
['هل يمكن لمنظمتي نشر مستجدات على هذا الموقع؟', 'يمكن للمنظمات الشريكة في الشبكة إرسال مستجداتها، فتظهر في غرفة الأخبار ضمن «من الشبكة» منسوبة إلى المنظمة.']
),
faq(
8,
['Where can I find the project’s publications?', 'Research, policy articles, project reports and fact sheets are all in Resources and Publications, filterable by type, topic and year.'],
['أين أجد منشورات المشروع؟', 'تجد الأبحاث والمقالات السياساتية وتقارير المشروع وأوراق الحقائق في قسم الموارد والمنشورات، مع إمكانية التصفية حسب النوع والموضوع والسنة.']
),
];
/* ---------------------------------------------------------------------------
NEWSROOM
------------------------------------------------------------------------ */
export const demoNews = (now: Date): readonly DemoEntry[] => {
const article = (
key: string,
days: number,
category: 'sharper-news' | 'from-the-network',
authorSlug: string | undefined,
topics: readonly string[],
regions: readonly string[],
en: { title: string; excerpt: string; body: string },
ar: { title: string; excerpt: string; body: string }
): DemoEntry => {
const slug = `${DEMO_PREFIX}${key}`;
const shared = { slug, category, publishDate: onDay(now, days), featured: false };
return {
slug,
topics,
regions,
...(authorSlug ? { partners: [authorSlug] } : {}),
data: {
en: { ...shared, title: en.title, excerpt: en.excerpt, body: en.body },
ar: { ...shared, title: ar.title, excerpt: ar.excerpt, body: ar.body },
},
};
};
return [
article(
'first-round-of-sub-projects-selected',
-7,
'sharper-news',
undefined,
['civil-society'],
[`${DEMO_PREFIX}tripoli`],
{
title: 'First round of community sub-projects selected',
excerpt: 'The selection panel has finished its review of the first Call for Expression of Interest.',
body: 'The selection panel has finished its review of the first Call for Expression of Interest. Successful organisations will be contacted directly, and a summary of the themes the round attracted will be published alongside the next call.\n\nThis is demo content and describes nothing that has happened.',
},
{
title: 'اختيار الجولة الأولى من المشاريع المجتمعية الفرعية',
excerpt: 'أنهت لجنة الاختيار مراجعتها لأول دعوة للتعبير عن الاهتمام.',
body: 'أنهت لجنة الاختيار مراجعتها لأول دعوة للتعبير عن الاهتمام. وسيتم التواصل مباشرة مع المنظمات المختارة، وسيُنشر ملخّص للمحاور التي استقطبتها الجولة مع الدعوة التالية.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
}
),
article(
'dialogue-facilitation-training-concludes',
-21,
'sharper-news',
undefined,
['dialogue'],
[`${DEMO_PREFIX}benghazi`],
{
title: 'Dialogue facilitation training concludes in Benghazi',
excerpt: 'Participants from eleven organisations completed the three-day facilitation course.',
body: 'Participants from eleven organisations completed the three-day facilitation course, which covers preparing a session, handling disagreement in the room, and following up on what was agreed.\n\nThis is demo content and describes nothing that has happened.',
},
{
title: 'اختتام التدريب على تيسير الحوار في بنغازي',
excerpt: 'أتمّ مشاركون من إحدى عشرة منظمة دورة التيسير التي استمرت ثلاثة أيام.',
body: 'أتمّ مشاركون من إحدى عشرة منظمة دورة التيسير التي استمرت ثلاثة أيام، والتي تتناول الإعداد للجلسة، وإدارة الاختلاف داخل القاعة، ومتابعة ما جرى الاتفاق عليه.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
}
),
article(
'perception-survey-fieldwork-begins',
-35,
'sharper-news',
undefined,
['local-governance'],
[],
{
title: 'Nationwide perception survey enters fieldwork',
excerpt: 'Data collection has begun for the project’s first nationwide perception study.',
body: 'Data collection has begun for the project’s first nationwide perception study. Findings will be published in Resources and Publications once analysis is complete.\n\nThis is demo content and describes nothing that has happened.',
},
{
title: 'بدء العمل الميداني للدراسة الاستطلاعية الوطنية',
excerpt: 'انطلق جمع البيانات لأول دراسة استطلاعية وطنية ينفّذها المشروع.',
body: 'انطلق جمع البيانات لأول دراسة استطلاعية وطنية ينفّذها المشروع. وستُنشر النتائج في قسم الموارد والمنشورات فور اكتمال التحليل.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
}
),
article(
'coastal-justice-forum-completes-sub-project',
-14,
'from-the-network',
`${DEMO_PREFIX}coastal-justice-forum`,
['rule-of-law'],
[`${DEMO_PREFIX}zawiya`],
{
title: 'Coastal Justice Forum completes its first community sub-project',
excerpt: 'Six legal awareness sessions reached residents across four neighbourhoods.',
body: 'Six legal awareness sessions reached residents across four neighbourhoods, focusing on civil documentation and how to raise a complaint with the municipality.\n\nThis is demo content and describes nothing that has happened.',
},
{
title: 'منتدى العدالة الساحلي يُنجز أول مشروع مجتمعي فرعي',
excerpt: 'ست جلسات توعية قانونية وصلت إلى سكان أربعة أحياء.',
body: 'ست جلسات توعية قانونية وصلت إلى سكان أربعة أحياء، وركّزت على الوثائق المدنية وكيفية تقديم شكوى إلى البلدية.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
}
),
article(
'open-municipality-publishes-budget-guide',
-28,
'from-the-network',
`${DEMO_PREFIX}open-municipality-initiative`,
['local-governance'],
[`${DEMO_PREFIX}misrata`],
{
title: 'Open Municipality Initiative publishes a plain-language budget guide',
excerpt: 'The guide explains how a municipal budget is drawn up and where residents can comment on it.',
body: 'The guide explains how a municipal budget is drawn up, which decisions are taken when, and where residents can comment on it.\n\nThis is demo content and describes nothing that has happened.',
},
{
title: 'مبادرة البلدية المفتوحة تنشر دليلًا مبسّطًا للميزانية',
excerpt: 'يشرح الدليل كيفية إعداد ميزانية البلدية وأين يمكن للسكان إبداء الرأي فيها.',
body: 'يشرح الدليل كيفية إعداد ميزانية البلدية، وأي القرارات تُتّخذ ومتى، وأين يمكن للسكان إبداء الرأي فيها.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
}
),
article(
'youth-civic-lab-opens-volunteer-round',
-3,
'from-the-network',
`${DEMO_PREFIX}youth-civic-lab`,
['civil-society'],
[`${DEMO_PREFIX}sebha`],
{
title: 'Youth Civic Lab opens its next volunteering round',
excerpt: 'Applications are open to anyone aged 18 to 30 living in the south.',
body: 'Applications are open to anyone aged 18 to 30 living in the south. Volunteers are matched with a local organisation for three months.\n\nThis is demo content and describes nothing that has happened.',
},
{
title: 'مختبر الشباب المدني يفتح جولة التطوّع التالية',
excerpt: 'التقديم مفتوح لمن تتراوح أعمارهم بين 18 و30 عامًا من سكان الجنوب.',
body: 'التقديم مفتوح لمن تتراوح أعمارهم بين 18 و30 عامًا من سكان الجنوب. ويُربط كل متطوّع بمنظمة محلية لمدة ثلاثة أشهر.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
}
),
];
};
/* ---------------------------------------------------------------------------
EVENTS
----------------------------------------------------------------------------
Spread across the calendar on purpose: two already over, one running, three
still to come, so the listing's filters and every registration state have
something to show.
------------------------------------------------------------------------ */
export const demoEvents = (now: Date): readonly DemoEntry[] => {
const event = (
key: string,
days: number,
options: {
eventType: string;
format: 'in-person' | 'online' | 'hybrid';
registrationStatus: 'open' | 'closed' | 'full' | 'waitlist' | 'not-required';
capacity?: number;
region?: string;
topics?: readonly string[];
partners?: readonly string[];
},
en: { title: string; description: string; venue?: string },
ar: { title: string; description: string; venue?: string }
): DemoEntry => {
const slug = `${DEMO_PREFIX}${key}`;
const shared = {
slug,
eventType: options.eventType,
format: options.format,
registrationStatus: options.registrationStatus,
startDateTime: atHour(now, days, 9),
endDateTime: atHour(now, days, 14),
timezone: 'Africa/Tripoli',
...(options.capacity ? { capacity: options.capacity } : {}),
...(options.registrationStatus === 'open'
? { registrationDeadline: atHour(now, days - 2, 23) }
: {}),
waitlistEnabled: options.registrationStatus === 'waitlist',
};
return {
slug,
...(options.topics ? { topics: options.topics } : {}),
...(options.region ? { regions: [options.region] } : {}),
...(options.partners ? { partners: options.partners } : {}),
data: {
en: { ...shared, title: en.title, description: en.description, ...(en.venue ? { venue: en.venue } : {}) },
ar: { ...shared, title: ar.title, description: ar.description, ...(ar.venue ? { venue: ar.venue } : {}) },
},
};
};
return [
event(
'suq-dialogue-tripoli',
21,
{
eventType: 'suq-dialogue',
format: 'in-person',
registrationStatus: 'open',
capacity: 60,
region: `${DEMO_PREFIX}tripoli`,
topics: ['dialogue', 'local-governance'],
},
{
title: 'SUQ Dialogue — water and sanitation services',
description: 'Residents and the municipal services department sit down together over the winter service plan.',
venue: 'Tripoli',
},
{
title: 'حوار SUQ — خدمات المياه والصرف الصحي',
description: 'يجلس السكان وإدارة الخدمات البلدية معًا لمناقشة خطة الخدمات الشتوية.',
venue: 'طرابلس',
}
),
event(
'facilitation-training-online',
35,
{
eventType: 'training',
format: 'online',
registrationStatus: 'open',
capacity: 25,
topics: ['dialogue'],
},
{
title: 'Dialogue facilitation training',
description: 'A three-session online course on preparing, running and following up a community dialogue.',
},
{
title: 'تدريب على تيسير الحوار',
description: 'دورة عبر الإنترنت من ثلاث جلسات حول الإعداد للحوار المجتمعي وإدارته ومتابعته.',
}
),
event(
'network-meeting-south',
49,
{
eventType: 'network-meeting',
format: 'hybrid',
registrationStatus: 'waitlist',
capacity: 40,
region: `${DEMO_PREFIX}sebha`,
topics: ['civil-society'],
},
{
title: 'SHARP Network regional meeting — South',
description: 'Member organisations in the south meet to plan the next six months of joint work.',
venue: 'Sebha',
},
{
title: 'اجتماع شبكة SHARP الإقليمي — الجنوب',
description: 'تجتمع المنظمات الأعضاء في الجنوب للتخطيط للأشهر الستة المقبلة من العمل المشترك.',
venue: 'سبها',
}
),
event(
'law-session-with-the-ccs',
-5,
{
eventType: 'law-session',
format: 'in-person',
registrationStatus: 'closed',
region: `${DEMO_PREFIX}benghazi`,
topics: ['rule-of-law', 'civil-society'],
},
{
title: 'Civil Society Law Session with the CCS',
description: 'A joint session on registration procedures and reporting obligations for civil society organisations.',
venue: 'Benghazi',
},
{
title: 'جلسة قانون المجتمع المدني مع لجنة المجتمع المدني',
description: 'جلسة مشتركة حول إجراءات التسجيل والتزامات التقارير لمنظمات المجتمع المدني.',
venue: 'بنغازي',
}
),
event(
'suq-dialogue-misrata',
-30,
{
eventType: 'suq-dialogue',
format: 'in-person',
registrationStatus: 'closed',
region: `${DEMO_PREFIX}misrata`,
topics: ['dialogue'],
},
{
title: 'SUQ Dialogue — street lighting and public safety',
description: 'A dialogue between neighbourhood representatives and the municipality on night-time safety.',
venue: 'Misrata',
},
{
title: 'حوار SUQ — الإنارة العامة والسلامة',
description: 'حوار بين ممثلي الأحياء والبلدية حول السلامة الليلية.',
venue: 'مصراتة',
}
),
event(
'project-open-day',
7,
{
eventType: 'other',
format: 'online',
registrationStatus: 'not-required',
topics: ['civil-society'],
},
{
title: 'SHARP-ER open session — how to apply',
description: 'An open walk-through of the application form and the selection criteria. No registration needed.',
},
{
title: 'جلسة SHARP-ER المفتوحة — كيف تتقدّم بطلب',
description: 'شرح مفتوح لنموذج التقديم ومعايير الاختيار. لا يلزم التسجيل.',
}
),
];
};
/* ---------------------------------------------------------------------------
OPPORTUNITIES
------------------------------------------------------------------------ */
export const demoOpportunities = (now: Date): readonly DemoEntry[] => {
const opportunity = (
key: string,
options: {
opportunityType: string;
opportunityStatus: 'upcoming' | 'open' | 'closed';
openDays: number;
deadlineDays: number;
topics?: readonly string[];
partners?: readonly string[];
},
en: { title: string; summary: string; eligibility: string; howToApply: string },
ar: { title: string; summary: string; eligibility: string; howToApply: string }
): DemoEntry => {
const slug = `${DEMO_PREFIX}${key}`;
const shared = {
slug,
opportunityType: options.opportunityType,
opportunityStatus: options.opportunityStatus,
openDate: onDay(now, options.openDays),
deadline: atHour(now, options.deadlineDays, 23),
};
return {
slug,
...(options.topics ? { topics: options.topics } : {}),
...(options.partners ? { partners: options.partners } : {}),
data: {
en: { ...shared, ...en },
ar: { ...shared, ...ar },
},
};
};
return [
opportunity(
'call-for-expression-of-interest-round-two',
{
opportunityType: 'call-eoi',
opportunityStatus: 'open',
openDays: -10,
deadlineDays: 25,
topics: ['civil-society', 'rule-of-law'],
partners: ['acted'],
},
{
title: 'Call for Expression of Interest — community sub-projects, round two',
summary: 'Funding for civil society organisations to design and run a community sub-project over nine months.',
eligibility: 'Open to civil society organisations registered in Libya with at least one year of operations. Consortia of two or more organisations are welcome.',
howToApply: 'Complete the application form on this page. Applications may be submitted in Arabic or English. Late submissions are not considered.',
},
{
title: 'دعوة للتعبير عن الاهتمام — المشاريع المجتمعية الفرعية، الجولة الثانية',
summary: 'تمويل لمنظمات المجتمع المدني لتصميم وتنفيذ مشروع مجتمعي فرعي على مدى تسعة أشهر.',
eligibility: 'الدعوة مفتوحة لمنظمات المجتمع المدني المسجّلة في ليبيا ولديها سنة عمل واحدة على الأقل. ويُرحَّب بالتحالفات بين منظمتين أو أكثر.',
howToApply: 'يُرجى تعبئة نموذج التقديم في هذه الصفحة. ويمكن التقديم بالعربية أو الإنجليزية. ولا يُنظر في الطلبات المتأخرة.',
}
),
opportunity(
'join-the-sharp-network',
{
opportunityType: 'network-membership',
opportunityStatus: 'open',
openDays: -60,
deadlineDays: 90,
topics: ['civil-society'],
partners: ['united-efforts-consultancy'],
},
{
title: 'Join the SHARP network',
summary: 'Become part of the civil society network behind SHARP-ER: regional meetings, peer learning, and joint events.',
eligibility: 'Open to civil society organisations working anywhere in Libya. There is no fee and no minimum size.',
howToApply: 'Send the membership form together with your registration certificate. Applications are reviewed on a rolling basis.',
},
{
title: 'انضم إلى شبكة SHARP',
summary: 'كن جزءًا من شبكة المجتمع المدني وراء SHARP-ER: اجتماعات إقليمية، وتعلّم بين الأقران، وفعاليات مشتركة.',
eligibility: 'مفتوح لمنظمات المجتمع المدني العاملة في أي مكان في ليبيا. لا توجد رسوم ولا حد أدنى للحجم.',
howToApply: 'أرسل نموذج العضوية مرفقًا بشهادة التسجيل. وتُراجَع الطلبات أولًا بأول.',
}
),
opportunity(
'training-monitoring-and-reporting',
{
opportunityType: 'training',
opportunityStatus: 'upcoming',
openDays: 20,
deadlineDays: 45,
topics: ['local-governance'],
},
{
title: 'Training — monitoring and reporting for small grants',
summary: 'A short course for organisations managing a grant for the first time.',
eligibility: 'Reserved for organisations that hold, or have applied for, a SHARP-ER sub-project grant.',
howToApply: 'Applications open on the date shown above. Places are limited and allocated in order of application.',
},
{
title: 'تدريب — المتابعة وإعداد التقارير للمنح الصغيرة',
summary: 'دورة قصيرة للمنظمات التي تدير منحة للمرة الأولى.',
eligibility: 'مخصّص للمنظمات الحاصلة على منحة مشروع فرعي من SHARP-ER أو المتقدّمة للحصول عليها.',
howToApply: 'يُفتح التقديم في التاريخ الموضّح أعلاه. المقاعد محدودة وتُخصَّص حسب أسبقية التقديم.',
}
),
opportunity(
'policy-to-rights-first-call',
{
opportunityType: 'grant',
opportunityStatus: 'closed',
openDays: -120,
deadlineDays: -40,
topics: ['rule-of-law'],
partners: ['acted'],
},
{
title: 'Policy-to-Rights (P2R) — first call',
summary: 'Support for organisations turning a policy commitment into a concrete change residents can see.',
eligibility: 'This call is closed. It was open to organisations with previous experience of policy advocacy.',
howToApply: 'This call is closed. The next round will be announced on this page.',
},
{
title: '«من السياسة إلى الحقوق» (P2R) — الدعوة الأولى',
summary: 'دعم للمنظمات التي تحوّل التزامًا سياساتيًا إلى تغيير ملموس يلمسه السكان.',
eligibility: 'هذه الدعوة مغلقة. وكانت مفتوحة للمنظمات ذات الخبرة السابقة في المناصرة السياساتية.',
howToApply: 'هذه الدعوة مغلقة. وسيُعلن عن الجولة التالية في هذه الصفحة.',
}
),
];
};
/* ---------------------------------------------------------------------------
RESOURCES
----------------------------------------------------------------------------
No files are attached: the seed uploads nothing, so each entry is a record
with its metadata and the listing draws its format chip from that. The
detail page shows the entry without a download, which is the state an
editor sees before they attach the PDF.
------------------------------------------------------------------------ */
export const demoResources = (now: Date): readonly DemoEntry[] => {
const resource = (
key: string,
options: {
resourceType: string;
resourceLanguage: 'en' | 'ar' | 'bilingual';
days: number;
pageCount?: number;
topics?: readonly string[];
partners?: readonly string[];
featured?: boolean;
authors?: string;
},
en: { title: string; description: string },
ar: { title: string; description: string }
): DemoEntry => {
const slug = `${DEMO_PREFIX}${key}`;
const shared = {
slug,
resourceType: options.resourceType,
resourceLanguage: options.resourceLanguage,
publicationDate: onDay(now, options.days),
featured: options.featured ?? false,
...(options.pageCount ? { pageCount: options.pageCount } : {}),
...(options.authors ? { authors: options.authors } : {}),
};
return {
slug,
...(options.topics ? { topics: options.topics } : {}),
...(options.partners ? { partners: options.partners } : {}),
data: {
en: { ...shared, ...en },
ar: { ...shared, ...ar },
},
};
};
return [
resource(
'nationwide-perception-study',
{
resourceType: 'research-perception',
resourceLanguage: 'bilingual',
days: -45,
pageCount: 64,
featured: true,
authors: 'Stichting Bureau Ensemble',
topics: ['local-governance', 'rule-of-law'],
partners: ['stichting-bureau-ensemble'],
},
{
title: 'Nationwide perception study — baseline',
description: 'How residents across Libya describe their access to justice and their local services.',
},
{
title: 'الدراسة الاستطلاعية الوطنية — خط الأساس',
description: 'كيف يصف السكان في مختلف أنحاء ليبيا وصولهم إلى العدالة وخدماتهم المحلية.',
}
),
resource(
'sharp-er-fact-sheet',
{
resourceType: 'visibility-factsheet',
resourceLanguage: 'bilingual',
days: -90,
pageCount: 2,
topics: ['civil-society'],
},
{ title: 'SHARP-ER fact sheet', description: 'A two-page summary of the project, its partners and its areas of work.' },
{ title: 'ورقة حقائق SHARP-ER', description: 'ملخّص من صفحتين عن المشروع وشركائه ومجالات عمله.' }
),
resource(
'policy-article-municipal-transparency',
{
resourceType: 'policy-article',
resourceLanguage: 'en',
days: -20,
pageCount: 12,
authors: 'Transparency Watch',
topics: ['local-governance'],
partners: [`${DEMO_PREFIX}transparency-watch-libya`],
},
{
title: 'What municipal transparency costs, and what it saves',
description: 'A Policy-to-Rights article on publishing procurement decisions at municipal level.',
},
{
title: 'ما تكلفة الشفافية البلدية وما الذي توفّره',
description: 'مقال ضمن سلسلة «من السياسة إلى الحقوق» حول نشر قرارات المشتريات على مستوى البلديات.',
}
),
resource(
'policy-article-legal-aid-access',
{
resourceType: 'policy-article',
resourceLanguage: 'ar',
days: -55,
pageCount: 9,
authors: 'Legal Aid Collective',
topics: ['rule-of-law'],
partners: [`${DEMO_PREFIX}legal-aid-collective`],
},
{
title: 'First-line legal advice: who asks, and for what',
description: 'What a year of free legal advice sessions says about the questions residents bring.',
},
{
title: 'الاستشارة القانونية الأولية: من يسأل، وعن ماذا',
description: 'ما الذي تكشفه سنة من جلسات الاستشارة القانونية المجانية عن أسئلة السكان.',
}
),
resource(
'project-report-first-half',
{
resourceType: 'project-report',
resourceLanguage: 'en',
days: -10,
pageCount: 28,
topics: ['civil-society'],
partners: ['acted'],
},
{
title: 'Project report — first reporting period',
description: 'Activities, participation and lessons from the project’s first reporting period.',
},
{
title: 'تقرير المشروع — فترة التقرير الأولى',
description: 'الأنشطة والمشاركة والدروس المستفادة من فترة التقرير الأولى للمشروع.',
}
),
resource(
'dialogue-practice-note',
{
resourceType: 'project-report',
resourceLanguage: 'bilingual',
days: -70,
pageCount: 16,
topics: ['dialogue'],
partners: ['united-efforts-consultancy'],
},
{
title: 'Practice note — running a SUQ dialogue',
description: 'What has worked, and what has not, across the first rounds of community dialogue.',
},
{
title: 'مذكّرة عملية — كيف يُدار حوار SUQ',
description: 'ما الذي نجح وما الذي لم ينجح عبر الجولات الأولى من الحوار المجتمعي.',
}
),
];
};
/* ---------------------------------------------------------------------------
KNOWLEDGE HUB
------------------------------------------------------------------------ */
export const demoKnowledge = (now: Date): readonly DemoEntry[] => {
const item = (
key: string,
itemType: string,
days: number,
topics: readonly string[],
en: { title: string; summary: string },
ar: { title: string; summary: string }
): DemoEntry => {
const slug = `${DEMO_PREFIX}${key}`;
const shared = { slug, itemType, publicationDate: onDay(now, days) };
return {
slug,
topics,
data: {
en: { ...shared, ...en },
ar: { ...shared, ...ar },
},
};
};
return [
item(
'module-governance-basics',
'training-module',
-80,
['local-governance'],
{ title: 'Training module — governance basics', summary: 'What a municipality does, who decides what, and where a resident fits in.' },
{ title: 'وحدة تدريبية — أساسيات الحوكمة', summary: 'ماذا تفعل البلدية، ومن يقرّر ماذا، وأين يقع دور الساكن.' }
),
item(
'module-facilitation-skills',
'training-module',
-60,
['dialogue'],
{ title: 'Training module — facilitation skills', summary: 'Preparing a session, keeping a room together, and closing on something concrete.' },
{ title: 'وحدة تدريبية — مهارات التيسير', summary: 'الإعداد للجلسة، وإبقاء القاعة متماسكة، والختام بنتيجة ملموسة.' }
),
item(
'toolkit-community-needs',
'toolkit',
-40,
['civil-society'],
{ title: 'Toolkit — identifying community needs', summary: 'A short set of tools for finding out what a neighbourhood actually wants changed.' },
{ title: 'دليل عملي — تحديد احتياجات المجتمع', summary: 'مجموعة أدوات موجزة لمعرفة ما يريد الحي تغييره فعلًا.' }
),
item(
'guide-law-session-registration',
'law-session-guide',
-30,
['rule-of-law'],
{ title: 'Law Session guide — registration and reporting', summary: 'Plain-language notes from the joint session with the Commission on Civil Society.' },
{ title: 'دليل جلسة قانونية — التسجيل وإعداد التقارير', summary: 'ملاحظات بلغة مبسّطة من الجلسة المشتركة مع لجنة المجتمع المدني.' }
),
item(
'case-study-water-dialogue',
'case-study',
-15,
['dialogue', 'local-governance'],
{ title: 'Case study — a dialogue that changed a service plan', summary: 'How one SUQ dialogue ended with a revised winter maintenance schedule.' },
{ title: 'دراسة حالة — حوار غيّر خطة خدمات', summary: 'كيف انتهى أحد حوارات SUQ بجدول صيانة شتوي معدَّل.' }
),
item(
'case-study-first-grant',
'case-study',
-5,
['civil-society'],
{ title: 'Case study — managing a first grant', summary: 'What a small organisation learned in its first nine months of grant management.' },
{ title: 'دراسة حالة — إدارة أول منحة', summary: 'ما تعلّمته منظمة صغيرة في أول تسعة أشهر من إدارة منحة.' }
),
];
};
/**
* The home page fields the demo set overrides.
*
* The status pill is the one piece of approved-looking copy the demo writes,
* and it is true of the demo data: `demo-call-for-expression-of-interest-
* round-two` really is open on a demo-seeded site.
*/
export const DEMO_HOME_PAGE: Localised<SeedData> = {
en: { hero: { eyebrow: 'Now open · Funding calls for Libyan organisations' } },
ar: { hero: { eyebrow: 'مفتوح الآن · دعوات تمويل للمنظمات الليبية' } },
};
+441
View File
@@ -0,0 +1,441 @@
/* ============================================================================
SEED - orchestration
----------------------------------------------------------------------------
Run it with `pnpm seed` from the repository root, or from apps/cms:
pnpm seed the approved content only (approved.ts)
pnpm seed --demo the same, plus the invented fixtures (demo.ts)
pnpm seed --demo --clean remove the invented fixtures, leave the rest
IDEMPOTENT. Every write goes through `upsert`, matched on a slug (or, for
the single types, on there being only one document), so a second run writes
the same fields over the same documents. It never deletes anything except on
`--demo --clean`, and then only what carries the demo slug prefix.
ORDER MATTERS. Partners and topics are written first because the activities,
the articles and the About page's consortium cards all point at them by
documentId, and a relation can only be set once its target exists.
WHAT IT DOES NOT TOUCH
* Media. The seed uploads nothing. Where a page already has a picture -
the home hero, most of all - the existing file is read back and written
out again with the new copy, because a component is replaced wholesale
on write and anything left out of the payload is gone.
* Submissions. `application`, `event-registration` and `contact-submission`
hold personal data. Nothing writes to them here, not even in demo mode.
* Locales and permissions. Those are reconciled at boot by src/bootstrap.
========================================================================= */
import type { Core } from '@strapi/strapi';
import {
ABOUT_PAGE,
ACTIVITIES,
CONSORTIUM,
CONSORTIUM_ROLES,
CONTACT_PAGE,
HOME_PAGE,
LAUNCH_ARTICLE,
SITE_SETTINGS,
TOPICS,
WHAT_WE_DO_PAGE,
type SeedEntries,
} from './approved';
import {
DEMO_FAQS,
DEMO_HOME_PAGE,
DEMO_PARTNERS,
DEMO_PARTNER_SLUGS,
DEMO_PREFIX,
DEMO_REGIONS,
demoEvents,
demoKnowledge,
demoNews,
demoOpportunities,
demoResources,
type DemoEntry,
} from './demo';
import {
deleteBySlugPrefix,
deleteWhere,
mediaId,
readExisting,
upsert,
type Localised,
type SeedData,
type SeedUid,
} from './upsert';
export interface SeedOptions {
/** Write the invented fixtures as well as the approved content. */
readonly demo: boolean;
/** With `demo`, remove those fixtures instead of writing them. */
readonly clean: boolean;
}
const UID = {
siteSettings: 'api::site-settings.site-settings',
homePage: 'api::home-page.home-page',
aboutPage: 'api::about-page.about-page',
whatWeDoPage: 'api::what-we-do-page.what-we-do-page',
contactPage: 'api::contact-page.contact-page',
partner: 'api::partner.partner',
topic: 'api::topic.topic',
region: 'api::region.region',
activity: 'api::activity.activity',
news: 'api::news-article.news-article',
event: 'api::event.event',
opportunity: 'api::opportunity.opportunity',
resource: 'api::resource.resource',
knowledge: 'api::knowledge-item.knowledge-item',
faq: 'api::faq.faq',
} as const satisfies Record<string, SeedUid>;
/** Every slugged type the demo set writes to, in the order `--clean` sweeps. */
const DEMO_SLUGGED_TYPES: readonly SeedUid[] = [
UID.knowledge,
UID.resource,
UID.opportunity,
UID.event,
UID.news,
UID.partner,
UID.region,
];
/** slug -> documentId, built as each type is written. */
type SlugIndex = Map<string, string>;
const log = (strapi: Core.Strapi, message: string): void => {
strapi.log.info(`[seed] ${message}`);
};
/** Write a list of slugged entries and return the slug -> documentId index. */
async function upsertAll(
strapi: Core.Strapi,
uid: SeedUid,
entries: ReadonlyArray<{ slug: string; data: Localised<SeedData> }>,
into: SlugIndex = new Map()
): Promise<SlugIndex> {
for (const entry of entries) {
const { documentId } = await upsert(strapi, uid, entry.data, {
slug: { $eq: entry.slug },
});
into.set(entry.slug, documentId);
}
return into;
}
/** Turn a list of slugs into the documentIds a relation field wants. */
const relate = (index: SlugIndex, slugs: readonly string[] | undefined): string[] =>
(slugs ?? []).map((slug) => index.get(slug)).filter((id): id is string => Boolean(id));
/**
* Add relation fields to both locales of an entry.
*
* Relations are held per locale in Strapi, so a translation that is written
* without them comes back with an empty list - which is how a page ends up
* with its topics in English and none in Arabic.
*/
function withRelations(
data: Localised<SeedData>,
relations: SeedData
): Localised<SeedData> {
return {
en: { ...data.en, ...relations },
ar: { ...data.ar, ...relations },
};
}
/* ---------------------------------------------------------------------------
THE APPROVED SET
------------------------------------------------------------------------ */
async function seedApproved(strapi: Core.Strapi): Promise<{
partners: SlugIndex;
topics: SlugIndex;
launchArticleId: string;
}> {
await upsert(strapi, UID.siteSettings, SITE_SETTINGS);
log(strapi, 'site settings');
const partners = await upsertAll(strapi, UID.partner, CONSORTIUM);
log(strapi, `${partners.size} consortium partners`);
const topics = await upsertAll(strapi, UID.topic, TOPICS as SeedEntries);
log(strapi, `${topics.size} topics`);
for (const activity of ACTIVITIES) {
await upsert(
strapi,
UID.activity,
withRelations(activity.data, {
implementingPartners: relate(partners, activity.partners),
topics: relate(topics, activity.topics),
}),
{ slug: { $eq: activity.slug } }
);
}
log(strapi, `${ACTIVITIES.length} activities`);
const launch = await upsert(
strapi,
UID.news,
withRelations(LAUNCH_ARTICLE.data, { topics: relate(topics, LAUNCH_ARTICLE.topics) }),
{ slug: { $eq: LAUNCH_ARTICLE.slug } }
);
log(strapi, 'the launch article');
/* The home hero already carries an uploaded picture on this database; read
it back so writing the new copy does not drop it. */
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media');
await upsert(strapi, UID.homePage, {
en: {
...HOME_PAGE.en,
hero: { ...(HOME_PAGE.en.hero as SeedData), ...(heroMedia ? { media: heroMedia } : {}) },
featuredNews: [launch.documentId],
},
ar: {
...HOME_PAGE.ar,
hero: { ...(HOME_PAGE.ar.hero as SeedData), ...(heroMedia ? { media: heroMedia } : {}) },
featuredNews: [launch.documentId],
},
});
log(strapi, 'home page');
/* The About page's consortium cards are components that point at the partner
documents, so they can only be built now that the partners exist. */
const consortiumFor = (locale: 'en' | 'ar'): SeedData[] =>
CONSORTIUM.map((member) => ({
partner: partners.get(member.slug),
...CONSORTIUM_ROLES[member.slug]![locale],
}));
await upsert(strapi, UID.aboutPage, {
en: { ...ABOUT_PAGE.en, consortium: consortiumFor('en') },
ar: { ...ABOUT_PAGE.ar, consortium: consortiumFor('ar') },
});
log(strapi, 'about page');
await upsert(strapi, UID.whatWeDoPage, WHAT_WE_DO_PAGE);
log(strapi, 'what we do page');
await upsert(strapi, UID.contactPage, CONTACT_PAGE);
log(strapi, 'contact page');
return { partners, topics, launchArticleId: launch.documentId };
}
/* ---------------------------------------------------------------------------
THE DEMO SET
------------------------------------------------------------------------ */
async function seedDemo(
strapi: Core.Strapi,
context: { partners: SlugIndex; topics: SlugIndex; launchArticleId: string }
): Promise<void> {
const now = new Date();
const regions = await upsertAll(strapi, UID.region, DEMO_REGIONS);
log(strapi, `${regions.size} demo regions`);
/* Written into the same index as the consortium: a resource or an article
can credit either a consortium member or a network organisation, and the
lookup should not have to know which. */
const partners = await upsertAll(strapi, UID.partner, DEMO_PARTNERS, context.partners);
log(strapi, `${DEMO_PARTNERS.length} demo network partners`);
for (const item of DEMO_FAQS) {
await upsert(strapi, UID.faq, item, { question: { $eq: item.ar.question } });
}
log(strapi, `${DEMO_FAQS.length} demo FAQs`);
/* `entry.partners` is the author on a news article (a single relation) and
the implementing/publishing organisations everywhere else. */
const news = demoNews(now);
const newsIndex: SlugIndex = new Map();
for (const entry of news) {
const author = relate(partners, entry.partners)[0];
const { documentId } = await upsert(
strapi,
UID.news,
withRelations(entry.data, {
topics: relate(context.topics, entry.topics),
regions: relate(regions, entry.regions),
...(author ? { authorOrganisation: author } : {}),
}),
{ slug: { $eq: entry.slug } }
);
newsIndex.set(entry.slug, documentId);
}
log(strapi, `${news.length} demo articles`);
const events = demoEvents(now);
for (const entry of events) {
await upsert(
strapi,
UID.event,
withRelations(entry.data, {
topics: relate(context.topics, entry.topics),
/* `region` is a single relation on this type, not a list. */
...(entry.regions?.length ? { region: relate(regions, entry.regions)[0] } : {}),
implementingPartners: relate(partners, entry.partners),
}),
{ slug: { $eq: entry.slug } }
);
}
log(strapi, `${events.length} demo events`);
const opportunities = demoOpportunities(now);
for (const entry of opportunities) {
await upsert(
strapi,
UID.opportunity,
withRelations(entry.data, {
topics: relate(context.topics, entry.topics),
implementingPartners: relate(partners, entry.partners),
}),
{ slug: { $eq: entry.slug } }
);
}
log(strapi, `${opportunities.length} demo opportunities`);
const resources = demoResources(now);
for (const entry of resources) {
await upsert(
strapi,
UID.resource,
withRelations(entry.data, {
topics: relate(context.topics, entry.topics),
partnerOrganisations: relate(partners, entry.partners),
}),
{ slug: { $eq: entry.slug } }
);
}
log(strapi, `${resources.length} demo resources`);
const knowledge = demoKnowledge(now);
for (const entry of knowledge) {
await upsert(
strapi,
UID.knowledge,
withRelations(entry.data, { topics: relate(context.topics, entry.topics) }),
{ slug: { $eq: entry.slug } }
);
}
log(strapi, `${knowledge.length} demo knowledge items`);
/* Finally the home page, which the demo set only adds to: the status pill,
the network logo wall (twelve organisations, which is what turns the wall
into the design's marquee), and two network stories beside the real launch
article. The hero is rewritten in full, media included, because writing a
component replaces it. */
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media');
const featured = [
context.launchArticleId,
newsIndex.get(`${DEMO_PREFIX}first-round-of-sub-projects-selected`),
newsIndex.get(`${DEMO_PREFIX}coastal-justice-forum-completes-sub-project`),
].filter((id): id is string => Boolean(id));
const homeFor = (locale: 'en' | 'ar'): SeedData => ({
...HOME_PAGE[locale],
hero: {
...(HOME_PAGE[locale].hero as SeedData),
...(DEMO_HOME_PAGE[locale].hero as SeedData),
...(heroMedia ? { media: heroMedia } : {}),
},
featuredNews: featured,
partners: relate(partners, DEMO_PARTNER_SLUGS),
});
await upsert(strapi, UID.homePage, { en: homeFor('en'), ar: homeFor('ar') });
log(strapi, 'home page, with the demo network wall');
}
/* ---------------------------------------------------------------------------
REMOVING THE DEMO SET
------------------------------------------------------------------------ */
async function cleanDemo(strapi: Core.Strapi): Promise<void> {
let removed = 0;
for (const uid of DEMO_SLUGGED_TYPES) {
removed += await deleteBySlugPrefix(strapi, uid, DEMO_PREFIX);
}
/* Both languages: the filter runs once per locale, and an Arabic question
never matches an English one. */
removed += await deleteWhere(strapi, UID.faq, {
question: {
$in: DEMO_FAQS.flatMap((item) => [item.en.question, item.ar.question]),
},
});
/* The home page is approved content with demo fields written onto it, so it
is put back rather than deleted. Deleting the partners above already
emptied the wall; this takes the status pill off again. */
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
const heroMedia = mediaId(existingHome?.hero, 'media');
await upsert(strapi, UID.homePage, {
en: {
...HOME_PAGE.en,
hero: { ...(HOME_PAGE.en.hero as SeedData), ...(heroMedia ? { media: heroMedia } : {}) },
},
ar: {
...HOME_PAGE.ar,
hero: { ...(HOME_PAGE.ar.hero as SeedData), ...(heroMedia ? { media: heroMedia } : {}) },
},
});
log(strapi, `removed ${removed} demo entries and reset the home page`);
}
/* ---------------------------------------------------------------------------
ENTRY POINT
------------------------------------------------------------------------ */
export async function runSeed(strapi: Core.Strapi, options: SeedOptions): Promise<void> {
/* The guard that makes demo content safe to have in the repository at all:
no flag combination writes invented entries to a production database. */
if (options.demo && process.env.NODE_ENV === 'production') {
throw new Error(
'[seed] refusing to touch demo content with NODE_ENV=production. ' +
'Run `pnpm seed` without --demo to write the approved content only.'
);
}
if (options.clean && !options.demo) {
throw new Error(
'[seed] --clean removes demo content and nothing else, so it needs --demo. ' +
'Approved content is never deleted by the seed.'
);
}
if (options.clean) {
await cleanDemo(strapi);
return;
}
const context = await seedApproved(strapi);
if (options.demo) {
log(strapi, 'writing DEMO fixtures - invented content, every slug prefixed "demo-"');
await seedDemo(strapi, context);
}
log(strapi, 'done.');
}
/** Read the options off a process argument list. */
export function parseSeedOptions(argv: readonly string[]): SeedOptions {
return {
demo: argv.includes('--demo'),
clean: argv.includes('--clean'),
};
}
export type { DemoEntry };
+238
View File
@@ -0,0 +1,238 @@
/* ============================================================================
SEED - idempotent document helpers
----------------------------------------------------------------------------
Every write in the seed goes through here, so the whole seed can be run
again over a database it has already written to and change nothing but the
fields it owns. Nothing is ever deleted except demo entries, and only on an
explicit `--demo --clean`.
THE SHAPE OF A WRITE
A seed entry is always a pair - the Arabic document and its English
translation - because every localised content type on this site ships in
both languages (CLAUDE.md rule 7) and Arabic is the CMS's default locale.
`upsert` writes the Arabic document first, because that is the one
carrying the identity (the slug), then writes the English locale onto the
SAME document, which is how Strapi 5 models a translation: one
`documentId`, one row per locale.
DRAFT AND PUBLISH
Content is written as a draft and then published explicitly. Publishing
is a separate call rather than `status: 'published'` on the write, so a
half-built entry is never live for the moment between the two locales.
Two content types - `topic` and `region` - are pure taxonomy and have
draft & publish switched off, and a document service for such a type has
no `publish` method and no `status` to filter on at all. `usesDraft`
below reads that off the schema rather than keeping a list, so a type that
gains or loses the setting needs no change here.
TYPES
`strapi.documents(uid)` is generated per content type, so a helper that
takes the uid as a parameter cannot be typed against one schema. The
single cast below is the seam: past it, the seed's own data is checked by
the content files' own types, and Strapi validates the payload at runtime
against the real schema - a wrong field name fails the seed loudly.
========================================================================= */
import type { Core } from '@strapi/strapi';
/** The two locales this site ships. */
export const LOCALES = ['en', 'ar'] as const;
export type SeedLocale = (typeof LOCALES)[number];
/** One piece of content, written once per locale. */
export type Localised<T> = Readonly<Record<SeedLocale, T>>;
/** A field payload - checked by Strapi against the real schema at runtime. */
export type SeedData = Record<string, unknown>;
/** Any content type uid `strapi.documents()` accepts. */
export type SeedUid = Parameters<Core.Strapi['documents']>[0];
interface SeedDocument {
documentId: string;
[field: string]: unknown;
}
/**
* The document service, minus the per-uid generics.
*
* See the TYPES note above: this is the one place the seed steps outside
* Strapi's generated types, and it is deliberate.
*/
interface LooseDocumentService {
findFirst(params: SeedData): Promise<SeedDocument | null>;
findMany(params: SeedData): Promise<SeedDocument[]>;
create(params: SeedData): Promise<SeedDocument>;
update(params: SeedData): Promise<SeedDocument | null>;
delete(params: SeedData): Promise<unknown>;
publish(params: SeedData): Promise<unknown>;
}
const documents = (strapi: Core.Strapi, uid: SeedUid): LooseDocumentService =>
strapi.documents(uid) as unknown as LooseDocumentService;
/** Whether this content type has draft & publish, and so a `publish` call. */
const usesDraft = (strapi: Core.Strapi, uid: SeedUid): boolean =>
Boolean(strapi.contentType(uid).options?.draftAndPublish);
/** `status: 'draft'`, but only where a status exists to ask about. */
const draftFilter = (strapi: Core.Strapi, uid: SeedUid): SeedData =>
usesDraft(strapi, uid) ? { status: 'draft' } : {};
/** What a seed step did, for the run summary. */
export interface SeedResult {
documentId: string;
created: boolean;
}
/**
* Write both locales of a document and publish it.
*
* `find` identifies the entry across runs - a slug for a collection type,
* nothing at all for a single type, which has only ever one document. When it
* matches nothing the document is created; when it matches, the same fields
* are written over the existing one, so an editor's OTHER changes survive a
* re-run and the fields the seed owns go back to the seeded value.
*/
export async function upsert(
strapi: Core.Strapi,
uid: SeedUid,
data: Localised<SeedData>,
find?: SeedData
): Promise<SeedResult> {
const service = documents(strapi, uid);
/* Looked up as a draft: a document that exists but has never been published
is still the same document, and creating a second one beside it is how a
seed ends up with duplicates. */
const existing = await service.findFirst({
...(find ? { filters: find } : {}),
locale: 'ar',
...draftFilter(strapi, uid),
});
let documentId: string;
let created = false;
if (existing) {
documentId = existing.documentId;
await service.update({ documentId, locale: 'ar', data: data.ar });
} else {
const document = await service.create({ locale: 'ar', data: data.ar });
documentId = document.documentId;
created = true;
}
/* An update against a locale the document does not have yet creates that
translation - there is no separate "add a locale" call. */
await service.update({ documentId, locale: 'en', data: data.en });
if (usesDraft(strapi, uid)) {
await service.publish({ documentId, locale: '*' });
}
return { documentId, created };
}
/**
* Delete the documents a filter matches, in every locale.
*
* TWO THINGS THE DOCUMENT SERVICE DOES NOT DO BY ITSELF, both of which leave
* Arabic rows behind that the next run then collides with on the unique slug:
*
* 1. `delete({ documentId })` with no locale removes the DEFAULT locale
* only, not the document. It takes `locale: '*'` to remove the lot.
* 2. `findMany({ locale })` answers for one locale, so a document whose
* base half has already gone is invisible to a search in that locale.
* Both locales are searched and the documentIds pooled, which also
* sweeps up orphans an earlier, less careful run left.
*/
async function deleteMatching(
strapi: Core.Strapi,
uid: SeedUid,
filters: SeedData
): Promise<number> {
const service = documents(strapi, uid);
const documentIds = new Set<string>();
for (const locale of LOCALES) {
const matches = await service.findMany({
filters,
locale,
...draftFilter(strapi, uid),
pagination: { limit: -1 },
});
for (const match of matches) documentIds.add(match.documentId);
}
for (const documentId of documentIds) {
await service.delete({ documentId, locale: '*' });
}
return documentIds.size;
}
/**
* Delete every document of a type whose slug starts with the given prefix.
*
* This is how `--demo --clean` finds what it planted: the prefix is on the
* slug itself, so nothing has to be remembered between runs and an entry an
* editor has since renamed out of the prefix is deliberately left alone.
*/
export async function deleteBySlugPrefix(
strapi: Core.Strapi,
uid: SeedUid,
prefix: string
): Promise<number> {
return deleteMatching(strapi, uid, { slug: { $startsWith: prefix } });
}
/**
* Delete documents matched by an arbitrary filter.
*
* The FAQ content type has no slug to prefix, so its demo entries are found by
* their exact questions instead - in both languages, because the filter is
* matched per locale. Anything the seed did not write is left untouched,
* because the filter is the seeded list itself.
*/
export async function deleteWhere(
strapi: Core.Strapi,
uid: SeedUid,
filters: SeedData
): Promise<number> {
return deleteMatching(strapi, uid, filters);
}
/**
* Read one field off a single type before overwriting it.
*
* Used for the media an editor has already uploaded: the seed owns the words
* on a page, not the pictures, so it reads the current hero image back and
* writes it out again with the new copy rather than dropping it. A component
* is replaced wholesale on write, so anything inside it that is not in the
* payload is gone.
*/
export async function readExisting(
strapi: Core.Strapi,
uid: SeedUid,
populate: string[],
find?: SeedData
): Promise<SeedDocument | null> {
return documents(strapi, uid).findFirst({
...(find ? { filters: find } : {}),
locale: 'ar',
...draftFilter(strapi, uid),
populate,
});
}
/** The id of a media entry nested in a populated component, if there is one. */
export function mediaId(component: unknown, field: string): number | undefined {
if (!component || typeof component !== 'object') return undefined;
const media = (component as Record<string, unknown>)[field];
if (!media || typeof media !== 'object') return undefined;
const id = (media as Record<string, unknown>).id;
return typeof id === 'number' ? id : undefined;
}
+44 -28
View File
@@ -96,19 +96,6 @@ export interface BlocksRichText extends Struct.ComponentSchema {
};
}
export interface BlocksStatsGrid extends Struct.ComponentSchema {
collectionName: 'components_blocks_stats_grids';
info: {
description: 'A grid of headline figures.';
displayName: 'Stats Grid';
icon: 'chartBubble';
};
attributes: {
note: Schema.Attribute.Text;
stats: Schema.Attribute.Component<'shared.stat', true>;
};
}
export interface BlocksVideoEmbed extends Struct.ComponentSchema {
collectionName: 'components_blocks_video_embeds';
info: {
@@ -123,6 +110,33 @@ export interface BlocksVideoEmbed extends Struct.ComponentSchema {
};
}
export interface EmailTemplate extends Struct.ComponentSchema {
collectionName: 'components_email_templates';
info: {
description: 'One outbound email template: a subject plus a markdown body. The body is converted to HTML at send time. Placeholders: {event}, {date} and {call}. A placeholder whose value is missing collapses together with the word before it, so "for {call}" (and "\u0639\u0644\u0649 {call}") reads cleanly when there is no call.';
displayName: 'Email Template';
icon: 'envelop';
};
attributes: {
body: Schema.Attribute.RichText & Schema.Attribute.Required;
enabled: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<true>;
key: Schema.Attribute.Enumeration<
[
'confirmation',
'waitlist',
'event-reminder',
'application-acknowledgement',
]
> &
Schema.Attribute.Required;
subject: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
maxLength: 250;
}>;
};
}
export interface NavigationFooterColumn extends Struct.ComponentSchema {
collectionName: 'components_navigation_footer_columns';
info: {
@@ -150,6 +164,21 @@ export interface NavigationNavGroup extends Struct.ComponentSchema {
};
}
export interface PageBandIntro extends Struct.ComponentSchema {
collectionName: 'components_page_band_intros';
info: {
description: 'The heading, lead and pictures of a page band, in one place so an editor is not hunting for half of it in the Blocks zone.';
displayName: 'Band intro';
icon: 'layer';
};
attributes: {
eyebrow: Schema.Attribute.String;
heading: Schema.Attribute.String;
images: Schema.Attribute.Media<'images', true>;
lead: Schema.Attribute.Text;
};
}
export interface PageConsortiumMember extends Struct.ComponentSchema {
collectionName: 'components_page_consortium_members';
info: {
@@ -297,19 +326,6 @@ export interface SharedSeo extends Struct.ComponentSchema {
};
}
export interface SharedStat extends Struct.ComponentSchema {
collectionName: 'components_shared_stats';
info: {
description: 'A single headline figure, e.g. 64 / CSO Partners.';
displayName: 'Stat';
icon: 'chartBubble';
};
attributes: {
label: Schema.Attribute.String & Schema.Attribute.Required;
value: Schema.Attribute.String & Schema.Attribute.Required;
};
}
export interface SharedTitledText extends Struct.ComponentSchema {
collectionName: 'components_shared_titled_texts';
info: {
@@ -333,10 +349,11 @@ declare module '@strapi/strapi' {
'blocks.partner-logos': BlocksPartnerLogos;
'blocks.quote': BlocksQuote;
'blocks.rich-text': BlocksRichText;
'blocks.stats-grid': BlocksStatsGrid;
'blocks.video-embed': BlocksVideoEmbed;
'email.template': EmailTemplate;
'navigation.footer-column': NavigationFooterColumn;
'navigation.nav-group': NavigationNavGroup;
'page.band-intro': PageBandIntro;
'page.consortium-member': PageConsortiumMember;
'page.workstream': PageWorkstream;
'shared.accordion-item': SharedAccordionItem;
@@ -346,7 +363,6 @@ declare module '@strapi/strapi' {
'shared.hero': SharedHero;
'shared.link': SharedLink;
'shared.seo': SharedSeo;
'shared.stat': SharedStat;
'shared.titled-text': SharedTitledText;
}
}
+90 -20
View File
@@ -464,7 +464,6 @@ export interface ApiAboutPageAboutPage extends Struct.SingleTypeSchema {
[
'blocks.rich-text',
'blocks.image-gallery',
'blocks.stats-grid',
'blocks.cta-banner',
'blocks.card-grid',
'blocks.quote',
@@ -588,7 +587,6 @@ export interface ApiActivityActivity extends Struct.CollectionTypeSchema {
[
'blocks.rich-text',
'blocks.image-gallery',
'blocks.stats-grid',
'blocks.cta-banner',
'blocks.card-grid',
'blocks.quote',
@@ -709,7 +707,7 @@ export interface ApiApplicationApplication extends Struct.CollectionTypeSchema {
> &
Schema.Attribute.Private;
opportunity: Schema.Attribute.Relation<
'oneToOne',
'manyToOne',
'api::opportunity.opportunity'
>;
organisation: Schema.Attribute.String &
@@ -854,6 +852,45 @@ export interface ApiContactSubmissionContactSubmission
};
}
export interface ApiEmailSettingsEmailSettings extends Struct.SingleTypeSchema {
collectionName: 'email_settings';
info: {
description: 'The outbound email templates (registration confirmation, waitlist, reminder, application acknowledgement). Markdown bodies become HTML at send time. The values seeded here on first boot are also the defaults the mails fall back to when the row is missing. Read by the mail jobs only; not exposed over the public API.';
displayName: 'Email Settings';
pluralName: 'email-settings-list';
singularName: 'email-settings';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
i18n: {
localized: true;
};
};
attributes: {
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String;
localizations: Schema.Attribute.Relation<
'oneToMany',
'api::email-settings.email-settings'
>;
publishedAt: Schema.Attribute.DateTime;
templates: Schema.Attribute.Component<'email.template', true> &
Schema.Attribute.Required &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface ApiEventRegistrationEventRegistration
extends Struct.CollectionTypeSchema {
collectionName: 'event_registrations';
@@ -873,7 +910,7 @@ export interface ApiEventRegistrationEventRegistration
Schema.Attribute.Private;
dietaryOrAccess: Schema.Attribute.Text;
email: Schema.Attribute.Email & Schema.Attribute.Required;
event: Schema.Attribute.Relation<'oneToOne', 'api::event.event'>;
event: Schema.Attribute.Relation<'manyToOne', 'api::event.event'>;
fullName: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
@@ -992,12 +1029,6 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
};
}> &
Schema.Attribute.DefaultTo<'in-person'>;
gallery: Schema.Attribute.Media<'images', true> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
implementingPartners: Schema.Attribute.Relation<
'oneToMany',
'api::partner.partner'
@@ -1165,11 +1196,16 @@ export interface ApiHomePageHomePage extends Struct.SingleTypeSchema {
};
};
attributes: {
approach: Schema.Attribute.Component<'page.band-intro', false> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
blocks: Schema.Attribute.DynamicZone<
[
'blocks.rich-text',
'blocks.image-gallery',
'blocks.stats-grid',
'blocks.cta-banner',
'blocks.card-grid',
'blocks.quote',
@@ -1216,12 +1252,6 @@ export interface ApiHomePageHomePage extends Struct.SingleTypeSchema {
localized: true;
};
}>;
stats: Schema.Attribute.Component<'shared.stat', true> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
@@ -1256,7 +1286,6 @@ export interface ApiKnowledgeItemKnowledgeItem
[
'blocks.rich-text',
'blocks.image-gallery',
'blocks.stats-grid',
'blocks.cta-banner',
'blocks.card-grid',
'blocks.quote',
@@ -1389,7 +1418,6 @@ export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
[
'blocks.rich-text',
'blocks.image-gallery',
'blocks.stats-grid',
'blocks.cta-banner',
'blocks.card-grid',
'blocks.quote',
@@ -1766,6 +1794,47 @@ export interface ApiRegionRegion extends Struct.CollectionTypeSchema {
};
}
export interface ApiReminderSettingsReminderSettings
extends Struct.SingleTypeSchema {
collectionName: 'reminder_settings';
info: {
description: 'Operational settings for the event reminder emails: whether they are enabled and how long before an event starts the reminder is sent. Read by the cron job only; not exposed over the public API.';
displayName: 'Reminder Settings';
pluralName: 'reminder-settings-list';
singularName: 'reminder-settings';
};
options: {
draftAndPublish: false;
};
attributes: {
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
enabled: Schema.Attribute.Boolean &
Schema.Attribute.Required &
Schema.Attribute.DefaultTo<true>;
leadHours: Schema.Attribute.Integer &
Schema.Attribute.Required &
Schema.Attribute.SetMinMax<
{
min: 0;
},
number
> &
Schema.Attribute.DefaultTo<48>;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'api::reminder-settings.reminder-settings'
> &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface ApiResourceResource extends Struct.CollectionTypeSchema {
collectionName: 'resources';
info: {
@@ -2134,7 +2203,6 @@ export interface ApiWhatWeDoPageWhatWeDoPage extends Struct.SingleTypeSchema {
[
'blocks.rich-text',
'blocks.image-gallery',
'blocks.stats-grid',
'blocks.cta-banner',
'blocks.card-grid',
'blocks.quote',
@@ -2715,6 +2783,7 @@ declare module '@strapi/strapi' {
'api::application.application': ApiApplicationApplication;
'api::contact-page.contact-page': ApiContactPageContactPage;
'api::contact-submission.contact-submission': ApiContactSubmissionContactSubmission;
'api::email-settings.email-settings': ApiEmailSettingsEmailSettings;
'api::event-registration.event-registration': ApiEventRegistrationEventRegistration;
'api::event.event': ApiEventEvent;
'api::faq.faq': ApiFaqFaq;
@@ -2724,6 +2793,7 @@ declare module '@strapi/strapi' {
'api::opportunity.opportunity': ApiOpportunityOpportunity;
'api::partner.partner': ApiPartnerPartner;
'api::region.region': ApiRegionRegion;
'api::reminder-settings.reminder-settings': ApiReminderSettingsReminderSettings;
'api::resource.resource': ApiResourceResource;
'api::site-settings.site-settings': ApiSiteSettingsSiteSettings;
'api::topic.topic': ApiTopicTopic;
+47
View File
@@ -0,0 +1,47 @@
# ---------------------------------------------------------------------------
# SHARP-ER web (React Router 8) - environment template
#
# Copy to .env and fill in real values: cp .env.example .env
# Never commit .env.
#
# `pnpm --filter web start` loads THIS file (apps/web/.env), the same way the
# CMS loads apps/cms/.env. It is per-application on purpose: PORT and HOST mean
# something different to each server, and a single shared value would move
# Strapi onto the website's port. The repository-root .env is docker compose's
# file and is not read here.
#
# A real environment variable always wins over this file, so a systemd unit,
# a container or `PORT=9090 pnpm --filter web start` overrides any line below.
#
# `pnpm dev:web` does NOT read this file - the dev server is Vite on 5173, and
# it only exposes variables prefixed VITE_, by inlining them into the CLIENT
# bundle. Never use that prefix here: STRAPI_TOKEN below is a server secret.
# ---------------------------------------------------------------------------
# --- Server ----------------------------------------------------------------
# Port react-router-serve binds. Leave it unset and the server takes the first
# FREE port from 3000 upwards - 3001, 3002, ... once 3000 is busy - which a
# reverse proxy cannot be pointed at. `pnpm dev:web` ignores this and uses 5173.
PORT=3000
# Interface to bind. Unset means every interface. Set it to 127.0.0.1 when a
# reverse proxy on the same host should be the only way in.
# HOST=127.0.0.1
# --- Content ---------------------------------------------------------------
# Public origin of the site. Canonical URLs, hreflang alternates and JSON-LD
# are built from it, so it must match how visitors actually reach the site -
# https://sharper.ly in production, never the internal http://127.0.0.1:3000.
# Leave it unset in local development and the request's own host is used.
SITE_URL=http://localhost:3000
# Where the server-side loaders fetch content from. This origin is also
# prefixed onto uploaded media URLs, which the browser has to resolve too -
# see "Images from the CMS" in README.md.
STRAPI_URL=http://localhost:1337
# Optional Strapi API token. Only needed to read draft or otherwise protected
# content; published content is public. Settings -> API Tokens in the admin.
STRAPI_TOKEN=
+25 -5
View File
@@ -26,6 +26,9 @@
* "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.
* Ghost is repainted to the Claude Design drafts rather than to the Figma
frame: text-only throughout, no hover fill, no lift, widening gap. See
the block below and the ghost tokens in semantic.css.
* 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.
@@ -190,11 +193,28 @@
--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);
/* GHOST IS A TEXT LINK, NOT A BUTTON.
The drafts draw "All updates ->" and "What we do ->" as plain text that
turns accent green while the label-to-arrow gap opens from 8px to 12px.
So the ghost variant opts out of the 3px lift the filled controls take -
nothing that has no box should appear to rise off the page - and animates
its gap instead. (The old Figma-quirk override that gave ghost md/lg
radius.md is gone with --radius-control-lg; every control is 4px.) */
.sh-btn[data-variant="ghost"] .sh-btn__content {
transition:
opacity var(--motion-duration-fast) var(--motion-ease-standard),
gap var(--motion-duration-base) var(--motion-ease-standard);
}
@media (hover: hover) {
.sh-btn[data-variant="ghost"]:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
transform: none;
}
.sh-btn[data-variant="ghost"]:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading])
.sh-btn__content {
gap: var(--spacing-sm);
}
}
/* ---------------------------------------------------------------------
+4 -1
View File
@@ -65,7 +65,10 @@ export function CardCTA({
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)]",
/* The drafts rest nothing on a shadow - a panel is a fill and an edge,
and the black is spent on what floats. --elevation-card is that
resting step: none on the dark surface, soft on the light one. */
"bg-surface-accent text-text-on-accent shadow-[var(--elevation-card)]",
"sm:flex-row sm:items-center",
className,
)}
@@ -85,7 +85,7 @@ export function CardContent({
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]">
<span className="absolute start-md top-md rounded-chip bg-overlay-scrim px-xs py-2xs label-md text-text-on-accent backdrop-blur-[4px]">
{badge}
</span>
) : null}
@@ -7,7 +7,7 @@
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;
rounded-card, ...) - 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
@@ -89,7 +89,6 @@ const CARD_ICON_SRC: Record<CardIconName, string> = {
"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 {
@@ -143,8 +142,10 @@ export interface CardShellProps {
}
/**
* The surface every card shares: raised fill, hairline border, 12px corner
* (Figma radius/lg) and the card elevation.
* The surface every card shares: raised fill, hairline border and the 4px
* corner the design drafts draw. `--elevation-card` is the drafts' resting
* shadow, which on the dark surface is none at all - the hairline does the
* separating, and the black is kept for the hover state in card.css.
*/
export function CardShell({
as = "article",
@@ -160,7 +161,7 @@ export function CardShell({
"sh-card",
linked && "sh-card--linked",
"relative flex bg-[var(--surface-card)] text-text-primary",
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]",
"rounded-card border border-border-subtle shadow-[var(--elevation-card)]",
className,
)}
>
@@ -1,106 +0,0 @@
/* ============================================================================
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>
);
}
+2 -5
View File
@@ -18,7 +18,6 @@
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>
@@ -30,14 +29,14 @@
import { CardLink, CardMedia, CardShell, CardIcon, cx } from "./CardShell";
import type { CardElement, CardIconName, CardImage, CardTitleElement } from "./types";
export type CardTeamSocialKind = "linkedin" | "mail" | "website";
export type CardTeamSocialKind = "linkedin" | "mail";
export interface CardTeamSocial {
kind: CardTeamSocialKind;
href: string;
/**
* Accessible name of the link. Defaults to the platform name; pass a
* translated string on Arabic pages ("البريد الإلكتروني", "الموقع").
* translated string on Arabic pages ("البريد الإلكتروني").
*/
label?: string;
}
@@ -59,13 +58,11 @@ export interface CardTeamProps {
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({
+17 -4
View File
@@ -2,7 +2,7 @@
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
(bg-surface-raised, text-text-accent, rounded-card, ...). This file only
holds the three things a utility cannot do:
1. paint an exported SVG asset in the current token colour (mask),
@@ -13,7 +13,7 @@
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.
`className` (`rounded-card`, `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.
========================================================================= */
@@ -88,17 +88,29 @@
/* ---------------------------------------------------------------------------
HOVER / FOCUS AFFORDANCES
------------------------------------------------------------------------ */
/* The drafts hover a card three ways at once: the hairline turns accent
green, the card rises, and a deep black lands under it. The lift is
pointer-only - a touch "hover" would leave the card raised after the tap -
and it is dropped entirely under prefers-reduced-motion below, because a
transform still lands instantly once app.css has flattened the duration. */
.sh-card--linked {
transition:
box-shadow var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard);
border-color var(--motion-duration-base) var(--motion-ease-standard),
transform var(--motion-duration-base) var(--motion-ease-standard);
}
.sh-card--linked:hover {
border-color: var(--border-default);
border-color: var(--border-accent);
box-shadow: var(--elevation-popover);
}
@media (hover: hover) {
.sh-card--linked:hover {
transform: translateY(-6px);
}
}
.sh-card--linked:hover .sh-card-title,
.sh-card--linked:has(.sh-card-link:focus-visible) .sh-card-title {
text-decoration: underline;
@@ -116,6 +128,7 @@
/* 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--linked:hover .sh-card-media img {
transform: none;
}
+8 -9
View File
@@ -1,16 +1,19 @@
/* ============================================================================
CARD - Figma frame 25:809
----------------------------------------------------------------------------
Five card types that share one surface, one link mechanism and one icon
Four 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 … />
<Card.Content … /> <Card.Team … />
<Card.Horizontal … /> <Card.CTA … />
or the components directly when you want the narrowest import:
import { CardStat } from "~/components/ui/Card";
import { CardTeam } from "~/components/ui/Card";
The fifth, Card.Stat, is gone along with the rest of the project's figures
machinery: no approved document carries a number, so nothing drew it.
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"]
@@ -20,7 +23,6 @@
export { CardContent } from "./CardContent";
export { CardTeam } from "./CardTeam";
export { CardHorizontal } from "./CardHorizontal";
export { CardStat } from "./CardStat";
export { CardCTA } from "./CardCTA";
export {
@@ -39,7 +41,6 @@ export type {
CardTeamSocialKind,
} from "./CardTeam";
export type { CardHorizontalProps } from "./CardHorizontal";
export type { CardStatProps, CardStatTrend } from "./CardStat";
export type { CardCTAProps, CardCTAAction } from "./CardCTA";
export type {
CardShellProps,
@@ -58,14 +59,12 @@ export type {
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. */
/** The card family, namespaced: Card.Content, Card.Team, Card.Horizontal, Card.CTA. */
export const Card = {
Content: CardContent,
Team: CardTeam,
Horizontal: CardHorizontal,
Stat: CardStat,
CTA: CardCTA,
} as const;
+2 -3
View File
@@ -33,10 +33,9 @@ export interface CardLinkTarget {
rel?: string;
}
/** The five icons the card family uses, each an asset exported from Figma. */
/** The icons the card family uses, each an asset exported from Figma. */
export type CardIconName =
| "arrow-right"
| "arrow-up"
| "linkedin"
| "mail"
| "globe";
| "mail";
+3 -1
View File
@@ -287,7 +287,9 @@ function SocialButton({ link }: { link: FooterSocialLink }) {
)}
>
<FooterSocialIcon platform={link.platform} className="h-[17px] w-[17px]" />
<span className="body-sm-bold">{link.label}</span>
{/* The label never wraps under its own icon: "Follow us on Facebook" is
one line at every width, as the draft has it. */}
<span className="body-sm-bold whitespace-nowrap">{link.label}</span>
</a>
);
}
+1 -1
View File
@@ -82,7 +82,7 @@
background-color: var(--field-bg);
color: var(--field-fg);
border: var(--control-border-width) solid var(--field-border);
border-radius: var(--radius-control-lg);
border-radius: var(--radius-control);
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;
@@ -1,273 +0,0 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { Link } from "react-router";
import "./LanguageSwitcher.css";
import {
buildLocalizedHref,
getDirection,
LOCALE_ENDONYM,
LOCALES,
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)]";
const TRANSITION =
"transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
type OptionPosition = "first" | "last";
export interface LanguageMenuProps {
/** 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;
/**
* Accessible name of the globe, in the reader's language ("Language"). It is
* required because the globe carries no text of its own.
*/
label: string;
/** Navigate with a full document request instead of a client transition. */
reloadDocument?: boolean;
className?: string;
}
/**
* The navigation's language control: the globe (figma 478:16381) as a button
* that opens a short list of the site's languages, each linking to the page the
* reader is on under that locale, with the query string and fragment kept.
*
* Not drawn in Figma - the design shows the globe alone - so the list is built
* from the tier-2 surface, border and elevation tokens a popover uses, and
* hangs from the globe's inline-end edge so it opens inward in both directions.
*
* A disclosure rather than an ARIA menu: the entries are ordinary links and are
* announced and followed as links. Each carries its own lang/hrefLang and its
* label its own dir, so a screen reader switches voice for "العربية". The
* current language is marked with aria-current and set in bold, so the state is
* not carried by colour alone. ArrowDown/ArrowUp open the list and move through it, Escape closes it
* and returns focus to the globe, and an outside press or focus leaving closes
* it. Focus is never trapped.
*/
export function LanguageMenu({
locale,
pathname,
search,
hash,
label,
reloadDocument = false,
className,
}: LanguageMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const pendingFocus = useRef<OptionPosition | null>(null);
const rawId = useId();
const listId = `sharper-language-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const options = useCallback(
(): HTMLElement[] =>
Array.from(
rootRef.current?.querySelectorAll<HTMLElement>("[data-language-option]") ??
[],
),
[],
);
const focusOption = useCallback(
(position: OptionPosition) => {
const all = options();
(position === "first" ? all[0] : all[all.length - 1])?.focus();
},
[options],
);
const close = useCallback(() => {
pendingFocus.current = null;
setOpen(false);
}, []);
/* Outside press. pointerdown rather than click so the list is already gone
by the time the click lands on whatever is underneath. */
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.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 the list can only be honoured
once the list has rendered. */
useEffect(() => {
const position = pendingFocus.current;
if (!open || position === null) return;
pendingFocus.current = null;
focusOption(position);
}, [open, focusOption]);
const openAndFocus = (position: OptionPosition) => {
if (open) {
focusOption(position);
return;
}
pendingFocus.current = position;
setOpen(true);
};
const handleButtonKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
openAndFocus("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openAndFocus("last");
}
};
const handleOptionKeyDown = (event: ReactKeyboardEvent<HTMLAnchorElement>) => {
const all = options();
const index = all.indexOf(event.currentTarget);
const moveTo = (to: number) => all[(to + all.length) % all.length]?.focus();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveTo(index + 1);
break;
case "ArrowUp":
event.preventDefault();
moveTo(index - 1);
break;
case "Home":
event.preventDefault();
moveTo(0);
break;
case "End":
event.preventDefault();
moveTo(all.length - 1);
break;
default:
break;
}
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape" || !open) return;
event.preventDefault();
close();
buttonRef.current?.focus();
};
/* React's onBlur is focusout, so it fires for descendants too. */
const handleBlur = (event: ReactFocusEvent<HTMLDivElement>) => {
const next = event.relatedTarget as Node | null;
if (!next || !rootRef.current?.contains(next)) close();
};
return (
<div
ref={rootRef}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={["relative", className].filter(Boolean).join(" ")}
>
<button
ref={buttonRef}
type="button"
aria-expanded={open}
aria-controls={listId}
aria-label={label}
title={label}
onClick={() => (open ? close() : setOpen(true))}
onKeyDown={handleButtonKeyDown}
className={[
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
"text-[var(--text-primary)] hover:text-[var(--text-accent)]",
"aria-expanded:text-[var(--text-accent)]",
TRANSITION,
FOCUS_RING,
].join(" ")}
>
<span aria-hidden="true" className="sh-language-globe" />
</button>
<ul
id={listId}
hidden={!open}
className={[
"absolute end-0 top-full z-[var(--layer-dropdown)] mt-sm",
"flex min-w-[10rem] flex-col gap-2xs p-2xs",
"rounded-control border border-[var(--border-subtle)]",
"bg-[var(--surface-raised)] shadow-[var(--elevation-popover)]",
].join(" ")}
>
{LOCALES.map((option) => {
const current = option === locale;
return (
<li key={option}>
<Link
to={buildLocalizedHref(pathname, option, { search, hash })}
reloadDocument={reloadDocument}
hrefLang={option}
lang={option}
aria-current={current ? "true" : undefined}
data-language-option={option}
onClick={close}
onKeyDown={handleOptionKeyDown}
className={[
current ? "body-md-bold" : "body-md",
"block rounded-control px-sm py-xs text-start no-underline",
"text-[var(--text-primary)] hover:bg-[var(--surface-muted)]",
"aria-[current=true]:text-[var(--text-accent)]",
TRANSITION,
FOCUS_RING,
].join(" ")}
>
{/* The label carries its own direction; the row does not, so
both names line up on the page's inline-start edge instead
of each hugging the side its own script reads from. */}
<span dir={getDirection(option)}>{LOCALE_ENDONYM[option]}</span>
</Link>
</li>
);
})}
</ul>
</div>
);
}
export default LanguageMenu;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// In the navigation bar, wired from the router's location:
const { pathname, search, hash } = useLocation();
<LanguageMenu
label={strings.languageMenuLabel}
locale={locale}
pathname={pathname}
search={search}
hash={hash}
/>
------------------------------------------------------------------------ */
@@ -1,35 +0,0 @@
/* ============================================================================
LANGUAGE SWITCHER - figma `globe` 478:16381 (navigation 20:236)
----------------------------------------------------------------------------
The navigation now carries the language control as a bare 24x24 globe in the
Actions slot, replacing the "AR" / "EN" pill that used to sit next to the EU
badge. The exported asset fills its path with a hard-coded #1D1D1B, which
would be invisible on the dark surface, so it is applied as a mask over
`currentColor` - the same technique as `.sh-arrow-icon` and `.sh-card-icon`.
That keeps the exact exported geometry while the colour comes from whichever
semantic token the bar has in force.
GEOMETRY, straight from Figma: a 24x24 box (`--control-icon-size`) with the
glyph filling it edge to edge. The globe is radially symmetric, so unlike the
arrow it needs no RTL mirroring.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-language-globe {
display: block;
flex: none;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
background-color: currentColor;
-webkit-mask-image: url("/assets/icons/globe-solid.svg");
mask-image: url("/assets/icons/globe-solid.svg");
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
}
}
@@ -53,7 +53,7 @@ export interface LanguageSwitcherProps {
* same page under that locale, preserving the rest of the path.
*
* The mobile drawer uses this, where a spelled-out target is clearer than an
* icon. The desktop bar uses <LanguageMenu />, the globe with a dropdown.
* icon.
*/
export function LanguageSwitcher({
locale,
@@ -1,6 +1,5 @@
import { Link } from "react-router";
import "./LanguageSwitcher.css";
import {
getCounterpartLocale,
getCounterpartPath,
@@ -34,15 +33,11 @@ export interface LanguageToggleProps {
}
/**
* Both languages, side by side: the globe, the current locale in an accent
* chip, and the other locale as a link to the same page under that locale -
* the redesign's nav control, replacing the globe-plus-dropdown on the desktop
* bar.
* Both languages, side by side: the current locale in an accent chip, and the
* other locale as a link to the same page under that locale.
*
* One tap to switch and no state to open, which is why the draft prefers it;
* the drawer still uses <LanguageSwitcher /> and <LanguageMenu /> remains for
* anywhere a list of more than two languages is needed.
*
* the drawer still uses <LanguageSwitcher />.
* The current language is marked with `aria-current`, not by colour alone. The
* link carries its own lang/hrefLang/dir and is named with the target
* language's endonym ("العربية"), so a screen reader switches voice for it
@@ -71,8 +66,6 @@ export function LanguageToggle({
.filter(Boolean)
.join(" ")}
>
<span aria-hidden="true" className="sh-language-globe" />
<span className="flex items-center">
<span
aria-current="true"
@@ -3,8 +3,6 @@ export type {
LanguageSwitcherProps,
LanguageSwitcherSize,
} from "./LanguageSwitcher";
export { LanguageMenu } from "./LanguageMenu";
export type { LanguageMenuProps } from "./LanguageMenu";
export { LanguageToggle } from "./LanguageToggle";
export type { LanguageToggleProps } from "./LanguageToggle";
@@ -16,7 +16,7 @@ export type Locale = (typeof LOCALES)[number];
export type Direction = "ltr" | "rtl";
export const DEFAULT_LOCALE: Locale = "en";
export const DEFAULT_LOCALE: Locale = "ar";
/** Locale -> the value that belongs in <html dir>. */
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
+76 -21
View File
@@ -3,28 +3,58 @@ 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",
};
/** One half of the animated brand lockup, drawn as its own SVG file. */
interface BrandMark {
src: string;
/** Intrinsic size of the file, for the aspect ratio the browser reserves. */
width: number;
height: number;
/** The mark's own drawn width, so the pair flexes to one scale. */
drawnWidth: number;
}
const LOGO_INTRINSIC_WIDTH = 334;
const LOGO_INTRINSIC_HEIGHT = 46;
/**
* The SHARP-ER lockup, split into its two drawn pieces so the "-ER" can slide
* out from behind the P and fade in once the page has settled (Navigation.css).
* Each surface ships its own export of the two pieces: white on the dark
* surface, the two-tone ink on the light surface. Both exports of a piece are
* the exact same box (218x46 and 112x46), so the pair shares one scale; each
* mark takes the share of the row its box gives it, and the suffix rides flush
* against the P's right edge.
*/
const BRAND_LOCKUP: Record<
NavSurface,
{ base: BrandMark; suffix: BrandMark }
> = {
light: {
base: { src: "/assets/sharp-wordmark.svg", width: 218, height: 46, drawnWidth: 218 },
suffix: { src: "/assets/sharp-er-suffix.svg", width: 112, height: 46, drawnWidth: 112 },
},
dark: {
base: { src: "/assets/sharp-wordmark-inverse.svg", width: 218, height: 46, drawnWidth: 218 },
suffix: { src: "/assets/sharp-er-suffix-inverse.svg", width: 112, height: 46, drawnWidth: 112 },
},
};
export interface BrandProps {
/** Locale-prefixed home path, e.g. "/en". */
href: string;
/** Accessible name of the link; the image itself is decorative. */
/** Accessible name of the link; the images are decorative. */
label: string;
surface: NavSurface;
/**
* Run the "-ER" reveal and its breathing loop. Off on the home page, whose
* hero plays the same reveal once - a second, looping copy beside it is
* jarring. Anywhere else the two animations run as designed.
*/
animate?: boolean;
className?: string;
}
/** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */
export function Brand({ href, label, surface, className }: BrandProps) {
export function Brand({ href, label, surface, animate = true, className }: BrandProps) {
const { base, suffix } = BRAND_LOCKUP[surface];
return (
<Link
to={href}
@@ -35,16 +65,41 @@ export function Brand({ href, label, surface, className }: BrandProps) {
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]"
/>
{/* `dir="ltr"` because a Latin wordmark never mirrors; its position on
the bar still follows the page's direction. */}
<span dir="ltr" className="flex w-[100px] items-start">
<img
src={base.src}
alt=""
width={base.width}
height={base.height}
loading="eager"
decoding="async"
draggable={false}
style={{ flex: `${base.drawnWidth} 1 0%` }}
className="block h-auto min-w-0"
/>
{/* The clipping box starts at the P's right edge, so the mark inside
it can slide out from behind the P (Navigation.css). Without
`animate` the box and the reveal classes are dropped and the full
lockup simply sits there. */}
<span
style={{ flex: `${suffix.drawnWidth} 1 0%` }}
className={cx("block min-w-0", animate && "sh-brand-suffix")}
>
<img
src={suffix.src}
alt=""
width={suffix.width}
height={suffix.height}
loading="eager"
decoding="async"
draggable={false}
className={cx("block h-auto w-full", animate && "sh-brand-suffix__mark")}
/>
</span>
</span>
</Link>
);
}
@@ -51,8 +51,14 @@ function MenuArrow() {
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
/* Mirrors with the page: the arrow points at the reading edge. */
className="shrink-0 rtl:-scale-x-100"
/* Mirrors with the page: the arrow points at the reading edge. It only
appears when the reader hovers or focus-es the link, so a row of
always-visible arrows does not announce every entry at once. */
className={cx(
"shrink-0 rtl:-scale-x-100 opacity-0",
"transition-opacity duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
"group-hover:opacity-100 group-focus-visible:opacity-100",
)}
>
<path d="M5 12h13M12.5 6.5L18 12l-5.5 5.5" />
</svg>
@@ -436,10 +442,14 @@ export function DesktopNav({
className={cx(
"body-sm-bold flex cursor-pointer items-center gap-2xs",
"whitespace-nowrap pb-[2px] text-start",
"text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
"aria-expanded:text-[var(--text-accent)]",
"data-[current=true]:text-[var(--text-accent)]",
/* The drafts rest a nav label in text/secondary and
brighten it to primary under the pointer; the section
the reader is in stays primary and is marked by the
accent rule below, not by green text. */
"text-[var(--text-secondary)]",
"hover:text-[var(--text-primary)]",
"aria-expanded:text-[var(--text-primary)]",
"data-[current=true]:text-[var(--text-primary)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
@@ -485,7 +495,7 @@ export function DesktopNav({
}
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
className={cx(
"body-sm-bold flex items-center justify-between gap-md",
"group body-sm-bold flex items-center justify-between gap-md",
"rounded-control px-sm py-xs no-underline",
"text-[var(--text-primary)]",
"hover:bg-[var(--surface-page)] hover:text-[var(--text-accent)]",
@@ -512,9 +522,10 @@ export function DesktopNav({
onFocus={closeGroup}
className={cx(
"body-sm-bold whitespace-nowrap pb-[2px] no-underline",
"text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
"aria-[current=page]:text-[var(--text-accent)]",
/* Same rest/hover/current treatment as a group trigger. */
"text-[var(--text-secondary)]",
"hover:text-[var(--text-primary)]",
"aria-[current=page]:text-[var(--text-primary)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
@@ -6,6 +6,7 @@ import {
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
import { EuBadge } from "../EuBadge";
@@ -28,6 +29,18 @@ const FOCUSABLE_SELECTOR = [
'[tabindex]:not([tabindex="-1"])',
].join(", ");
/**
* The hamburger and its close twin. The glyph is the drafts' 24px box, which is
* well under the 44x44 WCAG 2.5.5 target size, so the control is a 44px square
* around it. The negative inline-end margin gives back the 10px of padding that
* would otherwise push the glyph off the page gutter the wordmark opposite it
* lines up with - logical, so it mirrors in Arabic.
*/
const TAP_TARGET = cx(
"-me-[0.625rem] inline-flex size-11 shrink-0 cursor-pointer",
"items-center justify-center rounded-control",
);
function focusablesIn(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0,
@@ -55,6 +68,23 @@ export interface MobileNavProps {
* `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.
*
* THE DRAWER IS PORTALLED TO <body>, AND HAS TO BE.
* The bar it hangs off paints the drafts' translucent ground with
* `backdrop-filter: blur(14px)`, and a filtered element becomes the containing
* block for every `position: fixed` descendant (CSS Filter Effects 1 s.6.1).
* Rendered in place, `fixed inset-0` therefore resolved against the 64px bar
* rather than the viewport: the panel painted a 64px strip and its menu spilled
* over the page with no background behind it. The header also carries
* `z-index: var(--layer-header)`, which is a stacking context, so an
* `aria-modal` dialog nested inside it could never rise above anything painted
* above the bar either. A portal fixes both at once, and it is where a modal
* dialog belongs regardless.
*
* The portal is client-only, so the panel joins the DOM on mount. Nothing is
* lost: the trigger is a button with an onClick, so without JavaScript the
* drawer could not be opened anyway, and every link in it is already in the
* server-rendered DesktopNav markup.
*/
export function MobileNav({
items,
@@ -69,6 +99,9 @@ export function MobileNav({
className,
}: MobileNavProps) {
const [open, setOpen] = useState(false);
/* The portal target only exists in a browser, and the first client render has
to match the server's, so the panel is mounted one commit later. */
const [mounted, setMounted] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const drawerRef = useRef<HTMLDivElement>(null);
@@ -81,6 +114,8 @@ export function MobileNav({
const close = useCallback(() => setOpen(false), []);
useEffect(() => setMounted(true), []);
/* Focus in on open, and back to the hamburger on close. */
useEffect(() => {
if (open) {
@@ -94,11 +129,11 @@ export function MobileNav({
/* 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.
it. Rather than naming those landmarks, every ancestor up to <body> has its
OTHER children inerted; from the portal that is simply body's other
children, which is exactly the header, main and footer. `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. */
@@ -215,6 +250,119 @@ export function MobileNav({
FOCUS_RING,
);
const panel = (
<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",
)}
>
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand
href={homeHref}
label={strings.homeLinkLabel}
surface={surface}
animate={!isCurrentPath(homeHref, pathname)}
/>
<button
ref={closeRef}
type="button"
aria-label={strings.closeMenu}
onClick={close}
className={cx(TAP_TARGET, 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>
);
return (
<>
<button
@@ -224,123 +372,12 @@ export function MobileNav({
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,
)}
className={cx(TAP_TARGET, "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",
)}
>
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex 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>
{mounted ? createPortal(panel, document.body) : null}
</>
);
}
@@ -18,6 +18,114 @@
animation: sh-nav-menu-in var(--motion-duration-base)
var(--motion-ease-entrance) both;
}
/* The "-ER" after "SHARP" in the brand, sliding out from behind the P and
fading in once the page has settled. The same reveal the home hero used to
run on its wordmark, now owned by the navigation. The suffix sits in its
own box whose inline-start edge is the P's right edge, and that box clips;
the mark starts a full box-width back - entirely beside "SHARP", so
nothing overlaps it - and travels forward to rest, with the opacity
reaching 1 before the travel ends, so the glyphs read as solid for the
last, slowest part of the decelerating slide. The translate is physical
(translateX) on purpose: the lockup row is pinned to dir="ltr" because a
Latin wordmark never mirrors, so the P is always left of the suffix, in
Arabic too.
After that one-time reveal the mark keeps breathing: after a long rest
(~26s of stillness per cycle) it slides back out from behind the P and
returns, so the wordmark quietly draws the eye without the glyphs moving
while they are visible. On the home page none of this runs (Brand.tsx
drops the classes when the lockup links the page you are on): the hero
there plays the same reveal once, and a second, looping copy right above
it would be jarring. The two
animations are sequenced by delay - the reveal (once, `both` fill, so the
suffix stays hidden through its delay instead of flashing in) hands over
to the loop, whose later position in the list wins from its start, and
whose own delay is when the revealed lockup is simply at rest. Pure CSS,
so it runs from the server-rendered HTML without waiting for hydration.
Under prefers-reduced-motion both animations are removed outright -
app.css only collapses the duration and would leave the delay holding the
suffix hidden - so the full name is simply there from the start. */
.sh-brand-suffix {
overflow: hidden;
}
.sh-brand-suffix__mark {
animation:
sh-brand-suffix-in var(--motion-duration-reveal)
var(--motion-ease-entrance) var(--motion-delay-reveal) both,
sh-brand-breathe calc(var(--motion-duration-reveal) * 37.5)
ease-in-out calc(var(--motion-duration-reveal) * 5) infinite;
}
@media (prefers-reduced-motion: reduce) {
.sh-brand-suffix__mark {
animation: none;
}
}
}
@keyframes sh-brand-suffix-in {
from {
opacity: 0;
transform: translateX(-100%);
}
60% {
opacity: 1;
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* One 30s breath: dwell at rest (~26s - the stillness is what stretches, the
gestures stay at a human pace), slide back out behind the P (the reverse of
the reveal - solid while it pulls away, fading as it leaves), pass on
hidden, then play the reveal again, and hold. Looped, with the cycle ending
where it starts so the seam is invisible. Each slide is ONE eased interval
of roughly one reveal duration - the opacity stop inside the out-slide is
opacity-only, so it never splits the transform's easing and the glyphs
cannot stall or hitch mid-gesture. */
@keyframes sh-brand-breathe {
/* settled */
0%, 87% {
transform: translateX(0);
opacity: 1;
animation-timing-function: var(--motion-ease-standard);
}
/* pull back solid - opacity only, so the 87→90 transform still runs one
eased interval and the slide never hitches */
88.5% {
opacity: 1;
}
/* gone, behind the P */
90% {
transform: translateX(-100%);
opacity: 0;
}
/* pass, hidden, then the reveal again - one eased slide back in */
94% {
transform: translateX(-100%);
opacity: 0;
animation-timing-function: var(--motion-ease-entrance);
}
97% {
transform: translateX(0);
opacity: 1;
}
/* settled until the cycle hands over (0% is settled too) */
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes sh-nav-menu-in {
@@ -3,13 +3,17 @@ import { useMemo } from "react";
import {
buildLocalizedHref,
getDirection,
isCurrentPath,
LanguageToggle,
type Locale,
} from "../LanguageSwitcher";
import "./Navigation.css";
import { Brand } from "./Brand";
import { DesktopNav } from "./DesktopNav";
import { MobileNav } from "./MobileNav";
import { ThemeToggle } from "./ThemeToggle";
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
import { cx } from "./styles";
import type { NavItem, NavigationStrings, NavSurface } from "./types";
@@ -44,18 +48,20 @@ export interface NavigationProps {
* The SHARP-ER site navigation - Figma frame 20:236.
*
* Three slots, spread across the bar: the wordmark at the inline start, the
* mega-menu groups in the middle, and the language globe at the inline end.
* mega-menu groups in the middle, and the language control at the inline end.
* Everything is laid out with logical properties, so the Arabic bar mirrors
* (logo right, globe left) from `<html dir="rtl">` alone - there is no separate
* Arabic component, which is what variants 334:526 and 334:764 show.
* (logo right, language control left) from `<html dir="rtl">` alone - there is
* no separate Arabic component, which is what variants 334:526 and 334:764
* show.
*
* Below the xl breakpoint the bar collapses to wordmark + hamburger (20:220)
* and the whole tree moves into a full-screen drawer.
*
* EU VISIBILITY: the updated design has no funding badge in the bar - the
* Actions slot holds the language globe alone. The mandatory "Funded by the
* European Union" lockup is carried by <Footer />, which renders on every page,
* and by the mobile drawer; it is not dropped from the site.
* Actions slot holds the language control and the theme toggle. The mandatory
* "Funded by the European Union" lockup is carried by <Footer />, which
* renders on every page, and by the mobile drawer; it is not dropped from the
* site.
*/
export function Navigation({
locale = "en",
@@ -109,6 +115,9 @@ export function Navigation({
href={resolvedHomeHref}
label={strings.homeLinkLabel}
surface={surface}
/* The home page's own hero plays the reveal once; the brand beside
it stays still there instead of running a second copy. */
animate={!isCurrentPath(resolvedHomeHref, pathname)}
/>
<DesktopNav
@@ -120,16 +129,16 @@ export function Navigation({
/>
<div className="flex items-center gap-md">
{/* The globe is desktop-only: the mobile bar (20:220) is wordmark +
hamburger, and the drawer carries the switcher instead. Visibility
lives on this wrapper rather than on the child, because two
display utilities on one element are resolved by the order
{/* The language control is desktop-only: the mobile bar (20:220) is
wordmark + hamburger, and the drawer carries the switcher instead.
Visibility lives on this wrapper rather than on the child, because
two display utilities on one element are resolved by the order
Tailwind emits them, not by class-attribute order. */}
{showLanguageSwitcher && (
/* Figma 555:3848 sets a 20px rule between the links and the
actions, then 24px to the globe. It is decorative, so it is
hidden from assistive technology and drawn with a border token
rather than a rule element. */
actions, then 24px to the language cluster. It is decorative, so
it is hidden from assistive technology and drawn with a border
token rather than a rule element. */
<div className="hidden items-center gap-lg xl:flex">
<span
aria-hidden="true"
@@ -148,6 +157,11 @@ export function Navigation({
</div>
)}
{/* The theme control sits with the other actions at every breakpoint:
after the (desktop-only) language cluster on wide screens, beside
the hamburger on a compact bar. */}
<ThemeToggle locale={locale} />
<MobileNav
items={navItems}
pathname={pathname}
@@ -0,0 +1,111 @@
import { useMemo } from "react";
import { useFetcher, useRouteLoaderData } from "react-router";
import { createTranslator } from "~/i18n/t";
import type { AppTheme } from "~/lib/theme";
import type { Locale } from "../LanguageSwitcher";
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
/** The document surface the root loader currently paints on <html>. */
interface RootLoaderData {
theme?: AppTheme;
}
/** A minimal sun, for "switch to light". Decorative - the label calls it. */
function SunIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
);
}
/** A minimal crescent moon, for "switch to dark". Decorative. */
function MoonIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
}
export interface ThemeToggleProps {
/** Reading locale, for the accessible label. */
locale: Locale;
className?: string;
}
/**
* The site's one global theme control: sun in dark mode, moon in light.
*
* It renders the target of the next click - the label and the icon describe
* what a press DOES ("Switch to light theme"), never the current state - and
* posts that target to the root action at `/`. The action persists the
* `sharper-theme` cookie, the loader re-runs and the shell repaints
* `data-surface` on <html>, so the whole tier-2 palette re-resolves. Because
* the current theme is read off the root loader (which the server fed from
* the cookie), the SSR markup and the first client render always agree.
*/
export function ThemeToggle({ locale, className }: ThemeToggleProps) {
const fetcher = useFetcher();
const rootData = useRouteLoaderData<RootLoaderData>("root");
const t = useMemo(() => createTranslator(locale), [locale]);
const theme: AppTheme = rootData?.theme ?? "dark";
const targetTheme: AppTheme = theme === "light" ? "dark" : "light";
const label =
targetTheme === "light" ? t("nav.themeToLight") : t("nav.themeToDark");
return (
<fetcher.Form action="/" method="post">
<input type="hidden" name="theme" value={targetTheme} />
<button
type="submit"
aria-label={label}
title={label}
className={cx(
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center",
"rounded-control text-[var(--text-secondary)]",
"hover:text-[var(--text-primary)]",
HOVER_TRANSITION,
FOCUS_RING,
className,
)}
>
{targetTheme === "light" ? <SunIcon /> : <MoonIcon />}
</button>
</fetcher.Form>
);
}
export default ThemeToggle;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// In the nav actions cluster, between the language control and the
// hamburger. Reads the theme from the root loader on its own.
<ThemeToggle locale={locale} />
------------------------------------------------------------------------ */
@@ -41,7 +41,7 @@ export interface NavigationStrings {
closeMenu: string;
/** Accessible name of the logo link. */
homeLinkLabel: string;
/** Accessible name of the language globe, which carries no text of its own. */
/** Accessible name of the in-bar language control, which carries no text of its own. */
languageMenuLabel: string;
/** The mandatory EU funding statement. */
euStatement: string;
+2
View File
@@ -17,6 +17,8 @@
"closeMenu": "إغلاق القائمة",
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
"languageMenu": "اللغة",
"themeToLight": "التبديل إلى المظهر الفاتح",
"themeToDark": "التبديل إلى المظهر الداكن",
"skipToContent": "تخطَّ إلى المحتوى",
"sections": {
"home": "الرئيسية",
+2
View File
@@ -17,6 +17,8 @@
"closeMenu": "Close menu",
"homeLinkLabel": "SHARP-ER home",
"languageMenu": "Language",
"themeToLight": "Switch to light theme",
"themeToDark": "Switch to dark theme",
"skipToContent": "Skip to content",
"sections": {
"home": "Home",
+10 -10
View File
@@ -5,8 +5,8 @@
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
Arabic is the reference catalogue: `Messages` is derived from ar.json, and
en.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.
========================================================================= */
@@ -15,8 +15,8 @@ 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;
/** The shape of every catalogue. Derived from the Arabic source of truth. */
export type Messages = typeof ar;
/** Anything that may be interpolated into a message. */
export type TranslationParams = Readonly<
@@ -44,13 +44,13 @@ export type Translator = (
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;
/* The `Messages` annotation is the parity check: en.json must supply every
key ar.json declares, with a string in every leaf position. */
const englishMessages: Messages = en;
const CATALOGUES: Readonly<Record<Locale, Messages>> = {
en,
ar: arabicMessages,
en: englishMessages,
ar,
};
/** The whole catalogue for a locale - handy for spreading into a component. */
@@ -90,7 +90,7 @@ export function interpolate(
}
/**
* Translate one key. Falls back to the English string when a locale is
* Translate one key. Falls back to the default-locale string when a locale is
* missing it, and finally to the key itself so a page never renders "".
*/
export function translate(
+2 -2
View File
@@ -120,8 +120,8 @@ export function resetServerEnv(): void {
* 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 env = getServerEnv();
if (process.env.SITE_URL?.trim() || env.isProduction) return env.SITE_URL;
const url = new URL(request.url);
const forwardedHost =
+5 -4
View File
@@ -14,6 +14,7 @@
import type { MetaDescriptor } from "react-router";
import {
DEFAULT_LOCALE,
INTL_LOCALE,
OG_LOCALE,
SUPPORTED_LOCALES,
@@ -103,8 +104,8 @@ export interface LocaleAlternate {
/**
* 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.
* points at the default-locale URL because that is what a bare "/" resolves
* to when no locale can be negotiated.
*/
export function alternateUrls(
siteUrl: string,
@@ -121,9 +122,9 @@ export function alternateUrls(
return [
...alternates,
{
locale: "en",
locale: DEFAULT_LOCALE,
hrefLang: "x-default",
href: canonicalUrl(siteUrl, localizedPath(unprefixed, "en")),
href: canonicalUrl(siteUrl, localizedPath(unprefixed, DEFAULT_LOCALE)),
},
];
}
+33
View File
@@ -0,0 +1,33 @@
/* ============================================================================
SITE THEME
----------------------------------------------------------------------------
The one global theme decision the site makes. The whole tier-2 palette is
still per-surface (see app/styles/semantic.css); this simply lets a reader
flip WHICH surface the document stands on, `:root` dark by default. The
preference travels as the `sharper-theme` cookie so the server renders the
same surface the next page will paint - no flash-of-wrong-theme, no
hydration mismatch.
========================================================================= */
export type AppTheme = "dark" | "light";
export const THEME_COOKIE = "sharper-theme";
/** Default when no cookie is present. `:root` is the dark surface. */
export const DEFAULT_THEME: AppTheme = "dark";
/** Parse the cookie header into a theme. Anything unknown resolves to dark. */
export function readTheme(cookieHeader: string | null): AppTheme {
if (!cookieHeader) return DEFAULT_THEME;
const pair = cookieHeader
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${THEME_COOKIE}=`));
const value = pair?.split("=")[1];
return value === "light" ? "light" : value === "dark" ? "dark" : DEFAULT_THEME;
}
/** The Set-Cookie value that persists a theme choice for a year. */
export function themeCookie(theme: AppTheme): string {
return `${THEME_COOKIE}=${theme}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
+36 -5
View File
@@ -4,7 +4,9 @@
`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.
client render cannot disagree. The theme surface is the one deliberate
exception to "no cookies" - it is a cookie read in the loader, but because
the loader is what the server renders, the first byte still matches.
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
@@ -43,6 +45,7 @@ import {
} from "~/lib/route-handle";
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
import { DEFAULT_THEME, readTheme, themeCookie, type AppTheme } from "~/lib/theme";
/**
* Last-resort origin for the canonical and hreflang links. The root loader
@@ -80,9 +83,31 @@ export function loader({ request }: Route.LoaderArgs) {
dir: dirFor(locale),
pathname,
siteUrl: getSiteUrl(request),
/* Which surface the document stands on. Read from the theme cookie so the
server paints the same surface the reader asked for - the browser never
guesses, so there is no flash-of-wrong-theme and no hydration mismatch.
`:root` is dark, so the absence of a cookie is a dark first visit. */
theme: readTheme(request.headers.get("Cookie")),
};
}
/**
* The root action is the theme toggle's destination (the nav posts here at
* `/`). It validates the requested theme and persists it with Set-Cookie; the
* loader re-runs and the shell repaints the `data-surface` on <html>.
*/
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const requested = formData.get("theme");
const theme: AppTheme =
requested === "light" ? "light" : requested === "dark" ? "dark" : DEFAULT_THEME;
return new Response(null, {
status: 204,
headers: { "Set-Cookie": themeCookie(theme) },
});
}
function pathnameLocale(pathname: string): Locale {
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
@@ -126,13 +151,19 @@ export function Layout({ children }: { children: ReactNode }) {
const navItems = useMemo(() => buildNavItems(locale), [locale]);
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
const navSurface = useRouteHandleValue(readNavSurface) ?? "dark";
const footerSurface = useRouteHandleValue(readFooterSurface) ?? "dark";
/* The theme cookie decides which surface the DOCUMENT stands on. The header
and footer follow it, unless the route's handle forces one (`navSurface`
/ `footerSurface`) - so a page can still draw a light nav on a dark
surface in either theme. */
const rootData = useRouteLoaderData<typeof loader>("root");
const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
/* 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
@@ -142,7 +173,7 @@ export function Layout({ children }: { children: ReactNode }) {
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
return (
<html lang={locale} dir={dir} data-surface="dark">
<html lang={locale} dir={dir} data-surface={theme}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -114,9 +114,8 @@ const EN = {
uploaded here (see `documentsNote`), so no limit is quoted at all.
* "an automatic acknowledgment with your reference number" - there is no
reference-number field anywhere in the CMS, so no number is promised.
The acknowledgement itself IS modelled, as
`application.acknowledgementSentAt`, so it is the only half that
survives.
The acknowledgement itself IS sent: the CMS mails it on submission
and stamps `application.acknowledgementSentAt` when it went out.
* "follows up within four weeks" - that response time appears in no
approved document; §8 of the draft marks it "[X weeks] - TBD". The
follow-up is stated without a period attached, exactly as the per-call
@@ -168,7 +167,7 @@ const EN = {
*/
documentsNote:
"Documents are not uploaded through this form. Describe any supporting material in the summary above.",
/** `application.acknowledgementSentAt` - modelled, so it can be said. */
/** `application.acknowledgementSentAt` - sent, not just modelled. */
acknowledgement: "Your submission is acknowledged automatically by email.",
submit: "Submit application",
errorSummaryTitle: "Please check the form",
@@ -147,6 +147,7 @@ function toCard(entry: StrapiEvent, now: Date): EventCardData {
registrationStatus:
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
"not-required",
registrationDeadline: text(entry.registrationDeadline),
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
recapUrl: text(entry.recapUrl),
image: toImage(entry.coverImage, "medium"),
@@ -114,6 +114,7 @@ export interface EventCardData {
/** Name of the related region entry, not a free-text string. */
readonly region?: string;
readonly registrationStatus: EventRegistrationStatus;
readonly registrationDeadline?: string;
readonly timeStatus: EventTimeStatus;
/** Set on a past event that has a write-up - drives the "Read recap" label. */
readonly recapUrl?: string;
@@ -126,7 +127,6 @@ export interface EventDetailData extends EventCardData {
/** 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;
@@ -330,8 +330,16 @@ export function collectRegions(
* 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 {
export function isRegistrationOpen(
event: EventCardData,
now = Date.now(),
): boolean {
const deadline = event.registrationDeadline
? Date.parse(event.registrationDeadline)
: undefined;
return (
(deadline === undefined || (Number.isFinite(deadline) && now < deadline)) &&
event.timeStatus !== "completed" &&
(event.registrationStatus === "open" ||
event.registrationStatus === "full" ||
@@ -19,6 +19,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
export interface StrapiWriteInit {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
readonly forwardedFor?: string;
}
interface StrapiErrorEnvelope {
@@ -49,6 +50,7 @@ export async function createStrapiEntry<T>(
accept: "application/json",
};
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
if (init.forwardedFor) headers["x-forwarded-for"] = init.forwardedFor;
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
@@ -68,26 +68,43 @@ export interface RegistrationSubmission {
readonly values: RegistrationValues;
/** documentId of the event being registered for. */
readonly eventId: string;
readonly forwardedFor?: 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,
});
): Promise<"confirmed" | "waitlisted"> {
const created = await createStrapiEntry<{
readonly registrationState?: unknown;
}>(
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,
},
{ forwardedFor: input.forwardedFor },
);
if (
created?.registrationState !== "confirmed" &&
created?.registrationState !== "waitlisted"
) {
throw new Error("Strapi returned an invalid registration state.");
}
return created.registrationState;
}
export interface ApplicationSubmission {
readonly values: ApplicationValues;
/** documentId of the opportunity being applied to. */
readonly opportunityId: string;
readonly forwardedFor?: string;
}
/** The key the free-text answer is filed under inside `answers` (json). */
@@ -96,17 +113,21 @@ 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 },
});
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 },
},
{ forwardedFor: input.forwardedFor },
);
}
/* ---------------------------------------------------------------------------
@@ -134,6 +155,7 @@ export const APPLICATION_ANSWER_KEYS = {
export interface PathwayApplicationSubmission {
readonly values: PathwayApplicationValues;
readonly forwardedFor?: string;
/**
* documentId of the opportunity this application belongs to, when one is
* known. The Get Involved form is NOT tied to a single call - it is the
@@ -159,22 +181,26 @@ export async function submitPathwayApplication(
): Promise<void> {
const { values } = input;
await createStrapiEntry(APPLICATIONS_RESOURCE, {
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
applicantName: values.fullName,
email: values.email,
organisation: optional(values.organisation),
answers: {
[APPLICATION_ANSWER_KEYS.route]: values.route,
// Only the answers actually given: an empty string in the admin panel
// reads as "answered, with nothing", which is not what happened.
...(values.region
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
: {}),
...(values.yearsOperating
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
: {}),
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
await createStrapiEntry(
APPLICATIONS_RESOURCE,
{
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
applicantName: values.fullName,
email: values.email,
organisation: optional(values.organisation),
answers: {
[APPLICATION_ANSWER_KEYS.route]: values.route,
// Only the answers actually given: an empty string in the admin panel
// reads as "answered, with nothing", which is not what happened.
...(values.region
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
: {}),
...(values.yearsOperating
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
: {}),
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
},
},
});
{ forwardedFor: input.forwardedFor },
);
}
@@ -409,7 +409,7 @@ export function ConsentCheckbox({
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)]"
className="mt-2xs size-md shrink-0 rounded-chip border border-border-strong accent-[var(--interactive-primary-bg)]"
/>
<label htmlFor={name} className="body-sm text-text-primary">
{label}
+17 -5
View File
@@ -9,7 +9,7 @@
panel right after it. Deliberately NOT an ARIA accordion widget with arrow
key roving: there is one tab stop per question, which is what a keyboard
user expects from a list of links-like controls, and it matches the
disclosure the navigation already ships (DesktopNav, LanguageMenu).
disclosure the navigation already ships (DesktopNav, the language switcher).
The answer is CMS richtext (markdown), so it renders through <RichText />
rather than as a raw string.
@@ -40,9 +40,15 @@ export interface AccordionProps {
*/
defaultOpenIndex?: number | null;
className?: string;
/** Extra classes for each question row, e.g. a scroll-driven reveal. */
itemClassName?: string;
}
/** The +/- mark. The upright bar collapses as the question opens. */
/**
* The +/- mark. The upright bar collapses as the question opens, and the whole
* mark turns half a revolution with it - the design draft's own gesture, which
* carries the change of state even where the thin bar is hard to follow.
*/
function ToggleMark({ open }: { open: boolean }) {
return (
<span
@@ -50,8 +56,8 @@ function ToggleMark({ open }: { open: boolean }) {
className={cx(
"flex size-8 shrink-0 items-center justify-center rounded-pill",
"border border-[var(--border-default)] text-[var(--text-accent)]",
"transition-colors duration-[var(--motion-duration-base)]",
open && "border-[var(--border-accent)] bg-[var(--surface-accent-subtle)]",
"transition duration-[var(--motion-duration-base)]",
open && "rotate-180 border-[var(--border-accent)] bg-[var(--surface-accent-subtle)]",
)}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -71,7 +77,12 @@ function ToggleMark({ open }: { open: boolean }) {
);
}
export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionProps) {
export function Accordion({
items,
defaultOpenIndex = 0,
className,
itemClassName,
}: AccordionProps) {
const baseId = useId();
const initial =
defaultOpenIndex === null ? null : (items[defaultOpenIndex]?.id ?? null);
@@ -93,6 +104,7 @@ export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionP
"border-b border-[var(--border-default)]",
"transition-colors duration-[var(--motion-duration-base)]",
"hover:bg-[var(--surface-muted)]",
itemClassName,
)}
>
<button
+14 -5
View File
@@ -38,11 +38,19 @@ export interface CmsHero {
readonly ctas?: readonly CmsCta[] | null;
}
/** shared.stat - `value` is authored as a string so "48%" and "1,583" survive. */
export interface CmsStat {
/**
* page.band-intro - a page band's own heading, lead and pictures.
*
* It exists so a band is edited in one place. The alternative - a headline in
* one field and its pictures somewhere down the Blocks zone - is how an editor
* ends up unable to find half of a section.
*/
export interface CmsBandIntro {
readonly id?: number;
readonly value: string;
readonly label: string;
readonly eyebrow?: string | null;
readonly heading?: string | null;
readonly lead?: string | null;
readonly images?: readonly StrapiMedia[] | null;
}
/** shared.titled-text - pillars and principles. */
@@ -175,7 +183,8 @@ export interface CmsFaq extends StrapiEntity {
/** api::home-page.home-page */
export interface CmsHomePage extends StrapiEntity {
readonly hero?: CmsHero | null;
readonly stats?: readonly CmsStat[] | null;
/** The band under the hero - "A nationwide project, delivered locally". */
readonly approach?: CmsBandIntro | null;
readonly blocks?: readonly CmsBlock[] | null;
/** Curated newsroom picks; the page falls back to the latest articles. */
readonly featuredNews?: readonly CmsNewsArticle[] | null;
+38 -48
View File
@@ -2,14 +2,17 @@
PAGE COPY - EN / AR
----------------------------------------------------------------------------
SOURCE
Home page Figma frame 253:4799 "Home-en" is the source of truth.
The headline (I253:4800;228:2609), the launch heading
(I361:10853;361:11147), the one-paragraph launch lead
with its four bold runs (I361:10853;349:8838) and the
three captions are transcribed from that frame, NOT
from docs/SHARPER Home Page .docx - the design condenses
the draft, and the draft itself is headed "Placeholder
text for design purposes - not final copy".
Home page The Claude Design draft "Home page redesign with news
and FAQs" (third drop) is the source of truth. Its
headline, lead, primary call to action and the
"nationwide project, delivered locally" band are
transcribed from that draft, NOT from docs/SHARPER Home
Page .docx - the design condenses the draft, and the
draft itself is headed "Placeholder text for design
purposes - not final copy". Every claim in it is
nonetheless in docs/: the consortium, the partner
groups it works with, the EU funding and the December
2027 end date.
Everything else docs/SHARPER_Website_Draft_Section_EN_AR.docx
(sections 1, 2 and 9), which carries both columns, so
no string below is a machine translation of the other
@@ -32,8 +35,9 @@
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.
Figma hides the home page's figures band outright (frame 253:4835 is
hidden="true"), so the band renders only once the CMS `stats` component
supplies real numbers, and nothing stands in for it until then.
hidden="true") and the third design drop deletes it, so the band is gone
from the page and the `stats` component is gone from the CMS. There is
nowhere left for a figure to be typed in, which is the point.
========================================================================= */
import type { Locale } from "~/i18n";
@@ -69,17 +73,13 @@ export interface HomeCopy {
readonly heroCtaPrimary: string;
readonly heroCtaSecondary: string;
/**
* The figures band. Bundled so the band is never empty, and overridden by
* `home-page.stats` the moment an editor writes one. Every figure here is
* verifiable: three consortium members and the Dec 2025 - Dec 2027 run come
* from the project brief, the four areas of work are the What We Do
* workstreams, and the sixteen human security sub-projects are the approved
* EN/AR draft's own wording.
* The band under the hero picture: what the project is and who delivers it.
* It replaces the launch-event band of the previous drop, which had dated
* the whole home page to a single day in June.
*/
readonly stats: readonly { readonly value: string; readonly label: string }[];
readonly launchEyebrow: string;
readonly launchHeading: string;
readonly launchLead: string;
readonly approachEyebrow: string;
readonly approachHeading: string;
readonly approachLead: string;
readonly workHeading: string;
readonly workLinkLabel: string;
readonly newsEyebrow: string;
@@ -194,21 +194,17 @@ export interface PageCopy {
const EN: PageCopy = {
home: {
heroHeadline: "SHARP-ER Project Officially Launches in Libya",
heroHeadline: "Advancing Justice and Accountability, Led by Libyan Communities",
heroLead:
"Shared Action on Rule of Law Progress — Exit Route: a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
heroCtaPrimary: "Expression of Interest",
"SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — works with civil society organisations, municipalities, and community leaders across Libya to strengthen access to justice, accountability, and local governance. Funded by the European Union, running to December 2027.",
heroCtaPrimary: "See open calls",
heroCtaSecondary: "About the project",
stats: [
{ value: "3", label: "Consortium partners" },
{ value: "4", label: "Areas of work" },
{ value: "16", label: "Human security sub-projects" },
{ value: "25 mo", label: "Dec 2025 – Dec 2027" },
],
launchEyebrow: "30 June · Launch event",
launchHeading: "An important start for the project",
launchLead:
"The launch brought together representatives from the European Union Delegation, the Ministry of Youth, the Ministry of Local Governance, the Ministry of Women’s Affairs, civil society organisations, partners, and stakeholders from across Libya. The event marked the opening of the Expression of Interest and the launch of this website.",
approachEyebrow: "Nationwide, Libya",
/* The same sentence the About page heads its body with - one project, one
description of it. */
approachHeading: "A nationwide project, delivered locally",
approachLead:
"Local organisations lead the design and delivery of the work. SHARP-ER partners with Libyan civil society, municipalities, and community leaders — through funded sub-projects, training, and open dialogue between communities and the institutions that serve them.",
workHeading: "Three areas of change",
workLinkLabel: "What we do",
newsEyebrow: "Newsroom",
@@ -383,23 +379,17 @@ const EN: PageCopy = {
const AR: PageCopy = {
home: {
heroHeadline: "انطلاق مشروع SHARP-ER رسميًا في ليبيا",
heroHeadline: "تعزيز العدالة والمساءلة بقيادة المجتمعات الليبية",
heroLead:
"العمل المشترك لتعزيز سيادة القانون – مسار الخروج: مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات في ليبيا في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
heroCtaPrimary: "الدعوة للتعبير عن الاهتمام",
"يعمل SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج — مع منظمات المجتمع المدني والبلديات وقادة المجتمعات في مختلف أنحاء ليبيا على تعزيز الوصول إلى العدالة والمساءلة والحوكمة المحلية. بتمويل من الاتحاد الأوروبي، ويستمر حتى ديسمبر 2027.",
heroCtaPrimary: "اطّلع على الدعوات المفتوحة",
heroCtaSecondary: "عن المشروع",
/* Western digits, as the rest of the Arabic draft writes its numbers
("ديسمبر 2025", "SHARP I and II (2019–2024)"). */
stats: [
{ value: "3", label: "شركاء الائتلاف" },
{ value: "4", label: "مجالات العمل" },
{ value: "16", label: "مشروعًا فرعيًا للأمن الإنساني" },
{ value: "25 شهرًا", label: "ديسمبر 2025 – ديسمبر 2027" },
],
launchEyebrow: "30 يونيو · فعالية الإطلاق",
launchHeading: "بداية مهمة للمشروع",
launchLead:
"جمعت فعالية الإطلاق ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، ومنظمات المجتمع المدني، والشركاء، وأصحاب المصلحة من مختلف أنحاء ليبيا. وشهد الحدث افتتاح الدعوة للتعبير عن الاهتمام وإطلاق هذا الموقع الإلكتروني.",
/* The same two lines the About page uses for its coverage fact and its
body heading, so the Arabic reads identically on both pages. */
approachEyebrow: "على مستوى ليبيا بأكملها",
approachHeading: "مشروع وطني يُنفَّذ محليًا",
approachLead:
"تتولّى المنظمات المحلية تصميم العمل وتنفيذه. ويعمل SHARP-ER بالشراكة مع المجتمع المدني الليبي والبلديات وقادة المجتمعات، من خلال المشاريع الفرعية المموَّلة، والتدريب، والحوار المفتوح بين المجتمعات والمؤسسات التي تخدمها.",
workHeading: "ثلاثة مجالات للتغيير",
workLinkLabel: "ماذا نفعل",
newsEyebrow: "غرفة الأخبار",
+305 -5
View File
@@ -23,11 +23,12 @@
scroll block is additionally gated on `prefers-reduced-motion: no-preference`
so a reduced-motion visitor never gets a timeline-driven opacity ramp at all.
DIRECTION: `transform-origin` and `linear-gradient()` have no logical
keywords, so the progress bar and the brand stripe carry an explicit RTL
override. These are the only physical directions in the file, and they exist
because the property itself has no logical form - every box edge elsewhere is
still ps-/pe-/start-/end-.
DIRECTION: `transform-origin`, `linear-gradient()`, `clip-path: inset()` and
`translateX()` have no logical keywords, so the progress bar, the brand
stripe, the headline wipe, the hover rule and the logo marquee each carry an
explicit RTL override. These are the only physical directions in the file,
and they exist because the property itself has no logical form - every box
edge elsewhere is still ps-/pe-/start-/end-.
========================================================================= */
@layer theme, base, components, utilities;
@@ -59,6 +60,33 @@
--sh-rise-delay: calc(var(--motion-duration-fast) * 3);
}
/* The home headline. The third design drop stops lifting it and uncovers it
instead, from the inline start, so the words are read into place rather
than slid in. It reads the SAME `--sh-rise-delay` as the cascade above, so
`.sh-rise--3` &c. stagger the wipe alongside everything else. */
.sh-wipe {
animation: sh-wipe var(--motion-duration-reveal) var(--motion-ease-entrance)
var(--sh-rise-delay, 0ms) backwards;
}
[dir="rtl"] .sh-wipe {
animation-name: sh-wipe-rtl;
}
/* `backwards`, not `both`: the clip has to be GONE once the wipe is over.
`inset(0)` at rest would keep clipping to the border box, which shaves a
descender or an Arabic mark off a tight heading line. */
/* app.css shortens every animation but leaves its delay alone, which for a
clip means the headline is not merely faded but absent until the delay has
passed. Removing the animation outright puts the words on screen from the
first paint, which is what a reduced-motion reader asked for. */
@media (prefers-reduced-motion: reduce) {
.sh-wipe {
animation: none;
}
}
/* The brand stripe under the hero picture, drawn on from the inline start. */
.sh-bar-grow {
transform-origin: left center;
@@ -93,6 +121,69 @@
infinite;
}
/* The network logo wall, drifting past. The track carries the tiles TWICE,
so moving it exactly half its own width lands on an identical frame and
the loop never shows a seam; 42.5x the reveal duration is the draft's 34s
at the shipped token.
`width` is set here rather than with a `w-max` utility on purpose: the
reduced-motion block below has to be able to take it back, and a utility
sits in a later layer than this one.
It stops under the pointer and whenever anything inside it takes focus, so
a reader can hold the movement still to look at - or tab to - a logo
(WCAG 2.2.2). Under reduced motion it stops being a track at all: the row
simply wraps and the duplicate half is removed, so nothing moves and no
logo is shown twice. */
.sh-marquee {
width: max-content;
animation: sh-marquee calc(var(--motion-duration-reveal) * 42.5) linear
infinite;
}
/* RTL reads the other way, so the drift does too. */
[dir="rtl"] .sh-marquee {
animation-name: sh-marquee-rtl;
}
.sh-marquee:hover,
.sh-marquee:focus-within {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.sh-marquee {
width: auto;
flex-wrap: wrap;
animation: none;
}
.sh-marquee__clone {
display: none;
}
}
/* The wall fades out at both ends instead of being cut off mid-tile. `black`
and `transparent` here are alpha, not colour - a mask reads the channel
only - which is why this is not a surface token. The ramp is symmetric, so
it needs no RTL form. */
.sh-marquee-mask {
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 8%,
black 92%,
transparent
);
mask-image: linear-gradient(
to right,
transparent,
black 8%,
black 92%,
transparent
);
}
/* -------------------------------------------------------------------------
INTERACTION
---------------------------------------------------------------------- */
@@ -112,6 +203,48 @@
}
}
/* A gallery tile that grows as a WHOLE frame instead of pushing the picture
inside it - the draft hovers the frame, not the image. It keeps `.sh-lift`'s
rise and adds a uniform scale to it. Written after `.sh-lift:hover` so the
compound transform wins at equal specificity; `.sh-lift`'s transition
already covers `transform`, so the two move together. */
@media (hover: hover) {
.sh-lift--grow:hover {
transform: translateY(-5px) scale(1.04);
}
}
/* An accent rule drawn across the top of a tile under the pointer, growing
from the inline start. It is a pseudo-element so the tile's own markup
stays a heading and a paragraph. */
.sh-rule-top {
position: relative;
overflow: hidden;
}
.sh-rule-top::before {
content: "";
position: absolute;
inset-block-start: 0;
inset-inline: 0;
block-size: 3px;
background: var(--surface-accent);
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--motion-duration-slow)
var(--motion-ease-entrance);
}
[dir="rtl"] .sh-rule-top::before {
transform-origin: right center;
}
@media (hover: hover) {
.sh-rule-top:hover::before {
transform: scaleX(1);
}
}
/* An FAQ answer opening under its question. */
.sh-answer {
animation: sh-answer var(--motion-duration-base) var(--motion-ease-entrance)
@@ -134,6 +267,49 @@
animation-name: sh-reveal-in;
animation-range: entry 0% entry 80%;
}
/* The edge tiles of a row land in from their own side of the line - the
reading start and the reading end - instead of rising, so a row drags
into place edge-first while its middle rises. `translateX` has no
logical keyword, so the RTL block swaps the two names: the drift always
comes off the start edge, whichever script is on screen. */
.sh-reveal--from-start {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end {
animation-name: sh-rise-from-start;
}
/* A beat between the tiles of a row: each item after the first holds at
its hidden state until it is a little deeper in the viewport, so a row
cascades start-to-end instead of landing as one. Only the start of the
range shifts; `.sh-reveal--in` and the edge slides keep their ends. */
.sh-reveal--step-1 {
animation-range-start: entry 12%;
}
.sh-reveal--step-2 {
animation-range-start: entry 24%;
}
/* The hero picture drifting as the page scrolls past it. It is scaled up
before it travels, so the drift can never uncover an edge of the
frame. Chromium-only, like every timeline here: elsewhere the picture
simply sits still, which is the state the design falls back to. */
.sh-pan {
animation: sh-pan linear both;
animation-timeline: view();
animation-range: entry 100% exit 0%;
}
}
@supports (animation-timeline: scroll()) {
@@ -163,6 +339,49 @@
transform-origin: right center;
}
}
/* THE SAME REVEAL, WITHOUT A TIMELINE. `_page-kit/reveal.ts` tags a
`.sh-reveal` element `.sh-reveal-js` (hidden) in browsers that lack
scroll timelines, then `.sh-reveal-js--in` plays `sh-rise` once it
scrolls into view. Both classes are inert without the helper, so
content is always readable. */
.sh-reveal-js {
opacity: 0;
}
.sh-reveal-js--in {
animation: sh-rise var(--motion-duration-reveal)
var(--motion-ease-entrance) both;
}
/* The edge tiles keep their drift here too: the reader who never sees a
scroll timeline still gets the same from-start / from-end arrival. The
RTL mirrors swap the names, as the Chromium block above does. */
.sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
/* The same beat, expressed in time where there is no timeline. `both`
already answers for the hidden hold during the delay. */
.sh-reveal--step-1.sh-reveal-js--in {
animation-delay: var(--motion-duration-fast);
}
.sh-reveal--step-2.sh-reveal-js--in {
animation-delay: calc(var(--motion-duration-fast) * 2);
}
}
}
@@ -190,6 +409,33 @@
}
}
/* The same reveal turned sideways. `--from-start` is picked up by the RTL
block as the mirror of `--from-end`, so both names exist and the swap is
legible there instead of shipping one keyframe and praying. */
@keyframes sh-rise-from-start {
from {
opacity: 0;
transform: translateX(-20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-rise-from-end {
from {
opacity: 0;
transform: translateX(20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-bar-grow {
from {
transform: scaleX(0);
@@ -236,6 +482,60 @@
}
}
@keyframes sh-wipe {
from {
opacity: 0.4;
clip-path: inset(0 100% 0 0);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
@keyframes sh-wipe-rtl {
from {
opacity: 0.4;
clip-path: inset(0 0 0 100%);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
@keyframes sh-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@keyframes sh-marquee-rtl {
from {
transform: translateX(0);
}
to {
transform: translateX(50%);
}
}
@keyframes sh-pan {
from {
transform: translateY(-5%) scale(1.14);
}
to {
transform: translateY(5%) scale(1.14);
}
}
@keyframes sh-answer {
from {
opacity: 0;
+45
View File
@@ -0,0 +1,45 @@
/* Reveal-on-scroll for browsers without scroll-driven timelines.
----------------------------------------------------------------------------
motion.css handles `.sh-reveal` with `animation-timeline: view()`, which is
Chromium-only; elsewhere every band sits at rest, fully visible. This helper
gives those browsers the same `.sh-rise` reveal: it tags each `.sh-reveal`
element `.sh-reveal-js` (hidden), then flips it to `.sh-reveal-js--in` (the
animation runs) once it scrolls into view.
It is a progressive enhancement and is strictly opt-in per page:
- Chromium already animates via CSS, so this returns early and never
fights the scroll-driven version.
- Reduced-motion visitors are left alone (the CSS never hides them).
- With JS off, no classes are ever added, so content stays visible.
Renders nothing on the server; call from a `useEffect` in the page. */
const SELECTOR = ".sh-reveal";
export function enableReveals(): void {
if (typeof window === "undefined") return;
if (!("IntersectionObserver" in window)) return;
if ("CSS" in window && CSS.supports("animation-timeline: view()")) {
/* Chromium already plays the reveal; nothing to add. */
return;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const element = entry.target;
element.classList.add("sh-reveal-js--in");
element.classList.remove("sh-reveal-js");
observer.unobserve(element);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const element of document.querySelectorAll<HTMLElement>(SELECTOR)) {
element.classList.add("sh-reveal-js");
observer.observe(element);
}
}
+113 -130
View File
@@ -393,6 +393,12 @@ export function PageHero({
const rise = (step?: 2 | 3 | 4 | 5) =>
reveal ? cx("sh-rise", step && `sh-rise--${step}`) : undefined;
/* The same cascade, uncovering the words instead of lifting them - the
headline treatment of the home draft. `.sh-wipe` reads the very step
classes `rise` sets, so both share one stagger. */
const wipe = (step?: 2 | 3 | 4 | 5) =>
reveal ? cx("sh-wipe", step && `sh-rise--${step}`) : undefined;
const washes = ambient ?? [];
return (
@@ -436,45 +442,93 @@ export function PageHero({
) : null}
<Container className="flex flex-col gap-2xl">
{funderBadge || wordmark ? (
/* Figma groups the two (478:16183): the badge, then 24px, then the
lockup. The group as a whole keeps the 48px the Container's gap
already provides above the headline row. */
<div className={cx("flex flex-col gap-lg", rise())}>
{funderBadge}
<div className="flex flex-col gap-lg">
{funderBadge || wordmark ? (
/* Figma groups the two (478:16183): the badge, then 24px, then the
lockup. The group as a whole keeps the 48px the Container's gap
already provides above the headline row. */
<div className={cx("flex flex-col gap-lg", rise())}>
{funderBadge}
{wordmark ? <HeroWordmark wordmark={wordmark} /> : null}
</div>
) : null}
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
{leadWithActions ? (
<>
{/* Headline across the top ... */}
<div className={cx("flex max-w-[43.75rem] flex-col gap-sm", rise(3))}>
{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>
{wordmark ? <HeroWordmark wordmark={wordmark} /> : null}
</div>
) : null}
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
{leadWithActions ? (
<>
{/* Headline across the top ... */}
<div className="flex max-w-[43.75rem] flex-col gap-sm">
{eyebrow ? (
<p
className={cx(
"label-md uppercase text-[var(--text-accent)]",
rise(3),
)}
>
{eyebrow}
</p>
) : null}
{/* The wipe is on the heading itself, not on the column, so the
eyebrow above it still arrives with the rest of the cascade
rather than being uncovered along with the words. */}
<h1
className={cx(
"heading-h1 text-[var(--text-primary)] md:display-sm",
wipe(3),
)}
>
{title}
</h1>
</div>
{/* ... then the lead and the buttons sharing a row, sitting on the
same baseline, as the home draft draws them. */}
{lead || actions ? (
<div
className={cx(
"flex flex-col gap-xl lg:flex-row lg:items-end lg:justify-between",
rise(4),
)}
>
{lead ? (
<p
className={cx(
"body-lg max-w-[40rem]",
leadTone === "primary"
? "text-[var(--text-primary)]"
: "text-[var(--text-secondary)]",
)}
>
{lead}
</p>
) : null}
{actions ? (
<div className="flex flex-wrap items-center gap-lg lg:justify-end">
{actions}
</div>
) : null}
</div>
) : null}
</>
) : (
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
<div className={cx("flex max-w-[46rem] flex-col gap-sm", rise(3))}>
{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>
{/* ... then the lead and the buttons sharing a row, sitting on the
same baseline, as the home draft draws them. */}
{lead || actions ? (
<div
className={cx(
"flex flex-col gap-xl lg:flex-row lg:items-end lg:justify-between",
rise(4),
)}
>
{lead ? (
<p
className={cx(
"body-lg max-w-[40rem]",
"body-lg max-w-[var(--layout-prose-max)]",
leadTone === "primary"
? "text-[var(--text-primary)]"
: "text-[var(--text-secondary)]",
@@ -483,52 +537,21 @@ export function PageHero({
{lead}
</p>
) : null}
{actions ? (
<div className="flex flex-wrap items-center gap-lg lg:justify-end">
{actions}
</div>
) : null}
</div>
) : null}
</>
) : (
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
<div className={cx("flex max-w-[46rem] flex-col gap-sm", rise(3))}>
{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
{actions ? (
<div
className={cx(
"body-lg max-w-[var(--layout-prose-max)]",
leadTone === "primary"
? "text-[var(--text-primary)]"
: "text-[var(--text-secondary)]",
"flex flex-wrap items-center gap-lg lg:justify-end",
rise(4),
)}
>
{lead}
</p>
{actions}
</div>
) : null}
</div>
{actions ? (
<div
className={cx(
"flex flex-wrap items-center gap-lg lg:justify-end",
rise(4),
)}
>
{actions}
</div>
) : null}
</div>
)}
)}
</div>
{media ? (
mediaTreatment === "banner" ? (
@@ -539,13 +562,18 @@ export function PageHero({
mint tint over a shade, both painted on the photograph. */
<div className={cx("w-full", rise(5))}>
<div className="relative w-full overflow-hidden">
{/* `sh-pan` drifts the picture inside the band as the page
scrolls past it. It is scaled up inside the frame first,
so the drift never uncovers an edge; the washes above are
positioned against the frame, not the picture, so they do
not travel with it. */}
<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 object-cover md:aspect-[1244/415]"
className="sh-pan aspect-[16/9] w-full object-cover md:aspect-[1244/415]"
/>
<span
aria-hidden="true"
@@ -555,35 +583,26 @@ export function PageHero({
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-tint)]"
/>
{/* A highlight travelling across the picture - the draft's
`sheen`. Decorative, and it rides on top of both washes. */}
<span
aria-hidden="true"
className="sh-sheen pointer-events-none absolute inset-0 bg-[linear-gradient(100deg,transparent_35%,var(--overlay-veil)_50%,transparent_65%)]"
/>
</div>
{/* The colour band (478:16360): three blocks flush under the
picture, 6px tall. Figma lays out 390 / 495 / 363 inside a
1244-wide clipping frame, so the last block loses 4px and the
split that is actually drawn is 390 : 495 : 359 - carried here
as flex-grow ratios so it holds at every width instead of only
at 1244. The three fills are `background/accent`,
`background/inverse` and `background/danger`, which is why the
middle block reads white on the dark hero and near-black on a
light surface.
{/* The colour band (478:16360, redrawn in the drafts' home
hero): three blocks flush under the picture, 6px tall, split
390 : 495 : 363 and carried as flex-grow ratios so the split
holds at every width instead of only at the drafts' 1244.
The fills are `background/accent`, `background/inverse` and
`background/danger` - the drafts paint the middle block
#F8F9FA, so the stripe reads green / white / red on the dark
hero and flips to green / near-black / red on a light
surface.
Purely decorative, so it is hidden from assistive technology;
as an ordinary flex row it mirrors in Arabic along with the
rest of the page. */}
<div aria-hidden="true" className="sh-bar-grow flex h-[6px] w-full">
<span className="basis-0 grow-[390] bg-[var(--surface-accent)]" />
{/* The middle block is the raised dark, not the inverse:
the redesign drops pure white from the page entirely, so
the stripe reads green / dark / red on the near-black
band instead of green / white / red. */}
<span className="basis-0 grow-[495] bg-[var(--surface-raised)]" />
<span className="basis-0 grow-[359] bg-[var(--surface-danger)]" />
<span className="basis-0 grow-[495] bg-[#20201E]" />
<span className="basis-0 grow-[363] bg-[var(--surface-danger)]" />
</div>
</div>
) : (
@@ -750,39 +769,3 @@ export function ValueChips({
</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>
);
}
+7 -4
View File
@@ -20,7 +20,7 @@
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.
x-default pointing at the default locale when it 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
@@ -33,7 +33,7 @@
import type { MetaDescriptor } from "react-router";
import { SUPPORTED_LOCALES, type Locale } from "~/i18n/config";
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from "~/i18n/config";
import { sectionPath, type RouteSlugKey } from "~/i18n/paths";
import { canonicalUrl } from "~/lib/seo";
@@ -160,8 +160,11 @@ export function detailAlternateDescriptors({
});
}
// x-default is the English URL, because bare "/" negotiates towards English.
const fallback = paths.find((entry) => entry.locale === "en");
// x-default is the default-locale URL, because bare "/" negotiates towards
// the default locale. It must point at an existing page, so a detail page
// with no translation yet falls back to whichever locale does exist.
const fallback =
paths.find((entry) => entry.locale === DEFAULT_LOCALE) ?? paths[0];
if (fallback) {
descriptors.push({
tagName: "link",
+1 -1
View File
@@ -792,7 +792,7 @@ export function FacetGroup({
aria-hidden="true"
className={cx(
"mt-2xs flex size-[0.875rem] shrink-0 items-center justify-center",
"rounded-[var(--radius-control)] border",
"rounded-chip border",
option.isCurrent
? "border-border-accent"
: "border-border-default",
@@ -466,7 +466,7 @@ function StreamDisclosure({
<h3 className="flex-1 heading-h3">{copy.title}</h3>
<span className="hidden shrink-0 rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block">
<span className="hidden shrink-0 rounded-chip bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block">
{copy.partners}
</span>
@@ -127,7 +127,11 @@ export async function action({ params, request }: Route.ActionArgs) {
}
try {
await submitApplication({ values, opportunityId: opportunity.documentId });
await submitApplication({
values,
opportunityId: opportunity.documentId,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
console.error(
@@ -46,8 +46,7 @@
uploaded here at all (see `documentsNote`);
* "an automatic acknowledgment with your reference number" - no
reference-number field exists anywhere in the CMS. The acknowledgement
is modelled (`application.acknowledgementSentAt`) and survives; the
number does not;
IS sent (the CMS mails it on submission) and the number does not;
* "follows up within four weeks" - a response time that appears in no
approved document. §8 of the draft marks it "[X weeks] - TBD".
@@ -226,7 +225,10 @@ export async function action({ params, request }: Route.ActionArgs) {
}
try {
await submitPathwayApplication({ values });
await submitPathwayApplication({
values,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) {
if (!isStrapiError(error)) throw error;
console.error(
+15 -4
View File
@@ -36,7 +36,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
import { data, Form, Link, useLocation, useNavigation } from "react-router";
import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge";
@@ -60,6 +60,7 @@ import {
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
/* ---------------------------------------------------------------------------
LOADER
@@ -230,7 +231,10 @@ function validate(values: ContactValues, locale: Locale): ContactErrors {
* 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> {
async function createContactSubmission(
values: ContactValues,
forwardedFor?: string,
): Promise<void> {
const env = getServerEnv();
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
@@ -240,6 +244,7 @@ async function createContactSubmission(values: ContactValues): Promise<void> {
"content-type": "application/json",
accept: "application/json",
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
...(forwardedFor ? { "x-forwarded-for": forwardedFor } : {}),
},
body: JSON.stringify({
data: {
@@ -282,7 +287,10 @@ export async function action({ params, request }: Route.ActionArgs) {
}
try {
await createContactSubmission(values);
await createContactSubmission(
values,
request.headers.get("x-forwarded-for") ?? undefined,
);
} catch (error) {
console.error("[contact] submission failed", error);
const t = createTranslator(locale);
@@ -370,6 +378,9 @@ export default function Contact({
const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting";
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
const errors = actionData?.errors ?? {};
const values = actionData?.values ?? EMPTY_VALUES;
const formError =
@@ -681,7 +692,7 @@ export default function Contact({
size="sm"
statement={t("site.euStatement")}
locale={locale}
surface="dark"
surface={theme}
/>
</div>
</div>

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