Render the widget on the contact, event registration and both application forms, and verify the token with Siteverify in each action before anything is written to the CMS. Verification fails closed. The check is off unless both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set. Allow challenges.cloudflare.com in script-src and frame-src. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1061 lines
51 KiB
Markdown
1061 lines
51 KiB
Markdown
# SHARP-ER website
|
||
|
||
Bilingual (English / Arabic) website for **SHARP-ER** — *Shared Action on Rule of Law
|
||
Progress – Exit Route* — a European Union funded programme running nationwide in Libya
|
||
from December 2025 to December 2027.
|
||
|
||
SHARP-ER supports Libyan civil society organisations, local institutions and communities
|
||
working on justice, accountability and governance. It is implemented by a consortium of
|
||
**Acted** (lead), **United Efforts Consultancy (UEC)** and **Stichting Bureau Ensemble (SBE)**,
|
||
and continues the work of SHARP I and SHARP II (2019–2024).
|
||
|
||
The site publishes project information, events and registrations, calls and applications,
|
||
news from the project and its partner network, and a knowledge hub of resources and
|
||
publications — all of it in English and Arabic, with the Arabic side laid out right to left.
|
||
|
||
> EU visibility is a contractual requirement: the *Funded by the European Union* emblem is
|
||
> present on every page, and the three implementing partners are always shown together.
|
||
|
||
---
|
||
|
||
## Contents
|
||
|
||
- [The stack, and why](#the-stack-and-why)
|
||
- [Repository layout](#repository-layout)
|
||
- [Choosing a setup path](#choosing-a-setup-path)
|
||
- [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)
|
||
- [Fonts (licensed, not in this repository)](#fonts-licensed-not-in-this-repository)
|
||
- [Deployment and operations](#deployment-and-operations)
|
||
- [Troubleshooting](#troubleshooting)
|
||
- [Source documents](#source-documents)
|
||
|
||
---
|
||
|
||
## The stack, and why
|
||
|
||
| Layer | Choice | Why |
|
||
| --- | --- | --- |
|
||
| Front end | **React Router 8**, framework mode, `ssr: true` | The terms of reference require an SEO-friendly structure. Server-side rendering means every page — including the Arabic one — is delivered as complete HTML with the correct `<html lang>` and `<html dir>` in the first byte, plus canonical and `hreflang` tags a crawler can read without executing JavaScript. |
|
||
| UI | **React 19**, **TypeScript**, **Vite 8** | The framework's own toolchain. Typed loaders and route modules, so a content shape change is a compile error rather than a blank page. |
|
||
| Styling | **Tailwind CSS 4** over a **two-tier design-token** layer | Tier 1 holds raw values, tier 2 holds meanings; components only ever spend tier‑2 tokens. Re-branding is a one-line edit in one file. See [Design tokens](#design-tokens). |
|
||
| CMS | **Strapi 5.53** (TypeScript) | Self-hosted and handed over to UEC at the end of the assignment, as the ToR requires. Its i18n plugin models EN/AR as first-class locales of the same entry, and draft & publish gives editors a review step. |
|
||
| Database | **PostgreSQL 16** | Strapi's recommended production database; proper UTF‑8 and JSON support for bilingual content. |
|
||
| Runtime | **Node 22**, **pnpm 9** workspace | One lockfile, two applications, one `pnpm install`. |
|
||
|
||
The web application never talks to the database. It reads the Strapi REST API from its
|
||
**loaders on the server**, so the API token never reaches the browser, and every page falls
|
||
back to bundled draft copy if the CMS is unreachable — the site stays up when Strapi is down.
|
||
|
||
---
|
||
|
||
## Repository layout
|
||
|
||
```
|
||
sharp-er/
|
||
├── apps/
|
||
│ ├── web/ React Router 8 front end (SSR)
|
||
│ │ ├── app/
|
||
│ │ │ ├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, nav, footer
|
||
│ │ │ ├── routes.ts Route config: "/" redirect, ":lang" layout, the eleven sections
|
||
│ │ │ ├── routes/ One module per page (+ shared _page-kit / _shared helpers)
|
||
│ │ │ ├── components/ui/ Button, Input, Card, Navigation, Footer, LanguageSwitcher
|
||
│ │ │ ├── styles/ Design tokens — primitives.css, semantic.css, typography.css
|
||
│ │ │ ├── i18n/ Locale config, message catalogues (en.json / ar.json), formatters
|
||
│ │ │ └── lib/ strapi.ts (API client), seo.ts, env.server.ts, route-slugs.ts
|
||
│ │ ├── public/
|
||
│ │ │ ├── assets/ Logos, EU emblem, partner logos, icons
|
||
│ │ │ └── fonts/ Licensed woff2 files — NOT committed, see FONTS below
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ └── cms/ Strapi 5 CMS
|
||
│ ├── config/ server, admin, database, middlewares (CORS), plugins (i18n, upload)
|
||
│ ├── src/
|
||
│ │ ├── api/ One folder per content type (schema, controller, routes, service)
|
||
│ │ ├── components/ Reusable field groups (hero, cta, seo, blocks/*)
|
||
│ │ └── bootstrap/ Boot-time reconciliation: locales + public permissions
|
||
│ ├── .env.example Every Strapi variable, documented
|
||
│ └── Dockerfile
|
||
│
|
||
├── docs/ SOURCE MATERIAL — read only, never edited
|
||
├── scripts/ setup-db.sh, generate-secrets.sh, check-prereqs.sh
|
||
├── docker-compose.yml postgres + cms + web
|
||
├── .env.example Root template: docker compose and the web server read this
|
||
├── CLAUDE.md Working brief for AI agent sessions
|
||
└── pnpm-workspace.yaml
|
||
```
|
||
|
||
---
|
||
|
||
## Choosing a setup path
|
||
|
||
Both paths give you the same three processes. Pick one; you do not need both.
|
||
|
||
| | **Path A — manual** | **Path B — Docker** |
|
||
| --- | --- | --- |
|
||
| You install | Node 22, pnpm 9, PostgreSQL 16 | Docker Engine + Compose v2 |
|
||
| Best for | day-to-day development (hot reload, fast rebuilds) | trying the project out, review builds, servers |
|
||
| Web | `http://localhost:5173` (dev server) | `http://localhost:3000` (production build) |
|
||
| CMS | `http://localhost:1337/admin` | `http://localhost:1337/admin` |
|
||
| Database | your local PostgreSQL, port 5432 | container, published on port **5433** |
|
||
| Time to first run | ~5 minutes | ~10 minutes (image build) |
|
||
|
||
Check your machine before you start:
|
||
|
||
```bash
|
||
./scripts/check-prereqs.sh # both paths
|
||
./scripts/check-prereqs.sh manual # or just one
|
||
./scripts/check-prereqs.sh docker
|
||
```
|
||
|
||
---
|
||
|
||
## Path A — manual setup
|
||
|
||
### 1. Prerequisites
|
||
|
||
| Tool | Version | Notes |
|
||
| --- | --- | --- |
|
||
| **Node.js** | **>= 22.22.0** | React Router 8 will not run on anything older — this is a hard floor, not a recommendation. `.nvmrc` pins **22.23.2**, the version this repository is developed on. Strapi 5 accepts Node 20 – 26, so 22 satisfies both. |
|
||
| **pnpm** | **9.x** (repo pins `pnpm@9.12.3`) | npm and yarn will not work: the workspace, the lockfile and the hoisted `node_modules` layout in `.npmrc` are all pnpm's. |
|
||
| **PostgreSQL** | **16** (server + client tools) | Client tools give you `psql`, `createdb` and `pg_isready`. |
|
||
| **Git** | any recent version | |
|
||
|
||
```bash
|
||
# Node — with nvm, the pinned version is one command
|
||
nvm install "$(cat .nvmrc)" && nvm use
|
||
|
||
# pnpm — corepack ships with Node
|
||
corepack enable && corepack prepare pnpm@9.12.3 --activate
|
||
|
||
# PostgreSQL 16 on Debian / Ubuntu
|
||
sudo apt install postgresql-16 postgresql-client-16
|
||
sudo systemctl enable --now postgresql
|
||
|
||
# macOS with Homebrew
|
||
brew install node@22 pnpm postgresql@16 && brew services start postgresql@16
|
||
```
|
||
|
||
Verify: `node -v` → `v22.22.0` or newer, `pnpm -v` → `9.x`, `psql --version` → `16.x`.
|
||
|
||
### 2. Install dependencies
|
||
|
||
```bash
|
||
git clone <repository-url> sharp-er
|
||
cd sharp-er
|
||
pnpm install # installs BOTH apps from the single root lockfile
|
||
```
|
||
|
||
Always run `pnpm install` from the repository root. `.npmrc` sets `node-linker=hoisted`
|
||
because Strapi's plugin resolution expects a flat `node_modules`; installing inside
|
||
`apps/cms` or `apps/web` on its own produces a tree neither application can boot from.
|
||
|
||
### 3. Create the database
|
||
|
||
The database is called `sharper_cms` and is owned by the role Strapi connects as
|
||
(`postgres` by default).
|
||
|
||
```bash
|
||
./scripts/setup-db.sh
|
||
```
|
||
|
||
The script is idempotent — it creates the database only if it is missing, never drops
|
||
anything, and finishes by proving it can connect and that the role may create tables.
|
||
It reads its defaults from `.env` and `apps/cms/.env`, and takes
|
||
`--name`, `--user`, `--password`, `--host`, `--port` overrides (`--help` for the details).
|
||
|
||
Doing it by hand is two commands:
|
||
|
||
```bash
|
||
createdb -h 127.0.0.1 -U postgres sharper_cms
|
||
psql -h 127.0.0.1 -U postgres -d sharper_cms -c "SELECT current_database(), version();"
|
||
```
|
||
|
||
<details>
|
||
<summary>If <code>psql</code> asks for a password you do not have (fresh Ubuntu install)</summary>
|
||
|
||
A newly installed PostgreSQL has a `postgres` role with no password set, reachable only
|
||
over the local socket. Set one, then use it as `DATABASE_PASSWORD` in `apps/cms/.env`:
|
||
|
||
```bash
|
||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
|
||
```
|
||
|
||
For a shared or production machine, create a dedicated role instead:
|
||
|
||
```bash
|
||
sudo -u postgres psql -c "CREATE ROLE sharper WITH LOGIN PASSWORD 'a-strong-password' CREATEDB;"
|
||
./scripts/setup-db.sh --user sharper --password 'a-strong-password'
|
||
```
|
||
</details>
|
||
|
||
### 4. Environment files
|
||
|
||
Three files, with different jobs:
|
||
|
||
| File | Read by | Required in manual mode |
|
||
| --- | --- | --- |
|
||
| `apps/cms/.env` | Strapi (it loads its own `.env`) | **yes** |
|
||
| `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
|
||
cp apps/cms/.env.example apps/cms/.env
|
||
./scripts/generate-secrets.sh --write apps/cms/.env
|
||
|
||
# Set the database credentials you used in step 3
|
||
# DATABASE_CLIENT=postgres
|
||
# DATABASE_HOST=127.0.0.1
|
||
# DATABASE_PORT=5432
|
||
# DATABASE_NAME=sharper_cms
|
||
# DATABASE_USERNAME=postgres
|
||
# DATABASE_PASSWORD=...
|
||
```
|
||
|
||
`scripts/generate-secrets.sh` fills `APP_KEYS`, `API_TOKEN_SALT`, `ADMIN_JWT_SECRET`,
|
||
`JWT_SECRET`, `TRANSFER_TOKEN_SALT` and `ENCRYPTION_KEY` with 32 random bytes each. It never
|
||
overwrites a value that is already set unless you pass `--force`, so it is safe to re-run.
|
||
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 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 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
|
||
pnpm dev # web + cms together, output interleaved and prefixed
|
||
```
|
||
|
||
or, in two terminals:
|
||
|
||
```bash
|
||
pnpm dev:cms # Strapi -> http://localhost:1337
|
||
pnpm dev:web # React Router -> http://localhost:5173
|
||
```
|
||
|
||
The first `pnpm dev:cms` takes a minute: Strapi creates its tables, then the boot-time
|
||
reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on
|
||
every boot; it
|
||
|
||
- 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.
|
||
|
||
You never have to click through the Users & Permissions screens after adding a content type;
|
||
restart the CMS and the grant is there.
|
||
|
||
### Partner content review
|
||
|
||
Partner contributors use Strapi's **Author** admin role. To onboard one, create the admin
|
||
account, then as Super Admin create a **Partner Access** entry in Content Manager with the
|
||
admin user ID (from the user detail URL) and the linked Partner organisation. Each admin
|
||
account has one assignment; multiple accounts can be assigned to the same partner. Partner
|
||
Access has no public Content API and is reserved for Super Admins.
|
||
|
||
Authors can create and edit their own resource, news article, activity, event, or opportunity
|
||
drafts. The CMS enforces their partner assignment on every save, regardless of what the
|
||
browser submits, and removes the organisation selector from their edit fields. Authors cannot
|
||
publish or delete. An **Editor** reviews the draft in the side panel below Preview. Set its
|
||
status to **Approved for publishing** before publishing, or choose **Changes requested** or
|
||
**Rejected** and add feedback. The Publish action stays disabled until approval is saved, and
|
||
the server rejects publish requests for these content types unless their review status is approved.
|
||
The partner author can read the status and feedback in the CMS; both fields are excluded from the public API.
|
||
Resubmitting resets the status to **Pending** and clears old feedback.
|
||
|
||
The CMS also emails reviewers when a partner draft is first submitted or resubmitted, and emails
|
||
the submitting admin when the reviewer requests changes, rejects the draft, or publishes it.
|
||
Reviewer mail goes to all active Super Admins by default; set the optional comma-separated
|
||
`PARTNER_REVIEW_NOTIFICATION_EMAILS` to use a dedicated inbox. These notices use the configured
|
||
SMTP provider. Email failures are logged and do not undo a saved draft or review decision.
|
||
|
||
Strapi's built-in Author role limits authors to entries they created. Different users assigned
|
||
to the same partner therefore share the organisation assignment, but not each other's drafts.
|
||
Use Editor for central review; partner-wide collaboration would need a separate custom access
|
||
policy.
|
||
|
||
### 6. Create the first admin user
|
||
|
||
Strapi has no seeded administrator. On first run, open
|
||
|
||
**http://localhost:1337/admin**
|
||
|
||
and complete the registration form — the first account created becomes the super admin.
|
||
Or do it from the command line:
|
||
|
||
```bash
|
||
pnpm --filter cms exec strapi admin:create-user \
|
||
--firstname="Site" --lastname="Admin" \
|
||
--email="admin@example.org" --password="SomeStrongPassw0rd"
|
||
```
|
||
|
||
Passwords must be at least 8 characters and contain a number and an uppercase letter.
|
||
|
||
### 7. Confirm it works
|
||
|
||
| URL | Expected |
|
||
| --- | --- |
|
||
| `http://localhost:5173/` | 302 to `/en` or `/ar` depending on your `Accept-Language` |
|
||
| `http://localhost:5173/en` | English home page, `<html lang="en" dir="ltr">` |
|
||
| `http://localhost:5173/ar` | Arabic home page, `<html lang="ar" dir="rtl">`, mirrored layout |
|
||
| `http://localhost:1337/admin` | Strapi admin panel |
|
||
| `http://localhost:1337/api/home-page?locale=en` | JSON (or `404` until an entry is published) |
|
||
| `http://localhost:1337/_health` | `204 No Content` |
|
||
|
||
```bash
|
||
# The one check worth automating: is the SSR HTML right in the first byte?
|
||
curl -s http://localhost:5173/ar | head -c 200
|
||
```
|
||
|
||
Then run the type checker once, so you know the baseline is clean:
|
||
|
||
```bash
|
||
pnpm typecheck
|
||
```
|
||
|
||
---
|
||
|
||
## Path B — Docker
|
||
|
||
Three containers: **postgres** (database), **cms** (Strapi), **web** (React Router).
|
||
Nothing else is installed on your machine — no Node, no pnpm, no PostgreSQL.
|
||
|
||
### 1. Prerequisites
|
||
|
||
- **Docker Engine 24+** (or Docker Desktop) with the **Compose v2** plugin.
|
||
Check with `docker compose version`; the standalone `docker-compose` binary is *not*
|
||
supported — this file uses `depends_on.condition`, which is Compose v2 syntax.
|
||
- Roughly 3 GB of disk for the images and 2 GB of RAM while they build.
|
||
|
||
### 2. Configure
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
./scripts/generate-secrets.sh --write .env
|
||
```
|
||
|
||
`docker compose` reads the `.env` file sitting next to `docker-compose.yml` and substitutes
|
||
every `${VARIABLE}` in it. The six Strapi secrets are declared as `${VAR:?...}`, so a missing
|
||
value stops the stack straight away with a readable message instead of a Strapi crash loop.
|
||
|
||
In Docker mode `apps/cms/.env` is **not used** — the container receives its environment from
|
||
`docker-compose.yml`. The root `.env` is the only file you edit.
|
||
|
||
### 3. Start
|
||
|
||
```bash
|
||
docker compose up -d --build # first run: builds both images, ~5-10 minutes
|
||
docker compose ps # STATUS column shows (healthy) when each is up
|
||
```
|
||
|
||
Start-up is ordered by health, not by luck:
|
||
|
||
```
|
||
postgres healthy -> cms healthy -> web
|
||
pg_isready GET /_health GET /en
|
||
```
|
||
|
||
`cms` waits for a database that is actually accepting connections (`pg_isready`), and `web`
|
||
waits for a CMS that answers `/_health`, so the very first page render already has content.
|
||
The health checks themselves are declared in `apps/cms/Dockerfile` and `apps/web/Dockerfile`,
|
||
which means `docker run` and any orchestrator inherit them too.
|
||
|
||
Give the CMS up to 90 seconds on its first boot: it creates its tables, then runs the
|
||
locale and permission reconciliation described in Path A, step 5.
|
||
|
||
```bash
|
||
docker compose logs -f # everything, following
|
||
docker compose logs -f cms # one service
|
||
docker compose logs --tail=50 web
|
||
```
|
||
|
||
### 4. Create the first admin user
|
||
|
||
Open **http://localhost:1337/admin** and register — the first account is the super admin.
|
||
From the command line:
|
||
|
||
```bash
|
||
docker compose exec cms pnpm exec strapi admin:create-user \
|
||
--firstname="Site" --lastname="Admin" \
|
||
--email="admin@example.org" --password="SomeStrongPassw0rd"
|
||
```
|
||
|
||
### 5. Confirm it works
|
||
|
||
| URL | Expected |
|
||
| --- | --- |
|
||
| `http://localhost:3000/` | 302 to `/en` or `/ar` |
|
||
| `http://localhost:3000/en` and `/ar` | English and Arabic home pages |
|
||
| `http://localhost:1337/admin` | Strapi admin panel |
|
||
| `psql -h 127.0.0.1 -p 5433 -U postgres sharper_cms` | a database prompt |
|
||
|
||
### What the compose file sets up
|
||
|
||
| Service | Image | Host port | Persists in | Health check |
|
||
| --- | --- | --- | --- | --- |
|
||
| `postgres` | `postgres:16-alpine` | **5433** → 5432 | volume `sharper_postgres-data` | `pg_isready` |
|
||
| `cms` | built from `apps/cms/Dockerfile` | 1337 | volume `sharper_cms-uploads` (Media Library) | `GET /_health` |
|
||
| `web` | built from `apps/web/Dockerfile` | 3000 | nothing — stateless | `GET /en` |
|
||
|
||
The database is published on **5433** on purpose, so it cannot collide with a PostgreSQL
|
||
already listening on your host's 5432. Inside the compose network the CMS still reaches it as
|
||
`postgres:5432`. Change the published port with `POSTGRES_PUBLISHED_PORT` in `.env`.
|
||
|
||
Both images build from the **repository root** as their context, because the pnpm lockfile
|
||
lives there. They are not size-optimised: the CMS image keeps its dev dependencies, because
|
||
Strapi's CLI resolves its TypeScript toolchain when it starts.
|
||
|
||
### Images from the CMS
|
||
|
||
Uploaded media is served by Strapi, and the web server turns a relative `/uploads/x.jpg`
|
||
into an absolute URL by prefixing `STRAPI_URL`. Inside Docker that value is `http://cms:1337`,
|
||
a hostname Docker resolves but your browser does not — so pictures uploaded through the Media
|
||
Library will not load until it can. One line in your hosts file fixes it, permanently:
|
||
|
||
```bash
|
||
echo "127.0.0.1 cms" | sudo tee -a /etc/hosts # Windows: C:\Windows\System32\drivers\etc\hosts
|
||
```
|
||
|
||
`cms` then resolves to `127.0.0.1`, where compose publishes the CMS on port 1337, so the one
|
||
URL works from the browser and from inside the compose network alike. Everything else —
|
||
pages, text, layout, and the site's own images in `apps/web/public/assets/` — works without
|
||
this step.
|
||
|
||
In production this problem disappears: the CMS has a real hostname
|
||
(`https://cms.sharper.ly`), and `STRAPI_URL` is set to it.
|
||
|
||
### Everyday commands
|
||
|
||
```bash
|
||
docker compose ps # status and health
|
||
docker compose restart cms # restart one service
|
||
docker compose exec cms sh # shell inside the CMS container
|
||
docker compose exec postgres psql -U postgres sharper_cms
|
||
|
||
docker compose up -d --build web # rebuild after changing app code
|
||
docker compose build --no-cache cms # rebuild ignoring the layer cache
|
||
```
|
||
|
||
Source code is **not** bind-mounted: the images contain a production build, so a code change
|
||
needs a rebuild. For day-to-day development use Path A, or run only the database in Docker
|
||
(`docker compose up -d postgres`) and the two applications with `pnpm dev`.
|
||
|
||
### Stopping and tearing down
|
||
|
||
| Command | Containers | Database + uploads |
|
||
| --- | --- | --- |
|
||
| `docker compose stop` | stopped, kept | kept |
|
||
| `docker compose down` | removed | **kept** (named volumes survive) |
|
||
| `docker compose down -v` | removed | **deleted — irreversible** |
|
||
| `docker compose down --rmi local` | removed, images too | kept |
|
||
|
||
`down -v` is how you start from an empty database — every entry, every uploaded file and
|
||
the admin account are gone. Take a dump first if you are unsure:
|
||
|
||
```bash
|
||
# Back up
|
||
docker compose exec -T postgres pg_dump -U postgres sharper_cms > backup-$(date +%F).sql
|
||
|
||
# Restore into a fresh stack
|
||
docker compose exec -T postgres psql -U postgres sharper_cms < backup-2026-01-31.sql
|
||
|
||
# Uploaded media
|
||
docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
|
||
tar czf /backup/uploads-$(date +%F).tar.gz -C /data .
|
||
```
|
||
|
||
---
|
||
|
||
## Available scripts
|
||
|
||
**Repository root** (`package.json`) — these fan out across the workspace:
|
||
|
||
| Command | Does |
|
||
| --- | --- |
|
||
| `pnpm dev` | runs `web` and `cms` in parallel, streaming both logs |
|
||
| `pnpm dev:web` | React Router dev server with HMR → `http://localhost:5173` |
|
||
| `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 seed` | writes the approved content into the CMS — see [Seeding content](#seeding-content) |
|
||
| `pnpm seed -- --legacy-only` | writes SHARP I, SHARP II, and the home section below the hero through the bilingual Legacy page; preserves its uploaded media |
|
||
| `pnpm typecheck` | `tsc` in both applications, preceded in the web app by `react-router typegen` |
|
||
|
||
**`apps/web`** — run with `pnpm --filter web <script>`:
|
||
|
||
| Command | Does |
|
||
| --- | --- |
|
||
| `dev` | `react-router dev` — SSR dev server with HMR |
|
||
| `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) |
|
||
| `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>`:
|
||
|
||
| Command | Does |
|
||
| --- | --- |
|
||
| `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 |
|
||
|
||
**`scripts/`** — repository helpers, all read-only unless you pass `--write`:
|
||
|
||
| Script | Does |
|
||
| --- | --- |
|
||
| `./scripts/check-prereqs.sh [all\|manual\|docker]` | verifies Node, pnpm, PostgreSQL, Docker and the env files; exits non-zero if something required is missing |
|
||
| `./scripts/setup-db.sh` | idempotent `createdb` + connection/permission verification (Path A, step 3) |
|
||
| `./scripts/generate-secrets.sh [--write FILE] [--force]` | fresh Strapi secrets; fills only empty/placeholder keys unless forced |
|
||
|
||
---
|
||
|
||
## 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 -- --legacy-only # SHARP I/II and home Legacy section only
|
||
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.
|
||
|
||
The Legacy single type also holds the SHARP I and SHARP II records and the
|
||
Legacy section shown below the home hero. Its programme facts and figures come
|
||
from `SHARP I Programme SUMMARY.docx` and `SHARP II Programme SUMMARY.docx`;
|
||
the English text is a concise paraphrase and the Arabic text is a translation.
|
||
Run `pnpm seed -- --legacy-only` to update only that single type. The seed
|
||
preserves the hero and up to five gallery images selected in each locale.
|
||
|
||
What `docs/` does not supply is not written. There are no statistics anywhere
|
||
(outside the sourced Legacy page; the `docs/` source marks other numbers
|
||
"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.
|
||
|
||
```
|
||
app/app.css entry: tailwindcss, then the three files below
|
||
app/styles/primitives.css TIER 1 raw values, no meaning (@theme)
|
||
app/styles/semantic.css TIER 2 roles that alias tier 1 (per surface)
|
||
app/styles/typography.css TIER 2 type roles, @font-face, EN/AR font switch
|
||
```
|
||
|
||
**The one rule: components may reference only tier-2 tokens.** Never a raw hex, never a
|
||
tier-1 primitive.
|
||
|
||
```tsx
|
||
<h2 className="heading-h2 text-[var(--text-primary)]" /> // yes
|
||
<h2 className="text-[#f8f9fa] text-[28px]" /> // no — hex and raw type
|
||
<h2 className="text-[var(--color-neutral-50)]" /> // no — tier-1 primitive
|
||
```
|
||
|
||
Because every component spends meanings rather than values, moving the brand green is four
|
||
lines in `primitives.css` (`--color-green-600` and its hover / active / disabled steps) and
|
||
no component changes at all.
|
||
|
||
The home page is **dark by default**, while the navigation and footer also have light
|
||
variants, so the palette is modelled as **surfaces, not a global theme**: `:root` *is* the
|
||
dark surface, and any element can flip its own subtree with `data-surface="light"`, which
|
||
re-resolves every tier-2 token and paints itself.
|
||
|
||
Brand colours are White `#ffffff`, Dark Grey `#1D1D1B`, Dark Turquoise `#008764` and
|
||
Red `#DD322F`, taken from the communication guideline in `docs/`.
|
||
|
||
Full reference — every token family, the Tailwind bridge, the surface model, the type roles
|
||
and the one deliberate deviation from Figma: **[`apps/web/app/styles/README.md`](apps/web/app/styles/README.md)**.
|
||
|
||
---
|
||
|
||
## Bilingual EN / AR
|
||
|
||
**URLs carry an explicit locale prefix on both languages.** There is no `?lang=` parameter
|
||
anywhere in the product.
|
||
|
||
```
|
||
/ 302, Vary: Accept-Language -> /en or /ar
|
||
/en, /en/about, ... English, ltr
|
||
/ar, /ar/about, ... Arabic, rtl
|
||
/EN/about 301 -> /en/about
|
||
```
|
||
|
||
The slug itself is always the Latin one (`/ar/knowledge-hub`, not a transliterated path), so
|
||
a URL survives a language switch and analytics can aggregate the two.
|
||
|
||
`<html lang>` and `<html dir>` are correct **in the first SSR byte** — they are attributes of
|
||
the server-rendered document, not something JavaScript sets afterwards. `root.tsx` also emits
|
||
a canonical link and `hreflang` alternates for `en`, `ar` and `x-default` on every page.
|
||
|
||
### Where the words live
|
||
|
||
| Content | Lives in | Edited by |
|
||
| --- | --- | --- |
|
||
| UI strings (navigation, buttons, filters, form labels, error messages) | `apps/web/app/i18n/messages/en.json` and `ar.json` — identical key sets, enforced by the `Messages` type | developers |
|
||
| Page and article content | Strapi, as an `en` and an `ar` version of the same entry | editors |
|
||
| Draft fallback copy | `apps/web/app/routes/_page-kit/copy.ts`, `_shared/page-copy.ts` — the approved prose used when the CMS is empty or unreachable | developers |
|
||
|
||
Read a string with the `useTranslation()` hook in a component, or `translate(locale, key)` in
|
||
a loader; keys are dotted paths (`t("pagination.page", { page: 2, pageCount: 7 })`) and are
|
||
typechecked against `en.json`, so a missing translation is a build error.
|
||
|
||
### Right-to-left
|
||
|
||
RTL is not a stylesheet; it is a consequence of using **CSS logical properties everywhere**.
|
||
Write `margin-inline-start`, `padding-inline`, `inset-inline-start`, `text-align: start`,
|
||
`border-inline-start` — and in Tailwind the logical utilities `ps-` `pe-` `ms-` `me-`
|
||
`start-` `end-`, never `pl-` `pr-` `ml-` `mr-` `left-` `right-`. The Arabic layout then
|
||
mirrors for free. `rtl:` and `ltr:` variants exist for the rare case that genuinely cannot be
|
||
expressed logically — an asymmetric icon, a background position.
|
||
|
||
Dates and numbers go through `~/i18n` (`formatDate`, `formatDateRange`, `formatNumber`,
|
||
`formatFileSize`), which uses `en-GB` and `ar-LY` with Western digits, matching the approved
|
||
Arabic copy.
|
||
|
||
### Adding a third locale
|
||
|
||
The types are exhaustive `Record<Locale, …>` maps, so after step 1 the compiler lists every
|
||
place that still has to be filled in.
|
||
|
||
1. `apps/web/app/components/ui/LanguageSwitcher/locale-path.ts` — add the code to `LOCALES`,
|
||
and an entry to `DIRECTION_BY_LOCALE`, `LOCALE_ENDONYM` and `LOCALE_SHORT_LABEL`.
|
||
2. `apps/web/app/i18n/config.ts` — add it to `INTL_LOCALE` and `OG_LOCALE`.
|
||
3. `apps/web/app/i18n/messages/<code>.json` — copy `en.json`, translate it, and register it in
|
||
`getMessages()`.
|
||
4. `apps/cms/config/plugins.ts` — add `{ code, name }` to the `locales` array and restart the
|
||
CMS; `src/bootstrap/locales.ts` creates it in the database on the next boot.
|
||
5. Nothing else: routing (`/:lang`), Accept-Language negotiation, the navigation, the footer
|
||
link columns and the `hreflang` alternates all derive from `SUPPORTED_LOCALES` and
|
||
`ROUTE_SLUGS`.
|
||
|
||
Note that the language switcher's UI is built for exactly two languages (it renders *the
|
||
other* one); a third locale needs it turned into a menu.
|
||
|
||
---
|
||
|
||
## Adding a content type, end to end
|
||
|
||
Worked example: a **Stories** collection, listed at `/en/stories` and `/ar/stories`.
|
||
|
||
### 1. Model it in Strapi
|
||
|
||
Run the CMS in development mode (`pnpm dev:cms`) — the Content-Type Builder only writes
|
||
schemas in `strapi develop`. Create the collection type, then in **Advanced settings** enable
|
||
**Internationalization** and **Draft & publish**.
|
||
|
||
Strapi writes the result to `apps/cms/src/api/story/` (`content-types/story/schema.json`,
|
||
plus a controller, routes and a service). **Commit that folder** — it is the schema, not a
|
||
local artefact. You can equally write the JSON by hand and restart.
|
||
|
||
Field conventions used across this project: `title` (string, required), `slug`
|
||
(uid from title, required), `summary` (text), `body` (blocks or rich text — both render),
|
||
`cover` (single media), `publicationDate` (date — the field everything sorts and filters on),
|
||
`topics` (relation to the shared `topics` taxonomy, so a tag means the same thing everywhere).
|
||
|
||
### 2. Restart the CMS, then check the API
|
||
|
||
`apps/cms/src/bootstrap/public-permissions.ts` grants the Public role `find` and `findOne`
|
||
on every `api::*` content type at boot, so there is nothing to click:
|
||
|
||
```bash
|
||
curl "http://localhost:1337/api/stories?locale=en&populate=cover"
|
||
```
|
||
|
||
A collection holding personal data is the exception — add its API name to
|
||
`PUBLIC_SUBMISSION_CONTENT_TYPES` in `apps/cms/src/bootstrap/constants.ts` and it gets
|
||
`create` only, never `find`.
|
||
|
||
### 3. Give it a URL
|
||
|
||
For a new top-level section, add the slug to `apps/web/app/lib/route-slugs.ts`
|
||
(`ROUTE_SLUGS`, and the right group in `ROUTE_SECTION_GROUPS`) and a label under
|
||
`nav.sections` in **both** `app/i18n/messages/en.json` and `ar.json`. The navigation, the
|
||
footer sitemap and `sectionPath()` all derive from those two, so nothing else needs editing.
|
||
|
||
Then register the module in `apps/web/app/routes.ts`. Section modules are picked up by their
|
||
conventional path (`SECTION_MODULES`); a detail route is one explicit line:
|
||
|
||
```ts
|
||
route("stories/:slug", "routes/stories.$slug.tsx", { id: "storyDetail" }),
|
||
```
|
||
|
||
Vite does not re-evaluate `routes.ts` when a new file appears — **restart `pnpm dev:web`**
|
||
after adding a route module.
|
||
|
||
### 4. Write the route module
|
||
|
||
`apps/web/app/routes/stories.tsx`. Fetch on the server, in the loader; the Strapi client is
|
||
server-only, and the API token never reaches the browser.
|
||
|
||
```tsx
|
||
import { buildMeta } from "~/lib/seo";
|
||
import { getMany } from "~/lib/strapi";
|
||
import { readRootSeo, requireLocale } from "~/routes/_shared/listing";
|
||
import type { LoaderFunctionArgs, MetaArgs } from "react-router";
|
||
|
||
interface Story {
|
||
documentId: string;
|
||
title: string;
|
||
slug: string;
|
||
summary?: string;
|
||
publicationDate?: string;
|
||
}
|
||
|
||
export async function loader({ params }: LoaderFunctionArgs) {
|
||
const locale = requireLocale(params.lang); // 404s on anything but en | ar
|
||
|
||
const { data } = await getMany<Story>("stories", {
|
||
locale,
|
||
sort: "publicationDate:desc",
|
||
populate: { cover: { fields: ["url", "alternativeText"] } },
|
||
pagination: { page: 1, pageSize: 12 },
|
||
});
|
||
|
||
return { locale, stories: data };
|
||
}
|
||
|
||
export function meta({ matches }: MetaArgs) {
|
||
const root = readRootSeo(matches); // { locale, pathname, siteUrl }
|
||
return buildMeta({ ...root, title: "Stories", description: "…" });
|
||
}
|
||
|
||
export default function Stories({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
|
||
return <main>{/* Card.Content, PageShell, … from the shared kit */}</main>;
|
||
}
|
||
```
|
||
|
||
For a detail page use `requireEntity(await getBySlug<Story>("stories", params.slug, { locale }))`
|
||
— `requireEntity` throws the 404 the root ErrorBoundary renders, localised.
|
||
|
||
Do **not** pass `alternates: true` to `buildMeta`: `root.tsx` already emits the canonical link
|
||
and the `hreflang` alternates for every route, and a leaf `meta` that repeats them duplicates
|
||
the tags. Use `handle.canonicalPath` to override the canonical instead.
|
||
|
||
Render with the existing kit rather than new markup: `~/components/ui` (Button, Input, Card,
|
||
…) and `app/routes/_shared/listing-ui` / `_page-kit/sections` (PageShell, PageHeader,
|
||
FilterBar, Pagination, EmptyState, RichText).
|
||
|
||
### 5. Verify
|
||
|
||
```bash
|
||
pnpm typecheck
|
||
```
|
||
|
||
Then open `/en/stories` and `/ar/stories`, confirm the Arabic page mirrors, and confirm an
|
||
unknown slug renders the localised 404 rather than a 500. If the CMS is stopped, the page
|
||
must still render its fallback copy.
|
||
|
||
---
|
||
|
||
## Fonts (licensed, not in this repository)
|
||
|
||
The brand faces are **Panton** for English and **Montserrat Arabic** for Arabic.
|
||
|
||
> **Note on the guideline.** The communication guideline PDF names the Arabic face
|
||
> "Montaser Arabic". That is a typo in the guideline — the correct face is **Montserrat
|
||
> Arabic**, confirmed with the client on 2026-09-11. Both files are supplied.
|
||
|
||
Both are **commercial typefaces licensed to the project, not to this repository** —
|
||
`.gitignore` excludes every `woff2` under `apps/web/public/fonts/`, and `.dockerignore` keeps
|
||
them out of built images. Do not commit them, and do not publish them to a CDN that serves
|
||
them outside the project's licence.
|
||
|
||
Drop the files into **`apps/web/public/fonts/`**, with exactly these names — matching is
|
||
case-sensitive in production:
|
||
|
||
| File | Family | Weight | Status |
|
||
| --- | --- | --- | --- |
|
||
| `Panton-Regular.woff2` | Panton | 400 | supplied |
|
||
| `Panton-Bold.woff2` | Panton | 700 | supplied |
|
||
| `Panton-SemiBold.woff2` | Panton | 500 | **missing** |
|
||
| `Montserrat-Arabic-Regular.woff2` | Montserrat Arabic | 400 | supplied |
|
||
| `Montserrat-Arabic-Medium.woff2` | Montserrat Arabic | 500–600 | supplied |
|
||
| `Montserrat-Arabic-Bold.woff2` | Montserrat Arabic | 700 | supplied |
|
||
|
||
Because a fresh clone has none of them, the stacks in `app/styles/primitives.css` degrade
|
||
politely and the site is fully usable without any of them:
|
||
|
||
```css
|
||
--font-en: "Panton", "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
--font-ar: "Montserrat Arabic", "IBM Plex Sans Arabic", "Noto Sans Arabic", ui-sans-serif, system-ui, sans-serif;
|
||
```
|
||
|
||
The brand family is always **first** in the stack, so the moment a licensed file is dropped
|
||
in, it takes over — no code change, no rebuild of anything but the page. One current gap:
|
||
|
||
- **Panton SemiBold.** `label-md` asks for weight 500; with no SemiBold face declared, CSS
|
||
font matching resolves it to Panton Regular. When the cut arrives, add one `@font-face` for
|
||
`"Panton"` at `font-weight: 500 600` in `app/styles/typography.css`. Do not declare that
|
||
face before the file exists — a face that fails to load drops weight-500 text out of the
|
||
family altogether.
|
||
Under Docker, `docker-compose.yml` mounts `./apps/web/public/fonts` into the running web
|
||
container read-only, so a local container uses your licensed files without ever baking them
|
||
into an image. On a server, copy them into the deployed `build/client/fonts/` directory (or
|
||
mount them the same way) as part of the release, outside version control.
|
||
|
||
---
|
||
|
||
## Deployment and operations
|
||
|
||
### Building and running a release
|
||
|
||
```bash
|
||
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 :$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 |
|
||
| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | web | Cloudflare Turnstile on the public forms. Set both or neither — with either empty, no widget renders and submissions are not checked |
|
||
| `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links |
|
||
| `PARTNER_REVIEW_NOTIFICATION_EMAILS` | cms | Optional comma-separated addresses for partner draft notices; defaults to active Super Admins |
|
||
| `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted |
|
||
| `CORS_ORIGINS` | cms | `https://sharper.ly,https://www.sharper.ly` — an empty list blocks every cross-origin browser call, which is the safe default |
|
||
| `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
|
||
`config/middlewares.ts`).
|
||
|
||
### Requirements from the terms of reference
|
||
|
||
The ToR (`docs/UEC_SHARPER_Website_TOR V3.docx`, sections 3.5 and 3.6) commits the service
|
||
provider to an operating standard, not only to a delivery. In summary:
|
||
|
||
| Requirement | How it is met, and what still has to be set up |
|
||
| --- | --- |
|
||
| **Secure hosting with SSL** | Terminate TLS at the proxy (Let's Encrypt or the host's certificate), redirect HTTP to HTTPS, and enable HSTS. Strapi must run with `IS_PROXIED=true` behind it. |
|
||
| **Regular backups** | Nightly `pg_dump` of `sharper_cms` **plus** the uploads directory — the database alone does not contain the media files. Keep them off the application server and test a restore before go-live. The commands are in [Stopping and tearing down](#stopping-and-tearing-down). |
|
||
| **Security checks and updates** | Watch Strapi and Node releases; `pnpm --filter cms upgrade:dry` previews a Strapi upgrade's codemods. Rotate `STRAPI_TOKEN` and the admin accounts at handover. |
|
||
| **Analytics (e.g. Google Analytics)** | **Not yet integrated.** The tag belongs in `apps/web/app/root.tsx`, in the document `<head>`, and must respect the site's privacy notice — collect no personal data through it and document what is collected. |
|
||
| **Monthly viewership report** | Comes from the analytics property once it exists: sessions, users, top pages, EN vs AR split (the locale is in the URL prefix, so `/en/*` and `/ar/*` aggregate cleanly), referrers, devices. |
|
||
| **Errors addressed within one day of being reported** | Keep a monitored inbox or tracker, and an uptime check on `https://sharper.ly/en` and `https://cms.sharper.ly/_health`. Both applications log to stdout; capture it with the process manager. |
|
||
| **Bi-weekly content support** | Editors work in the Strapi admin panel; no deployment is needed for content. Draft & publish gives a review step before anything goes live. |
|
||
| **Fast loading, mobile responsive, basic accessibility** | SSR HTML, no client-side data fetching on first paint, `font-display: swap`, responsive layouts and logical properties throughout. Re-check with Lighthouse on both `/en` and `/ar` before each release. |
|
||
| **SEO-friendly structure** | Server-rendered HTML, one canonical per page, `hreflang` for `en` / `ar` / `x-default`, JSON-LD (Organization, WebSite, Article, Event, BreadcrumbList), and `noindex` on filtered listing permutations. A `sitemap.xml` and `robots.txt` are **not yet built** — add them before launch. |
|
||
| **CMS handover to UEC** | The schema lives in `apps/cms/src/api/**` in version control, the admin panel is self-hosted, and `.env.example` documents every variable. Hand over: the repository, the server access, the database credentials and a super-admin account. |
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
**`Unsupported engine` / the dev server exits immediately after `pnpm dev:web`.**
|
||
React Router 8 requires **Node >= 22.22.0** — this was the first thing to bite while
|
||
bootstrapping the project. Check with `node -v`; `nvm install "$(cat .nvmrc)" && nvm use`
|
||
fixes it. `pnpm install` warns but does not stop you, because `.npmrc` sets
|
||
`engine-strict=false` so that Strapi's narrower range does not block the workspace.
|
||
|
||
**Strapi exits with `Cannot find module 'pg'` or `knex: Required dependency pg not found`.**
|
||
`DATABASE_CLIENT=postgres` needs the `pg` driver. It is already a dependency of `apps/cms`,
|
||
so this means the install is incomplete or was run in the wrong place: run `pnpm install`
|
||
from the **repository root**, never inside `apps/cms`.
|
||
|
||
**`pnpm install` inside `apps/web` or `apps/cms` produces an app that will not boot.**
|
||
Same cause. `.npmrc` sets `node-linker=hoisted` for the whole workspace; a nested install
|
||
builds a different tree from the one Strapi's plugin resolution expects.
|
||
|
||
**`password authentication failed for user "postgres"`.**
|
||
A fresh PostgreSQL install has no password for that role. See the collapsed note in
|
||
[Path A, step 3](#3-create-the-database).
|
||
|
||
**`Missing app.keys` on CMS start-up.** The six secrets are not set. Run
|
||
`./scripts/generate-secrets.sh --write apps/cms/.env` (manual) or
|
||
`./scripts/generate-secrets.sh --write .env` (Docker).
|
||
|
||
**Port already in use.** 5432 usually means a PostgreSQL is already running — in Docker mode
|
||
the container is published on 5433 for exactly that reason (`POSTGRES_PUBLISHED_PORT`). For
|
||
1337, 3000 and 5173: `ss -ltnp | grep -E '1337|3000|5173'`.
|
||
|
||
**`WARN The field "pnpm.onlyBuiltDependencies" was found in apps/cms/package.json`.**
|
||
Harmless. pnpm only honours that field at the workspace root, where it is already declared.
|
||
|
||
**A new page 404s even though the route module exists.** `app/routes.ts` existence-checks the
|
||
section modules while Vite builds the route config, and Vite does not re-evaluate it when a
|
||
file appears. Restart `pnpm dev:web`.
|
||
|
||
**`Property 'data' does not exist` in a route's `meta`, or an Arabic page renders English
|
||
metadata.** In React Router 8 a meta match exposes `loaderData`, not `data`;
|
||
`matches[0].data` is `undefined` at runtime and a type error at compile time. Use
|
||
`readRootSeo(matches)` from `~/routes/_shared/listing`.
|
||
|
||
**Type errors about `./+types/...`.** Those files are generated. Run `pnpm typecheck`
|
||
(which runs `react-router typegen` first) or `pnpm --filter web exec react-router typegen`.
|
||
|
||
**The browser reports a CORS error calling the API.** Add the origin to `CORS_ORIGINS` in the
|
||
CMS environment and restart it. `http://localhost:5173` and `http://127.0.0.1:5173` are
|
||
always allowed outside production.
|
||
|
||
**Images uploaded in the CMS do not load under Docker.** Expected until `cms` resolves in
|
||
your browser — see [Images from the CMS](#images-from-the-cms).
|
||
|
||
**`docker compose` complains about `depends_on` / `condition`.** You are on the old
|
||
`docker-compose` v1 binary. Install the Compose v2 plugin and use `docker compose` (a space,
|
||
not a hyphen).
|
||
|
||
**The site renders in a different typeface than the design.** Expected on a fresh clone: the
|
||
licensed fonts are not in the repository. See [Fonts](#fonts-licensed-not-in-this-repository).
|
||
|
||
**Everything looks broken after a schema change in Strapi.** `strapi develop` writes schema
|
||
changes to `apps/cms/src/api/**` and migrates the database; if the two disagree, stop the
|
||
CMS, check `git status` in that folder, and restart. As a last resort in **development
|
||
only**, drop and recreate the database (`dropdb sharper_cms && ./scripts/setup-db.sh`, or
|
||
`docker compose down -v`) — this destroys all content.
|
||
|
||
---
|
||
|
||
## Source documents
|
||
|
||
`docs/` holds the material this site is built from. It is **read-only source material** —
|
||
never edit it, and never invent a figure that is not in it.
|
||
|
||
| File | What it is |
|
||
| --- | --- |
|
||
| `UEC_SHARPER_Website_TOR V3.docx` | Terms of reference: scope, functional and technical requirements, deliverables, timeline |
|
||
| `SHARPER_Website_Draft_Section_EN_AR.docx` | Approved draft copy for every section, English and Arabic |
|
||
| `SHARPER Home Page .docx` | Home page copy |
|
||
| `SHARP-ER_Website Recommendations.docx` | Structure and content recommendations |
|
||
| `Sharp Updated communication guidline_ (2).pdf` | Brand: colours, typefaces, EU visibility and partner-logo rules, tone of voice |
|
||
|
||
Tone, everywhere: professional, neutral, factual, inclusive and non-confrontational. The site
|
||
is not political, not advocacy and not campaign-style.
|
||
|
||
---
|
||
|
||
## Licence and credits
|
||
|
||
Funded by the European Union. Implemented by Acted, United Efforts Consultancy and
|
||
Stichting Bureau Ensemble. The views expressed on the site are those of the implementing
|
||
consortium and do not necessarily reflect those of the European Union.
|
||
|
||
Content, branding and the licensed typefaces belong to the project and its partners; they are
|
||
not covered by any open-source licence granted over this source code.
|