pravda-jo

PRAVDA

Production and advertising, Amman. Next.js 15, App Router, bilingual Arabic/English, hand-written Three.js.

Run

npm install
npm run dev     # http://localhost:3000 → redirects to /ar
npm run build   # writes to .next-prod
npm start       # serves .next-prod

next dev and next build write to different directories on purpose. Both default to .next, so building while the dev server is running clobbers its module graph and it starts throwing Cannot find module './403.js' — the chunk names are real but the files were replaced underneath it. distDir is set from NEXT_DIST_DIR, and the build/start scripts point it at .next-prod. If you ever see that error, the cause is a collision, not corruption: stop the server, rm -rf .next, restart.

Routes

Every page under /{lang} exists in both locales. /ar is canonical.

Path   Indexed
/ 307 → /ar
/{lang} Home — the flight yes
/{lang}/work The archive yes
/{lang}/work/[slug] One piece yes
/{lang}/cast Cast & crew — reached only through work yes
/{lang}/teardown What a Teardown is, and the handle intake yes
/{lang}/teardown/sample 308 → /specimen/{lang} (kept: it’s heroCta2 and was in the old sitemap) no
/{lang}/studio Founders and the entity block yes
/{lang}/pricing Published rates yes
/{lang}/privacy Privacy yes
/{lang}/notice PDPL Article 9 processing notice yes
/{lang}/terms Terms yes
/{lang}/data The six data rights and how to exercise them yes
/{lang}/instagram-professional Helper for the ~1/3 of accounts that are personal yes
/specimen/[lang] The one complete, public specimen sheet — the proof /teardown sends every cold visitor to yes
/s/[token] The delivered Sheet — the one artefact a real prospect receives no
/ops Operator console — queue, clients, deals, roster no
/t Talent portal — a provider’s own bookings, by passcode no
/doc/proposal/[id], /doc/invoice/[talentId] Print-only documents, no PDF library — see CLAUDE.md no
/api/health Firestore + Meta token self-check, unauthenticated
/api/cron/read The read sweeper Cloud Scheduler calls; x-pravda-cron gated

/s, /ops, /t and /doc carry noindex, nofollow, nocache, are absent from the sitemap, and are disallowed in robots.txt (app/robots.ts) — each is either per-recipient or operator/provider-only and must never surface in search. /specimen is the deliberate exception: it is the one artefact meant to be found by a stranger, so it is in both the sitemap and the routes above that are indexed.

There used to be two delivery surfaces, /r/[token] (a long-form report) and /p/[token] (its one-screen preview), each with its own renderer. Both are deleted, not redirected — see CLAUDE.md’s “The one artefact” section for why, and do not resurrect either for a one-off case.

sitemap.xml, robots.txt, icon.svg, an error boundary and a skeleton loading state are all generated.

How a lead flows

  1. Intake — a stranger types an Instagram handle into /{lang}/teardown. POST /api/lead writes a Client (openClient) before anything is read, so a Meta outage never loses a lead, and tells Khaled before responding — the notice is more time-critical than the read itself.
  2. Queue — the read is claimed, not just started (claimForRead, a Firestore transaction). The fast path runs inline via after(); if that instance dies mid-read, GET /api/cron/read (Cloud Scheduler, once a minute) sweeps anything left in new or a reading state whose lease expired, up to three attempts before a client is marked failed.
  3. Sheetlib/teardown/run.ts composes a Sheet: signals, findings, recommended concepts, a vertical guess. Nothing in it is invented — see “Only what we can prove” in CLAUDE.md.
  4. Approve — an operator reviews the sheet at /ops, casts real talent against it (never a placeholder), and approves it. Approving mints a share link; it does not by itself tell the client anything.
  5. Sendcompose-share hands the operator the Arabic message and a wa.me link; mark-share-sent is the separate, human-confirmed act that actually advances the client to sent. Two steps on purpose: opening WhatsApp is not sending.
  6. Win — the client opens /s/[token], and if they say yes, the sheet converts into a Deal.
  7. Bookings — real talent are offered days against the deal (POST /api/ops/booking), each a Booking that never carries what the client paid — the connector invariant in CLAUDE.md.
  8. Portal — a talent signs into /t with their own passcode to accept or decline, set availability, and see their own upcoming days.
  9. Documents/doc/proposal/[id] and /doc/invoice/[talentId] are print stylesheets a human opens and prints/saves as PDF from the browser; there is no server-side PDF generation anywhere in this codebase.

The entity block

The registered name, CR number, street address and phone appear in the footer of every page and in the final scene of the homepage flight, which has no footer. That is not decoration: Meta Business Verification requires them on the site, and a cold visitor checking whether PRAVDA is real looks for exactly those four things. They live in lib/data/company.ts — one source of truth.

Locale is a URL segment, so lang and dir are set server-side on <html>. No hydration flash, correct for crawlers, correct with JS disabled.

The homepage is a flight

/ar and /en are not a scrolling page. The document never scrolls — overflow is fixed and wheel/touch/keyboard input is integrated into a single normalised progress value (0..1). That one value drives both the camera and the type, so they can never drift apart.

Two things learned building it, both of which look like bugs if you hit them:

Scroll model — one gesture, one section

The flight advances in discrete sections rather than continuously, because a hard flick on a trackpad emits dozens of momentum events and a continuous integrator turns that into three or four skipped sections.

Keyboard: arrows step, space and PageUp/PageDown jump. Reduced motion or a device below the capability gate gets .flat — the same six scenes as an ordinary scrolling page.

The WebGL layer

components/webgl/Plate.tsx — one fullscreen triangle, one fragment shader, one draw call. No scene graph, no loaders, no post-processing stack.

The shader does three things:

Why it is safe

ThreeUI

The hero and teardown fields are ThreeUI’s EmeraldHorizonBackground and RibbonFieldBackground, wrapped in components/webgl/Horizon.tsx.

Three things the wrapper adds, because the library omits them: a capability gate (so no shader is compiled on a device that would stall), a visibilitychange pause, and webglcontextlost handling. ThreeUI does pause on IntersectionObserver already.

Two integration notes worth knowing:

ThreeUI imports three128 and three165 internally. next.config.mjs aliases both onto the installed three — the API surface its shader components touch is unchanged in r172. Without the alias the home route ships two runtimes and weighs 368 kB; with it, 245 kB.

Work images still use the hand-written Plate, because no ThreeUI component accepts a src — its entire API is shader knobs.

Arabic

Four rules enforced globally in globals.css, not per-component:

  1. letter-spacing: 0 — tracking shatters cursive joins.
  2. No text-transform — Arabic has no case.
  3. No faux-italic — Arabic has no oblique.
  4. Ragged right, never justified — kashida justification is unimplemented in every shipping browser.

The Arabic type scale is forked, not derived: it shares no size token with the Latin and sets ~1.35× at 1.85 line-height.

Budgets

  Target Actual
First Load JS, WebGL routes ≤250 kB 245 kB
First Load JS, text routes ≤250 kB 109–111 kB
Shared chunk 105 kB
/s/[token] ≤150 kB

/s/[token] — where every real prospect’s teardown lands, and all cold traffic that isn’t the specimen — is held to the stricter budget the old /r used to carry, and ships no canvas at all. scripts/check-bundle.mjs enforces this in CI against next build’s own “First Load JS” output.

Placeholders

public/plates/*.svg are generated abstract plates, deliberately not stock photography. Replace with real work — AVIF, q=60, explicit sizes.

Tests

npm run test:unit    # pure engine — signals, findings, recommend, auth,
                      # msisdn/handle normalisation. node --test, no network,
                      # no Firestore. Fast; run this on every change.
npm run test:itest   # the same code paths against a real Firestore emulator —
                      # queue claiming, status transitions, contact merges,
                      # deals/bookings, the golden sheet snapshot.
npm test             # both, wrapped in `firebase-tools emulators:exec`

Itests need a JDK for the Firestore emulator (firebase-tools requires 21+). On macOS, if the emulator refuses to start over a Java version error, point JAVA_HOME at a JDK 21+ before running:

JAVA_HOME=$(/usr/libexec/java_home -v 21) npm test

npx tsc --noEmit, npm run lint (next lint) and npm run lint:eslint (ESLint 9’s own CLI, same rules via eslint.config.mjs) round out what CI runs on every push — see .github/workflows/ci.yml.

Runbook and the plan

Operational procedures — rotating a secret, reissuing the Meta token, re-running a stuck read, honouring a PDPL export/delete request, standing up staging — live in docs/RUNBOOK.md. Cross-cutting rules an agent must not violate live in CLAUDE.md. The reconciled plan across every workstream — the decisions, the unified data model, the execution waves — is docs/plan/MASTER-PLAN.md; read it before a change that touches more than one file.