commit 20b384039cbe884178fec99aa7f6ca4bf532d81b Author: Hisham Aburaqibah Date: Fri Sep 11 02:46:56 2026 +0200 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14dc2d3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# ============================================================================ +# Build context for BOTH images is the repository root (see docker-compose.yml). +# Everything below is either reinstalled/rebuilt inside the image or must never +# enter one. +# ============================================================================ + +# Dependencies and build output - recreated by pnpm install / pnpm build. +**/node_modules +apps/web/build +apps/web/.react-router +apps/cms/dist +apps/cms/build +apps/cms/.strapi +apps/cms/.tmp +apps/cms/.cache + +# Secrets. The containers receive their environment from docker compose. +.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. +apps/web/public/fonts/*.woff2 +apps/web/public/fonts/*.woff +apps/web/public/fonts/*.ttf +apps/web/public/fonts/*.otf + +# Locally uploaded media lives in the cms-uploads volume, not in the image. +apps/cms/public/uploads/* +!apps/cms/public/uploads/.gitkeep + +# Nothing here changes the built artefact; excluding it keeps the layer cache warm. +.git +.github +.vscode +.idea +docs +**/*.log +**/.DS_Store +**/coverage +**/*.tsbuildinfo diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..de7d26f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +# https://editorconfig.org +# One formatting contract for every editor working on SHARP-ER. +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 100 + +# Markdown keeps trailing whitespace: two spaces are a hard line break. +[*.md] +trim_trailing_whitespace = false +max_line_length = off + +# Makefiles are tab-indented by definition. +[Makefile] +indent_style = tab + +[*.{yml,yaml}] +indent_size = 2 + +[*.{json,jsonc}] +indent_size = 2 + +[*.sh] +indent_size = 2 + +# Lockfiles and generated files are written by tooling - leave them alone. +[{pnpm-lock.yaml,package-lock.json}] +insert_final_newline = unset +trim_trailing_whitespace = unset diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63e5537 --- /dev/null +++ b/.env.example @@ -0,0 +1,114 @@ +# ============================================================================ +# SHARP-ER - root environment template +# ---------------------------------------------------------------------------- +# Copy once, then fill in: cp .env.example .env +# Generate the CMS secrets: ./scripts/generate-secrets.sh --write .env +# +# 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/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 +# container instead, so apps/cms/.env is not used there. +# +# Never commit .env. Never paste a real secret back into this template. +# ============================================================================ + + +# --------------------------------------------------------------------------- +# DATABASE +# Manual mode: your local PostgreSQL 16 instance (scripts/setup-db.sh uses +# these values). Docker mode: the postgres service is created with them. +# --------------------------------------------------------------------------- + +DATABASE_NAME=sharper_cms +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=postgres + +# Manual mode only - where the local server listens. Inside Docker the CMS +# always reaches the database as postgres:5432 on the compose network. +DATABASE_HOST=127.0.0.1 +DATABASE_PORT=5432 + +# Docker mode only - host port the containerised database is published on. +# 5433 by default so it cannot collide with a PostgreSQL you already run on +# 5432. Connect with: psql -h 127.0.0.1 -p 5433 -U postgres sharper_cms +POSTGRES_PUBLISHED_PORT=5433 + + +# --------------------------------------------------------------------------- +# CMS (Strapi 5) SECRETS +# Required. Every value must be a fresh random string - generate them with +# ./scripts/generate-secrets.sh --write .env +# or by hand with `openssl rand -base64 32`. +# APP_KEYS is a comma-separated list (at least two, so keys can be rotated). +# Rotating APP_KEYS logs everyone out; rotating ENCRYPTION_KEY makes values +# already encrypted in the database unreadable. +# --------------------------------------------------------------------------- + +APP_KEYS= +API_TOKEN_SALT= +ADMIN_JWT_SECRET= +JWT_SECRET= +TRANSFER_TOKEN_SALT= +ENCRYPTION_KEY= + + +# --------------------------------------------------------------------------- +# PORTS +# Host ports the two applications are published on in Docker mode, and the +# ports they listen on in manual mode. +# --------------------------------------------------------------------------- + +# React Router server (production build / Docker). `pnpm dev:web` uses 5173. +WEB_PORT=3000 +# Strapi API + admin panel. +CMS_PORT=1337 + +# Docker mode only - the absolute URL the admin panel and Strapi's generated +# links use. Change it together with CMS_PORT, and set it to the public origin +# in production (e.g. https://cms.sharper.ly). +CMS_PUBLIC_URL=http://localhost:1337 + + +# --------------------------------------------------------------------------- +# WEB APP +# --------------------------------------------------------------------------- + +# Public origin of the site. Used for canonical URLs, hreflang alternates and +# JSON-LD, so it must match how visitors actually reach the site. +# 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 +# Docker section of README.md. +DOCKER_STRAPI_URL=http://cms:1337 + +# Optional Strapi API token. Only needed to read draft or otherwise protected +# content; published content is public. Create it in the admin panel under +# Settings -> API Tokens, then paste the value here (it is shown once). +STRAPI_TOKEN= + + +# --------------------------------------------------------------------------- +# CMS RUNTIME +# --------------------------------------------------------------------------- + +# Browser origins allowed to call the API, comma separated. localhost:5173 and +# 127.0.0.1:5173 are always allowed outside production and need not be listed. +CORS_ORIGINS=http://localhost:3000 + +# Log verbosity: fatal | error | warn | info | http | debug | trace +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a7fb5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# ============================================================================ +# SHARP-ER - repository ignore rules +# Anything listed here is either generated, machine-local, or licensed +# material that must not be redistributed through this repository. +# ============================================================================ + +# --- Dependencies ---------------------------------------------------------- +node_modules/ +.pnpm-store/ +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/releases + +# --- Build output ---------------------------------------------------------- +# apps/web (React Router framework mode) +apps/web/build/ +apps/web/.react-router/ +# apps/cms (Strapi 5: dist/ is the compiled server, build/ the admin panel) +apps/cms/dist/ +apps/cms/build/ +apps/cms/.strapi/ +apps/cms/.tmp/ +apps/cms/.cache/ +apps/cms/exports/ +apps/cms/.strapi-updater.json +apps/cms/.strapi-cloud.json +apps/cms/license.txt +# Uploaded media lives in the database-backed media library, not in git. +apps/cms/public/uploads/* +!apps/cms/public/uploads/.gitkeep + +# --- Environment ----------------------------------------------------------- +# Real secrets never enter git; the *.example templates always do. +.env +.env.* +!.env.example +!.env.*.example + +# --- Logs and diagnostics -------------------------------------------------- +*.log +npm-debug.log* +yarn-error.log +pnpm-debug.log* +logs/ +pids/ +*.pid +coverage/ +.tsbuildinfo +*.tsbuildinfo +.eslintcache + +# --- Local databases / dumps ---------------------------------------------- +*.sqlite +*.sqlite3 +*.db +*.sql +!scripts/**/*.sql + +# --- Editors and OS -------------------------------------------------------- +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.example.json +*.swp +*.swo +*~ +.nbproject/ + +# --- Agent / tooling scratch ---------------------------------------------- +.agents/**/tmp/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..45f7272 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# Strapi and React Router both expect a flat-ish tree for their plugin resolution. +node-linker=hoisted +engine-strict=false diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..c947119 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.23.2 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d406544 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md — working brief for agent sessions + +Read this before touching anything. Human setup instructions live in [README.md](README.md); +this file is the short version plus the rules that are easy to break. + +## What the project is + +**SHARP-ER** — *Shared Action on Rule of Law Progress – Exit Route*. EU-funded, nationwide +Libya, December 2025 – December 2027. Implemented by **Acted** (lead), **United Efforts +Consultancy (UEC)** and **Stichting Bureau Ensemble (SBE)**. Supports Libyan civil society, +local institutions and communities on justice, accountability and governance. Successor to +SHARP I and II (2019–2024). + +Tone in all copy: professional, neutral, factual, inclusive, non-confrontational. +**Not** political, **not** advocacy, **not** campaign-style. + +## Architecture in one screen + +``` +apps/web React Router 8 (framework mode, ssr: true) · React 19 · Vite 8 · Tailwind 4 + SSR only reads the CMS from loaders. No client-side fetch on first paint. + / 302 (Vary: Accept-Language) -> /en or /ar + /:lang locale layout, validates params.lang, provides { locale, dir } + /:lang/slug ten top-level sections + detail routes + 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 + 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) + config/ server · admin · database · middlewares (CORS) · plugins + +docs/ Source material: ToR, approved EN/AR draft copy, brand guideline. READ ONLY. +``` + +Data flow: browser → React Router SSR loader → Strapi REST (`~/lib/strapi`, server only) → +PostgreSQL. `STRAPI_TOKEN` never reaches the browser (`~/lib/env.server.ts` is `.server`, and +the bundler enforces it). Every page falls back to bundled draft copy when the CMS is +unreachable — the site must never 500 because Strapi is down. + +## Hard rules + +1. **Design tokens are two-tier. Components use tier 2 only.** + Tier 1 (`app/styles/primitives.css`) holds raw values; tier 2 (`semantic.css`, + `typography.css`) holds meanings. Never a raw hex, never a tier-1 primitive, never a raw + `font-size` / `line-height` — use the type-role utilities (`display-sm`, `heading-h2`, + `body-md`, `label-md`, …). + Check: `grep -rn "#[0-9a-fA-F]\{6\}\|--color-green\|--color-neutral\|--color-red" apps/web/app/components apps/web/app/routes` + → must return nothing. +2. **Logical properties only. Never `left` / `right`.** + `ps-` `pe-` `ms-` `me-` `start-` `end-`, `margin-inline-start`, `text-align: start`. + 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. +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 + `get_design_context`, read `skill://figma/figma-design-to-code/SKILL.md` and pass + `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. +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 + an English string is a bug — that is why `Card.Content`'s `actionLabel` is required. +8. **EU visibility is contractual.** "Funded by the European Union" badge top-right, project + logo top-left, the three partner logos always shown together, no standalone partner + branding. Do not restyle or omit them. +9. **No long-running servers or installs without being asked.** Do not run `pnpm install`, + `pnpm dev`, `strapi develop` or `docker compose up` speculatively. If you need a new + dependency, report it instead of installing it. +10. **No `?lang=` parameter, ever.** The locale is a URL prefix on both languages. + +## Where things live + +| You need | It is in | +| --- | --- | +| Tokens, surfaces, type roles | `apps/web/app/styles/` (+ its own `README.md`) | +| UI components | `apps/web/app/components/ui/{Button,Input,Card,Navigation,Footer,LanguageSwitcher}` | +| Page modules | `apps/web/app/routes/*.tsx`; shared kits in `routes/_page-kit/`, `routes/_shared/`, `routes/_events-opportunities/` | +| Route config, slugs | `apps/web/app/routes.ts`, `apps/web/app/lib/route-slugs.ts` | +| i18n | `apps/web/app/i18n/` (`config.ts`, `paths.ts`, `t.ts`, `format.ts`, `messages/*.json`) | +| Strapi client, SEO, env | `apps/web/app/lib/{strapi.ts,seo.ts,env.server.ts,route-handle.ts,site-navigation.ts}` | +| HTML shell, canonical, hreflang | `apps/web/app/root.tsx` | +| Content types | `apps/cms/src/api//content-types//schema.json` | +| Boot-time locale + permission setup | `apps/cms/src/bootstrap/` | +| CORS, upload rules, i18n locales | `apps/cms/config/{middlewares.ts,plugins.ts}` | + +## Conventions that bite + +- **`meta` reads `loaderData`, not `data`.** In React Router 8, `matches[0].data` is + `undefined`; use `readRootSeo(matches)` from `~/routes/_shared/listing`. +- **Do not pass `alternates: true` to `buildMeta`.** `root.tsx` emits the canonical and the + `hreflang` alternates for every route; a leaf that repeats them duplicates the tags. Use + `handle.canonicalPath` to override the canonical. +- **`app/routes.ts` existence-checks section modules** and Vite does not re-evaluate it when a + file appears — restart the dev server after adding a route module. +- **Strapi 5 addresses entries by `documentId`**, not by a numeric `id`, and localisation is a + `locale` query parameter on the same entry. +- **Submission content types are write-only for the public** (`create`, never `find`). They + hold personal data. The field is `language`, not `locale` — `locale` is reserved by the i18n + plugin. +- **`.server.ts` files never reach the browser.** Anything touching `STRAPI_TOKEN`, the + database or `process.env` belongs in one. +- **Licensed fonts are not in the repository** (`.gitignore` excludes `public/fonts/*.woff2`). + Expect fallback typefaces locally; never commit a font file. + +## Verifying a change + +```bash +pnpm typecheck # react-router typegen + tsc (web; the CMS is checked by its build) +pnpm --filter web build # the SSR build must succeed +bash -n scripts/.sh # any shell script you touched +docker compose config # any docker-compose.yml change (needs the docker CLI) +``` + +Then, in a browser or with `curl`, on **both** locales: + +```bash +curl -sI http://localhost:5173/ # 302 + Vary: Accept-Language +curl -s http://localhost:5173/en | head -c 200 +curl -s http://localhost:5173/ar | head -c 200 # lang="ar" dir="rtl" in the first bytes +``` + +Checklist before calling a UI change done: renders in **both** locales; Arabic mirrors +correctly; no raw hex or tier-1 token; no physical-direction utility; keyboard focus is +visible; text comes from the message catalogue or the CMS, not from a literal in the +component. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8a45c2 --- /dev/null +++ b/README.md @@ -0,0 +1,906 @@ +# 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) +- [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 `` and `` 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: , canonical + hreflang, nav, footer +│ │ │ ├── routes.ts Route config: "/" redirect, ":lang" layout, the ten 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 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();" +``` + +
+If psql asks for a password you do not have (fresh Ubuntu install) + +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' +``` +
+ +### 4. Environment files + +Two 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` | + +```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 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: + +```bash +cp .env.example .env +set -a; . ./.env; set +a # export it into the current shell +pnpm dev +``` + +### 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 `en` (default) and `ar` 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. + +### 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, `` | +| `http://localhost:5173/ar` | Arabic home page, ``, 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 typecheck` | the web app's `react-router typegen && tsc` (the CMS has no `typecheck` script — its TypeScript is checked by `strapi build`) | + +**`apps/web`** — run with `pnpm --filter web