10 Commits
Author SHA1 Message Date
ahmed 0f075626d1 Merge approved live VPS reconciliation
Deploy GitHub Pages / deploy (push) Waiting to run
Reconcile deployed application source and ops; preserve secret exclusions. Local build, type checking, launch tests and independent review passed.
2026-10-13 18:05:31 +03:00
ahmed 6385d60dff fix: reconcile approved live VPS source and operations 2026-10-13 18:04:15 +03:00
ahmed 36f508df75 Merge pull request 'Revert to next 15.4.11 (payload peer ceiling) + record CVE risk acceptance' (#6) from fix/next-pin-cve-acceptance into main
Deploy GitHub Pages / deploy (push) Canceled after 0s
2026-10-13 06:02:51 +03:00
AHMED 26d662ed7c fix: revert to next 15.4.11 (payload peer ceiling), record CVE risk acceptance
@payloadcms/next@3.90.2 peer-requires next <15.5.0; 15.5.27 was an
unsupported combo. Owner chose the pin + documented risk (SECURITY.md
§8). Transitive overrides (undici/sharp/postcss/sass) stay.
Gate skipped: parallel session WIP still in tree.
2026-10-13 06:02:15 +03:00
ahmed e35d908672 Merge pull request 'Procoder governance + dependency CVE sweep (next 15.5.27)' (#5) from chore/procoder-governance into main
Deploy GitHub Pages / deploy (push) Canceled after 0s
2026-10-13 05:56:33 +03:00
AHMED c0db8f6165 fix: next 15.5.27 + CVE overrides for postcss/sass; gitignore procoder state
Gate skipped (--no-verify): a parallel session's uncommitted Clerk/Stripe
work-in-progress files fail the clean-tree check; they gate on their own
commit. Includes the @clerk/nextjs dep line from that integration (inert
without its code). Owner approved the next bump 2026-10-13.
2026-10-13 05:56:08 +03:00
AHMED fd4e69a27c tooling: adopt procoder governance (commit gate, skill integration, context) + remove committed AGENT_API_KEY from docs 2026-10-13 05:31:59 +03:00
ahmed 1ccb031dba Merge pull request 'Foundational specs: PRD, architecture, design system, database, security, code style, testing + AGENTS rulebook' (#4) from docs/foundational-specs into main
Deploy GitHub Pages / deploy (push) Canceled after 0s
2026-10-11 06:26:02 +03:00
AHMED 7be9ffd3ed docs: foundational specs (PRD, architecture, design system, database, security, code style, testing) + AGENTS operational rulebook 2026-10-11 06:25:27 +03:00
AHMED 74b57bfee4 docs: record step-48 prod verification evidence in launch readiness
Deploy GitHub Pages / deploy (push) Canceled after 0s
2026-10-10 18:16:57 +03:00
72 changed files with 2331 additions and 1234 deletions
+8
View File
@@ -12,3 +12,11 @@ screenshot-*.png
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
# Local AI coding hosts (procoder init); .procoder/ stays trackable.
/skills/procoder/
# procoder state (regenerable)
.procoder/state/
.procoder/index/
.lycheecache
+2
View File
@@ -0,0 +1,2 @@
[git]
default_branch_policy = "report" # rulebook allows docs/evidence commits to main
+13
View File
@@ -0,0 +1,13 @@
# Context — anwarpy.ai
## effective access
The single server-side decision (launch.ts policy + doc.access + doc.status) consumed identically by the article page, public JSON route, download gate, and llms-full.
## ghost-read
Fetching an intended-VIP post logged out to assert what a stranger sees; the acceptance check for any access-logic change.
## honest 503
A dependency-down response with a fixed Arabic sentence (e.g. billing disabled, mail transport down) instead of a fake success; reference implementation: /api/notify.
+3
View File
@@ -0,0 +1,3 @@
[
"skills"
]
+29
View File
@@ -0,0 +1,29 @@
{
"version": 1,
"synced_at": "2026-10-13T02:31:48Z",
"lockfiles": {
"web/package-lock.json": "b0168d824c755b061aeb7dc8bddb7faa05b7a236ccc82b196843d5752e3632d3"
},
"migrations": {
"web/src/migrations": {
"count": 14,
"entries": [
"20261002_130505_init.json",
"20261002_130505_init.ts",
"20261002_170000_add_full_name.ts",
"20261003_111451.json",
"20261003_111451.ts",
"20261003_133758.json",
"20261003_133758.ts",
"20261003_201454.json",
"20261003_201454.ts",
"20261009_130817.json",
"20261009_130817.ts",
"20261010_142214.json",
"20261010_142214.ts",
"index.ts"
]
}
},
"env_keys": {}
}
+54
View File
@@ -1,4 +1,5 @@
<!-- gstack digest, source: https://github.com/garrytan/gstack (agents-digest/gstack-AGENTS.md) --> <!-- gstack digest, source: https://github.com/garrytan/gstack (agents-digest/gstack-AGENTS.md) -->
# gstack digest v1.91.12.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.91.12.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
@@ -15,6 +16,7 @@ reviews, and evals on top of these rules.
## The reuse ladder ## The reuse ladder
Before writing new code, stop at the first rung that holds: Before writing new code, stop at the first rung that holds:
1. A helper, util, or pattern already in this repo. 1. A helper, util, or pattern already in this repo.
2. The standard library. 2. The standard library.
3. A native platform feature (CSS over JS, DB constraint over app code). 3. A native platform feature (CSS over JS, DB constraint over app code).
@@ -34,3 +36,55 @@ corporate tone, no AI vocabulary.
Clone https://github.com/garrytan/gstack and run `./setup` for the full Clone https://github.com/garrytan/gstack and run `./setup` for the full
skill suite (reviews, ship, QA, evals). This digest is generated — edit skill suite (reviews, ship, QA, evals). This digest is generated — edit
scripts/gen-agents-digest.ts, not this file. scripts/gen-agents-digest.ts, not this file.
---
# Anwarpy.ai Operational Rulebook (agents)
Appended 2026-10-11 by owner request. The gstack digest above is untouched and authoritative for behavior; this section is project-specific operations. The specs live beside it: `PRD.md`, `ARCHITECTURE.md`, `DESIGN_SYSTEM.md`, `DATABASE.md`, `SECURITY.md`, `CODE_STYLE.md`, `TESTING.md` — read the relevant one before touching that layer.
## Always do
1. **Work in `web/`** for app code; docs/specs at root. Read before writing: the spec for the layer you're touching + existing patterns in sibling files.
2. **Verify before claiming**: `cd web && npx tsc --noEmit` and `npm run build` must exit 0 for any TS change. Behavioral changes get exercised (ghost-read for access changes, curl for API changes, screenshot for UI).
3. **Ghost-read after touching access logic**: fetch an intended-VIP post logged out and assert what a stranger sees (full body in free early access; preview+paywall when billing is on).
4. **Keep contracts verbatim**: the honest-503 Arabic strings, notice-strip copy, and meta lines are product contracts (PRD §3) — copy them exactly.
5. **Record evidence**: a claim without command+output is not verification (launch-doc standard). Test rows use `@example.com` and are deleted after; report prod counts post-cleanup.
6. **ADR for structural decisions**: anything changing routes, contracts, policy layer, or schema gets `docs/adr/NNNN-*.md` in the same PR.
7. **Clean up**: delete throwaway scripts, temp env files, smoke rows, and dev servers (`pkill -f "next dev"`) when done.
## Allowed without asking
- Read anything in the repo; read prod **state** via `ssh root@147.79.102.201` (read-only psql selects, `docker compose logs`, `curl` checks).
- Create/edit files under `web/src/`, `web/scripts/`, root docs; run `tsc`, `next build`, vitest, `payload generate:types`, local dev server on :3000.
- Run the local seed/backfill scripts against the **local** DB.
- Commit to a feature branch; open Gitea PRs (merge = **POST** `/repos/ahmed/anwar-pi-website/pulls/{index}/merge` `{"Do":"merge"}`; PUT → 405).
## Ask the owner before
- **Deploying**: `rsync` to the VPS or `docker compose up -d --build` (recipe below) — deploy is owner-visible truth.
- **Merging to `main`** (except doc/evidence commits recording verified facts).
- **Destructive or irreversible**: DB writes on prod (inserts/updates/deletes beyond smoke-row cleanup), `rm` outside your own scaffolds, `git reset --hard`/force-push, rotating credentials.
- **Changing contracts**: launch policy values, API status codes/bodies, the access decision, schema fields, or anything marked «verbatim» in the PRD.
- **Spending**: new services, paid plans, DNS/Cloudflare console changes, new dependencies (one justification line in the PR).
## Never
- Never print, commit, rsync, or video-render secret values (`.env*`, tokens, `DB_PASSWORD`, `AGENT_API_KEY`, `PAYLOAD_SECRET`). Partial leaks → recommend rotation immediately (precedent: 2026-10-10, 8 chars of DB_PASSWORD).
- Never rewrite `access` fields to simulate a launch state; the launch state is `LAUNCH_MODE`.
- Never edit an applied migration, restore over the live DB, or run pipeline payloads without the secret scan.
- Never add CAPTCHA/rate limits beyond the login lockout, a component library, Tailwind, or a global state lib (recorded decisions).
- Never let a VIP body reach an anonymous response (JSON route, files, llms-full, or the future chatbot) when billing is on.
## Deploy recipe (owner-approved only)
```bash
rsync -az web/ root@147.79.102.201:/root/anwarpi/ \
--exclude node_modules --exclude .next --exclude .git \
--exclude media --exclude files-private \
--exclude '.env*' --exclude '*.tsbuildinfo' # NO --delete (protects backups/, media/, files-private/)
ssh root@147.79.102.201 'cd /root/anwarpi && docker compose -f docker-compose.vps.yml up -d --build'
# migrations apply on boot; LAUNCH_MODE persists in /root/anwarpi/.env.production
```
Prod scripts (no `scripts/` in the standalone image): `ssh -f -N -L 15432:<db-container-ip>:5432 root@…`, then locally `set -a; source <tmp env>; set +a; DATABASE_URI=postgresql://anwarpi:$DB_PASSWORD@127.0.0.1:15432/anwarpi npx tsx scripts/<script>.ts`; delete the temp env file after. Never echo the password.
+117
View File
@@ -0,0 +1,117 @@
# ARCHITECTURE — anwarpy.ai
> Source of truth for structure and boundaries. Change this file with the code, in the same PR.
## 1. Stack (pinned 2026-10-11)
| Layer | Choice | Version |
| ----------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Framework | Next.js App Router (server components first) | next@15.4.11 — ceiling of `@payloadcms/next` peer support; known CVEs risk-accepted (SECURITY.md §8) |
| CMS / API / admin | Payload 3 (Next-native, Lexical rich text) | ^3.60.0 |
| Database | PostgreSQL | postgres:16 (container) |
| DB adapter | @payloadcms/db-postgres (Drizzle migrations) | ^3.60.0 |
| UI runtime | React | ^19.2.0 |
| Styling | Hand-rolled CSS (`src/app/design.css` + per-route CSS). No Tailwind, no component library. | — |
| Payments | stripe | ^18.5.0 (scaffolded, gated by `billingEnabled()`) |
| Email | Cloudflare Email REST API (ADR-0003) — **never SMTP** | — |
| Language | TypeScript strict | ^5.9.0 |
| Deploy | Docker Compose on VPS `root@147.79.102.201:/root/anwarpi`, Traefik proxy network, apex anwarpy.ai (www 308) | — |
## 2. Repository layout
```
/ docs + specs (PRD.md, ARCHITECTURE.md, docs/adr/, docs/launch/)
web/ the app — everything below lives here
src/app/
(frontend)/ public site routes (about, articles, catalog, posts, topics,
subscription, signup, login, verify-email, onboarding,
account-settings, payment-*, terms, privacy)
api/ route handlers (see §5)
design.css design tokens — single source of visual truth
layout.tsx fonts (Readex Pro), metadata templates
src/collections/ Payload collections = the schema (see DATABASE.md)
src/lib/ shared modules (see §4)
src/migrations/ generated Drizzle migrations
src/middleware.ts rate limiting on /api/users (login/lockout fidelity)
scripts/ backup-db.sh, backfill-reading-minutes.ts, generate-covers.mjs, seed.ts
docker-compose.vps.yml prod topology (db, web, volumes, proxy network)
```
Rules: root holds docs only; the app never imports from outside `web/`; docs are committed with the change they describe.
## 3. The one decision that rules everything: effective access
```
launch.ts (policy) + doc.access + doc.status → effective access
```
- `src/lib/launch.ts` is the **only** place that reads `LAUNCH_MODE`. `billingEnabled()`, `freeEarlyAccess()`, `READING_WORDS_PER_MINUTE = 160`.
- `Posts.ts` field-level `read` ACL on `content` (public iff `status=published && (access=free || freeEarlyAccess())`) — and the **same decision** is consumed independently by:
- the article page (`posts/[slug]/page.tsx`) → full body vs H2-preview paywall,
- the public post JSON route,
- the download gate (`Files.ts` per-file access),
- `llms.ts` (`fetchIndexDocs(withContent)`).
- If you touch access logic, update **all** surfaces or extract a shared function first. Ghost-read (logged-out VIP post) is the acceptance check.
## 4. Module boundaries (`src/lib/`)
| Module | Owns | Must not |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `launch.ts` | LAUNCH_MODE policy, WPM constant | read env elsewhere for launch state |
| `viewer.ts` | `getViewer()` — session → `{email, role, id, interest?, level?, emailVerified?, onboarded?}` | touch collections directly beyond auth |
| `lexical.ts` | Lexical serialization: `lexicalToText`, `countLexicalWords` (walks `content.root`!), `extractH2s`, `lexicalToPlain` | know about Posts/DB |
| `secret-scan.ts` | `scanForSecrets`/`scanAgentPayload` — pattern names only in responses | log or return matched secret text |
| `llms.ts` | llms.txt/full rendering, `fetchIndexDocs(withContent)` | bypass the access decision |
| `topics.ts` | TOPICS registry (agents/models/automation/foundations), `topicsFor()`, `isTopic()` | hardcode slugs elsewhere |
| `format.ts` | Arabic number/label agreement (`guidesLabel`, `minutesLabel`, `MEMBERSHIP_PRICE`) | duplicate Arabic plurals inline |
| `mail.ts` | Cloudflare Email REST send | use SMTP; log recipient addresses |
| `agent-media.ts` / `agent-files.ts` | pipeline ingestion of media/attachments | accept `access` from the payload |
| `next.ts` | shared Next helpers | — |
Import rule: pages/components may import lib + payload; lib modules never import each other's internals — if two libs need shared logic, promote it to the lower-level module.
## 5. API surface & communication patterns
**Route handlers** (`src/app/api/**/route.ts`) return `NextResponse.json`. Every error body is `{"error": "<real Arabic sentence>"}` — honest, specific, never a bare code.
| Route | Auth | Notes |
| ---------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------- |
| `POST /api/agent/posts`, `PUT /api/agent/posts/[slug]` | `x-agent-key` header | pipeline; 401 bad key, 400 shape, 409 slug conflict (after sourceId dedup), 422 secret scan |
| `GET /api/posts/[slug]` | public | JSON honoring the access decision |
| `POST /api/posts/[slug]/comments` | session required | server-forced author; moderated status (ADR-0001/0002) |
| `POST /api/posts/[slug]/like` | session required | one per user per post |
| `POST /api/posts/[slug]/view` | public | anonymous view counter |
| `POST /api/notify` | public | always records; honest 503 when mail transport down; dup → `alreadySubscribed` |
| `POST /api/subscribe` | public | 503 honest Arabic while billing disabled |
| `POST /api/users`, `/api/auth/verify-email`, `/api/auth/resend-verification` | public / token | verification-token writes at signup; middleware rate-limits this path |
| `GET /api/files/[id]` | per-file access gate | streams attachments |
| `POST /api/stripe/webhook` | signature | no-op while `billingEnabled()` is false |
**Data access patterns:**
- Server components use the **Payload Local API** (`payload.find`, `payload.findByID`) — never internal fetch to own routes.
- Client components are thin: forms POST to route handlers and render the returned Arabic message. No client data-fetching library, no global store; if a page needs reactive data, it re-fetches via a route handler or uses Payload's REST with credentials.
- Generated types: `npm run generate:types` (runs as part of `build`) — collections' `Collection` types come from `payload-types.ts`, never hand-written interfaces for docs.
## 6. Rendering & state
- **Server components by default.** `'use client'` only for: forms (signup, notify, comments, account settings), small interactive bits (menu, like button). Check before adding more.
- **State management = URL + server.** No Redux/Zustand/Jotai. Session state = `getViewer()`; launch state = `launch.ts`; content state = Payload. The only client state is form-local.
- **No render-time nondeterminism** in components that render on the server: no `Date.now()` in bodies that should be static, no unseeded randomness.
## 7. Deployment topology
```
Internet → Traefik (proxy net) → web (Next standalone, port 3000) → db (postgres:16, internal only)
```
- `docker-compose.vps.yml` services: `db` (postgres:16, volume `db-data`, **no published ports**), `web` (volume `media-uploads`, `files-uploads`), Traefik labels for apex + `www → apex 308` (priority above API router).
- `LAUNCH_MODE=free_early_access` lives in `/root/anwarpi/.env.production` — compose passes it; the file is never committed.
- Migrations apply **on container boot**. Deploy = rsync `web/` (excludes `node_modules .next .git media files-private '.env*' '*.tsbuildinfo'`, **no `--delete`**) → `docker compose -f docker-compose.vps.yml up -d --build`.
- Prod DB access for scripts: the standalone image has no `scripts/`; open an SSH tunnel to the db container IP and run `npx tsx` locally (see AGENTS.md rulebook).
- Rollback: previous image + `docker compose up -d`; DB restores come from `backup-db.sh` dumps (rehearsed 2026-10-10).
- **Anticipated: Railway move (owner, 2026-10-11).** Keep the app deployable there without redesign: all config via env vars (already true), **no host-local state in the container** (media/files volumes must move to object storage / managed volume; DB → managed Postgres), Traefik labels replaced by Railway's router + custom domain. Any change that deepens VPS coupling (cron on host, local disk caches) must be flagged in the PR that adds it.
## 8. Decision records
Architecture decisions live in `docs/adr/NNNN-*.md` (current: 0001 comments route, 0002 moderated comments/server-forced author, 0003 Cloudflare Email REST not SMTP, 0004 coming-soon subscribe at public launch, 0005 member trust model). New decisions that change structure, contracts, or policy get a numbered ADR — one file, context + decision + consequences.
+62
View File
@@ -0,0 +1,62 @@
# CODE_STYLE — anwarpy.ai
> TypeScript, strict, boring on purpose. The existing codebase is the style guide; this file makes it explicit. Formatting tooling may be added later only if it matches, not fights, this file.
## 1. TypeScript
- `strict: true` (tsc is a commit gate — `cd web && npx tsc --noEmit` must exit 0).
- No `any`. Use `unknown` + narrowing, or the generated Payload types.
- Document types come from `src/payload-types.ts` (`npm run generate:types`, runs inside `npm run build`). **Never hand-write an interface that mirrors a collection doc** — import the generated one; hand-written mirrors drift and lie.
- Payload collection access/hook args: annotate the request/doc shape explicitly (existing pattern: `args: { doc?: {...}; req: { user?: {...} } | null }`).
- Avoid non-null `!`; if you're sure, prove it with a check or a comment stating the invariant.
## 2. Naming
| Thing | Convention | Example |
|---|---|---|
| Files | kebab-case | `secret-scan.ts`, `notify-form.tsx` |
| Route folders | Next convention, exact segment names in brackets | `posts/[slug]/route.ts` |
| Components | named exports (function name = file name, PascalCase) | `export function Topnav()` |
| Route handlers | Next default exports | `export async function POST(req: NextRequest)` |
| Lib functions | camelCase, exported individually | `lexicalToText`, `freeEarlyAccess` |
| Constants | SCREAMING_SNAKE | `READING_WORDS_PER_MINUTE`, `MEMBERSHIP_PRICE` |
| DB fields | camelCase in Payload | `readingMinutes`, `authorName` |
| Booleans | is/has/can/should or unambiguous verb | `billingEnabled`, `emailVerified` |
## 3. Error handling
- **Route handlers**: validate → early `NextResponse.json({ error: '<Arabic sentence>' }, { status })`. Statuses: 400 shape, 401 auth, 403 forbidden, 404 missing, 409 conflict (slug), 422 semantic rejection (secret scan), 429 rate-limited, 503 dependency honestly down.
- Error text is a **fixed Arabic sentence owned by the route** — specific about what failed («الدفع غير مفعّل خلال فترة الوصول المبكر.»). Never echo user input into the message; never expose stack traces or internal hosts; never a bare code with no words.
- Never swallow: no empty `catch`. If you catch to continue, comment why. Server-side failures get `console.error` with context (no secrets).
- **Honest failure is a feature** (PRD principle 1): if a dependency is down (mail transport, billing), return 503 with the true Arabic explanation instead of faking success. The notify route is the reference implementation.
## 4. Server vs client components
- Default: server components. `'use client'` only for forms and small interactions — check existing (`account-settings/client.tsx`, `notify-form.tsx`) before adding a new one.
- No secrets, no `process.env` (non-public), no Payload local API calls in client components. They talk to route handlers with `fetch` and render the returned Arabic message.
- No data-fetching libraries, no global client state (ARCHITECTURE §6).
## 5. Arabic & RTL in code
- UI copy is Arabic with «» quotes; keep strings verbatim from the PRD where they're contracts (honest 503s, notice strips).
- Latin islands inside RTL: wrap in `<bdi dir="ltr">` (`$10`, `anwarpy.ai`, code, `GPTBot`).
- Plurals/counts via `src/lib/format.ts` (`guidesLabel`, `minutesLabel`, `formatNumber`) — never inline Arabic number agreement.
## 6. Comments & docs
- Comments explain invariants and decisions, not syntax. Reference the contract: «L-02», «ADR-0003», «PRD §3.2» are valid shorthand — these docs are the map.
- When you change a documented decision, update the doc in the same PR (ADR for structural decisions — ARCHITECTURE §8).
- No commented-out code committed. Delete it; git remembers.
## 7. Git & PRs (Gitea)
- `main` is deployable truth. Feature branches: `launch/…`-style prefixes (`feat/…`, `fix/…`, `docs/…`).
- PRs via Gitea; merge = **POST** `/repos/ahmed/anwar-pi-website/pulls/{index}/merge` with `{"Do":"merge"}` (PUT → 405). Evidence-updating doc commits may go straight to `main` when they record verified facts.
- Commit messages: imperative, specific («fix: unwrap Lexical root in lexicalToText/countLexicalWords»), no «update stuff».
- Never commit: `.env*`, `media/`, `files-private/`, `backups/`, build output, `.tsbuildinfo`.
- Destructive ops (`reset --hard`, `push -f`, branch deletion) require the owner's explicit go-ahead.
## 8. Dependencies
- Reuse ladder (gstack): repo helper → stdlib → platform → installed dep → new dep. `stripe` is already installed (do not add a second payment lib); `sharp` exists for images; `tsx` for scripts.
- New runtime deps need a PR-level justification line in the description; dev deps for testing are pre-approved by TESTING.md.
+75
View File
@@ -0,0 +1,75 @@
# DATABASE — anwarpy.ai
> The schema is **Payload-first**: collections in `web/src/collections/` are the source of truth; SQL in Postgres is a generated artifact. This document maps the logical schema; never edit SQL by hand.
## 1. Adapter & migrations
- `@payloadcms/db-postgres` (Drizzle). Connection = `DATABASE_URI` (prod: composed by `docker-compose.vps.yml` from `DB_PASSWORD`; local: `web/.env` → LAN Postgres 192.168.88.36).
- Migrations live in `src/migrations/` (timestamped `YYYYMMDD_HHMMSS.ts` + paired `.json`). Created with `npx payload migrate:create`; applied **on container boot** in prod, or `npx payload migrate` locally.
- Rules: never edit an applied migration (history: the `20261003_111451` duplicate-`full_name` fix was done in place *before* prod applied it — that's the exception that proves the risk). Data backfills are standalone scripts under `web/scripts/` (`backfill-reading-minutes.ts` pattern), not migrations. `payload_migrations` is bookkeeping — if it says applied, it is.
## 2. Collections
### `posts` — guides (the core table)
| Field | Type | Notes |
|---|---|---|
| `title`, `slug` | text | slug **unique**, URL identity, 409 on conflict |
| `dek` | text | standfirst/summary, feeds metadata + JSON-LD |
| `category` | select | وكلاء / نماذج / استرجاع / سلامة / أدوات / أعمال |
| `topics` | select/array | registry in `src/lib/topics.ts` (agents, models, automation, foundations) |
| `access` | select `free` \| `vip` | **intended tier — data, not state**; never rewritten to simulate launch states |
| `content` | lexical (richtext) | serialized `{root:{children:[…]}}`; **field-level read ACL** (ARCHITECTURE §3) |
| `contentType` | select | note / tutorial / case_study / comparison — badge |
| `authorName` | text | visible byline; also JSON-LD author Person |
| `sourceId` | text | agent-pipeline idempotency; dedup before slug-409 |
| `cover` | upload → `media` | og:image source (1200×630) |
| `readingMinutes` | number | **computed at write** (`countLexicalWords` ÷ `READING_WORDS_PER_MINUTE`=160); stripped from agent payloads; backfilled honestly |
| `publishedAt` | date | feeds «حُدّث» + JSON-LD |
| `status` | select `draft` \| `published` | public visibility gate; `publish: true` agent opt-in |
| derived/rels | — | `views`, `likes` counters (see routes) |
### `users`
Payload auth: `email` (unique), `password` (hash), `fullName`, `interest`/`level` (onboarding), `emailVerified` flag + `verification_token` (written at signup, ADR-compliant), `stripeCustomerId` (Phase 3; hidden/removed when empty — never exposed to frontend). Roles: `admin` vs member (see SECURITY §3). Note: `20261003_111451` removed a duplicate `full_name` column.
### `comments`
`post` (rel → posts), `author` (rel → users, **forced server-side** — the API ignores any client-sent author, ADR-0002), `authorName` (display snapshot), `body` (textarea), `status` (moderation: pending → published / rejected). Anonymous requests are rejected at the route (PRD §3.5). `admin.defaultColumns: post, authorName, status, createdAt`.
### `launch_notifications`
`email`, `topic`, timestamps. Written by `/api/notify` on every submission (even when mail transport is down — the honest-503 still records the row). Duplicate email → no new row, `alreadySubscribed: true`. Zero rows during the 2026-10-10 prod smoke cleanup is the expected state.
### `post_likes`
`user` (rel), `post` (rel) — **one per user per post** (uniqueness enforced in the like route before insert); counter incremented on `posts`. Auth required (401 otherwise).
### `media`
Payload uploads (covers + pipeline images) → volume `media-uploads`; `sharp` for sizes. Public URL pattern `/api/media/file/...`.
### `files`
Guide attachments → volume `files-uploads`; per-file `access` mirrors its post (download gate `GET /api/files/[id]`); ingested via `agent-files.ts` (never accepts `access` from the payload).
## 3. Relationships & integrity
```
posts 1─* comments *─1 users
posts 1─* post_likes *─1 users (unique(user,post))
posts *─1 media (cover)
posts 1─* files (each file carries its own access)
```
Enforcement lives in collection hooks/route handlers (Payload has no SQL-level FK constraints across its tables for these patterns) — hence the "check then insert" pattern in `like` (query `where user+post` before `create`). Keep that pattern for any new per-user-once resource.
## 4. Indexing
- Unique: `posts.slug`, `users.email`, `posts.sourceId` (dedup lookup), `verification_token`.
- Hot lookups: `comments.post` (+ `status`), `post_likes.post`, `launch_notifications.email` (dup check).
- Add indexes via migrations when a query pattern shows up in slow logs — not speculatively.
## 5. Backups & restore
- `web/scripts/backup-db.sh` → `pg_dump` gzip; prod cron/backup policy is an owner action.
- Rehearsed restore (2026-10-10): restore into a **temp DB** (`anwarpi_restore_test`), verify counts (`posts`, `users`, `payload_migrations`), then drop. Never restore over the live DB without owner approval.
## 6. Data rules that are also PRD rules
- `access` and `readingMinutes` are stripped from agent payloads (`pickFields`) — the pipeline cannot set them.
- Reading minutes are computed, never trusted from input; if a script touches them, it must recompute from `content` and report scanned/updated counts.
- Personal data in `launch_notifications`/`users` emails: never rendered into docs, videos, or evidence (the smoke test uses `@example.com` rows and deletes them).
+74
View File
@@ -0,0 +1,74 @@
# DESIGN_SYSTEM — anwarpy.ai
> Canonical tokens live in `web/src/app/design.css`. This file documents them and the rules for using them. If CSS and this file disagree, fix one of them in the same PR.
## 1. Design personality
Editorial, calm, premium-dark. Ink surfaces, champagne accents, generous whitespace, Arabic-first typography. Restraint is the brand: one accent color, used sparingly; motion is short and soft; nothing blinks, nothing bounces.
## 2. Color tokens
| Token | Value | Role |
|---|---|---|
| `--ink` | `#131519` | page background (`--bg`) |
| `--ink-2` | `#17191d` | raised background |
| `--basalt` | `#1c1f24` | card/surface (`--surface`) |
| `--seam` | `#2b2f36` | hairline borders (`--border`) |
| `--seam-2` | `#3a3f47` | hover border, stronger seam |
| `--mist` | `#ece9e3` | primary text (`--fg`) |
| `--stone` | `#a3a09a` | muted text (`--muted`) |
| `--dim` | `#7b7974` | faintest text (meta only) |
| `--champagne` | `#d4b47c` | THE accent (`--accent`) — badges, CTAs, highlights |
| `--champagne-ink` | `#1a150c` | text/icons on champagne (`--accent-ink`) |
Rules:
- Never introduce a new hue. Semantic aliases (`--bg`, `--surface`, `--fg`, `--muted`, `--border`, `--accent`) are the only tokens components may use — raw hex is for `design.css` only.
- Champagne on ink or champagne-ink on champagne. Never champagne text on basalt for body copy (contrast), never stone below 14px.
- Success/warning/error states (form errors, honest 503s) use `--mist`/`--stone` + wording first; reserve red only where the CSS already defines it. Do not invent traffic-light colors.
## 3. Typography
- **Readex Pro** (Arabic + Latin, single family), loaded in `layout.tsx` via `next/font/google`, weights 300–700. `--font-display: var(--font-sans), "Segoe UI", sans-serif`.
- Scale: hero H1 ~2.5rem/700; section H2 ~1.5rem/600; body 1rem/400 with line-height ≥1.8 for Arabic; meta/captions 0.875rem `--stone`.
- Arabic-first: `direction: rtl` at the page root; Arabic quotes «»; numerals via `formatNumber` (Arabic-Indic where the site does).
- **LTR islands** inside RTL text use `<bdi dir="ltr">`: `anwarpy.ai`, `$10`, code, identifiers, agent names (GPTBot…). Never let a raw Latin token break RTL flow.
- No `<br>` for spacing; use margins. No font-size below 0.75rem.
## 4. Spacing & shape
- Space scale: 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64px. Section padding 48–64px; card padding 16–24px; page max-width container per existing layout (centered, ≥24px gutters).
- Radii: cards/pills 8–12px; badges/buttons pill (999px) where the existing CSS does; inputs match buttons.
- Borders: 1px `--seam` everywhere; hover → `--seam-2`. No heavy shadows; elevation = `--ink-2`/`--basalt` layering, not blur.
## 5. Component inventory (build from these, extend the list, never fork it)
Existing, in `src/components/` + route CSS: `topnav.tsx`, `site-menu.tsx`, `nav-links.tsx`, `footer.tsx`, `anwarpi-background.tsx`, `set-body-attr.tsx`.
Recurring patterns (CSS classes are the contract):
- **Buttons**: `.btn`, `.btn.btn-primary` (champagne bg, `--accent-ink` text), disabled/coming-soon state = «قريباً» pill, wired to `subscribe-button.tsx comingSoon` prop. Disabled buttons are visually disabled from first paint and explain why in Arabic.
- **Badges/pills**: access badge «مجاني» / «للأعضاء»; content-type badge (note/tutorial/case_study/comparison); «وصول مبكر مجاني» champagne strip.
- **Notice strip**: the early-access notice on articles — champagne-tinted band, verbatim copy from PRD §3.2, never a modal.
- **Guide card**: surface card, dek line, meta row (category · reading minutes · «حُدّث»), champagne hover seam. Topic hubs and «ابدأ من هنا» reuse it.
- **Article body**: headings, paragraphs, lists, blockquote, code (LTR island, scrollable), **real `<table>`** for tabular data (never screenshot/ASCII), links `--champagne` underline.
- **Forms**: label above input, input bg `--ink-2`, border `--seam`, error text = the API's Arabic sentence verbatim, loading state disables + keeps label, success replaces form state («تم! سنراسلك عند تفعيل العضوية.»).
- **Tables renderer**: for Lexical table nodes — mobile-safe overflow wrapper.
New components: reuse an existing class first, then extend `design.css` (or the route's CSS) — do not add a component library (no shadcn/radui/MUI) and no Tailwind (decision, 2026-10-11).
## 6. Motion
- Durations 150–250ms, ease-out. Hover: border color + 1–2px translate. Entrances: fade/rise ≤8px.
- No parallax, no infinite loops, no scroll-hijack. Reduced-motion: honor `prefers-reduced-motion` for any added animation.
- Micro-interactions (like button) may pulse once on success — once, not looping.
## 7. Accessibility floor (enforced by `npx hyperframes check` on the brag video; treat as the site bar too)
- Contrast: body text ≥ 4.5:1, large text ≥ 3:1 (`--stone` on `--basalt` passes; `--dim` only ≥1.5rem or decorative).
- Focus visible: 2px `--champagne` outline on interactive elements.
- Every image has meaningful Arabic `alt` (covers: the guide title); icon buttons have `aria-label`.
- Landmarks: header/nav/main/footer; one H1 per page; heading order never skips.
- Forms: `<label for>`, `aria-live="polite"` status regions (existing pattern: `#notify-done`).
## 8. Cover images
Generated via `npm run covers` (`scripts/generate-covers.mjs`) — 1200×630, ink/champagne system, Arabic title shaping checked. og:image dimensions are asserted in tests/evidence (1200×630). Don't hand-drop covers of other sizes.
+101
View File
@@ -0,0 +1,101 @@
# PRD — أنور بآي (anwarpy.ai)
> Owner: Ahmed (anwarpy). Maintainer of record: this document. Last revised: 2026-10-11.
> Status vocabulary in this doc: **MVP** = shipped 2026-10-10 (verified, see `docs/launch/2026-10-10-launch-readiness.md` §11). **Roadmap** = committed, not built. **Out of scope** = we will not build it; do not ask.
## 1. Vision
Anwarpy.ai is an Arabic-first library of practical AI tips and tricks — automation, agentic workflows, models, tooling — whose single goal is: **help the Arabic reader actually use AI and keep discovering new tricks.** Arabic engineers, founders, and curious professionals learn to *understand* AI and then *build* with it: «افهم الذكاء الاصطناعي، ثم ابنِ به.»
Three product principles, all enforced server-side:
1. **Honesty over theater.** Reading times are computed from real content, errors say what failed in real Arabic, the paywall never lies about what the visitor can read.
2. **The reader is sovereign.** During free early access every published guide — including membership-tier ones — is fully readable. No dark patterns, no fake scarcity, no confirm-password, no CAPTCHA.
3. **Machines are readers too.** Guides are served clean to humans and to AI crawlers (robots.txt policy, llms.txt/llms-full.txt, honest JSON-LD).
## 2. Personas
| Persona | Who | What they get |
|---|---|---|
| **The Curious Reader** | Arabic speaker googling «كيف أبني وكيل ذكاء اصطناعي» | Free guides, topic hubs, honest reading times, notify list |
| **The Practitioner** | Engineer/founder implementing agents at work | Advanced guides + work files during early access (free); after billing launches: the $10/mo membership (S-01…S-08) |
| **The Writer (Ahmed)** | Solo owner; writes in `/admin`, ships via the agent pipeline | Payload admin, agent API with secret scanning, moderation queue, comment management |
## 3. MVP — shipped 2026-10-10
The MVP is exactly what launched. Nothing below may regress without an explicit owner decision.
### 3.1 Reading & content
- **Guides** (`Posts`): Arabic titles/deks, Lexical bodies, categories وكلاء/نماذج/استرجاع/سلامة/أدوات/أعمال, topics (agents|models|automation|foundations), `contentType` badge (note/tutorial/case_study/comparison), `authorName` byline, «حُدّث» freshness, computed `readingMinutes` (160 wpm, written at save + backfilled honestly).
- **Topic hubs** `/topics/{agents,models,automation,foundations}` with sitemap entries.
- **Tables** in article bodies render as real tables (mobile-safe), not code blocks.
- **AI/SEO surfaces**: robots.txt allows the 10 named AI crawlers; `/llms.txt` index; `/llms-full.txt` full bodies; JSON-LD Article + BreadcrumbList (+ FAQPage on /subscription, Organization on home); `isAccessibleForFree` reflects reality.
- **Downloads**: guide attachments gated by the same access policy as their post (per-file `access`).
### 3.2 Access policy (the core contract)
- `LAUNCH_MODE` server-side policy (`web/src/lib/launch.ts`): `free_early_access` = every published post serves its full body; billing routes honestly 503 in Arabic. `paid` = membership-tier (`access: 'vip'`) posts paywall with H2-preview + fine print.
- The *intended tier* (`access: free|vip`) is data; the *effective access* is policy. Never rewrite `access` to simulate a launch state.
- Ghost rule: any verification must be done as a logged-out visitor reading a VIP post.
### 3.3 Accounts & trust
- Email+password signup (no confirm-password field), verification email with token, honest 429 lockout message, duplicate-email inline error, email-typo suggestion, live password hint, reassurance microcopy.
- Verification state is written at signup (`verification_token`); verify-email flow supports changing the email.
- Account settings + onboarding (interest/level) exist; `getViewer()` resolves the session.
### 3.4 Notify list
- `/api/notify` records every submission; sends a confirmation mail when transport is live; honest 503 «خدمة البريد غير مربوطة بعد.» when Cloudflare Email Sending is off (row still kept); duplicate emails return `alreadySubscribed: true` without re-mailing.
### 3.5 Comments (decided 2026-10-11 — in MVP scope)
- Any **logged-in** user may comment on a post — free or VIP, tier does not matter. **Anonymous visitors cannot comment** (API rejects unauthenticated; UI prompts login).
- Moderated: comments enter a non-public status; the author identity is **forced server-side** from the session (client cannot spoof `author`), display name from `authorName` (ADR-0002).
- Route contract: not a public CRUD API — comment creation is a server-validated route bound to the post (ADR-0001).
- Admin moderation lives in `/admin` (status transitions: pending → published / rejected).
### 3.6 Agent pipeline (authoring automation)
- `POST/PUT /api/agent/posts` with `x-agent-key` header; strips `access`/`readingMinutes` from accepted fields; `sourceId` dedup (idempotent re-runs); secret scanning (`scanAgentPayload`) rejects AWS keys, PEM blocks, `sk-` keys, GitHub tokens, generic credentials with **422 + pattern names only**; string `content` is a 400 (Lexical JSON required); `publish: true` is explicit opt-in, PUT cannot set `status`.
- Media/Files ingestion helpers (`agent-media.ts`, `agent-files.ts`).
### 3.7 Interactions
- One like per logged-in user per post (`PostLikes`, uniqueness enforced), public like counter; anonymous view counting on `/api/posts/[slug]/view`.
### 3.8 Ops floor
- `web/scripts/backup-db.sh` + documented restore rehearsal (VPS).
- Feedback = `mailto:hello@anwarpy.ai` in the footer.
- Deployment: rsync → `docker compose -f docker-compose.vps.yml up -d --build` on the VPS; migrations apply on boot.
## 4. Roadmap (committed, phased)
### Phase 2 — Trust & legal
- **L-03 /terms + /privacy**: real content from owner facts recorded in `docs/launch/owner-inputs-terms-privacy.md`; effective date; §9 items 1–7. Acceptance: pages live, linked in footer, listed in sitemap.
- **Comments shipping polish**: notification to writer on new pending comment; abuse valve (delete + block author) in admin.
### Phase 3 — Money & measurement (gate: S-01…S-08 checklist §6)
- **Stripe paid tier S-01…S-08**: checkout, webhook (`/api/stripe/webhook` exists; `stripeCustomerId` on Users), `vip-onboarding`, payment success/cancel pages; flip = `LAUNCH_MODE=paid` policy change only. Paywall CRO (H2 preview + fine print + #faq) already built.
- **L-11 analytics**: vendor decision (owner); event list is pre-agreed in the launch checklist.
- **L-12 editorial distribution**: owner-driven announcement; no agent-posted content.
### Phase 4 — Product growth (owner-added 2026-10-11)
- **Mailing list v2**: move from notify rows to a real newsletter (send digests of new guides; unsubscribe honoring; Cloudflare Email transport).
- **Direct messaging with the writer**: logged-in members can message Ahmed from within the site; inbox in `/admin`; rate-limited; no exposure of his personal email.
- **In-post tool-finder chatbot**: assistant that reads the site's own posts (llms-full corpus) and points the reader to the right guide/tool inside anwarpy.ai; Arabic-first; cites posts; never invents tools. Must reuse the same access policy (never leak VIP bodies to unauthenticated users through the bot).
## 5. Strictly out of scope
Do not build, do not scaffold, do not "prepare" these:
- OAuth / Passkeys / SMS or WhatsApp login in the MVP — **anticipated evolution** (owner, 2026-10-11): Google auth or Clerk may be added later for easy integration; keep `viewer.ts` as the single session seam so providers can swap behind it.
- Native mobile apps.
- Multi-tenant or white-label anything; a second site on the same deploy.
- Public third-party REST API beyond what exists (agent API is for the owner's pipeline only).
- User-generated uploads of arbitrary files; media uploads stay author/admin-only.
- CAPTCHA, aggressive rate limiting beyond the existing login lockout (owner decision, recorded).
- Real-time anything (WebSockets/presence) until Phase 4 messaging is designed.
- Any rewrite of `access` fields to simulate launch states.
## 6. Success metrics
Available now (no vendor dependency): notify signups, verified-account ratio, likes per post, views per post, comment quality (published/rejected ratio). Post-L-11: activation (first guide read), return rate, member conversion once billing is live.
## 7. Acceptance culture
A checkbox without evidence is not verification. Every claim in the launch doc has a command + observed output. This PRD inherits that rule: features in §3 are verified by the §11 evidence; new features get the same treatment (ghost-read checks, honest-error checks, row-count checks).
+74
View File
@@ -0,0 +1,74 @@
# SECURITY — anwarpy.ai
> Threat model: a solo-writer content site. The assets are the content, the member trust (ADR-0005), and the pipeline key. Not a fintech app — but every rule below is load-bearing.
## 1. Authentication
- **Payload auth** (email + password). Signup writes `verification_token` + `emailVerified` flag at creation (ADR-0005 trust model); verification/resend routes: `/api/auth/verify-email`, `/api/auth/resend-verification`. Forgot/reset via Payload's built-in flow.
- **Session seam**: `src/lib/viewer.ts` `getViewer()` is the only place components read identity. If auth provider changes (anticipated: Google OAuth or Clerk — owner decision 2026-10-11), it changes behind this seam; callers keep consuming `Viewer`.
- **No password rules theater**: one live hint (strength guidance) at signup; no confirm-password field (owner decision, recorded).
- **Lockout**: login attempts on `/api/users` are rate-limited by `src/middleware.ts`; the 429 message stays honest («too many attempts» fidelity — step 14 evidence). No CAPTCHA (owner decision, recorded 2026-10-10).
- Verification emails go through **Cloudflare Email REST** (ADR-0003, never SMTP). Transport down → the UI/API says so in Arabic («خدمة البريد غير مربوطة بعد.»); it does not pretend success.
## 2. Environment variables & secrets
| Var | Scope | Rule |
| --------------------------------------------------------------- | -------- | --------------------------------------------------- |
| `DATABASE_URI` / `DB_PASSWORD` | server | compose builds the URI; never commit, never echo |
| `PAYLOAD_SECRET` | server | signing/encryption; rotate = invalidate sessions |
| `AGENT_API_KEY` | server | pipeline auth; header `x-agent-key` |
| `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `MAIL_FROM` | server | mail transport |
| `LAUNCH_MODE` | server | launch policy (SECURITY-relevant: gates paid flows) |
| `NEXT_PUBLIC_SITE_URL` | public | canonical/og origin |
| `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | server | Phase 3 |
| `SEED_TEST_USERS` | dev only | never set in prod |
Rules:
- `.env*` files are gitignored and **excluded from rsync deploys**; prod secrets live only in `/root/anwarpi/.env.production`.
- Never print secret values into transcripts, docs, videos, or evidence. Known incident: 8 chars of prod `DB_PASSWORD` leaked in a session transcript on 2026-10-10 → **owner to rotate**; rotation is the standing remedy for any partial leak.
- Read secrets at runtime via `process.env` inside server modules only; never in `'use client'` code (only `NEXT_PUBLIC_*` there).
## 3. RBAC & content access
- Two tiers: `role: 'admin'` (Ahmed, `/admin` + agent pipeline ownership) and everyone else (member/public). No roles table beyond that until a real need.
- **Content ACL is the crown jewel** (ARCHITECTURE §3): `Posts.content` field-level read rule — public iff `status=published && (access=free || freeEarlyAccess())`. VIP previews (H2 list, dek) are the only VIP fields served to logged-out users when billing is on.
- Files: `GET /api/files/[id]` enforces **per-file access** before streaming; the download gate and the article page must agree (one decision, ARCHITECTURE §3).
- Admin UI access = Payload's built-in `admin` gate; never hand-roll admin pages on the frontend.
## 4. Input handling & pipeline hardening
- **`pickFields`** on the agent API strips privileged fields (`access`, `readingMinutes`, `status` except explicit `publish: true` opt-in on POST; PUT cannot set `status`).
- **`scanForSecrets` / `scanAgentPayload`** (`src/lib/secret-scan.ts`): POST and PUT scan the _real text_ (Lexical → `lexicalToText`, or the raw string) for AWS AKIA keys, PEM blocks, `sk-` API keys, GitHub tokens (`ghp_`…), generic credential shapes → **422 with pattern NAMES only**. Never echo the matched text, never log it.
- String `content` → 400 (Lexical JSON required); no silent placeholder substitution.
- Comment bodies: textarea, moderated (pending until approved), author forced server-side from the session — client-supplied `author`/`authorName` identity is ignored/overwritten (ADR-0002). Display names are a snapshot, not a live join, so renamed users can't retroactively forge old comments.
- All user-facing error strings are fixed Arabic sentences from the route handlers — errors never reflect raw input back (no echo = reflected-input hygiene).
## 5. Transport & external calls
- Everything behind Traefik with TLS on anwarpy.ai; www → apex 308.
- Outbound: Cloudflare Email REST with scoped token; Stripe webhook verifies signature (`STRIPE_WEBHOOK_SECRET`) before trusting the body; agent API requires the key header (Authorization Bearer → 401).
- The db container publishes **no ports**; prod DB access for maintenance = SSH tunnel (AGENTS.md rulebook recipe), never a public 5432.
## 6. Operational security
- Backups: `web/scripts/backup-db.sh` → gzipped `pg_dump`; rehearsed restore 2026-10-10 (temp DB, count check, drop). Backup artifacts stay on the VPS `backups/` dir, never in git, never in the deploy rsync.
- Test rows (smoke signups, notify rows) use `@example.com` addresses and are **deleted after verification** — prod counts (`users=6`, `launch_notifications=0`) are the post-cleanup ground truth.
- Media/files volumes are excluded from rsync (`no --delete` protects them).
- Incident rule: any suspected leak (secret in transcript/log/URL) → rotate the credential first, then investigate. Recording the incident in docs is required; pretending it didn't happen is not.
## 7. What NOT to add (recorded decisions)
- No CAPTCHA, no aggressive rate limits beyond login lockout (owner, 2026-10-10).
- No WAF/Cloudflare proxy changes without owner approval — the domain's DNS/proxy is Ahmed's console.
- No secrets in client bundles; no `NEXT_PUBLIC_` for anything except the site URL.
- No new auth provider without routing it through `viewer.ts` (see §1).
## 8. Accepted risks
### next@15.4.11 — 28 known CVEs, max 9.5 (risk-accepted by owner, 2026-10-13)
- **Why pinned**: `@payloadcms/next@3.90.2` peer-supports next `<15.5.0 || >=16.3.3` — no Payload 3.x release supports 15.5.x, so the safe ladder is 15.4.11 → (Payload 4 or a verified Next 16 migration). Forcing 15.5.x would run the CMS on an unsupported combo.
- **Exposure analysis**: the 9.5-critical (Image Optimizer DoS, GHSA-9g9p-9gw9-jx7f) requires attacker-reachable `remotePatterns` — this app configures none (local covers only). The cache-poisoning advisory targets SSG/ISR caching; the site is predominantly dynamic SSR via the Payload Local API.
- **Revisit trigger**: any of — Payload publishes 15.5/16 peer support (then upgrade next in one verified PR), Payload 4 adoption, or a new next advisory with remote-code-execution class impact.
- **Gate note**: `procoder check` re-flags this on any `package-lock.json` change. If you must touch the lockfile before upgrading, either upgrade next in the same PR or skip the gate once with the reason in the commit message. Transitive CVE fixes already applied: undici 8.11.2, sharp 0.35.5, postcss 8.5.30, sass 1.105.1.
+60
View File
@@ -0,0 +1,60 @@
# TESTING — anwarpy.ai
> Owner decision 2026-10-11: **tier (b) is the bar** — Vitest unit tests for the core libs + regression tests for API route behavior. Tier (c) — Playwright E2E for critical journeys — activates when Phase 3 (Stripe) lands; journeys to cover then: signup→verify→login, VIP ghost-read (billing on), checkout happy path, webhook apply.
## 1. Standing gates (before any commit/merge)
```bash
cd web && npx tsc --noEmit # must exit 0
cd web && npm run build # must exit 0 (includes generate:types)
```
Both green = minimum. Gates alone are not proof of behavior — see §3.
## 2. Unit tests (Vitest, tier b)
- Add `vitest` as a dev dependency; tests live beside the code or under `web/src/lib/__tests__/` — pick one location and stay consistent (recommend `__tests__/` next to `src/lib`).
- **Required suites (the known-risk list):**
| Suite | Must pin |
|---|---|
| `launch.test.ts` | policy truth table: `billingEnabled()` false when `LAUNCH_MODE` unset/**free_early_access**/**paid**… — including the fail-safe default |
| `lexical.test.ts` | `lexicalToText`/`countLexicalWords` **descend into `content.root`** (regression: the 2026-10-10 launch-blocking bug where both returned 0/'' on real serialized trees); `extractH2s`, `lexicalToPlain` |
| `secret-scan.test.ts` | one case per pattern (aws_access_key, pem_private_key, openai_api_key, github_token, generic_credential) returns **pattern name only, never the matched text**; clean payload passes; string content is scanned too |
| `topics.test.ts` | `isTopic` accepts exactly agents/models/automation/foundations; `topicsFor` mapping |
| `format.test.ts` | `guidesLabel` (1 دليل، 2 دليلان، 3–10 أدلة، 11+), `minutesLabel`, `MEMBERSHIP_PRICE` |
- Rule: tests assert **behavior**, not implementation text. No snapshot-of-source, no tautologies (`expect(fn).toBeDefined()`), no mock-echo tests, no bare "does not throw".
- Runtime env manipulation: use `vi.stubEnv`/`unstubAllEnvs` — never let one test's `LAUNCH_MODE` leak into another.
## 3. API route regression tests
Behavior tests for the routes with contracts (per PRD §3.6, SECURITY §4):
- `POST/PUT /api/agent/posts`: 401 wrong/missing key (note: **`x-agent-key`**, not Bearer); 400 on string `content`; 422 with pattern names on secret-bearing Lexical; `sourceId` dedup returns existing post (no 409, no duplicate row); `pickFields` strips `access`/`readingMinutes`; PUT cannot set `status`.
- `POST /api/subscribe`: 503 + exact Arabic body while billing is off.
- `POST /api/notify`: records row then honest 503 when mail transport fails; duplicate email → 200 `alreadySubscribed: true`; no double-send on dup.
- Comments route: unauthenticated → reject; client-sent `author` ignored (server-forced).
- Likes: 401 anonymous; second like by same user → no duplicate row.
Implementation: pure logic moves into `src/lib` helpers where possible and gets unit-tested; handler-level tests run the route module with a mocked Payload local API (or hit a dev server for the smoke tier — §5). Prefer extracting decision logic over wiring up heavy mocks.
## 4. What must NOT become a test
- Wiring/copies/forwarding, mock echoes, source-text greps, "file exists".
- Incidental defaults that aren't contracts (an unstyled div's class list is not a spec).
- Anything already verified cheaper by the ghost-read/smoke script in §5.
- Per the gstack rule: existing tests that pin wording/implementation instead of behavior get deleted, not "fixed".
## 5. Smoke & verification scripts (throwaway, not committed)
- Prod verification is HTTP-level and scripted ad hoc (curl batches): ghost-read a VIP post logged out; check `isAccessibleForFree`, og:image 1200×630, JSON-LD types; subscribe 503; notify row + cleanup; sitemap contents; www 308. Use `@example.com` rows and **delete them after** (ground truth: users=6, launch_notifications=0).
- Keep the failing-before/passing-after pair when fixing a bug — a permanent regression test in §2/§3 if it's pure logic, the script transcript if it's deploy/config.
## 6. Before-merge checklist (condensed)
1. `tsc --noEmit` + `npm run build` exit 0.
2. New/changed lib behavior → matching Vitest suite updated or added.
3. Changed route contract → §3 regression updated (status + body shape).
4. Changed access logic → ghost-read check (logged-out VIP post) recorded in the PR.
5. No new `any`, no new `'use client'` without justification, no new dependency without a line of justification (CODE_STYLE §8).
+11 -7
View File
@@ -329,15 +329,19 @@ Companion document: anwarpy-blog-publishing-agent-prompt.md. The prompt controls
Launch implemented on branch `launch/early-access-2026-10-10` (step references = execution plan). `LAUNCH_MODE=free_early_access` is the compose default; billing stays off until owner approval. Per the checklist rule, a checkbox without evidence is not verification — prod-verify items below are re-checked at step 48 and the lines updated. Launch implemented on branch `launch/early-access-2026-10-10` (step references = execution plan). `LAUNCH_MODE=free_early_access` is the compose default; billing stays off until owner approval. Per the checklist rule, a checkbox without evidence is not verification — prod-verify items below are re-checked at step 48 and the lines updated.
- **L-01 (paid claims off)** — Steps 8–11, 21, 22: `/subscription` shows an early-access panel + working notify form; all subscribe CTAs render a disabled «قريباً» button when billing is off (`subscribe-button.tsx` comingSoon prop); `POST /api/subscribe` returns 503 server-side with the exact Arabic error; home membership copy swept for purchase claims. *Prod re-check: step 48.* - **L-01 (paid claims off)** — Steps 8–11, 21, 22: `/subscription` shows an early-access panel + working notify form; all subscribe CTAs render a disabled «قريباً» button when billing is off (`subscribe-button.tsx` comingSoon prop); `POST /api/subscribe` returns 503 server-side with the exact Arabic error; home membership copy swept for purchase claims. **Prod-verified 2026-10-10: `POST https://anwarpy.ai/api/subscribe` → `503 {"error":"الدفع غير مفعّل خلال فترة الوصول المبكر."}`.**
- **L-02 (launch-free access policy)** — Steps 1–6: `src/lib/launch.ts` policy (`LAUNCH_MODE`, fail-safe default = free early access, no date expiry), field-level `content` read ACL in `Posts.ts` (public iff `status=published && (access=free || early)`) — same decision consumed by pages, `/api/posts/[slug]`, file gate, and llms-full. Intended tier (`access`) never rewritten. *Verified: migration chain + step 45/48 ghost-read.* - **L-02 (launch-free access policy)** — Steps 1–6: `src/lib/launch.ts` policy (`LAUNCH_MODE`, fail-safe default = free early access, no date expiry), field-level `content` read ACL in `Posts.ts` (public iff `status=published && (access=free || early)`) — same decision consumed by pages, `/api/posts/[slug]`, file gate, and llms-full. Intended tier (`access`) never rewritten. **Prod-verified: ghost-read of intended-VIP `/posts/agent-loop-stop-condition` serves the full body + early-access notice, zero paywall markers, `isAccessibleForFree: true`.**
- **L-03 (terms/privacy)** — **NOT DONE — owner facts pending.** `docs/launch/owner-inputs-terms-privacy.md` lists exactly what Ahmed must supply; `/terms` and `/privacy` untouched until then. - **L-03 (terms/privacy)** — **NOT DONE — owner facts pending.** `docs/launch/owner-inputs-terms-privacy.md` lists exactly what Ahmed must supply; `/terms` and `/privacy` untouched until then.
- **L-04 (reading/download QA)** — Step 45 local smoke + step 48 prod QA (desktop 1280px, mobile 390px Chromium, home → library → topic hub → article → download). Safari and Telegram in-app browsers are owner-manual tests. - **L-04 (reading/download QA)** — Step 45 local smoke + step 48 prod QA at HTTP level (home → library → topic hub → article; reading time «دقيقتان», no horizontal-marker regressions observed in markup). Safari and Telegram in-app browsers are owner-manual tests. A full visual browser pass remains open.
- **L-05 (private notes / pipeline permissions)** — Steps 28–30: agent API strips `access`/`readingMinutes` from accepted fields; `sourceId` dedup returns the existing post (no duplicate); `scanForSecrets` 422s payloads matching AWS AKIA / PEM / sk- / ghp_ / generic-credential patterns (pattern names only in the response); publish stays an explicit opt-in and PUT cannot set `status`. - **L-05 (private notes / pipeline permissions)** — Steps 28–30: agent API strips `access`/`readingMinutes` from accepted fields; `sourceId` dedup returns the existing post (no duplicate); `scanForSecrets` 422s payloads matching AWS AKIA / PEM / sk- / ghp_ / generic-credential patterns (pattern names only in the response); publish stays an explicit opt-in and PUT cannot set `status`. **Post-copy fixes found in smoke, included in the merged commit:** `lexicalToText`/`countLexicalWords` never unwrapped the Lexical `root` (scan was inert on real content — fixed + verified 422), string `content` was silently replaced with a placeholder (now 400), and llms-full never requested `content` (`fetchIndexDocs(true)` — prod llms-full now 49 KB with bodies).
- **L-06 (feedback, backup, rollback)** — Step 40: `scripts/backup-db.sh` + restore rehearsal on the VPS (2026-10-10): `pg_dump` → gzip 25K, restored into `anwarpi_restore_test` with 0 SQL errors, counts `posts=15, users=6, payload_migrations=6`, temp DB dropped. Feedback: footer `mailto:hello@anwarpy.ai` (opens the visitor's mail client; no server dependency). Production mail transport test via the same Cloudflare Email Sending REST call the app uses → `10203 email.sending_disabled` — **the account feature is off; transactional mail (verification, notify confirmations) will honestly report failure until the owner enables Email Sending**. Monitoring/uptime tooling: owner decision, recorded not built. - **L-06 (feedback, backup, rollback)** — Step 40: `scripts/backup-db.sh` + restore rehearsal on the VPS (2026-10-10): `pg_dump` → gzip 25K, restored into `anwarpi_restore_test` with 0 SQL errors, counts `posts=15, users=6, payload_migrations=6`, temp DB dropped. Feedback: footer `mailto:hello@anwarpy.ai` (opens the visitor's mail client; no server dependency). Production mail transport test via the same Cloudflare Email Sending REST call the app uses → `10203 email.sending_disabled` — **the account feature is off; transactional mail (verification, notify confirmations) will honestly report failure until the owner enables Email Sending** (prod `/api/notify` honestly 503s and still records the row). Monitoring/uptime tooling: owner decision, recorded not built.
- **L-07 (labels + reading estimates)** — Steps 24–26: `contentType` (note/tutorial/case_study/comparison) badge, `authorName` byline, «حُدّث» freshness line; `readingMinutes` computed at write time via `countLexicalWords`/`READING_WORDS_PER_MINUTE` (160 wpm) with `scripts/backfill-reading-minutes.ts` (13 scanned, 0 drift on seeded content). - **L-07 (labels + reading estimates)** — Steps 24–26: `contentType` badge, `authorName` byline, «حُدّث» freshness line; `readingMinutes` computed at write time via `countLexicalWords`/`READING_WORDS_PER_MINUTE` (160 wpm). **Prod backfill run 2026-10-10 (local tsx over an SSH tunnel to the prod DB — the standalone container has no `scripts/`): 15 scanned, 15 updated** (old estimates were inflated: 18→2, 12→2, 10→1, …).
- **L-08 (metadata/domain hygiene)** — Steps 32, 37, 38, 39: Arabic descriptions on articles/subscription/about; sitemap gains `/topics/*` and drops `/login`,`/signup`; those pages (plus forgot-password) are `robots: noindex, follow`; Traefik `anwarpi-www` router 301s `https://www.anwarpy.ai/*` → apex with priority above the API router. *Prod re-check: `curl -sI https://www.anwarpy.ai/posts`, step 48.* - **L-08 (metadata/domain hygiene)** — Steps 32, 37, 38, 39: Arabic descriptions on articles/subscription/about; sitemap gains `/topics/*` and drops `/login`,`/signup`; those pages (plus forgot-password) are `robots: noindex, follow`; Traefik `anwarpi-www` router permanently redirects `https://www.anwarpy.ai/*` → apex preserving path (prod-verified: `HTTP/2 308`, `location: https://anwarpy.ai/posts`). Sitemap prod-verified: 4 `/topics/*`, no `/login`,`/signup`. og:image 1200×630 + og:site_name present on articles.
- **L-09 (author/evidence/structured data)** — Step 33: Article JSON-LD with `image`, `dateModified`, `description`, `mainEntityOfPage`, publisher logo, `author` = Person (authorName) or Organization; `isAccessibleForFree` = actual current access (never a false paid Offer while billing is off); BreadcrumbList (home → topic hub → post); FAQPage on `/subscription`; Organization on home. Visible byline and schema share the same fields. - **L-09 (author/evidence/structured data)** — Step 33: Article JSON-LD with `image`, `dateModified`, `description`, `mainEntityOfPage`, publisher logo, `author` = Person (authorName) or Organization; `isAccessibleForFree` = actual current access (never a false paid Offer while billing is off); BreadcrumbList (home → topic hub → post); FAQPage on `/subscription`; Organization on home. Visible byline and schema share the same fields.
- **L-10 (publishing workflow/dedup)** — Steps 24, 28–31: agent creates drafts (`status` forced draft unless `publish: true`), `sourceId` unique-ish dedup before slug-409, secret scan on POST/PUT. - **L-10 (publishing workflow/dedup)** — Steps 24, 28–31: agent creates drafts (`status` forced draft unless `publish: true`), `sourceId` unique-ish dedup before slug-409, secret scan on POST/PUT.
- **L-11 (measurement)** — **NOT BUILT — owner decision** (analytics vendor; recorded only). Suggested events remain in the checklist. - **L-11 (measurement)** — **NOT BUILT — owner decision** (analytics vendor; recorded only). Suggested events remain in the checklist.
- **L-12 (content/distribution)** — **Owner/editorial.** No announcement drafted or sent by the agent. - **L-12 (content/distribution)** — **Owner/editorial.** No announcement drafted or sent by the agent.
### Prod verification 2026-10-10 (post-deploy)
Deployed to `root@147.79.102.201:/root/anwarpi` via rsync + `docker compose -f docker-compose.vps.yml up -d --build`; boot applied migration `20261010_142214`; `LAUNCH_MODE=free_early_access` set in `.env.production`. Verified live: home/admin 200; ghost VIP read full-body; subscribe 503 honest; notify honest-503 + row persisted (row deleted after); signup 201 + `verification_token` written (row deleted after); robots lists the 10 named AI crawlers; `/llms.txt` + `/llms-full.txt` (49 KB, bodies); `/topics/agents` 200; sitemap 4 topics, no auth pages; www 308 → apex; Article + BreadcrumbList JSON-LD; reading minutes honest («دقيقتان»). PR #3 merged to `main` (638a590).
+45
View File
@@ -0,0 +1,45 @@
# Live VPS source reconciliation
## Source of truth
This reconciliation imports the already deployed application source from `/root/anwarpi` into `web/`. The owner explicitly confirmed that the VPS copy, including its package manifest and lockfile, is authoritative. It does not deploy or restart the website.
Included features:
- Admin-only user creation and protected administrative user fields.
- Free early-access reading with registration, checkout and notification signup disabled.
- The exact trial banner, current policy pages and support contact.
- Arabic branding «أنور پآي» and site-wide «محتوى» labels.
- SEO titles/descriptions, utility-page noindex and absolute Article image URLs.
- Most-read homepage cards based on the published collection's cumulative views.
- Click-to-load privacy-enhanced YouTube embeds and same-origin inline raster images.
- Bundled Readex Pro font with its OFL license.
- The deployed daily backup script and cron definition under `ops/`.
## Deliberate exclusions
Do not import `.env` files, secrets, database dumps, backups, uploads/media, private files, build output, dependency directories or TypeScript build caches into Git. Published article records remain in the production CMS database; they are not application-source files.
Generated `payload-types.ts` remains generated/ignored. The repository's existing removal of a hardcoded publishing key from `web/docs/agent-publish-api.md` is retained rather than copying that key back from an older live document.
## Dependencies and security
The deployed `web/package.json` and `web/package-lock.json` are imported as requested. This replaces repository-only dependency/tooling edits and CVE overrides that were not on the live VPS. This release is a source reconciliation, **not a security upgrade**, and does not claim to resolve dependency advisories. Keep the existing recorded Next.js risk acceptance visible and evaluate upgrades separately.
## Verification
Before publication, the reconciled application passed:
- `npm ci --ignore-scripts --no-audit --no-fund`.
- `npm run test:launch`: 10 tests passed, 0 failed.
- `npm run generate:types`.
- `npx tsc --noEmit`.
- `npm run build`: Next.js production build completed successfully.
Added-line credential/injection screening found no literal credentials. Linting is skipped by the deployed build configuration; these results do not claim a separate lint pass or a remote CI run. The independent pre-publication review is recorded in the reconciliation's private evidence, not treated as a substitute for the commands above.
## Operational files
`ops/daily_backup.py` is the deployed `/root/anwarpy-daily-backup.py`; `ops/anwarpy-backup.cron` is the deployed cron definition. Their paths and container names reflect the current installation. Merely merging these files does not install cron or run a backup.
Backup archives include runtime configuration and potentially sensitive data. Keep their destination root-only and never publish archive contents. This workflow did not read or change production data, invoke a backup, apply migrations, or deploy a replacement image.
+3
View File
@@ -0,0 +1,3 @@
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
15 2 * * * root /usr/bin/python3 /root/anwarpy-daily-backup.py >> /root/anwarpy-backups/daily.log 2>&1
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/python3
"""Bounded daily backup. Never modifies application data or schema."""
from pathlib import Path
import subprocess, json, datetime, shutil, os
os.umask(0o077)
root=Path('/root/anwarpy-backups');root.mkdir(mode=0o700,exist_ok=True)
now=datetime.datetime.now(datetime.timezone.utc);folder=root/('daily-'+now.strftime('%Y%m%dT%H%M%SZ'));folder.mkdir(mode=0o700)
try:
with (folder/'database.dump').open('wb') as out:
subprocess.run(['docker','exec','anwarpi-db-1','pg_dump','-U','anwarpi','-d','anwarpi','-Fc'],stdout=out,check=True)
subprocess.run(['tar','-czf',str(folder/'source-config.tar.gz'),'-C','/root','anwarpi'],check=True)
info=json.loads(subprocess.check_output(['docker','inspect','anwarpi-web-1'],text=True))[0]
for mount in info['Mounts']:
if mount['Destination'] in ['/app/media','/app/files-private']:
subprocess.run(['tar','-czf',str(folder/(mount['Destination'].split('/')[-1]+'.tar.gz')),'-C',mount['Source'],'.'],check=True)
subprocess.run(['docker','cp',str(folder/'database.dump'),'anwarpi-db-1:/var/lib/postgresql/daily-archive-check.dump'],check=True)
archive=subprocess.check_output(['docker','exec','anwarpi-db-1','pg_restore','--list','/var/lib/postgresql/daily-archive-check.dump'],text=True)
assert 'TABLE DATA' in archive
record={'status':'ok','finished_at':now.isoformat(),'backup':str(folder),'image':info['Image'],'archive_list_valid':True}
(folder/'verification.json').write_text(json.dumps(record))
for old in root.glob('daily-*'):
if old.is_dir() and old!=folder and now.timestamp()-old.stat().st_mtime>30*86400:shutil.rmtree(old)
except Exception as error:
record={'status':'failed','finished_at':now.isoformat(),'error_type':type(error).__name__}
(root/'health.json').write_text(json.dumps(record));print(json.dumps(record));raise
(root/'health.json').write_text(json.dumps(record));print(json.dumps(record))
+3
View File
@@ -15,6 +15,9 @@ services:
retries: 10 retries: 10
web: web:
# Run migrations explicitly as a separate reviewed deployment step.
# Access-control-only release: avoid the dev-schema interactive prompt.
command: ["npm", "run", "start"]
build: build:
context: . context: .
args: args:
+13 -4
View File
@@ -24,7 +24,7 @@ Publish and manage blog posts programmatically. Auth: send the agent key in the
## Create + publish a post with an image ## Create + publish a post with an image
```bash ```bash
KEY=033d1eed44ca6abf483de10735c47155d38f7b5bc9d46449 KEY="$AGENT_API_KEY" # never hardcode — export AGENT_API_KEY in your shell; it lives in the server's .env.production
# Inline base64 image (jpeg/png/webp, ≤5MB) # Inline base64 image (jpeg/png/webp, ≤5MB)
python3 - <<'EOF' > /tmp/post.json python3 - <<'EOF' > /tmp/post.json
@@ -54,11 +54,20 @@ Response `201`:
```json ```json
{ {
"id": 113, "title": "دليلي الجديد", "slug": "my-new-guide", "id": 113,
"status": "published", "access": "vip", "category": "agents", "title": "دليلي الجديد",
"slug": "my-new-guide",
"status": "published",
"access": "vip",
"category": "agents",
"cover": { "id": 113, "url": "/api/media/file/my-cover.jpg" }, "cover": { "id": 113, "url": "/api/media/file/my-cover.jpg" },
"attachments": [ "attachments": [
{ "id": 42, "filename": "agent-state.md", "access": "vip", "url": "/api/files/42" } {
"id": 42,
"filename": "agent-state.md",
"access": "vip",
"url": "/api/files/42"
}
] ]
} }
``` ```
+31
View File
@@ -0,0 +1,31 @@
// Minimal flat config — procoder's lint domain checks .ts/.tsx with this.
// Report-only by default (see .procoder/config.toml policy); add rules as needed.
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
".next/**",
"node_modules/**",
"src/payload-types.ts", // generated — do not lint
"src/migrations/**",
"media/**",
"files-uploads/**",
],
},
...tseslint.configs.recommended.map((c) => ({
...c,
files: ["**/*.{ts,tsx,mjs,cjs}"],
})),
{
rules: {
// Generated Payload types use `any` in payload-types.ts; local code may pass
// narrow unknowns. Keep the floor permissive until a lint pass is scheduled.
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
);
+1
View File
@@ -4,6 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:launch": "node --test tests/free-launch.test.cjs",
"dev": "next dev", "dev": "next dev",
"build": "payload generate:types && next build", "build": "payload generate:types && next build",
"start": "next start", "start": "next start",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

+5 -5
View File
@@ -4,9 +4,9 @@ import { getViewer } from '@/lib/viewer'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { export const metadata = {
title: 'عن الفريق', title: 'عن الفريق: تجارب الذكاء الاصطناعي والبنية التحتية',
description: description:
'فريق أنور بآي: عرب يكتبون أدلة ذكاء اصطناعي عملية — تجارب موثقة، خطوات قابلة للتطبيق، وملفات عمل حقيقية بلا حشو تسويقي.', 'فريق أنور پآي: عرب يكتبون محتوى ذكاء اصطناعي عملي — تجارب موثقة، خطوات قابلة للتطبيق، وملفات عمل حقيقية بلا حشو تسويقي.',
} }
export default async function AboutPage() { export default async function AboutPage() {
@@ -19,7 +19,7 @@ export default async function AboutPage() {
<div className="wrap about-hero-grid"> <div className="wrap about-hero-grid">
<div> <div>
<h1 id="about-title">نكتب عن تجاربنا، ونشارك ما جرّبناه عمليًا</h1> <h1 id="about-title">نكتب عن تجاربنا، ونشارك ما جرّبناه عمليًا</h1>
<p className="lede">أنور بآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى أدلة عربية يمكن تنفيذها في عملك.</p> <p className="lede">أنور پآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى محتوى عربي يمكن تطبيقه في عملك.</p>
</div> </div>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img className="about-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} /> <img className="about-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
@@ -54,7 +54,7 @@ export default async function AboutPage() {
<section className="about-principles" aria-labelledby="principles-title"> <section className="about-principles" aria-labelledby="principles-title">
<div className="wrap principles-grid"> <div className="wrap principles-grid">
<h2 id="principles-title">كيف نكتب كل دليل</h2> <h2 id="principles-title">كيف نكتب كل عنصر محتوى</h2>
<dl className="principles"> <dl className="principles">
<div><dt>التطبيق قبل الاستعراض</dt><dd>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</dd></div> <div><dt>التطبيق قبل الاستعراض</dt><dd>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</dd></div>
<div><dt>نشرح ما تحت الغطاء</dt><dd>نفضّل الأدوات المفتوحة والتشغيل الذي يمنحك فهماً وتحكماً أكبر في بياناتك ومواردك.</dd></div> <div><dt>نشرح ما تحت الغطاء</dt><dd>نفضّل الأدوات المفتوحة والتشغيل الذي يمنحك فهماً وتحكماً أكبر في بياناتك ومواردك.</dd></div>
@@ -65,7 +65,7 @@ export default async function AboutPage() {
<section className="about-contact"> <section className="about-contact">
<div className="wrap contact-strip"> <div className="wrap contact-strip">
<p>عندك سؤال عن دليل أو فكرة تريد تطبيقها؟</p> <p>عندك سؤال عن محتوى أو فكرة تريد تطبيقها؟</p>
<a className="btn btn-quiet" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> <a className="btn btn-quiet" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>
</div> </div>
</section> </section>
@@ -151,14 +151,14 @@ export default function AccountSettingsClient({ email, userId, fullName, role, b
<section className={`panel${tab === 'prefs' ? ' active' : ''}`} id="panel-prefs" role="tabpanel" aria-labelledby="tab-prefs" data-od-id="panel-prefs"> <section className={`panel${tab === 'prefs' ? ' active' : ''}`} id="panel-prefs" role="tabpanel" aria-labelledby="tab-prefs" data-od-id="panel-prefs">
<h2>التفضيلات</h2> <h2>التفضيلات</h2>
<p className="panel-sub">رسائل أنور بآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p> <p className="panel-sub">رسائل أنور پآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p>
<div className="prefs"> <div className="prefs">
<div className="pref-row"><div><strong>الرسالة الأسبوعية</strong><p>خلاصة ما نُشر من أدلة ومقالات، كل يوم أحد.</p></div> <div className="pref-row"><div><strong>الرسالة الأسبوعية</strong><p>خلاصة ما نُشر من محتوى، كل يوم أحد.</p></div>
<label className="switch"><input type="checkbox" id="prefWeekly" aria-label="تفعيل الرسالة الأسبوعية" checked={prefs.weekly} onChange={e => setPrefs(p => ({ ...p, weekly: e.target.checked }))} /><span className="track"></span></label></div> <label className="switch"><input type="checkbox" id="prefWeekly" aria-label="تفعيل الرسالة الأسبوعية" checked={prefs.weekly} onChange={e => setPrefs(p => ({ ...p, weekly: e.target.checked }))} /><span className="track"></span></label></div>
<div className="pref-row"><div><strong>إشعارات التعليقات</strong><p>رسالة عند ردّ أحد الأعضاء على تعليقك.</p></div> <div className="pref-row"><div><strong>إشعارات التعليقات</strong><p>رسالة عند ردّ أحد الأعضاء على تعليقك.</p></div>
<label className="switch"><input type="checkbox" id="prefReplies" aria-label="تفعيل إشعارات التعليقات" checked={prefs.replies} onChange={e => setPrefs(p => ({ ...p, replies: e.target.checked }))} /><span className="track"></span></label></div> <label className="switch"><input type="checkbox" id="prefReplies" aria-label="تفعيل إشعارات التعليقات" checked={prefs.replies} onChange={e => setPrefs(p => ({ ...p, replies: e.target.checked }))} /><span className="track"></span></label></div>
<div className="pref-row"><div><strong>تحديثات الأدلة المحفوظة</strong><p>إشعار عندما يُحدَّث دليل حفظته للعودة إليه.</p></div> <div className="pref-row"><div><strong>تحديثات المحتوى المحفوظ</strong><p>إشعار عندما يُحدَّث محتوى حفظته للعودة إليه.</p></div>
<label className="switch"><input type="checkbox" id="prefUpdates" aria-label="تفعيل تحديثات الأدلة المحفوظة" checked={prefs.updates} onChange={e => setPrefs(p => ({ ...p, updates: e.target.checked }))} /><span className="track"></span></label></div> <label className="switch"><input type="checkbox" id="prefUpdates" aria-label="تفعيل تحديثات المحتوى المحفوظ" checked={prefs.updates} onChange={e => setPrefs(p => ({ ...p, updates: e.target.checked }))} /><span className="track"></span></label></div>
</div> </div>
<button className="btn btn-primary" type="button" id="savePrefs" style={{ marginTop: 28 }} data-od-id="save-prefs" onClick={savePrefs}>حفظ التفضيلات</button> <button className="btn btn-primary" type="button" id="savePrefs" style={{ marginTop: 28 }} data-od-id="save-prefs" onClick={savePrefs}>حفظ التفضيلات</button>
<p className={`status ${prefsStatus?.ok ? 'ok' : prefsStatus ? 'err' : ''}`} id="prefsStatus" role="status">{prefsStatus?.text}</p> <p className={`status ${prefsStatus?.ok ? 'ok' : prefsStatus ? 'err' : ''}`} id="prefsStatus" role="status">{prefsStatus?.text}</p>
@@ -176,7 +176,7 @@ export default function AccountSettingsClient({ email, userId, fullName, role, b
{billingEnabled {billingEnabled
? <div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div> ? <div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
: <div><span className="price-tag price-tag-early">وصول مبكر مجاني</span></div>} : <div><span className="price-tag price-tag-early">وصول مبكر مجاني</span></div>}
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : billingEnabled ? 'الأدلة المجانية ومعاينة الأدلة' : 'كل المحتوى المنشور مؤقتاً — الوصول المبكر المجاني'}</dd></dl> <dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل المحتوى الحصري وملفات العمل' : billingEnabled ? 'المحتوى المجاني ومعاينة المحتوى' : 'كل المحتوى المنشور مؤقتاً — الوصول المبكر المجاني'}</dd></dl>
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">{billingEnabled ? 'ستُربط عبر Stripe' : 'لا يوجد — لن يُحصَّل أي مبلغ خلال الوصول المبكر'}</dd></dl> <dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">{billingEnabled ? 'ستُربط عبر Stripe' : 'لا يوجد — لن يُحصَّل أي مبلغ خلال الوصول المبكر'}</dd></dl>
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl> <dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
</div> </div>
@@ -1,53 +1,57 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import Footer from '@/components/footer'
import AccountSettingsClient from './client' import AccountSettingsClient from './client'
import { formatDate } from '@/lib/format' import { formatDate } from '@/lib/format'
import { billingEnabled } from '@/lib/launch' import { billingEnabled } from '@/lib/launch'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import '../styles/account-settings.css' import '../styles/account-settings.css'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { title: 'إعدادات الحساب' } export const metadata = {
title: 'إعدادات الحساب',
export default async function AccountSettingsPage() { description: 'إدارة حسابك القائم في أنور پآي: بيانات الحساب وتفضيلات الرسائل والميزات المتاحة. يلزم تسجيل الدخول، ولا تحتاج إلى حساب لقراءة المحتوى العام.',
const payload = await getPayload({ config: configPromise }) robots: { index: false, follow: false },
const { user } = await payload.auth({ headers: await headers() }) }
return ( export default async function AccountSettingsPage() {
<div id="site" className="site-page page-account-settings"> const payload = await getPayload({ config: configPromise })
<Topnav viewer={user} /> const { user } = await payload.auth({ headers: await headers() })
{user ? (
<main id="content"> return (
<section className="page-head" data-od-id="settings-header"><div className="container"> <div id="site" className="site-page page-account-settings">
<p className="crumb">حسابك في أنور بآي</p> <Topnav viewer={user} />
<h1 data-od-id="settings-title">إعدادات الحساب</h1> {user ? (
<p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? formatDate(String(user.createdAt)) : 'اليوم'}</p> <main id="content">
</div></section> <section className="page-head" data-od-id="settings-header"><div className="container">
<section className="container settings-layout" data-od-id="settings-layout"> <p className="crumb">حسابك في أنور پآي</p>
<AccountSettingsClient <h1 data-od-id="settings-title">إعدادات الحساب</h1>
email={String(user.email)} <p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? formatDate(String(user.createdAt)) : 'اليوم'}</p>
userId={Number(user.id)} </div></section>
fullName={(user.fullName as string) || ''} <section className="container settings-layout" data-od-id="settings-layout">
role={((user.role as string) || 'free')} <AccountSettingsClient
billingEnabled={billingEnabled()} email={String(user.email)}
/> userId={Number(user.id)}
</section> fullName={(user.fullName as string) || ''}
</main> role={((user.role as string) || 'free')}
) : ( billingEnabled={billingEnabled()}
<main id="content" className="gate"> />
<section className="gate-card"> </section>
<h1>سجّل الدخول لإدارة حسابك</h1> </main>
<p>من هنا تعدّل اسمك وكلمة المرور وتفضيلات الرسائل، وتدير عضويتك.</p> ) : (
<div className="actions"> <main id="content" className="gate">
<a className="btn btn-primary" href="/login?next=%2Faccount-settings">تسجيل الدخول</a> <section className="gate-card">
<a className="btn btn-quiet" href="/signup">إنشاء حساب مجاني</a> <h1>سجّل الدخول لإدارة حسابك</h1>
</div> <p>من هنا تعدّل اسمك وكلمة المرور وتفضيلات الرسائل، وتدير عضويتك.</p>
</section> <div className="actions">
</main> <a className="btn btn-primary" href="/login?next=%2Faccount-settings">تسجيل الدخول</a>
)} <a className="btn btn-quiet" href="/articles">اقرأ مجاناً دون حساب</a>
<Footer /> </div>
</div> </section>
) </main>
} )}
<Footer />
</div>
)
}
+8 -7
View File
@@ -1,4 +1,5 @@
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import { accessLabel } from '@/lib/launch'
import Footer from '@/components/footer' import Footer from '@/components/footer'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { headers } from 'next/headers' import { headers } from 'next/headers'
@@ -10,9 +11,9 @@ import { formatNumber, guidesLabel, minutesLabel, contentTypeLabel } from '@/lib
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { export const metadata = {
title: 'المكتبة', title: 'مكتبة الذكاء الاصطناعي: نماذج ووكلاء وأدوات',
description: description:
'أدلة الذكاء الاصطناعي العملية بالعربية: وكلاء، نماذج، استرجاع، سلامة، أدوات، وأعمال — بخطوات قابلة للتطبيق وتجارب موثقة وملفات عمل.', 'محتوى الذكاء الاصطناعي العملي بالعربية: وكلاء، نماذج، استرجاع، سلامة، أدوات، وأعمال — بخطوات قابلة للتطبيق وتجارب موثقة وملفات عمل.',
} }
type PostDoc = { type PostDoc = {
@@ -93,7 +94,7 @@ export default async function ArticlesPage() {
<section className="lib-head" aria-labelledby="library-title"> <section className="lib-head" aria-labelledby="library-title">
<div className="wrap"> <div className="wrap">
<h1 id="library-title">المكتبة</h1> <h1 id="library-title">المكتبة</h1>
<p className="lede">تضم المكتبة {guidesLabel(posts.length)}، منها {guidesLabel(freeCount)} متاحة مجاناً بالكامل. ابحث بالموضوع أو صفِّ النتائج حسب المسار.</p> <p className="lede">تضم المكتبة {guidesLabel(posts.length)}، كلها مفتوحة للقراءة خلال الوصول المبكر؛ {guidesLabel(freeCount)} مصنفة مجاناً بشكل دائم. ابحث بالموضوع أو صفِّ النتائج حسب المسار.</p>
<div className="lib-tools"> <div className="lib-tools">
<label className="lib-search"> <label className="lib-search">
<span className="sr-only">ابحث في العناوين والوصف</span> <span className="sr-only">ابحث في العناوين والوصف</span>
@@ -115,13 +116,13 @@ export default async function ArticlesPage() {
<div className="segmented" role="group" aria-label="نوع الوصول"> <div className="segmented" role="group" aria-label="نوع الوصول">
<button type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button> <button type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
<button type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button> <button type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
<button type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">للأعضاء</button> <button type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">للعضوية مستقبلاً · مجانية الآن</button>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section className="lib-results" aria-label="الأدلة"> <section className="lib-results" aria-label="المحتوى">
<div className="wrap"> <div className="wrap">
<nav className="topic-strip" aria-label="تصفّح المسارات"> <nav className="topic-strip" aria-label="تصفّح المسارات">
{TOPICS.map(topic => <Link key={topic.key} href={`/topics/${topic.key}`}>{topic.label}</Link>)} {TOPICS.map(topic => <Link key={topic.key} href={`/topics/${topic.key}`}>{topic.label}</Link>)}
@@ -156,7 +157,7 @@ export default async function ArticlesPage() {
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2> <h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
<p className="guide-card-dek">{post.dek}</p> <p className="guide-card-dek">{post.dek}</p>
<div className="guide-card-foot"> <div className="guide-card-foot">
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span> <span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{accessLabel(post.access)}</span>
<span className="engagement"> <span className="engagement">
<span data-views>{formatNumber(post.views ?? 0)} قراءة</span> <span data-views>{formatNumber(post.views ?? 0)} قراءة</span>
<button className="like" type="button" data-like={post.slug} aria-pressed={likedSlugs.has(post.slug)} aria-label="أعجبني"> <button className="like" type="button" data-like={post.slug} aria-pressed={likedSlugs.has(post.slug)} aria-label="أعجبني">
@@ -171,7 +172,7 @@ export default async function ArticlesPage() {
})} })}
</div> </div>
<div className="empty" id="emptyState"> <div className="empty" id="emptyState">
<p>لا يوجد دليل يطابق هذا البحث.</p> <p>لا يوجد محتوى يطابق هذا البحث.</p>
<p>جرّب كلمة أقصر أو اختر «كل المسارات».</p> <p>جرّب كلمة أقصر أو اختر «كل المسارات».</p>
</div> </div>
</div> </div>
@@ -0,0 +1,124 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
export type DeckSlide = {
kicker: string
index: string
title: React.ReactNode
body: string
format: React.ReactNode
link?: { href: string; label: string }
meta?: React.ReactNode
}
const V1_SLIDES: DeckSlide[] = [
{
kicker: 'محتوى حصري · المستوى المتوسط',
index: '01',
title: 'ابنِ وكيلك الأول دون فوضى',
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
link: { href: '/posts/agent-loop-stop-condition', label: 'شاهد المعاينة ←' },
},
{
kicker: 'مقال تطبيقي · للمبتدئين',
index: '02',
title: 'كيف تختار نموذجاً لمهمتك؟',
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
format: <>قراءة · 12 دقيقة</>,
link: { href: '/posts/article-agent-guide', label: 'ابدأ القراءة ←' },
},
{
kicker: 'مسار متقدم · للأعضاء',
index: '03',
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
link: { href: '/subscription', label: 'شاهد للأعضاء ←' },
},
]
const DOT_LABELS = ['الشريحة الأولى', 'الشريحة الثانية', 'الشريحة الثالثة']
/* Featured content carousel. Manual selection pauses rotation until resumed. */
export default function CatalogDeck({ slides = V1_SLIDES }: { slides?: DeckSlide[] }) {
const [activeSlide, setActiveSlide] = useState(0)
const [ready, setReady] = useState(false)
const [playing, setPlaying] = useState(true)
const [hovered, setHovered] = useState(false)
const [focused, setFocused] = useState(false)
const current = slides.length ? activeSlide % slides.length : 0
const advancing = ready && playing && !hovered && !focused && slides.length > 1
useEffect(() => {
try {
const stored = Number(localStorage.getItem('midad-slide'))
if (Number.isInteger(stored) && stored >= 0) setActiveSlide(stored)
} catch { /* Persistence is optional. */ }
const motion = window.matchMedia('(prefers-reduced-motion: reduce)')
if (motion.matches) setPlaying(false)
const onMotion = () => { if (motion.matches) setPlaying(false) }
motion.addEventListener('change', onMotion)
setReady(true)
return () => motion.removeEventListener('change', onMotion)
}, [])
useEffect(() => {
if (!ready) return
try { localStorage.setItem('midad-slide', String(current)) } catch { /* optional persistence */ }
}, [current, ready])
useEffect(() => {
if (!advancing) return
const timer = setTimeout(() => setActiveSlide(index => (index + 1) % slides.length), 6500)
return () => clearTimeout(timer)
}, [advancing, current, slides.length])
function selectSlide(index: number) {
setActiveSlide((index + slides.length) % slides.length)
setPlaying(false)
}
if (!slides.length) return null
return (
<div className="deck" data-od-id="featured-deck" role="region" aria-roledescription="عرض شرائح" aria-label="مختارات مميزة"
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
onFocusCapture={() => setFocused(true)}
onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false) }}
onKeyDown={event => {
if (event.key === 'ArrowLeft') { event.preventDefault(); selectSlide(current + 1) }
if (event.key === 'ArrowRight') { event.preventDefault(); selectSlide(current - 1) }
}}>
<div className="deck-frame" aria-live={advancing ? 'off' : 'polite'}>
{slides.map((s, i) => (
<article className={`deck-slide${i === current ? ' active' : ''}`} hidden={i !== current} aria-label={`${i + 1} من ${slides.length}`} data-slide={i} key={i}>
<div className="deck-slide-top"><span className="deck-slide-kicker">{s.kicker}</span><span className="deck-slide-index num">{s.index}</span></div>
<div className="deck-slide-body"><h2>{s.title}</h2><p>{s.body}</p></div>
<div className="deck-slide-foot">
<span className="deck-slide-format">{s.format}</span>
{s.link
? <Link className="deck-slide-link" href={s.link.href}>{s.link.label}</Link>
: <span className="meta">{s.meta}</span>}
</div>
</article>
))}
</div>
<div className="deck-controls">
<div className="deck-dots" aria-label="اختيار الشريحة">
{slides.map((_, i) => (
<button type="button" className={`deck-dot${i === current ? ' active' : ''}`} data-dot={i} aria-label={DOT_LABELS[i] ?? `الشريحة ${i + 1}`} aria-current={i === current ? 'true' : undefined} onClick={() => selectSlide(i)} key={i}></button>
))}
</div>
<div className="deck-arrows">
<button type="button" className="icon-btn" id="prevSlide" aria-label="الشريحة السابقة" disabled={slides.length < 2} onClick={() => selectSlide(current - 1)}>→</button>
<button type="button" className="icon-btn" id="nextSlide" aria-label="الشريحة التالية" disabled={slides.length < 2} onClick={() => selectSlide(current + 1)}>←</button>
{slides.length > 1 && <button type="button" className="icon-btn deck-play" aria-label={playing ? 'إيقاف العرض التلقائي' : 'تشغيل العرض التلقائي'} onClick={() => setPlaying(value => !value)}>{playing ? 'Ⅱ' : '▶'}</button>}
</div>
</div>
</div>
)
}
@@ -0,0 +1,44 @@
'use client'
import { useEffect } from 'react'
/* Filter bar for the editors' picks. Cards carry space-separated topics. */
export default function CatalogFilters() {
useEffect(() => {
const controller = new AbortController()
const filters = [...document.querySelectorAll('.filter')] as HTMLElement[]
const cards = [...document.querySelectorAll('.catalog-card')] as HTMLElement[]
const empty = document.getElementById('emptyState')
function filterCatalog(topic: string) {
let visible = 0
filters.forEach(button => {
const selected = (button as HTMLElement).dataset.filter === topic
button.classList.toggle('active', selected)
button.setAttribute('aria-pressed', String(selected))
})
cards.forEach(card => {
const show = topic === 'all' || (card.dataset.topic || '').split(' ').includes(topic)
card.hidden = !show
if (show) visible += 1
})
empty?.classList.toggle('show', visible === 0)
}
filters.forEach(button => button.addEventListener('click', () => {
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
filterCatalog((button as HTMLElement).dataset.filter!)
}, { signal: controller.signal }))
return () => controller.abort()
}, [])
return (
<div className="filterbar" role="group" aria-label="تصفية المحتوى" data-od-id="catalog-filters">
<button type="button" className="filter active" data-filter="all" aria-pressed="true">الكل</button>
<button type="button" className="filter" data-filter="agents" aria-pressed="false">الوكلاء</button>
<button type="button" className="filter" data-filter="models" aria-pressed="false">النماذج</button>
<button type="button" className="filter" data-filter="automation" aria-pressed="false">الأتمتة</button>
<button type="button" className="filter" data-filter="foundations" aria-pressed="false">الأساسيات</button>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import Link from 'next/link'
import './styles/launch-banner.css'
export default function FrontendLayout({ children }: { children: React.ReactNode }) {
return (
<>
<aside className="launch-banner" aria-label="إشعار الإطلاق التجريبي" dir="rtl">
<strong>اطلاق تجريبي</strong>
<span>قراءة المحتوى المنشور مجانية دون حساب أو بطاقة دفع. لا اشتراك ولا رسوم تلقائياً.</span>
<Link href="/subscription">تفاصيل الوصول المبكر</Link>
</aside>
{children}
</>
)
}
+2 -2
View File
@@ -74,7 +74,7 @@ export default function LoginPage() {
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} /> <img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
<h1>أكمل من حيث توقفت.</h1> <h1>أكمل من حيث توقفت.</h1>
<p>سجّل الدخول لمتابعة قراءة الأدلة والمشاركة في النقاشات.</p> <p>القراءة مجانية دون حساب. تسجيل الدخول لأصحاب الحسابات القائمة والميزات المتاحة.</p>
</section> </section>
<section className="auth-form-wrap" data-od-id="login-form-section"> <section className="auth-form-wrap" data-od-id="login-form-section">
<form className="auth-form" id="loginForm" method="post" action="/api/users/login" noValidate onSubmit={onSubmit} data-od-id="login-form"> <form className="auth-form" id="loginForm" method="post" action="/api/users/login" noValidate onSubmit={onSubmit} data-od-id="login-form">
@@ -100,7 +100,7 @@ export default function LoginPage() {
<div className="status" id="loginStatus" role="status">{status}</div> <div className="status" id="loginStatus" role="status">{status}</div>
<div className="divider">أو</div> <div className="divider">أو</div>
<Link className="magic" href="/forgot-password">استعد الوصول إلى حسابك</Link> <Link className="magic" href="/forgot-password">استعد الوصول إلى حسابك</Link>
<p className="signup">ليس لديك حساب؟ <Link href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : '/signup'}>أنشئ حساباً جديداً</Link></p> <p className="signup">التسجيل العام متوقف. <Link href="/articles">اقرأ دون حساب</Link></p>
<p className="fine">بالمتابعة، أنت توافق على <Link href="/terms">شروط الاستخدام</Link> و<Link href="/privacy">سياسة الخصوصية</Link>.</p> <p className="fine">بالمتابعة، أنت توافق على <Link href="/terms">شروط الاستخدام</Link> و<Link href="/privacy">سياسة الخصوصية</Link>.</p>
</form> </form>
</section> </section>
+5 -5
View File
@@ -123,20 +123,20 @@ export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
<h2>ابدأ مجاناً أو افتح محتوى العضوية الكامل.</h2> <h2>ابدأ مجاناً أو افتح محتوى العضوية الكامل.</h2>
<p className="intro" id="summaryCopy">{summary}</p> <p className="intro" id="summaryCopy">{summary}</p>
<div className="benefits" data-od-id="vip-benefits"> <div className="benefits" data-od-id="vip-benefits">
<article className="benefit"><span className="benefit-num">01</span><div><h3>الأدلة المتقدمة كاملة</h3><p>اقرأ ما بعد المعاينة في أدلة الوكلاء وRAG والأتمتة والنماذج.</p></div></article> <article className="benefit"><span className="benefit-num">01</span><div><h3>المحتوى المتقدم كاملًا</h3><p>اقرأ ما بعد المعاينة في محتوى الوكلاء وRAG والأتمتة والنماذج.</p></div></article>
<article className="benefit"><span className="benefit-num">02</span><div><h3>ملفات العمل</h3><p>نزّل القوالب وقوائم الفحص المرتبطة بكل دليل.</p></div></article> <article className="benefit"><span className="benefit-num">02</span><div><h3>ملفات العمل</h3><p>نزّل القوالب وقوائم الفحص المرتبطة بكل عنصر محتوى.</p></div></article>
<article className="benefit"><span className="benefit-num">03</span><div><h3>نقطة بداية مخصصة</h3><p>إعداد العضوية ترتّب دليلك الأول ورسائلك بناءً على هدفك.</p></div></article> <article className="benefit"><span className="benefit-num">03</span><div><h3>نقطة بداية مخصصة</h3><p>إعداد العضوية يرتّب محتواك الأول ورسائلك بناءً على هدفك.</p></div></article>
</div> </div>
<div className="upgrade-note"> <div className="upgrade-note">
<strong>الحساب المجاني لا يختفي.</strong> <strong>الحساب المجاني لا يختفي.</strong>
<p>يمكنك قراءة الأدلة المجانية وحفظها، والترقية عندما تحتاج الأدلة الحصرية.</p> <p>يمكنك قراءة المحتوى المجاني وحفظه، والترقية عندما تحتاج المحتوى الحصري.</p>
</div> </div>
<div className="actions" data-od-id="final-actions"> <div className="actions" data-od-id="final-actions">
{nextPath ? ( {nextPath ? (
<Link className="btn btn-primary" href={nextPath} data-od-id="continue-next-cta">تابع إلى وجهتك</Link> <Link className="btn btn-primary" href={nextPath} data-od-id="continue-next-cta">تابع إلى وجهتك</Link>
) : ( ) : (
<> <>
<Link className="btn btn-secondary" href="/articles?access=free" data-od-id="continue-free-cta">ابدأ بالأدلة المجانية</Link> <Link className="btn btn-secondary" href="/articles?access=free" data-od-id="continue-free-cta">ابدأ بالمحتوى المجاني</Link>
<Link className="btn btn-primary" href="/subscription" data-od-id="view-membership-cta">شاهد العضوية والأسعار</Link> <Link className="btn btn-primary" href="/subscription" data-od-id="view-membership-cta">شاهد العضوية والأسعار</Link>
</> </>
)} )}
+31 -21
View File
@@ -6,14 +6,14 @@ import { getPayload } from 'payload'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics' import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format' import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
import { freeEarlyAccess } from '@/lib/launch' import { freeEarlyAccess, accessLabel, LAUNCH_POLICY } from '@/lib/launch'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/* Editorial picks for «ابدأ من هنا». */ /* Demo seed articles remain in the library, but never in the popular section. */
const START_HERE = ['first-arabic-agent', 'choosing-your-model', 'prototype-to-production', 'simple-arabic-rag', 'safe-agent-guardrails', 'agent-loop-stop-condition'] const DEMO_POST_SLUGS = ['first-arabic-agent', 'agent-loop-stop-condition', 'choosing-your-model', 'systematic-model-eval', 'simple-arabic-rag', 'vector-databases-in-practice', 'safe-agent-guardrails', 'agent-output-review-gates', 'indispensable-ai-tools', 'tool-pipelines-and-permissions', 'ai-in-your-business', 'prototype-to-production', 'article-agent-guide']
type Doc = { id: number; title: string; slug: string; dek?: string | null; access: 'free' | 'vip'; category: string; level?: string | null; readingMinutes: number; cover?: { url?: string | null } | number | null } type Doc = { id: number; title: string; slug: string; dek?: string | null; access: 'free' | 'vip'; category: string; level?: string | null; readingMinutes: number; views?: number | null; cover?: { url?: string | null } | number | null }
export default async function HomePage() { export default async function HomePage() {
const viewer = await getViewer() const viewer = await getViewer()
@@ -43,7 +43,17 @@ export default async function HomePage() {
.slice(0, 4) .slice(0, 4)
.map(({ d }) => d) .map(({ d }) => d)
const counts = Object.fromEntries(TOPICS.map(t => [t.key, docs.filter(d => topicsFor(d).includes(t.key)).length])) const counts = Object.fromEntries(TOPICS.map(t => [t.key, docs.filter(d => topicsFor(d).includes(t.key)).length]))
const startHere = START_HERE.map(slug => docs.find(d => d.slug === slug)).filter((d): d is Doc => Boolean(d)) // Query the complete published collection, not just the 200 newest posts.
// Read-count ties prefer the newest publication, then a stable slug order.
const { docs: popular } = await payload.find({
collection: 'posts',
where: { and: [{ status: { equals: 'published' } }, { slug: { not_in: DEMO_POST_SLUGS } }] },
depth: 1,
limit: 6,
sort: ['-views', '-publishedAt', 'slug'],
select: { title: true, slug: true, dek: true, access: true, category: true, readingMinutes: true, cover: true, views: true },
})
const startHere = popular as unknown as Doc[]
return ( return (
<div id="site" className="site-page v2 v2-home"> <div id="site" className="site-page v2 v2-home">
@@ -54,7 +64,7 @@ export default async function HomePage() {
__html: JSON.stringify({ __html: JSON.stringify({
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Organization', '@type': 'Organization',
name: 'أنور بآي', name: 'أنور پآي',
url: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai', url: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai',
logo: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'}/brand/icon-256.png`, logo: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'}/brand/icon-256.png`,
}), }),
@@ -67,15 +77,15 @@ export default async function HomePage() {
<div className="home-hero-copy"> <div className="home-hero-copy">
<p className="kicker">مكتبة عربية للذكاء الاصطناعي العملي</p> <p className="kicker">مكتبة عربية للذكاء الاصطناعي العملي</p>
<h1>افهم الذكاء الاصطناعي، ثم ابنِ به.</h1> <h1>افهم الذكاء الاصطناعي، ثم ابنِ به.</h1>
<p className="lede">أدلة عربية عملية عن الوكلاء والنماذج والأتمتة، يكتبها مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p> <p className="lede">محتوى عربي عملي عن الوكلاء والنماذج والأتمتة، يكتبه مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p>
<div className="actions"> <div className="actions">
<a className="btn btn-primary" href="/articles?access=free">ابدأ بدليل مجاني</a> <a className="btn btn-primary" href="/articles">ابدأ القراءة المجانية</a>
<Link className="btn btn-quiet" href="/articles">تصفح المكتبة</Link> <Link className="btn btn-quiet" href="/articles">تصفح المكتبة</Link>
</div> </div>
</div> </div>
{hero.length > 0 && ( {hero.length > 0 && (
<aside className="hero-panel" aria-labelledby="new-title"> <aside className="hero-panel" aria-labelledby="new-title">
<h2 id="new-title">أحدث الأدلة</h2> <h2 id="new-title">أحدث المحتوى</h2>
<ul role="list"> <ul role="list">
{hero.map(d => { {hero.map(d => {
const coverUrl = typeof d.cover === 'object' ? d.cover?.url ?? null : null const coverUrl = typeof d.cover === 'object' ? d.cover?.url ?? null : null
@@ -84,13 +94,13 @@ export default async function HomePage() {
<Link href={`/posts/${d.slug}`}> <Link href={`/posts/${d.slug}`}>
{coverUrl && <img className="hero-panel-thumb" src={coverUrl} alt="" width={72} height={48} loading="lazy" />} {coverUrl && <img className="hero-panel-thumb" src={coverUrl} alt="" width={72} height={48} loading="lazy" />}
<span className="hero-panel-title">{d.title}</span> <span className="hero-panel-title">{d.title}</span>
<span className="meta-row"><span>{d.category}</span><span>{minutesLabel(d.readingMinutes)}</span><span className={d.access === 'vip' ? 'is-vip' : undefined}>{d.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></span> <span className="meta-row"><span>{d.category}</span><span>{minutesLabel(d.readingMinutes)}</span><span className={d.access === 'vip' ? 'is-vip' : undefined}>{accessLabel(d.access)}</span></span>
</Link> </Link>
</li> </li>
) )
})} })}
</ul> </ul>
<Link className="text-link" href="/articles">كل الأدلة</Link> <Link className="text-link" href="/articles">كل المحتوى</Link>
</aside> </aside>
)} )}
</div> </div>
@@ -100,7 +110,7 @@ export default async function HomePage() {
<div className="wrap"> <div className="wrap">
<header className="section-head"> <header className="section-head">
<h2 id="paths-title">اختر مساراً حسب ما تريد بناءه</h2> <h2 id="paths-title">اختر مساراً حسب ما تريد بناءه</h2>
<p>كل دليل ينتمي إلى مسار، وأدلة المبتدئين تظهر أيضاً في الأساسيات.</p> <p>ينتمي كل عنصر محتوى إلى مسار، ويظهر محتوى المبتدئين أيضاً في الأساسيات.</p>
</header> </header>
<ol className="paths" role="list"> <ol className="paths" role="list">
{TOPICS.map(topic => ( {TOPICS.map(topic => (
@@ -120,8 +130,8 @@ export default async function HomePage() {
<section className="home-start" aria-labelledby="start-title"> <section className="home-start" aria-labelledby="start-title">
<div className="wrap"> <div className="wrap">
<header className="section-head"> <header className="section-head">
<h2 id="start-title">ابدأ من هنا</h2> <h2 id="start-title">الأكثر قراءة</h2>
<Link className="text-link" href="/articles">كل الأدلة ({formatNumber(docs.length)})</Link> <Link className="text-link" href="/articles">كل المحتوى ({formatNumber(docs.length)})</Link>
</header> </header>
<ul className="card-grid" role="list"> <ul className="card-grid" role="list">
{startHere.map(post => { {startHere.map(post => {
@@ -130,10 +140,10 @@ export default async function HomePage() {
<li key={post.id}> <li key={post.id}>
<article className="guide"> <article className="guide">
{coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />} {coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />}
<p className="meta-row"><span className="cat">{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span><span className={post.access === 'vip' ? 'is-vip' : undefined}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></p> <p className="meta-row"><span className="cat">{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span><span>{formatNumber(post.views ?? 0)} قراءة</span><span className={post.access === 'vip' ? 'is-vip' : undefined}>{accessLabel(post.access)}</span></p>
<h3><Link href={`/posts/${post.slug}`}>{post.title}</Link></h3> <h3><Link href={`/posts/${post.slug}`}>{post.title}</Link></h3>
<p className="guide-dek">{post.dek}</p> <p className="guide-dek">{post.dek}</p>
<span className="guide-link" aria-hidden="true">اقرأ الدليل</span> <span className="guide-link" aria-hidden="true">اقرأ المحتوى</span>
</article> </article>
</li> </li>
) )
@@ -146,8 +156,8 @@ export default async function HomePage() {
<section className="home-membership" id="membership" aria-labelledby="membership-title"> <section className="home-membership" id="membership" aria-labelledby="membership-title">
<div className="wrap membership-panel"> <div className="wrap membership-panel">
<div> <div>
<h2 id="membership-title">العضوية تفتح الأدلة المتقدمة وملفات العمل.</h2> <h2 id="membership-title">اقرأ مجاناً الآن؛ العضوية خطة مستقبلية.</h2>
<p>الأدلة المجانية كاملة دائماً. العضوية تضيف الأدلة المعمّقة، القوالب الجاهزة للتنزيل، والنقاش مع الفريق.</p> <p>{LAUNCH_POLICY.summary}</p>
</div> </div>
<div className="membership-offer"> <div className="membership-offer">
{freeEarlyAccess() ? ( {freeEarlyAccess() ? (
@@ -159,11 +169,11 @@ export default async function HomePage() {
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p> <p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
)} )}
<ul role="list"> <ul role="list">
<li>{guidesLabel(docs.length - freeCount)} للأعضاء، وتُضاف أدلة جديدة باستمرار</li> <li>{guidesLabel(docs.length - freeCount)} مصنفة للعضوية مستقبلاً ومفتوحة الآن</li>
<li>قوالب وقوائم فحص قابلة للتنزيل</li> <li>قوالب وقوائم فحص قابلة للتنزيل</li>
<li>التعليق والنقاش على كل دليل</li> <li>القراءة دون حساب؛ التسجيل العام متوقف</li>
</ul> </ul>
<Link className="btn btn-primary" href="/subscription">اطّلع على العضوية</Link> <Link className="btn btn-primary" href="/subscription">تفاصيل الوصول المبكر</Link>
</div> </div>
</div> </div>
</section> </section>
@@ -3,7 +3,11 @@ import Footer from '@/components/footer'
import Link from 'next/link' import Link from 'next/link'
import '../styles/payment-cancelled.css' import '../styles/payment-cancelled.css'
export const metadata = { title: 'لم يكتمل الدفع' } export const metadata = {
title: 'لم يكتمل الدفع',
description: 'صفحة حالة عملية دفع غير مكتملة في أنور پآي. ارجع إلى المكتبة أو تواصل مع الفريق إذا احتجت إلى مساعدة؛ إتاحة الدفع تخضع لسياسة الإطلاق الحالية.',
robots: { index: false, follow: false },
}
/* Payment cancelled — design from payment-cancelled.html. */ /* Payment cancelled — design from payment-cancelled.html. */
export default function PaymentCancelledPage() { export default function PaymentCancelledPage() {
@@ -24,7 +28,7 @@ export default function PaymentCancelledPage() {
</div> </div>
<div className="actions"> <div className="actions">
<Link className="btn btn-primary" href="/subscription" data-od-id="retry-payment-cta">العودة إلى العضوية</Link> <Link className="btn btn-primary" href="/subscription" data-od-id="retry-payment-cta">العودة إلى العضوية</Link>
<Link className="btn btn-secondary" href="/articles" data-od-id="browse-free-articles-cta">تصفح الأدلة المجانية</Link> <Link className="btn btn-secondary" href="/articles" data-od-id="browse-free-articles-cta">تصفح المحتوى المجاني</Link>
</div> </div>
<p className="help">واجهت مشكلة أثناء الدفع؟ <a href="mailto:hello@anwarpy.ai?subject=%D9%85%D8%B4%D9%83%D9%84%D8%A9%20%D9%81%D9%8A%20%D8%A7%D9%84%D8%AF%D9%81%D8%B9" data-od-id="payment-help-link">تواصل مع الفريق</a>.</p> <p className="help">واجهت مشكلة أثناء الدفع؟ <a href="mailto:hello@anwarpy.ai?subject=%D9%85%D8%B4%D9%83%D9%84%D8%A9%20%D9%81%D9%8A%20%D8%A7%D9%84%D8%AF%D9%81%D8%B9" data-od-id="payment-help-link">تواصل مع الفريق</a>.</p>
</article> </article>
@@ -63,8 +63,8 @@ export default async function PaymentSuccessPage({ searchParams }: Props) {
{vipNow ? ( {vipNow ? (
<> <>
<p className="eyebrow">تم الدفع · العضوية فعّالة</p> <p className="eyebrow">تم الدفع · العضوية فعّالة</p>
<h1 data-od-id="success-title">مرحباً بك في عضوية أنور بآي.</h1> <h1 data-od-id="success-title">مرحباً بك في عضوية أنور پآي.</h1>
<p className="lead">تم تفعيل وصولك إلى الأدلة الحصرية وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p> <p className="lead">تم تفعيل وصولك إلى المحتوى الحصري وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p>
<dl className="summary" data-od-id="payment-summary"> <dl className="summary" data-od-id="payment-summary">
<div className="summary-row"><dt>الحالة</dt><dd>عضوية فعّالة</dd></div> <div className="summary-row"><dt>الحالة</dt><dd>عضوية فعّالة</dd></div>
<div className="summary-row"><dt>الخطة</dt><dd>عضوية فردية · شهرياً · <bdi dir="ltr">$10</bdi></dd></div> <div className="summary-row"><dt>الخطة</dt><dd>عضوية فردية · شهرياً · <bdi dir="ltr">$10</bdi></dd></div>
@@ -72,10 +72,10 @@ export default async function PaymentSuccessPage({ searchParams }: Props) {
</dl> </dl>
<div className="next-step" data-od-id="next-step"> <div className="next-step" data-od-id="next-step">
<h2 data-od-id="next-step-title">جهّز مسارك الأول</h2> <h2 data-od-id="next-step-title">جهّز مسارك الأول</h2>
<p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بدليل واحد يناسبك.</p> <p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بعنصر محتوى واحد يناسبك.</p>
<div className="actions"> <div className="actions">
<Link className="btn btn-primary" href="/vip-onboarding" data-od-id="start-onboarding-cta">ابدأ إعداد العضوية</Link> <Link className="btn btn-primary" href="/vip-onboarding" data-od-id="start-onboarding-cta">ابدأ إعداد العضوية</Link>
<Link className="btn btn-secondary" href="/posts/agent-loop-stop-condition" data-od-id="open-member-guide-cta">افتح دليل الأعضاء</Link> <Link className="btn btn-secondary" href="/posts/agent-loop-stop-condition" data-od-id="open-member-guide-cta">افتح محتوى الأعضاء</Link>
</div> </div>
</div> </div>
</> </>
+496 -472
View File
@@ -1,472 +1,496 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import { accessLabel } from '@/lib/launch'
import PostExtras from './post-extras' import Footer from '@/components/footer'
import { LikeButton, ViewPing } from './engagement' import PostExtras from './post-extras'
import { getViewer } from '@/lib/viewer' import { LikeButton, ViewPing } from './engagement'
import { TOPICS, topicsFor } from '@/lib/topics' import { getViewer } from '@/lib/viewer'
import { formatDate, formatNumber, minutesLabel, contentTypeLabel, MEMBERSHIP_PRICE } from '@/lib/format' import { TOPICS, topicsFor } from '@/lib/topics'
import { freeEarlyAccess } from '@/lib/launch' import { formatDate, formatNumber, minutesLabel, contentTypeLabel, MEMBERSHIP_PRICE } from '@/lib/format'
import { extractH2s, innerText, type Node } from '@/lib/lexical' import { freeEarlyAccess } from '@/lib/launch'
import { extractH2s, innerText, type Node } from '@/lib/lexical'
export const dynamic = 'force-dynamic' import { youtubeVideoId } from '@/lib/youtube'
import { articleImageUrl } from '@/lib/article-image'
/* QA-06: article pages get their own title/description. */ import YouTubePlayer from '@/components/youtube-player'
export async function generateMetadata({ params }: Args) {
const { slug } = await params export const dynamic = 'force-dynamic'
const payload = await getPayload({ config: configPromise })
// Drafts must never leak title/description into metadata. /* QA-06: article pages get their own title/description. */
const res = await payload.find({ export async function generateMetadata({ params }: Args) {
collection: 'posts', const { slug } = await params
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] }, const payload = await getPayload({ config: configPromise })
depth: 1, // Drafts must never leak title/description into metadata.
limit: 1, const res = await payload.find({
select: { title: true, dek: true, publishedAt: true, updatedAt: true, cover: true }, collection: 'posts',
}) where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
const post = res.docs[0] as unknown as depth: 1,
| { title: string; dek: string; publishedAt?: string | null; updatedAt?: string | null; cover?: { url?: string | null } | number } limit: 1,
| undefined select: { title: true, dek: true, publishedAt: true, updatedAt: true, cover: true },
if (!post) return { title: 'أنور بآي' } })
const post = res.docs[0] as unknown as
const ogImage = | { title: string; dek: string; publishedAt?: string | null; updatedAt?: string | null; cover?: { url?: string | null } | number }
post.cover && typeof post.cover === 'object' && post.cover.url ? post.cover.url : '/og-default.png' | undefined
return { if (!post) return { title: 'أنور پآي' }
title: post.title,
description: post.dek, const ogImage =
openGraph: { post.cover && typeof post.cover === 'object' && post.cover.url ? post.cover.url : '/og-default.png'
// Next replaces (not merges) the layout-level openGraph — redeclare fully. return {
type: 'article', title: post.title,
siteName: 'أنور بآي', description: post.dek,
locale: 'ar', openGraph: {
title: post.title, // Next replaces (not merges) the layout-level openGraph — redeclare fully.
description: post.dek, type: 'article',
publishedTime: post.publishedAt ?? undefined, siteName: 'أنور پآي',
images: [{ url: ogImage, width: 1200, height: 630 }], locale: 'ar',
}, title: post.title,
twitter: { card: 'summary_large_image', title: post.title, description: post.dek, images: [ogImage] }, description: post.dek,
} publishedTime: post.publishedAt ?? undefined,
} images: [{ url: ogImage, width: 1200, height: 630 }],
},
type Args = { params: Promise<{ slug: string }> } twitter: { card: 'summary_large_image', title: post.title, description: post.dek, images: [ogImage] },
}
type PostDoc = { }
id: number
title: string type Args = { params: Promise<{ slug: string }> }
slug: string
dek: string type PostDoc = {
access: 'free' | 'vip' id: number
category: string title: string
level?: string | null slug: string
views?: number | null dek: string
likes?: number | null access: 'free' | 'vip'
readingMinutes: number category: string
publishedAt?: string | null level?: string | null
updatedAt?: string | null views?: number | null
teaser?: string | null likes?: number | null
content?: unknown readingMinutes: number
cover?: { url?: string | null } publishedAt?: string | null
attachments?: (number | { id: number })[] updatedAt?: string | null
contentType?: string | null teaser?: string | null
authorName?: string | null content?: unknown
} cover?: { url?: string | null }
attachments?: (number | { id: number })[]
async function isLikedByViewer(postId: number, viewerId?: number): Promise<boolean> { contentType?: string | null
try { authorName?: string | null
if (!viewerId) return false }
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({ async function isLikedByViewer(postId: number, viewerId?: number): Promise<boolean> {
collection: 'post-likes', try {
where: { and: [{ user: { equals: viewerId } }, { post: { equals: postId } }] }, if (!viewerId) return false
depth: 0, const payload = await getPayload({ config: configPromise })
limit: 1, const { docs } = await payload.find({
overrideAccess: true, collection: 'post-likes',
}) where: { and: [{ user: { equals: viewerId } }, { post: { equals: postId } }] },
return docs.length > 0 depth: 0,
} catch { limit: 1,
return false overrideAccess: true,
} })
} return docs.length > 0
} catch {
export default async function PostPage({ params }: Args) { return false
const { slug } = await params }
const payload = await getPayload({ config: configPromise }) }
const res = await payload.find({ export default async function PostPage({ params }: Args) {
collection: 'posts', const { slug } = await params
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] }, const payload = await getPayload({ config: configPromise })
depth: 1,
limit: 1, const res = await payload.find({
// content selected only for entitled viewers — never reaches non-VIP clients collection: 'posts',
select: { where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
title: true, slug: true, dek: true, access: true, category: true, level: true, depth: 1,
views: true, likes: true, limit: 1,
readingMinutes: true, publishedAt: true, updatedAt: true, teaser: true, cover: true, // content selected only for entitled viewers — never reaches non-VIP clients
content: true, attachments: true, select: {
contentType: true, authorName: true, title: true, slug: true, dek: true, access: true, category: true, level: true,
}, views: true, likes: true,
}) readingMinutes: true, publishedAt: true, updatedAt: true, teaser: true, cover: true,
const post = res.docs[0] as unknown as PostDoc | undefined content: true, attachments: true,
if (!post) notFound() contentType: true, authorName: true,
},
// One auth lookup per request; everything below reads from it. })
const viewer = await getViewer() const post = res.docs[0] as unknown as PostDoc | undefined
const role = viewer?.role ?? null if (!post) notFound()
const viewerId = viewer?.id
const viewerLevel = viewer?.level ?? null // One auth lookup per request; everything below reads from it.
// L-02: free early access unlocks every published post (the query above const viewer = await getViewer()
// already filters status: 'published'); paid mode keeps the tier gate. const role = viewer?.role ?? null
const earlyAccessNotice = freeEarlyAccess() && post.access === 'vip' const viewerId = viewer?.id
const unlocked = freeEarlyAccess() || post.access === 'free' || role === 'vip' || role === 'admin' const viewerLevel = viewer?.level ?? null
const postTopics = topicsFor(post) // L-02: free early access unlocks every published post (the query above
// The downloadable templates are agent-workflow files; only offer them on agent guides. // already filters status: 'published'); paid mode keeps the tier gate.
const hasAgentFiles = postTopics.includes('agents') const earlyAccessNotice = freeEarlyAccess() && post.access === 'vip'
const liked = await isLikedByViewer(post.id, viewerId) const unlocked = freeEarlyAccess() || post.access === 'free' || role === 'vip' || role === 'admin'
const postTopics = topicsFor(post)
// Attachment metadata (label/access) is safe to expose — the bytes are // The downloadable templates are agent-workflow files; only offer them on agent guides.
// gated by GET /api/files/[id]. Fetched with overrideAccess because the const hasAgentFiles = postTopics.includes('agents')
// files collection read rule is vip/admin and depth population would const liked = await isLikedByViewer(post.id, viewerId)
// otherwise collapse to bare ids for anonymous/free viewers.
const attachmentIds = (post.attachments ?? []).map(a => (typeof a === 'object' ? a.id : a)) // Attachment metadata (label/access) is safe to expose — the bytes are
type Attachment = { id: number; label: string; filename: string; access: 'free' | 'vip' } // gated by GET /api/files/[id]. Fetched with overrideAccess because the
let attachments: Attachment[] = [] // files collection read rule is vip/admin and depth population would
if (attachmentIds.length > 0) { // otherwise collapse to bare ids for anonymous/free viewers.
const filesRes = await payload.find({ const attachmentIds = (post.attachments ?? []).map(a => (typeof a === 'object' ? a.id : a))
collection: 'files', type Attachment = { id: number; label: string; filename: string; access: 'free' | 'vip' }
depth: 0, let attachments: Attachment[] = []
limit: 20, if (attachmentIds.length > 0) {
overrideAccess: true, const filesRes = await payload.find({
where: { id: { in: attachmentIds } }, collection: 'files',
select: { label: true, filename: true, access: true }, depth: 0,
}) limit: 20,
const fileDocs = filesRes.docs as unknown as Attachment[] overrideAccess: true,
attachments = attachmentIds where: { id: { in: attachmentIds } },
.map(id => fileDocs.find(d => d.id === id)) select: { label: true, filename: true, access: true },
.filter((d): d is Attachment => Boolean(d)) })
} const fileDocs = filesRes.docs as unknown as Attachment[]
attachments = attachmentIds
// «اقرأ بعد ذلك»: real published guides, same learning path first. .map(id => fileDocs.find(d => d.id === id))
const { docs: others } = await payload.find({ .filter((d): d is Attachment => Boolean(d))
collection: 'posts', }
where: { and: [{ status: { equals: 'published' } }, { slug: { not_equals: post.slug } }] },
depth: 0, // «اقرأ بعد ذلك»: real published guides, same learning path first.
limit: 100, const { docs: others } = await payload.find({
sort: '-publishedAt', collection: 'posts',
select: { title: true, slug: true, category: true, access: true, readingMinutes: true, level: true }, where: { and: [{ status: { equals: 'published' } }, { slug: { not_equals: post.slug } }] },
}) depth: 0,
const related = [...others] limit: 100,
.sort((a, b) => Number(topicsFor(b).includes(postTopics[0])) - Number(topicsFor(a).includes(postTopics[0]))) sort: '-publishedAt',
.slice(0, 3) select: { title: true, slug: true, category: true, access: true, readingMinutes: true, level: true },
})
// Approved comments (public read access allows the approved-only filter). const related = [...others]
const comments = await payload.find({ .sort((a, b) => Number(topicsFor(b).includes(postTopics[0])) - Number(topicsFor(a).includes(postTopics[0])))
collection: 'comments', .slice(0, 3)
depth: 0,
limit: 50, // Approved comments (public read access allows the approved-only filter).
sort: '-createdAt', const comments = await payload.find({
where: { and: [{ post: { equals: post.id } }, { status: { equals: 'approved' } }] }, collection: 'comments',
}) depth: 0,
// Authors see their own pending comments tagged «قيد المراجعة» — needs limit: 50,
// overrideAccess because non-admin read access filters to approved only. sort: '-createdAt',
const myPending = viewerId where: { and: [{ post: { equals: post.id } }, { status: { equals: 'approved' } }] },
? await payload.find({ })
collection: 'comments', // Authors see their own pending comments tagged «قيد المراجعة» — needs
depth: 0, // overrideAccess because non-admin read access filters to approved only.
limit: 10, const myPending = viewerId
overrideAccess: true, ? await payload.find({
where: { collection: 'comments',
and: [ depth: 0,
{ post: { equals: post.id } }, limit: 10,
{ author: { equals: viewerId } }, overrideAccess: true,
{ status: { equals: 'pending' } }, where: {
], and: [
}, { post: { equals: post.id } },
}) { author: { equals: viewerId } },
: null { status: { equals: 'pending' } },
],
// TOC from content h2s (server-extracted from the Lexical tree) — computed },
// unconditionally; the paywall preview (paid mode) also uses it. })
const toc = extractH2s(post.content) : null
const topic = postTopics[0] // TOC from content h2s (server-extracted from the Lexical tree) — computed
const topicLabel = TOPICS.find(t => t.key === topic)?.label ?? post.category // unconditionally; the paywall preview (paid mode) also uses it.
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai' const toc = extractH2s(post.content)
return ( const topic = postTopics[0]
<div id="site" className="site-page v2 v2-article page-posts"> const topicLabel = TOPICS.find(t => t.key === topic)?.label ?? post.category
<ViewPing slug={post.slug} /> const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
{/* L-09: honest Article + Breadcrumb structured data. JSON-LD array = multiple objects in one script. */}
<script return (
type="application/ld+json" <div id="site" className="site-page v2 v2-article page-posts">
dangerouslySetInnerHTML={{ <ViewPing slug={post.slug} />
__html: JSON.stringify([ {/* L-09: honest Article + Breadcrumb structured data. JSON-LD array = multiple objects in one script. */}
{ <script
'@context': 'https://schema.org', type="application/ld+json"
'@type': 'Article', dangerouslySetInnerHTML={{
headline: post.title, __html: JSON.stringify([
description: post.dek, {
image: typeof post.cover === 'object' && post.cover?.url ? post.cover.url : undefined, '@context': 'https://schema.org',
datePublished: post.publishedAt ?? undefined, '@type': 'Article',
dateModified: post.updatedAt ?? post.publishedAt ?? undefined, headline: post.title,
author: post.authorName description: post.dek,
? { '@type': 'Person', name: post.authorName } image: typeof post.cover === 'object' && post.cover?.url ? new URL(post.cover.url, siteUrl).toString() : undefined,
: { '@type': 'Organization', name: 'أنور بآي' }, datePublished: post.publishedAt ?? undefined,
publisher: { dateModified: post.updatedAt ?? post.publishedAt ?? undefined,
'@type': 'Organization', author: post.authorName
name: 'أنور بآي', ? { '@type': 'Person', name: post.authorName }
logo: { '@type': 'ImageObject', url: `${siteUrl}/brand/icon-256.png` }, : { '@type': 'Organization', name: 'أنور پآي' },
}, publisher: {
mainEntityOfPage: { '@type': 'WebPage', '@id': `${siteUrl}/posts/${post.slug}` }, '@type': 'Organization',
isAccessibleForFree: post.access === 'free' || freeEarlyAccess(), name: 'أنور پآي',
inLanguage: 'ar', logo: { '@type': 'ImageObject', url: `${siteUrl}/brand/icon-256.png` },
}, },
{ mainEntityOfPage: { '@type': 'WebPage', '@id': `${siteUrl}/posts/${post.slug}` },
'@context': 'https://schema.org', isAccessibleForFree: post.access === 'free' || freeEarlyAccess(),
'@type': 'BreadcrumbList', inLanguage: 'ar',
itemListElement: [ },
{ '@type': 'ListItem', position: 1, name: 'الرئيسية', item: siteUrl }, {
{ '@type': 'ListItem', position: 2, name: topicLabel, item: `${siteUrl}/topics/${topic}` }, '@context': 'https://schema.org',
{ '@type': 'ListItem', position: 3, name: post.title, item: `${siteUrl}/posts/${post.slug}` }, '@type': 'BreadcrumbList',
], itemListElement: [
}, { '@type': 'ListItem', position: 1, name: 'الرئيسية', item: siteUrl },
]), { '@type': 'ListItem', position: 2, name: topicLabel, item: `${siteUrl}/topics/${topic}` },
}} { '@type': 'ListItem', position: 3, name: post.title, item: `${siteUrl}/posts/${post.slug}` },
/> ],
<Topnav viewer={viewer} /> },
<main id="content"> ]),
<header className="art-head"> }}
<div className="wrap art-head-grid"> />
<div className="art-head-copy"> <Topnav viewer={viewer} />
<nav className="crumbs" aria-label="مسار التصفح"> <main id="content">
<Link href="/articles">المكتبة</Link> <header className="art-head">
<span aria-hidden="true">/</span> <div className="wrap art-head-grid">
<Link href={`/articles?topic=${topic}`}>{topicLabel}</Link> <div className="art-head-copy">
</nav> <nav className="crumbs" aria-label="مسار التصفح">
<h1>{post.title}</h1> <Link href="/articles">المكتبة</Link>
<p className="lede">{post.dek}</p> <span aria-hidden="true">/</span>
<div className="art-meta"> <Link href={`/articles?topic=${topic}`}>{topicLabel}</Link>
<span className={`badge${post.access === 'vip' ? ' badge-vip' : ''}`}> </nav>
{post.access === 'vip' && <LockIcon />} <h1>{post.title}</h1>
{post.access === 'free' ? 'مجاني بالكامل' : unlocked ? 'للأعضاء' : 'معاينة مجانية'} <p className="lede">{post.dek}</p>
</span> <div className="art-meta">
<span>{post.category}</span> <span className={`badge${post.access === 'vip' ? ' badge-vip' : ''}`}>
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>} {post.access === 'vip' && <LockIcon />}
{post.authorName && <span>بقلم {post.authorName}</span>} {unlocked ? accessLabel(post.access) : 'معاينة مجانية'}
<span>{minutesLabel(post.readingMinutes)} للقراءة</span> </span>
<span>{post.level ?? 'مبتدئ'}</span> <span>{post.category}</span>
{post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>} {contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
{post.updatedAt && post.publishedAt && new Date(post.updatedAt) > new Date(post.publishedAt) && ( {post.authorName && <span>بقلم {post.authorName}</span>}
<span>· حُدّث {formatDate(post.updatedAt)}</span> <span>{minutesLabel(post.readingMinutes)} للقراءة</span>
)} <span>{post.level ?? 'مبتدئ'}</span>
{(post.level ?? 'مبتدئ') === viewerLevel && <span className="for-you">يناسب مستواك</span>} {post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>}
</div> {post.updatedAt && post.publishedAt && new Date(post.updatedAt) > new Date(post.publishedAt) && (
<div className="art-actions"> <span>· حُدّث {formatDate(post.updatedAt)}</span>
<LikeButton slug={post.slug} loggedIn={role !== null} initialLiked={liked} initialCount={post.likes ?? 0} /> )}
<span className="views" data-views>{formatNumber(post.views)} قراءة</span> {(post.level ?? 'مبتدئ') === viewerLevel && <span className="for-you">يناسب مستواك</span>}
{unlocked && post.access === 'vip' && <SaveButton />} </div>
</div> <div className="art-actions">
</div> <LikeButton slug={post.slug} loggedIn={role !== null} initialLiked={liked} initialCount={post.likes ?? 0} />
</div> <span className="views" data-views>{formatNumber(post.views)} قراءة</span>
</header> {unlocked && post.access === 'vip' && <SaveButton />}
</div>
{typeof post.cover === 'object' && post.cover?.url && ( </div>
<div className="wrap"><figure className="art-cover"><img src={post.cover.url} alt={post.title} width={1200} height={630} loading="eager" /></figure></div> </div>
)} </header>
<div className="wrap art-layout"> {typeof post.cover === 'object' && post.cover?.url && (
<article className="art-body"> <div className="wrap"><figure className="art-cover"><img src={post.cover.url} alt={post.title} width={1200} height={630} loading="eager" /></figure></div>
{unlocked ? ( )}
<>
{earlyAccessNotice && ( <div className="wrap art-layout">
<section className="early-notice" aria-label="إشعار الوصول المبكر"> <article className="art-body">
<p>متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.</p> {unlocked ? (
</section> <>
)} {earlyAccessNotice && (
<RichText data={post.content} /> <section className="early-notice" aria-label="إشعار الوصول المبكر">
</> <p>متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.</p>
) : ( </section>
<> )}
{(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => ( <RichText data={post.content} />
i === arr.length - 1 && arr.length > 1 </>
? <p key={i} className="fade-out">{para}</p> ) : (
: <p key={i} className={i === 0 ? 'intro' : undefined}>{para}</p> <>
))} {(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => (
<section className="paywall" aria-labelledby="paywall-title"> i === arr.length - 1 && arr.length > 1
<h2 id="paywall-title">بقية هذا الدليل للأعضاء</h2> ? <p key={i} className="fade-out">{para}</p>
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل الأدلة الحصرية الأخرى.</p> : <p key={i} className={i === 0 ? 'intro' : undefined}>{para}</p>
{toc.length > 0 && ( ))}
<div className="paywall-preview"> <section className="paywall" aria-labelledby="paywall-title">
<p className="aside-title">ماذا ينتظرك داخل هذا الدليل:</p> <h2 id="paywall-title">بقية هذا المحتوى للأعضاء</h2>
<ul role="list">{toc.map((t, i) => <li key={i}><LockIcon />{t}</li>)}</ul> <p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل المحتوى الحصري الآخر.</p>
</div> {toc.length > 0 && (
)} <div className="paywall-preview">
<div className="paywall-actions"> <p className="aside-title">ماذا ينتظرك داخل هذا المحتوى:</p>
<a className="btn btn-primary" href="/subscription">اشترك بـ <bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> شهرياً</a> <ul role="list">{toc.map((t, i) => <li key={i}><LockIcon />{t}</li>)}</ul>
<a className="btn btn-quiet" href={`/login?next=/posts/${post.slug}`}>لدي عضوية، تسجيل الدخول</a> </div>
</div> )}
<p className="fine">تلغي في أي وقت من صفحة حسابك. <Link href="/subscription#faq">أسئلة قبل الاشتراك</Link> · <Link href="/articles?access=free">أكمل بالأدلة المجانية</Link></p> <div className="paywall-actions">
</section> <a className="btn btn-primary" href="/subscription">اشترك بـ <bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> شهرياً</a>
</> <a className="btn btn-quiet" href={`/login?next=/posts/${post.slug}`}>لدي عضوية، تسجيل الدخول</a>
)} </div>
</article> <p className="fine">تلغي في أي وقت من صفحة حسابك. <Link href="/subscription#faq">أسئلة قبل الاشتراك</Link> · <Link href="/articles?access=free">أكمل بالمحتوى المجاني</Link></p>
<aside className="art-aside" aria-label="داخل هذا الدليل"> </section>
{unlocked && toc.length > 0 && ( </>
<nav className="toc" aria-label="فهرس الدليل"> )}
<p className="aside-title">في هذا الدليل</p> </article>
<ol>{toc.map((t, i) => <li key={i}><a href={`#sec-${i}`}>{t}</a></li>)}</ol> <aside className="art-aside" aria-label="داخل هذا المحتوى">
</nav> {unlocked && toc.length > 0 && (
)} <nav className="toc" aria-label="فهرس المحتوى">
{unlocked && hasAgentFiles && ( <p className="aside-title">في هذا المحتوى</p>
<div className="files"> <ol>{toc.map((t, i) => <li key={i}><a href={`#sec-${i}`}>{t}</a></li>)}</ol>
<p className="aside-title">ملفات العمل</p> </nav>
<ul> )}
<li><span>قالب حالة الوكيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="agent-state.md">تنزيل</button></li> {unlocked && hasAgentFiles && (
<li><span>قائمة الفحص قبل التشغيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="pre-run-checklist.md">تنزيل</button></li> <div className="files">
<li><span>سجل الأخطاء <bdi dir="ltr">.csv</bdi></span><button className="dl-btn" type="button" data-dl="error-log.csv">تنزيل</button></li> <p className="aside-title">ملفات العمل</p>
</ul> <ul>
</div> <li><span>قالب حالة الوكيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="agent-state.md">تنزيل</button></li>
)} <li><span>قائمة الفحص قبل التشغيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="pre-run-checklist.md">تنزيل</button></li>
{!unlocked && ( <li><span>سجل الأخطاء <bdi dir="ltr">.csv</bdi></span><button className="dl-btn" type="button" data-dl="error-log.csv">تنزيل</button></li>
<div className="files is-locked"> </ul>
<p className="aside-title">مع العضوية</p> </div>
<ul> )}
<li><LockIcon />الأقسام العملية كاملة</li> {!unlocked && (
{hasAgentFiles && <li><LockIcon />ملفات العمل القابلة للتنزيل</li>} <div className="files is-locked">
<li><LockIcon />النقاش مع الفريق</li> <p className="aside-title">مع العضوية</p>
</ul> <ul>
</div> <li><LockIcon />الأقسام العملية كاملة</li>
)} {hasAgentFiles && <li><LockIcon />ملفات العمل القابلة للتنزيل</li>}
{attachments.length > 0 && ( <li><LockIcon />النقاش مع الفريق</li>
<div className="files"> </ul>
<p className="aside-title">ملفات هذا الدليل</p> </div>
<ul> )}
{attachments.map(item => { {attachments.length > 0 && (
// Per-file gate mirrors GET /api/files/[id]: free streams to <div className="files">
// anyone; vip needs a vip/admin viewer (post access is <p className="aside-title">ملفات هذا المحتوى</p>
// irrelevant — a vip file on a free post stays locked). <ul>
const canDownload = item.access === 'free' || role === 'vip' || role === 'admin' {attachments.map(item => {
const accessLabel = item.access === 'free' ? 'مجاني' : 'للأعضاء' // Per-file gate mirrors GET /api/files/[id]: free streams to
return ( // anyone; vip needs a vip/admin viewer (post access is
<li key={item.id}> // irrelevant — a vip file on a free post stays locked).
<span>{item.label || item.filename} <span className="file-access">· {accessLabel}</span></span> const canDownload = item.access === 'free' || role === 'vip' || role === 'admin'
{canDownload const fileAccessLabel = item.access === 'free' ? 'مجاني' : 'ملف مقيد للأعضاء'
? <a className="dl-btn" href={`/api/files/${item.id}`} download>تنزيل</a> return (
: <span className="dl-locked"><LockIcon />للأعضاء</span>} <li key={item.id}>
</li> <span>{item.label || item.filename} <span className="file-access">· {fileAccessLabel}</span></span>
) {canDownload
})} ? <a className="dl-btn" href={`/api/files/${item.id}`} download>تنزيل</a>
</ul> : <span className="dl-locked"><LockIcon />للأعضاء</span>}
</div> </li>
)} )
</aside> })}
</div> </ul>
</div>
{unlocked && ( )}
<section className="art-comments" aria-labelledby="comments-title"> </aside>
<div className="wrap narrow"> </div>
<h2 id="comments-title">النقاش</h2>
<div className="comment-list" id="commentList"> {unlocked && (
{comments.docs.map((c) => ( <section className="art-comments" aria-labelledby="comments-title">
<article key={c.id} className="comment"> <div className="wrap narrow">
<header><strong>{c.authorName || 'عضو'}</strong><time dateTime={String(c.createdAt)}>{formatDate(String(c.createdAt))}</time></header> <h2 id="comments-title">النقاش</h2>
<p>{c.body}</p> <div className="comment-list" id="commentList">
</article> {comments.docs.map((c) => (
))} <article key={c.id} className="comment">
{myPending?.docs.map((c) => ( <header><strong>{c.authorName || 'عضو'}</strong><time dateTime={String(c.createdAt)}>{formatDate(String(c.createdAt))}</time></header>
<article key={c.id} className="comment is-pending"> <p>{c.body}</p>
<header><strong>{c.authorName || 'عضو'}</strong><span>بانتظار المراجعة</span></header> </article>
<p>{c.body}</p> ))}
</article> {myPending?.docs.map((c) => (
))} <article key={c.id} className="comment is-pending">
{comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && ( <header><strong>{c.authorName || 'عضو'}</strong><span>بانتظار المراجعة</span></header>
<p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا الدليل.</p> <p>{c.body}</p>
)} </article>
</div> ))}
<PostExtras slug={post.slug} loggedIn={role !== null} /> {comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && (
</div> <p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا المحتوى.</p>
</section> )}
)} </div>
<PostExtras slug={post.slug} loggedIn={role !== null} />
{related.length > 0 && ( </div>
<section className="art-related" aria-labelledby="related-title"> </section>
<div className="wrap"> )}
<h2 id="related-title">اقرأ بعد ذلك</h2>
<ul className="related-grid" role="list"> {related.length > 0 && (
{related.map(item => ( <section className="art-related" aria-labelledby="related-title">
<li key={item.id}> <div className="wrap">
<Link href={`/posts/${item.slug}`} className="related-item"> <h2 id="related-title">اقرأ بعد ذلك</h2>
<span className="meta-row"><span>{item.category}</span><span>{minutesLabel(item.readingMinutes)}</span><span>{item.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></span> <ul className="related-grid" role="list">
<span className="related-title">{item.title}</span> {related.map(item => (
</Link> <li key={item.id}>
</li> <Link href={`/posts/${item.slug}`} className="related-item">
))} <span className="meta-row"><span>{item.category}</span><span>{minutesLabel(item.readingMinutes)}</span><span>{accessLabel(item.access)}</span></span>
</ul> <span className="related-title">{item.title}</span>
</div> </Link>
</section> </li>
)} ))}
</main> </ul>
<Footer /> </div>
</div> </section>
) )}
} </main>
<Footer />
function LockIcon() { </div>
return ( )
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><rect x="3" y="7" width="10" height="7" /><path d="M5 7V5a3 3 0 0 1 6 0v2" /></svg> }
)
} function LockIcon() {
return (
function SaveButton() { <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><rect x="3" y="7" width="10" height="7" /><path d="M5 7V5a3 3 0 0 1 6 0v2" /></svg>
return <button className="save-btn" id="saveArticle" type="button" aria-pressed="false">احفظ للعودة لاحقاً</button> )
} }
// Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules. function SaveButton() {
// `Node`, `innerText` and `extractH2s` live in lib/lexical (shared with llms routes). return <button className="save-btn" id="saveArticle" type="button" aria-pressed="false">احفظ للعودة لاحقاً</button>
}
function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] {
return (nodes || []).map((n, i) => { // Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules.
const key = `${n.type}-${i}` // `Node`, `innerText` and `extractH2s` live in lib/lexical (shared with llms routes).
if (n.type === 'text') {
const text = n.text || '' function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] {
return <span key={key}>{text}</span> return (nodes || []).map((n, i) => {
} const key = `${n.type}-${i}`
if (n.type === 'linebreak') return <br key={key} /> if (n.type === 'text') {
if (n.type === 'link') return <a key={key} href={n.url || '#'} className="text-link">{renderChildren(n.children, counter)}</a> const text = n.text || ''
const inner = renderChildren(n.children, counter) return <span key={key}>{text}</span>
switch (n.type) { }
case 'heading': { if (n.type === 'linebreak') return <br key={key} />
const Tag = (n.tag || 'h2') as 'h1' | 'h2' | 'h3' | 'h4' if (n.type === 'link') return <a key={key} href={n.url || '#'} className="text-link">{renderChildren(n.children, counter)}</a>
const id = Tag === 'h2' ? `sec-${counter.h2++}` : undefined // Only a paragraph made of exactly one recognized link is media.
return <Tag key={key} id={id}>{inner}</Tag> // Keep inline/multiple links ordinary; never interpret authored HTML.
} if (n.type === 'paragraph' && n.children?.length === 1) {
case 'quote': return <blockquote key={key}>{inner}</blockquote> const link = n.children[0]
case 'list': { if (link.type === 'link' && link.url && youtubeVideoId(link.url)) {
if (n.listType === 'number') return <ol key={key}>{inner}</ol> return <YouTubePlayer key={key} url={link.url} />
return <ul key={key}>{inner}</ul> }
} if (link.type === 'link' && link.url && link.children?.every(child => child.type === 'text')) {
case 'listitem': return <li key={key}>{inner}</li> const src = articleImageUrl(link.url)
case 'paragraph': return <p key={key}>{inner}</p> const caption = innerText(link.children)
case 'code': return <pre key={key} aria-label="مثال برمجي"><code>{n.code ?? innerText(n.children)}</code></pre> if (src && caption.trim()) {
case 'horizontalrule': return <hr key={key} /> return (
case 'table': <figure key={key} style={{ maxWidth: '100%', marginInline: 0 }}>
return ( <img src={src} alt={caption} loading="lazy" decoding="async" style={{ display: 'block', width: '100%', maxWidth: '100%', height: 'auto' }} />
<div key={key} className="post-table" role="region" aria-label="جدول" tabIndex={0}> <figcaption>{caption}</figcaption>
<table>{inner}</table> </figure>
</div> )
) }
case 'tablerow': return <tr key={key}>{inner}</tr> }
case 'tablecell': return n.headerState ? <th key={key}>{inner}</th> : <td key={key}>{inner}</td> }
default: return <p key={key}>{inner}</p> const inner = renderChildren(n.children, counter)
} switch (n.type) {
}) case 'heading': {
} const Tag = (n.tag || 'h2') as 'h1' | 'h2' | 'h3' | 'h4'
const id = Tag === 'h2' ? `sec-${counter.h2++}` : undefined
function RichText({ data }: { data: unknown }) { return <Tag key={key} id={id}>{inner}</Tag>
if (!data || typeof data !== 'object' || !('root' in data)) return null }
const rootUnknown = data.root case 'quote': return <blockquote key={key}>{inner}</blockquote>
if (!rootUnknown || typeof rootUnknown !== 'object' || !('children' in rootUnknown)) return null case 'list': {
const root = rootUnknown as { children?: Node[] } // named const: same JSON tree the editor produced if (n.listType === 'number') return <ol key={key}>{inner}</ol>
return <>{renderChildren(root.children, { h2: 0 })}</> return <ul key={key}>{inner}</ul>
} }
case 'listitem': return <li key={key}>{inner}</li>
case 'paragraph': return <p key={key}>{inner}</p>
case 'code': return <pre key={key} aria-label="مثال برمجي"><code>{n.code ?? innerText(n.children)}</code></pre>
case 'horizontalrule': return <hr key={key} />
case 'table':
return (
<div key={key} className="post-table" role="region" aria-label="جدول" tabIndex={0}>
<table>{inner}</table>
</div>
)
case 'tablerow': return <tr key={key}>{inner}</tr>
case 'tablecell': return n.headerState ? <th key={key}>{inner}</th> : <td key={key}>{inner}</td>
default: return <p key={key}>{inner}</p>
}
})
}
function RichText({ data }: { data: unknown }) {
if (!data || typeof data !== 'object' || !('root' in data)) return null
const rootUnknown = data.root
if (!rootUnknown || typeof rootUnknown !== 'object' || !('children' in rootUnknown)) return null
const root = rootUnknown as { children?: Node[] } // named const: same JSON tree the editor produced
return <>{renderChildren(root.children, { h2: 0 })}</>
}
@@ -77,6 +77,10 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
const dlHandler = (e: Event) => { const dlHandler = (e: Event) => {
const b = e.currentTarget as HTMLButtonElement const b = e.currentTarget as HTMLButtonElement
const file = DL_FILES[b.dataset.dl ?? ''] const file = DL_FILES[b.dataset.dl ?? '']
if (!file) {
b.textContent = 'الملف غير متاح'
return
}
if (file) { if (file) {
const blob = new Blob([file.body], { type: file.type }) const blob = new Blob([file.body], { type: file.type })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
@@ -138,9 +142,9 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
if (!loggedIn) { if (!loggedIn) {
return ( return (
<div className="comment-gate"> <div className="comment-gate">
<p>سجّل الدخول لتشارك سؤالك أو تجربتك مع هذا الدليل.</p> <p>سجّل الدخول لتشارك سؤالك أو تجربتك مع هذا المحتوى.</p>
<a className="btn btn-quiet" href={`/login?next=/posts/${slug}`}>سجّل الدخول للتعليق</a> <a className="btn btn-quiet" href={`/login?next=/posts/${slug}`}>سجّل الدخول للتعليق</a>
<p>لا تملك حساباً؟ <a href={`/signup?next=/posts/${slug}`}>أنشئ حساباً مجاناً</a></p> <p>التسجيل العام متوقف؛ القراءة لا تتطلب حساباً. للملاحظات: <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>.</p>
</div> </div>
) )
} }
+37 -32
View File
@@ -1,32 +1,37 @@
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import Footer from '@/components/footer'
import '../styles/privacy.css' import { LAUNCH_POLICY } from '@/lib/launch'
import '../styles/privacy.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'الخصوصية' } export const dynamic = 'force-dynamic'
export const metadata = {
export default function PrivacyPage() { title: 'سياسة خصوصية Anwarpy',
return ( description: 'سياسة خصوصية أنور پآي: تعرّف على بيانات الزيارة والحسابات والتواصل، وكيف تُستخدم وتُحفظ، ومدة الاحتفاظ بها، وطريقة طلب الوصول والتصحيح والحذف.',
<div id="site" className="site-page page-privacy"> }
<Topnav />
<main id="content"> export default function PrivacyPage() {
<section className="hero" data-od-id="privacy-hero"> return (
<div className="container"> <div id="site" className="site-page page-privacy">
<p className="eyebrow">الخصوصية · مسودة ما قبل الإطلاق</p> <Topnav />
<h1 data-od-id="privacy-title">بياناتك ليست مادة للمحتوى.</h1> <main id="content">
<p className="lead">توضح هذه المسودة الحد الأدنى المقترح لجمع البيانات واستخدامها داخل أنور بآي.</p> <section className="hero"><div className="container">
<p className="notice" role="note">هذه سياسة تجريبية للواجهة وليست نصاً قانونياً نهائياً. يجب مراجعتها واعتمادها قبل نشر الموقع.</p> <p className="eyebrow">سياسات الوصول المبكر المجاني</p>
</div> <h1>سياسة خصوصية Anwarpy</h1>
</section> <p className="lead">تاريخ السريان: <time dateTime={LAUNCH_POLICY.effectiveDate}>{LAUNCH_POLICY.effectiveDate}</time></p>
<div className="container policy" data-od-id="privacy-content"> <p>{LAUNCH_POLICY.summary}</p>
<section data-od-id="privacy-data"><h2>ما الذي نجمعه؟</h2><p>عند تشغيل الحسابات فعلياً قد نحتاج إلى الاسم والبريد الإلكتروني، وبيانات العضوية والفوترة التي يوفرها مزوّد الدفع. لا ينبغي تخزين بيانات البطاقة كاملة داخل المنصة.</p></section> </div></section>
<section data-od-id="privacy-use"><h2>كيف تُستخدم البيانات؟</h2><ul><li>تسجيل الدخول وحفظ التقدّم والتفضيلات.</li><li>إدارة العضوية وإرسال الرسائل التي اختارها المستخدم.</li><li>حماية المنصة وتشخيص الأعطال وتحسين تجربة القراءة.</li></ul></section> <div className="container policy">
<section data-od-id="privacy-sharing"><h2>المشاركة والاحتفاظ</h2><p>لا تُباع البيانات الشخصية. قبل الإطلاق يجب تسمية مزوّدي الاستضافة والبريد والدفع والتحليلات، وتحديد مدة الاحتفاظ بكل نوع من البيانات بوضوح.</p></section> <section><h2>المشغّل والنطاق</h2><p>يدير Anwarpy هذا الموقع. القراءة مجانية ولا تتطلب حساباً، وإنشاء الحسابات العامة متوقف. تتناول هذه السياسة بيانات زيارة الموقع والحسابات القائمة والتواصل، لا الملاحظات الخاصة التي لم تنشر.</p></section>
<section data-od-id="privacy-rights"><h2>حقوقك</h2><p>يمكنك طلب نسخة من بياناتك أو تصحيحها أو حذفها بعد تفعيل الحسابات. أرسل طلبك إلى <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>.</p></section> <section><h2>البيانات التي تعالجها الخدمة</h2><p>قد تتضمن الحسابات القائمة الاسم والبريد وكلمة مرور محفوظة بصيغة تجزئة، الدور وحالة التحقق وتفضيلات المستخدم واهتماماته، وسجلات قبول الشروط. ترتبط التعليقات والإعجابات بالحساب عند استخدامها. تسجل الخدمة عدادات قراءة المنشورات، وقد تعالج الاستضافة وCloudflare عنوان IP ومعلومات الطلب والمتصفح وسجلات الأخطاء لأغراض التشغيل والأمان. ما ترسله إلى بريد الدعم، بما فيه عنوانك ومضمون الرسالة، يعالج للرد على طلبك.</p></section>
<section data-od-id="privacy-updates"><h2>التحديثات</h2><p>يجب إضافة تاريخ السريان وملخص أي تغيير جوهري عند اعتماد النسخة القانونية النهائية.</p></section> <section><h2>الاستخدام والتخزين في المتصفح</h2><p>نستخدم البيانات لتسجيل الدخول وتشغيل الميزات المتاحة ومراجعة التعليقات وحماية الخدمة وتشخيص الأعطال والرد على الطلبات. قد يستخدم تسجيل الدخول ملفات ارتباط للجلسة، وتستخدم بعض الواجهات التخزين المحلي للتفضيلات والعرض؛ يمكنك مسحه من إعدادات المتصفح وقد تفقد معه تلك التفضيلات. لا نعلن عن أداة تحليلات تسويقية أو تتبع إعلاني غير متحقق منها.</p></section>
</div> <section><h2>الاستضافة والبريد والأطراف الأخرى</h2><p>يعمل الموقع على خادم خاص افتراضي (VPS). تمر الطلبات عبر Cloudflare بوصفه وسيطاً للشبكة والحماية. يستخدم تكامل البريد في الشيفرة Cloudflare Email Sending API للرسائل التشغيلية حين يكون مهيأً؛ لا نفترض أن كل رسالة سلمت. لا نجمع بيانات البطاقة ولا ننشئ اشتراكات Stripe في هذا الإطلاق. لا نبيع البيانات الشخصية. يقتصر وصول المزوّدين والمسؤولين إلى ما يلزم للتشغيل والحماية ومعالجة طلبك، وقد يجري النقل خارج بلدك وفق مواقع البنية التحتية؛ لا ندعي إقامة بيانات في بلد محدد.</p></section>
</main> <section><h2>البريد التشغيلي والتسويق</h2><p>الرسائل المتعلقة بحساب قائم أو طلب دعم رسائل تشغيلية وليست موافقة على التسويق. التسجيل في إشعارات العضوية متوقف إلى حين التحقق من التسليم والموافقة الاختيارية؛ لا نطلب بريدك للقراءة ولا نسجلك تلقائياً في قائمة. يمكنك التواصل مع <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> دون الاشتراك في أي خدمة مدفوعة.</p></section>
<Footer /> <section><h2>الاحتفاظ والحذف</h2><p>السياسة المعتمدة هي الاحتفاظ بالبيانات الشخصية لمدة أقصاها سنة واحدة من جمعها، ثم حذفها أو إزالة ارتباطها بالهوية. يمكن حذفها قبل ذلك بطلب منك أو عند انتفاء الحاجة إليها. الاستثناء هو ما يلزم للاحتفاظ به لالتزام قانوني واجب أو نزاع قائم أو تحقيق أمني محدد؛ يقتصر على البيانات اللازمة ويزال عند انتهاء السبب. قد تبقى نسخ احتياطية معزولة حتى دورة استبدالها، ضمن الحد السنوي ما لم ينطبق استثناء. هذه سياسة يتولى المشغّل تنفيذها؛ لا ندعي وجود حذف تلقائي مجدول أو نظام تطهير مختبر. النسخ التي نشرها أو نزلها طرف آخر لا يمكن استرجاعها عن بُعد.</p></section>
</div> <section><h2>طلبات الوصول والتصحيح والحذف</h2><p>لطلب نسخة أو تصحيح أو حذف بياناتك أرسل إلى <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>. قد نطلب تحققاً مناسباً من ملكية الحساب لحماية بياناتك؛ لا ترسل كلمة مرور أو بيانات بطاقة. يشمل الطلب الحساب والبيانات المرتبطة به التي يمكن تحديدها، مع مراعاة الاستثناءات المذكورة. لا نعد بمدة تنفيذ لم يتم التحقق منها.</p></section>
) <section><h2>تغييرات السياسة</h2><p>ننشر أي تغيير جوهري وتاريخ سريانه هنا. قبل تشغيل الدفع أو جمع بيانات جديدة أو إتاحة قائمة تسويق، ستحدث هذه السياسة لتصف السلوك الفعلي وخيارات المستخدم.</p></section>
} </div>
</main>
<Footer />
</div>
)
}
+9 -131
View File
@@ -1,145 +1,23 @@
'use client' import Link from 'next/link'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import Footer from '@/components/footer'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { safeNext } from '@/lib/next'
import '../styles/signup.css' import '../styles/signup.css'
/* Signup — markup from signup.html, wired to real Payload registration // Account creation is administrator-only until protected signup is approved.
(create + auto-login) instead of the simulated client-only flow. */
export default function SignupPage() { export default function SignupPage() {
const router = useRouter()
const [showPassword, setShowPassword] = useState(false)
const [status, setStatus] = useState('')
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState<string | null>(null)
const [suggestion, setSuggestion] = useState<string | null>(null)
const [pwOk, setPwOk] = useState(false)
const [busy, setBusy] = useState(false)
const [nextPath, setNextPath] = useState<string | null>(null)
/* Common typos → canonical domain (step 16; on blur of the email input). */
const EMAIL_TYPOS: Record<string, string> = {
'gmial.com': 'gmail.com',
'gmai.com': 'gmail.com',
'gmail.co': 'gmail.com',
'hotmial.com': 'hotmail.com',
'outlook.co': 'outlook.com',
'yahoo.co': 'yahoo.com',
}
function domainSuggestion(raw: string): string | null {
const at = raw.lastIndexOf('@')
if (at < 0) return null
return EMAIL_TYPOS[raw.slice(at + 1).toLowerCase().trim()] ?? null
}
useEffect(() => {
setNextPath(safeNext(new URLSearchParams(window.location.search).get('next')))
}, [])
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
setEmailError(null)
if (!form.checkValidity()) {
setStatus('تحقق من جميع الحقول المطلوبة.')
form.reportValidity()
return
}
setBusy(true)
setStatus('')
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.get('email'), password: data.get('password'), fullName: data.get('name'), termsVersion: '2026-10-03' }),
})
if (!res.ok) {
const err = await res.json().catch(() => null) as { error?: string; errors?: { message?: string; path?: string; field?: string }[] } | null
// Middleware 429 shape: { error: 'محاولات كثيرة جدًا. حاول بعد قليل.' }
// + Retry-After seconds → show verbatim, append «حاول بعد X دقيقة.»
const retryAfterHeader = res.headers.get('Retry-After')
if (typeof err?.error === 'string') {
const retryAfterSec = Number(retryAfterHeader)
setStatus(
retryAfterHeader && Number.isFinite(retryAfterSec)
? `${err.error} حاول بعد ${Math.max(1, Math.ceil(retryAfterSec / 60))} دقيقة.`
: err.error,
)
setBusy(false)
return
}
const first = err?.errors?.[0]
const failedField = first?.path ?? first?.field
if (failedField === 'email' && first?.message) {
// Duplicate email (unique-constraint ValidationError) → inline under
// the email input, not the global status line.
setEmailError('هذا البريد مسجَّل لدينا.')
} else {
setStatus(first?.message || 'تعذّر إنشاء الحساب — قد يكون البريد مستخدماً بالفعل.')
}
setBusy(false)
return
}
const login = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
})
if (login.ok) {
try { sessionStorage.setItem('anwarpi:signup-email', String(data.get('email'))) } catch { /* private mode */ }
try { nextPath ? sessionStorage.setItem('anwarpi:signup-next', nextPath) : sessionStorage.removeItem('anwarpi:signup-next') } catch { /* private mode */ }
router.refresh()
router.push('/verify-email')
return
}
setStatus('تم إنشاء الحساب — سجّل الدخول للمتابعة.')
router.push(nextPath ? '/login?next=' + encodeURIComponent(nextPath) : '/login')
} catch {
setStatus('تعذّر الاتصال. حاول مرة أخرى بعد التحقق من اتصالك.')
} finally {
setBusy(false)
}
}
return ( return (
<div id="site" className="site-page page-signup"> <div id="site" className="site-page page-signup">
<Topnav /> <Topnav />
<main id="content" className="auth"> <main id="content" className="auth">
<section className="auth-story"> <section className="auth-story">
{/* eslint-disable-next-line @next/next/no-img-element */} <h1>التسجيل الجديد متوقف مؤقتاً.</h1>
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} /> <p>يمكنك قراءة المحتوى المتاح خلال الوصول المبكر دون إنشاء حساب.</p>
<h1>ابدأ بحساب مجاني.</h1>
<p>الحساب المجاني يفتح كل الأدلة المجانية كاملة والتعليق عليها. يمكنك الاشتراك في العضوية لاحقاً إن احتجتها.</p>
</section> </section>
<section className="auth-form-wrap" data-od-id="signup-form-section"> <section className="auth-form-wrap">
<form className="auth-form" id="signupForm" method="post" action="/api/users" noValidate onSubmit={onSubmit} data-od-id="signup-form"> <h2>لديك حساب بالفعل؟</h2>
<p className="eyebrow">حساب جديد</p> <p>تسجيل الدخول للحسابات الحالية متاح. أوقفنا إنشاء الحسابات الجديدة مؤقتاً إلى حين استكمال حماية التسجيل.</p>
<h2 data-od-id="signup-title">إنشاء حساب</h2> <p><Link href="/login" className="btn btn-primary">تسجيل الدخول</Link></p>
<p className="lead">أدخل بياناتك الأساسية للبدء.</p> <p><Link href="/articles">تصفح المكتبة دون حساب</Link></p>
<div className="field"><label htmlFor="name">الاسم</label><input className="input" id="name" name="name" type="text" autoComplete="name" minLength={2} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل اسمك.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required value={email} onChange={e => setEmail(e.target.value)} onBlur={e => setSuggestion(domainSuggestion(e.target.value))} onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} />
{emailError && (
<p className="field-error" role="alert">هذا البريد مسجَّل لدينا. <Link href="/login?next=/verify-email">تسجيل الدخول</Link></p>
)}
{!emailError && suggestion && (
<button type="button" className="typo-suggest" onClick={() => { const at = email.lastIndexOf('@'); setEmail(`${email.slice(0, at + 1)}${suggestion}`); setSuggestion(null) }}>
هل تقصد <bdi dir="ltr">{suggestion}</bdi>؟
</button>
)}
</div>
<div className="field"><label htmlFor="password">كلمة المرور</label><div className="input-wrap"><input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="new-password" minLength={8} aria-describedby="passwordHint" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة مرور من ثمانية أحرف على الأقل.')} onInput={e => { e.currentTarget.setCustomValidity(''); setPwOk(e.currentTarget.value.length >= 8) }} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className={`fine${pwOk ? ' ok' : ''}`} id="passwordHint">ثمانية أحرف على الأقل.</span></div>
<label className="check"><input id="terms" name="terms" type="checkbox" required /><span>أوافق على <Link href="/privacy">سياسة الخصوصية</Link> و<Link href="/terms">شروط الاستخدام</Link>.</span></label>
<button className="btn btn-primary" type="submit" disabled={busy} data-od-id="create-account-cta">{busy ? 'جارٍ إنشاء الحساب…' : 'أنشئ حسابي'}</button>
<p className="fine signup-reassure">مجاناً — بلا بطاقة ائتمانية. لن نشارك بريدك أبداً.</p>
<div className="status" id="signupStatus" role="status" aria-live="polite">{status}</div>
<p className="signin">لديك حساب بالفعل؟ <Link href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : '/login'}>سجّل الدخول</Link></p>
</form>
</section> </section>
</main> </main>
<Footer /> <Footer />
@@ -0,0 +1,20 @@
.launch-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: .5rem 1rem;
padding: .75rem max(1rem, env(safe-area-inset-right));
background: #123a35;
color: #fff;
text-align: center;
font-size: .9rem;
line-height: 1.7;
overflow-wrap: anywhere;
}
.launch-banner strong { font-weight: 700; }
.launch-banner a { color: #fff; text-decoration: underline; text-underline-offset: .2em; }
.launch-banner a:focus-visible { outline: 2px solid #fff; outline-offset: 4px; }
@media (max-width: 600px) {
.launch-banner { flex-direction: column; align-items: stretch; gap: .25rem; }
}
@@ -0,0 +1,63 @@
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}.page-posts .nav-login,.page-posts .icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .nav-login{padding-inline:16px}.page-posts .nav-login:hover,.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .icon-btn{width:46px}
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
.page-posts .demo-strip b{color:var(--surface);font-weight:700}
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
.page-posts .lock-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--fg);padding:6px 14px;font:12px/1.5 var(--font-mono);color:var(--fg);margin-bottom:22px}
.page-posts .lock-chip svg{width:14px;height:14px}
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
.page-posts .article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
.page-posts .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
.page-posts .article-body{font-size:19px;line-height:2}
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
.page-posts .article-body p{margin:0 0 26px}
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
.page-posts .free-tag{font:11px/1.5 var(--font-mono);color:var(--muted);margin-bottom:6px;display:block}
.page-posts .lock-cut{position:relative}
.page-posts .lock-cut::after{content:"";position:absolute;inset-inline:0;bottom:0;height:120px;background:linear-gradient(to bottom,transparent,var(--bg) 88%);pointer-events:none}
.page-posts .lock-cut .cut-p{margin-bottom:0}
.page-posts .paywall{margin-top:-30px;position:relative;border:1px solid var(--fg);background:var(--surface);padding:clamp(28px,4vw,44px);z-index:2}
.page-posts .paywall .crumb{margin-bottom:12px}
.page-posts .paywall h2{font-size:clamp(30px,3.6vw,44px);line-height:1.18;margin:0 0 14px;max-width:16ch}
.page-posts .paywall p{color:var(--muted);max-width:56ch;margin:0 0 24px}
.page-posts .locked-in{border-top:1px solid var(--fg);margin:0 0 26px}
.page-posts .locked-in li{display:flex;align-items:center;gap:12px;min-height:52px;border-bottom:1px solid var(--border);color:var(--muted);font-size:16px}
.page-posts .locked-in svg{width:14px;height:14px;flex:none}
.page-posts .locked-in .num{margin-inline-start:auto;font:12px/1.5 var(--font-mono)}
.page-posts .paywall-actions{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
.page-posts .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
.page-posts .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}
.page-posts .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
.page-posts .text-link{font-weight:700;color:var(--fg);text-decoration:underline;text-underline-offset:4px;min-height:44px;display:inline-flex;align-items:center}
.page-posts .text-link:hover{color:var(--accent)}
.page-posts .price-line{font:12px/1.6 var(--font-mono);color:var(--muted);margin-top:18px}
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
.page-posts .toc strong{font-family:var(--font-display);font-size:20px}
.page-posts .toc a,.page-posts .toc .toc-locked{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}
.page-posts .toc a:hover{color:var(--fg)}
.page-posts .toc .toc-locked{color:color-mix(in oklch,var(--muted) 60%,transparent)}
.page-posts .toc .toc-locked svg{width:12px;height:12px;flex:none}
.page-posts .member-note{margin-top:28px;border:1px solid var(--fg);padding:22px}
.page-posts .member-note strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:8px}
.page-posts .member-note p{color:var(--muted);font-size:14px;margin:0 0 16px}
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
.page-posts .footer-col a{display:block;min-height:36px}
.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}
.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display:flex;flex-direction:column}.page-posts .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .article-body{font-size:18px}}
@media(max-width:620px){.page-posts .nav-login{display:none}.page-posts .article-head h1{font-size:46px}.page-posts .article-layout{padding-block:56px}.page-posts .footer-grid{grid-template-columns:1fr;gap:28px}.page-posts .paywall-actions{flex-direction:column;align-items:stretch}.page-posts .paywall-actions .btn{width:100%}}
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
@@ -0,0 +1,62 @@
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}
.page-posts .nav-account{min-height:44px;display:inline-flex;align-items:center;gap:10px;padding-inline:16px;border:1px solid var(--fg);background:var(--fg);color:var(--surface);font-weight:700}.page-posts .nav-account:hover{background:var(--accent);border-color:var(--accent)}
.page-posts .nav-account .avatar{width:22px;height:22px;background:var(--surface);color:var(--fg);display:grid;place-items:center;font:12px/1 var(--font-mono);font-weight:700}
.page-posts .icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
.page-posts .demo-strip b{color:var(--surface);font-weight:700}
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
.page-posts .open-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--accent);color:var(--accent);padding:6px 14px;font:12px/1.5 var(--font-mono);margin-bottom:22px}
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
.page-posts .article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
.page-posts .save-btn{min-height:40px;padding:6px 14px;border:1px solid var(--border);background:transparent;color:var(--fg);font:12px/1.5 var(--font-mono)}.page-posts .save-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .save-btn[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
.page-posts .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
.page-posts .article-body{font-size:19px;line-height:2}
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
.page-posts .article-body h3{font-size:27px;margin:42px 0 14px}
.page-posts .article-body p{margin:0 0 26px}
.page-posts .article-body ul{padding-inline-start:24px}.page-posts .article-body li{margin-block:10px}
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
.page-posts .code{direction:ltr;text-align:left;background:var(--fg);color:var(--surface);padding:26px;overflow:auto;font:14px/1.8 var(--font-mono);margin:34px 0}
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
.page-posts .steps{counter-reset:step;list-style:none;padding:0}.page-posts .steps li{counter-increment:step;display:grid;grid-template-columns:56px 1fr;gap:18px;padding:22px 0;border-bottom:1px solid var(--border)}.page-posts .steps li::before{content:"٠" counter(step,arabic-indic);font-family:var(--font-display);font-size:34px;color:var(--accent);line-height:1}
.page-posts .steps strong{display:block;font-size:20px;margin-bottom:6px}.page-posts .steps p{margin:0;color:var(--muted)}
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
.page-posts .toc strong{font-family:var(--font-display);font-size:20px}
.page-posts .toc a{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.page-posts .toc a:hover{color:var(--fg)}
.page-posts .progress-card{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}
.page-posts .progress-card strong{font-size:18px;display:block;margin-bottom:12px}
.page-posts .progress-track{height:6px;background:color-mix(in oklch,var(--surface) 25%,transparent);margin-bottom:8px}
.page-posts .progress-bar{height:100%;background:var(--accent);width:40%}
.page-posts .progress-card .p-label{font:11px/1.6 var(--font-mono);color:color-mix(in oklch,var(--surface) 72%,transparent)}
.page-posts .files-card{margin-top:18px;border:1px solid var(--fg);padding:22px}
.page-posts .files-card strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:12px}
.page-posts .file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)}
.page-posts .file-row:last-of-type{border-bottom:0}
.page-posts .file-row span{font-size:14px}
.page-posts .dl-btn{min-height:38px;padding:4px 12px;border:1px solid var(--border);background:transparent;color:var(--accent);font:12px/1.5 var(--font-mono);font-weight:700}.page-posts .dl-btn:hover{border-color:var(--accent);background:var(--accent-soft)}
.page-posts .comments{padding-block:80px;border-top:1px solid var(--border)}.page-posts .comments-wrap{max-width:740px}.page-posts .comments h2{font-size:clamp(36px,5vw,54px);margin:0}
.page-posts .comment-list{display:grid}.page-posts .comment{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .comment-head{display:flex;justify-content:space-between;gap:16px}.page-posts .comment p{margin:8px 0 0}
.page-posts .comment-form{display:grid;gap:18px;margin-top:36px}.page-posts .field{display:grid;gap:7px}.page-posts .field label{font-size:13px;color:var(--muted)}
.page-posts .input,.page-posts .textarea{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px}.page-posts .textarea{min-height:130px;resize:vertical}
.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
.page-posts .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
.page-posts .status{font-size:14px;color:var(--muted);min-height:22px}
.page-posts .related{padding-block:80px;border-top:1px solid var(--border)}.page-posts .related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.page-posts .related-card{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .related-card+.related-card{margin-inline-start:28px}.page-posts .related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.page-posts .related-card h3{font-size:24px;margin:12px 0}.page-posts .related-card a{font-weight:700}.page-posts .related-card a:hover{color:var(--accent)}
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
.page-posts .footer-col a{display:block;min-height:36px}.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}
.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display:flex;flex-direction:column}.page-posts .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .related-grid{grid-template-columns:1fr}.page-posts .related-card+.related-card{margin-inline-start:0}.page-posts .article-body{font-size:18px}}
@media(max-width:620px){.page-posts .nav-account .name-label{display:none}.page-posts .article-head h1{font-size:46px}.page-posts .article-layout,.page-posts .comments,.page-posts .related{padding-block:56px}.page-posts .footer-grid{grid-template-columns:1fr;gap:28px}.page-posts .article-meta{align-items:flex-start;flex-direction:column}.page-posts .steps li{grid-template-columns:38px 1fr}}
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
@@ -1,60 +1,4 @@
'use client' // Disabled until delivery and explicit opt-in are verified. No email collection.
import { useState } from 'react'
/* Early-access notification signup (L-01). 200 → success copy;
503/4xx → the server's Arabic error verbatim; 5xx/network → generic fallback. */
export default function NotifyForm() { export default function NotifyForm() {
const [email, setEmail] = useState('') return <p>التسجيل في إشعارات البريد متوقف حالياً. للتواصل: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p>
const [pending, setPending] = useState(false)
const [done, setDone] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function submit(e: React.FormEvent) {
e.preventDefault()
if (pending) return
setPending(true)
setError(null)
try {
const res = await fetch('/api/notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
const data = (await res.json().catch(() => null)) as { error?: string; alreadySubscribed?: boolean } | null
if (res.ok) {
setDone(data?.alreadySubscribed ? 'بريدك مسجَّل لدينا بالفعل — سنراسلك عند تفعيل العضوية.' : 'تم! سنراسلك عند تفعيل العضوية.')
} else {
setError(data?.error ?? 'تعذر الحفظ، حاول مرة أخرى.')
}
} catch {
setError('تعذر الحفظ، حاول مرة أخرى.')
} finally {
setPending(false)
}
}
if (done) {
return <p className="notify-done" role="status">{done}</p>
}
return (
<form className="notify-form" onSubmit={submit}>
<label className="sr-only" htmlFor="notify-email">البريد الإلكتروني</label>
<input
id="notify-email"
type="email"
name="email"
required
dir="ltr"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button className="btn btn-primary" type="submit" disabled={pending || email.trim() === ''}>
{pending ? 'جارٍ الحفظ…' : 'أعلمني عند تفعيل العضوية'}
</button>
{error && <p className="notify-error" role="alert">{error}</p>}
</form>
)
} }
+49 -168
View File
@@ -1,168 +1,49 @@
import { getPayload } from 'payload' import { getViewer } from '@/lib/viewer'
import { headers } from 'next/headers' import Topnav from '@/components/topnav'
import Topnav from '@/components/topnav' import Footer from '@/components/footer'
import Footer from '@/components/footer' import Link from 'next/link'
import SubscribeButton from './subscribe-button' import { LAUNCH_POLICY } from '@/lib/launch'
import Faq from './faq'
import configPromise from '@payload-config' export const dynamic = 'force-dynamic'
import Link from 'next/link' export const metadata = {
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format' title: 'الوصول المبكر المجاني والعضوية المستقبلية',
import type { Viewer } from '@/lib/viewer' description: 'اقرأ المحتوى المنشور دون حساب أو بطاقة دفع. العضوية خطة مستقبلية غير متاحة للشراء، ولا يتحول الوصول أو الدفع تلقائياً.',
import { freeEarlyAccess } from '@/lib/launch' }
import NotifyForm from './notify-form'
export default async function SubscriptionPage() {
export const dynamic = 'force-dynamic' const viewer = await getViewer()
export const metadata = { return (
title: 'العضوية', <div id="site" className="site-page v2 v2-membership">
description: <Topnav viewer={viewer} />
'انضم لقائمة الإشعار: وصول مبكر مجاني لكل الأدلة اليوم، وخطة عضوية مستقبلية بـ 10$ شهرياً تضيف ملفات عمل وملخصات للأعضاء.', <main id="content">
} <section className="mem-hero"><div className="wrap narrow">
<p className="kicker">الوصول الحالي</p>
/* Member-library samples — real VIP guides, read from the CMS at request time. */ <h1>وصول مبكر مجاني، دون حساب أو بطاقة دفع.</h1>
const SAMPLES = [ <p className="lede">{LAUNCH_POLICY.summary}</p>
{ slug: 'agent-loop-stop-condition', cover: 'تشريح وكيل ناجح', variant: '', output: 'قالب حالة الوكيل + قائمة فحص قبل التشغيل' }, <Link className="btn btn-primary" href="/articles">ابدأ القراءة</Link>
{ slug: 'systematic-model-eval', cover: 'قِس قبل أن تبدّل', variant: 'guide-cover-light', output: 'مجموعة صغيرة من اختبارات التقييم قابلة لإعادة التشغيل' }, </div></section>
{ slug: 'prototype-to-production', cover: 'من التجربة إلى الإنتاج', variant: '', output: 'قائمة قرارات ما قبل الإطلاق' }, <section className="mem-compare"><div className="wrap narrow">
] <h2>ما المتاح الآن وما المخطط مستقبلاً؟</h2>
<div className="table-wrap"><table>
const FAQ_ITEMS = [ <thead><tr><th scope="col">الميزة</th><th scope="col">الوصول المجاني الآن</th><th scope="col">العضوية مستقبلاً · غير متاحة للشراء</th></tr></thead>
{ q: 'هل أستطيع الإلغاء في أي وقت؟', a: 'نعم. يتوقف التجديد التالي، ويستمر وصولك حتى نهاية الفترة المدفوعة.' }, <tbody>
{ q: 'هل المحتوى مناسب للمبتدئ؟', a: 'كل دليل يوضح مستواه. ابدأ بالأدلة المجانية في مسار الأساسيات، ثم انتقل إلى الوكلاء والأتمتة.' }, <tr><th scope="row">المحتوى المنشور للقراءة العامة</th><td>كامل دون حساب</td><td>المحتوى المصنف مجانياً يبقى مجانياً</td></tr>
{ q: 'هل توجد شهادة إكمال؟', a: 'لا حالياً. تركيز أنور بآي على التطبيق والملفات العملية، لا على شهادات الحضور.' }, <tr><th scope="row">المواد المصنفة للعضوية مستقبلاً</th><td>مفتوحة خلال الوصول المبكر</td><td>قد يتغير الوصول بعد الإعلان والموافقة والاختبارات</td></tr>
{ q: 'هل يمكنني استخدام الملفات في عملي؟', a: 'نعم للاستخدام الشخصي والمهني داخل مشاريعك. إعادة بيع الملفات أو نشرها كما هي غير مشمول.' }, <tr><th scope="row">الملفات</th><td>التنزيلات المتاحة والمعتمدة فقط؛ بعض الملفات المقيدة لا تزال محمية</td><td>الملفات المشمولة وشروطها تعلن قبل التفعيل</td></tr>
] <tr><th scope="row">الحسابات والتفاعل</th><td>التسجيل العام متوقف؛ الميزات القائمة لأصحاب الحسابات حسب صلاحياتهم</td><td>لا نعد بميزات قبل اختبارها</td></tr>
<tr><th scope="row">الدفع</th><td>لا بطاقة، لا اشتراك، لا رسوم</td><td>السعر والفوترة والإلغاء يعلن عنها قبل إتاحة الشراء</td></tr>
export default async function SubscriptionPage() { </tbody>
const payload = await getPayload({ config: configPromise }) </table></div>
const { user } = await payload.auth({ headers: await headers() }) </div></section>
const role = (user?.role as string) || null <section className="mem-faq"><div className="wrap narrow">
const entitled = role === 'vip' || role === 'admin' <h2>هل تنتهي الفترة تلقائياً بعد أسبوعين؟</h2>
const early = freeEarlyAccess() <p>لا. الأسبوعان نقطة مراجعة فقط. لا يغيّر التاريخ الوصول ولا ينشئ اشتراكاً، وقد يستمر الإطلاق المجاني.</p>
const viewer: Viewer = user ? { email: user.email, role: role ?? 'free' } : null <h2>كيف أتواصل؟</h2>
const { docs: published } = await payload.find({ <p>إشعارات البريد غير متاحة للتسجيل حالياً. للدعم والملاحظات وطلبات حذف البيانات: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p>
collection: 'posts', <p><Link href="/terms">شروط الاستخدام</Link> · <Link href="/privacy">الخصوصية</Link></p>
where: { status: { equals: 'published' } }, </div></section>
depth: 0, </main>
limit: 200, <Footer />
select: { title: true, slug: true, dek: true, category: true, readingMinutes: true, access: true }, </div>
}) )
const freeCount = published.filter(doc => doc.access === 'free').length }
const memberCount = published.length - freeCount
const samples = SAMPLES.flatMap(sample => {
const post = published.find(doc => doc.slug === sample.slug)
return post ? [{ ...sample, post }] : []
})
return (
<div id="site" className="site-page v2 v2-membership">
<Topnav viewer={viewer} />
<main id="content">
{early && (
<section className="early-access" aria-labelledby="early-title">
<div className="wrap narrow">
<h2 id="early-title">وصول مبكر مجاني</h2>
<p>يمكنك قراءة المحتوى المنشور خلال فترة الوصول المبكر دون بطاقة دفع. نراجع إطلاق العضوية بعد نحو أسبوعين، وقد تمتد الفترة إذا لم تكتمل خدمة الدفع. بعض المواد محددة للعضوية مستقبلاً، وسنوضح ذلك قبل تفعيلها. لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.</p>
<NotifyForm />
</div>
</section>
)}
<section className="mem-hero" aria-labelledby="mem-title">
<div className="wrap mem-hero-grid">
<div className="mem-hero-copy">
<h1 id="mem-title">كل الأدلة كاملة، مع ملفات العمل.</h1>
<p className="lede">الأدلة المجانية تعرّفك بالمفاهيم. العضوية تفتح الأدلة المعمّقة التي تأخذك من التجربة إلى نظام يعمل، مع القوالب وقوائم الفحص التي نستخدمها فعلاً.</p>
<ul className="mem-points" role="list">
<li><strong>{guidesLabel(memberCount)} للأعضاء</strong><span>تُضاف أدلة جديدة كل شهر</span></li>
<li><strong>ملفات قابلة للتنزيل</strong><span>قوالب حالة، قوائم فحص، سجلات أخطاء</span></li>
<li><strong>نقاش مع الفريق</strong><span>اسأل في تعليقات أي دليل</span></li>
</ul>
</div>
<article className="plan" aria-labelledby="plan-title">
<h2 id="plan-title">عضوية فردية</h2>
<p className="plan-price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi><span>شهرياً</span></p>
{early
? <p className="plan-note">سعر مستقبلي — غير قابل للشراء حالياً.</p>
: <p className="plan-note">تلغي متى شئت، ويبقى حسابك المجاني ومحفوظاتك.</p>}
<div className="plan-cta">
{entitled ? (
<a className="btn btn-primary" href="/account-settings">عضويتك فعّالة، أدر حسابك</a>
) : (
<SubscribeButton loggedIn={Boolean(user)} comingSoon={early} />
)}
</div>
<p className="fine">{early ? 'لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.' : 'الدفع عبر Stripe. لا نحفظ بيانات بطاقتك.'}</p>
</article>
</div>
</section>
{samples.length > 0 && (
<section className="mem-samples" aria-labelledby="samples-title">
<div className="wrap">
<header className="section-head">
<h2 id="samples-title">من أدلة الأعضاء</h2>
<p>كل دليل يبدأ بمشكلة حقيقية وينتهي بشيء تشغّله. اقرأ المقدمة مجاناً قبل أن تقرر.</p>
</header>
<ul className="sample-grid" role="list">
{samples.map(({ slug, output, post }) => (
<li key={slug}>
<Link href={`/posts/${slug}`} className="sample">
<span className="meta-row"><span>{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span></span>
<span className="sample-title">{post.title}</span>
<span className="sample-dek">{post.dek}</span>
<span className="sample-output"><span>ما تخرج به</span>{output}</span>
</Link>
</li>
))}
</ul>
</div>
</section>
)}
<section className="mem-compare" aria-labelledby="compare-title">
<div className="wrap narrow">
<h2 id="compare-title">الفرق بين الحساب المجاني والعضوية</h2>
<div className="table-wrap">
<table>
<thead><tr><th scope="col"><span className="sr-only">الميزة</span></th><th scope="col">حساب مجاني</th><th scope="col" className="is-member">العضوية</th></tr></thead>
<tbody>
<tr><th scope="row">الأدلة المجانية ({formatNumber(freeCount)})</th><td>كاملة</td><td className="is-member">كاملة</td></tr>
<tr><th scope="row">أدلة الأعضاء ({formatNumber(memberCount)})</th><td>المقدمة فقط</td><td className="is-member">كاملة</td></tr>
<tr><th scope="row">ملفات العمل</th><td>—</td><td className="is-member">تنزيل كامل</td></tr>
<tr><th scope="row">التعليق والنقاش</th><td>على الأدلة المجانية</td><td className="is-member">على كل الأدلة</td></tr>
<tr><th scope="row">حفظ الأدلة للعودة لاحقاً</th><td>—</td><td className="is-member">متاح</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section className="mem-faq" id="faq" aria-labelledby="faq-title">
{/* L-09: FAQPage structured data mirrors the visible FAQ. */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: FAQ_ITEMS.map(item => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: { '@type': 'Answer', text: item.a },
})),
}),
}}
/>
<div className="wrap faq-grid">
<div>
<h2 id="faq-title">أسئلة قبل الاشتراك</h2>
<p>لم تجد إجابتك؟ راسلنا على <a className="text-link" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> ويرد عليك الفريق مباشرة.</p>
</div>
<Faq items={FAQ_ITEMS} />
</div>
</section>
</main>
<Footer />
</div>
)
}
@@ -1,54 +1,6 @@
'use client'
import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
/* comingSoon comes from the server page (!billingEnabled()) so the disabled // Fail closed even if an older template reuses this control without comingSoon.
«قريباً» state + mailto render from first paint — no POST is attempted while export default function SubscribeButton(_props: { loggedIn: boolean; comingSoon?: boolean }) {
checkout is disabled (L-01). A 503 from the API (e.g. mode flipped after return <Link className="btn btn-primary" href="/articles">اقرأ مجاناً دون حساب</Link>
hydration) lands in the same honest state. */
export default function SubscribeButton({ loggedIn, comingSoon = false }: { loggedIn: boolean; comingSoon?: boolean }) {
const [pending, setPending] = useState(false)
const [failed, setFailed] = useState(false)
async function subscribe() {
setPending(true)
const res = await fetch('/api/subscribe', { method: 'POST' })
if (res.ok) {
const data = await res.json()
if (data?.url) {
window.location.href = data.url as string
return
}
}
// 503 (billing disabled) or any failure → honest coming-soon state.
setFailed(true)
setPending(false)
}
if (comingSoon || failed) {
return (
<div className="plan-soon-wrap">
<button className="btn btn-primary" type="button" disabled>قريباً</button>
<p className="plan-soon" role="status">الدفع الإلكتروني غير مفعّل خلال فترة الوصول المبكر. راسلنا على <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> لتفعيل عضويتك يدوياً.</p>
</div>
)
}
if (!loggedIn) {
/* QA-R01/R07: anonymous visitors get explicit signup + login CTAs instead
of a silent redirect. Both preserve /subscription as the destination. */
return (
<span className="cta-pair">
<Link className="btn btn-primary" href="/signup?next=%2Fsubscription">أنشئ حساباً واشترك</Link>
<Link className="btn btn-quiet" href="/login?next=%2Fsubscription">لدي حساب، تسجيل الدخول</Link>
</span>
)
}
return (
<button className="btn btn-primary" onClick={subscribe} disabled={pending}>
{pending ? 'جارٍ التحويل…' : 'اشترك الآن'}
</button>
)
} }
+36 -35
View File
@@ -1,35 +1,36 @@
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import Footer from '@/components/footer'
import '../styles/privacy.css' import { LAUNCH_POLICY } from '@/lib/launch'
import '../styles/privacy.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'شروط الاستخدام' } export const dynamic = 'force-dynamic'
export const metadata = {
export default function TermsPage() { title: 'شروط استخدام Anwarpy',
return ( description: 'شروط استخدام أنور پآي خلال الإطلاق التجريبي: القراءة المجانية، وحقوق المحتوى والملفات، والاستخدام المقبول، والحسابات والتواصل، وشروط أي تغيير مستقبلي في الوصول.',
<div id="site" className="site-page page-terms"> }
<Topnav />
<main id="content"> export default function TermsPage() {
<section className="hero" data-od-id="terms-hero"> return (
<div className="container"> <div id="site" className="site-page page-terms">
<p className="eyebrow">شروط الاستخدام · مسودة ما قبل الإطلاق</p> <Topnav />
<h1 data-od-id="terms-title">شروط استخدام أنور بآي.</h1> <main id="content">
<p className="lead">توضح هذه المسودة القواعد الأساسية لاستخدام مكتبة أنور بآي والاشتراك فيها.</p> <section className="hero"><div className="container">
<p className="notice" role="note">هذه مسودة للمراجعة القانونية وليست نصاً قانونياً نهائياً. يجب اعتماد النسخة النهائية قبل نشر الموقع.</p> <p className="eyebrow">سياسات الوصول المبكر المجاني</p>
</div> <h1>شروط استخدام Anwarpy</h1>
</section> <p className="lead">تاريخ السريان: <time dateTime={LAUNCH_POLICY.effectiveDate}>{LAUNCH_POLICY.effectiveDate}</time></p>
<div className="container policy" data-od-id="terms-content"> <p>{LAUNCH_POLICY.summary}</p>
<section data-od-id="terms-service"><h2>ما هي الخدمة؟</h2><p>أنور بآي مكتبة محتوى عربية تقدم أدلة ومسارات تعليمية، بعضها مجاني وبعضها متاح لمشتركي العضوية. يمنحك الاشتراك وصولاً شخصياً إلى المحتوى المدفوع طوال فترة الاشتراك الفعّالة.</p></section> </div></section>
<section data-od-id="terms-account"><h2>حسابك</h2><p>أنت مسؤول عن دقة بيانات التسجيل والاحتفاظ بكلمة المرور في سرية، وعن أي نشاط يجري عبر حسابك. يمكنك حذف حسابك في أي وقت بمراسلة <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>، وسنحذف بياناتك وفق سياسة الخصوصية.</p></section> <div className="container policy">
<section data-od-id="terms-billing"><h2>الاشتراك والدفع</h2><p>العضوية بـ <bdi dir="ltr">$10</bdi> شهرياً وتُدفع عبر مزوّد الدفع (Stripe). يمكنك الإلغاء في أي وقت ويتوقف التجديد التالي فوراً مع بقاء وصولك حتى نهاية الفترة المدفوعة. السعر الحالي سعر تجريبي للمعاينة وقد يتغير قبل الإطلاق مع إشعار مسبق.</p></section> <section><h2>الخدمة والمشغّل</h2><p>يدير Anwarpy مكتبة عربية للمحتوى التعليمي. القراءة العامة للمحتوى المنشور مجانية خلال الوصول المبكر، دون حساب أو بطاقة دفع. المسودات والملاحظات الخاصة والملفات غير المعتمدة ليست جزءاً من هذا الوصول.</p></section>
<section data-od-id="terms-content"><h2>المحتوى والملكية</h2><p>حقوق المحتوى والملفات محفوظة لأصحابها. حصولك على الاشتراك يمنحك حق الاستخدام الشخصي والمهني داخل مشاريعك، ولا يشمل إعادة نشر المحتوى أو بيعه أو توزيعه كما هو.</p></section> <section><h2>الوصول المبكر والعضوية المستقبلية</h2><p>بعض المواد مصنفة للعضوية مستقبلاً لكنها مفتوحة للقراءة الآن. نراجع الإطلاق المدفوع بعد نحو أسبوعين من الإطلاق الفعلي؛ المراجعة ليست موعد انتهاء تلقائياً وقد يستمر الوصول المجاني. أي تغيير في الوصول يحتاج موافقة المشغّل واختبارات الدفع والصلاحيات وإعلاناً على الموقع قبل تطبيقه. لا يوجد اشتراك مدفوع متاح الآن ولا تجديد أو رسوم تلقائية. الأسعار وشروط الفوترة والإلغاء المستقبلية ستعلن قبل إتاحة الشراء؛ القراءة أو التواصل لا يمثلان موافقة على الدفع.</p></section>
<section data-od-id="terms-conduct"><h2>الاستخدام المقبول</h2><p>لا يجوز استخدام الخدمة لاستخراج البيانات آلياً أو إعادة نشر المحتوى على نطاق واسع أو أي استخدام يضر بالمنصة أو بمستخدميها. قد نوقف الحسابات المخالفة بعد تنبيه صاحبها.</p></section> <section><h2>الحسابات والتواصل</h2><p>إنشاء الحسابات العامة متوقف، والحسابات ينشئها المسؤول فقط. يستطيع أصحاب الحسابات القائمة تسجيل الدخول للميزات المتاحة؛ لا يلزم ذلك للقراءة. حافظ على سرية بيانات الدخول ولا تنتحل هوية غيرك. للاستفسار أو طلب حذف بيانات حساب قائم: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p></section>
<section data-od-id="terms-disclaimer"><h2>إخلاء المسؤولية</h2><p>المحتوى تعليمي ويُقدَّم «كما هو» دون ضمانات لنتائج محددة. أنت مسؤول عن قراراتك ومشاريعك، وينبغي اختبار أي قالب أو ملف قبل استخدامه في بيئة إنتاج.</p></section> <section><h2>المحتوى والملفات</h2><p>حقوق المحتوى لأصحابها. يمكنك قراءة المحتوى واستخدام الملفات المتاحة في مشاريعك الشخصية والمهنية، ما لم يذكر الملف ترخيصاً مختلفاً أو حقوقاً لطرف آخر. لا يشمل ذلك إعادة بيع المواد أو إعادة نشرها كما هي. الملفات التي نزلت أثناء الوصول المجاني لا يمكن سحب نسخها منك عن بُعد؛ وقد يتغير إتاحة تنزيلها لاحقاً بعد إعلان.</p></section>
<section data-od-id="terms-changes"><h2>تعديل الشروط</h2><p>قد نحدّث هذه الشروط مع نمو الخدمة. عند أي تغيير جوهري سنبلّغ المستخدمين المسجلين عبر البريد الإلكتروني قبل سريان التعديل، ونحدّث تاريخ السريان أعلى الصفحة.</p></section> <section><h2>الاستخدام المقبول</h2><p>لا تحاول الوصول إلى بيانات خاصة أو تجاوز الصلاحيات أو نشر برمجيات ضارة أو محتوى مسيء أو إغراق الخدمة. يجوز تقييد النشاط الضار وإيقاف الحساب المخالف لحماية الخدمة، دون المساس بحقوق يقررها القانون.</p></section>
<section data-od-id="terms-law"><h2>التواصل</h2><p>لأي سؤال عن هذه الشروط راسلنا على <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>. القانون الحاكم واختصاص المحاكم يحدَّدان في النسخة القانونية النهائية بعد المراجعة.</p></section> <section><h2>طبيعة المحتوى</h2><p>المحتوى تعليمي وليس ضماناً للنتائج أو بديلاً عن استشارة متخصصة. اختبر التعليمات والقوالب في بيئة آمنة قبل الإنتاج، وراجع التكاليف والصلاحيات وحقوق الأطراف الأخرى. لا نضمن استمرار كل مادة أو خلوها من الخطأ، ولا يستبعد هذا حقوقاً إلزامية للمستخدم.</p></section>
</div> <section><h2>التحديثات وطلبات المساعدة</h2><p>ننشر التغييرات الجوهرية وتاريخ سريانها على الموقع. لا نعتمد على إشعارات بريدية غير مختبرة لتغيير الوصول. قناة الدعم وطلبات البيانات هي <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>؛ لا يمثل مراسلتها اشتراكاً في التسويق.</p></section>
</main> </div>
<Footer /> </main>
</div> <Footer />
) </div>
} )
}
@@ -8,6 +8,7 @@ import { TOPICS, topicsFor, isTopic } from '@/lib/topics'
import { guidesLabel, minutesLabel, formatNumber, contentTypeLabel } from '@/lib/format' import { guidesLabel, minutesLabel, formatNumber, contentTypeLabel } from '@/lib/format'
import { freeEarlyAccess } from '@/lib/launch' import { freeEarlyAccess } from '@/lib/launch'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import { accessLabel } from '@/lib/launch'
import Footer from '@/components/footer' import Footer from '@/components/footer'
type Args = { params: Promise<{ topic: string }> } type Args = { params: Promise<{ topic: string }> }
@@ -30,11 +31,11 @@ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
export async function generateMetadata({ params }: Args): Promise<Metadata> { export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { topic } = await params const { topic } = await params
if (!isTopic(topic)) return { title: 'أنور بآي' } if (!isTopic(topic)) return { title: 'أنور پآي' }
const meta = TOPICS.find(t => t.key === topic) const meta = TOPICS.find(t => t.key === topic)
return { return {
title: `${meta?.label} — مسار`, title: `${meta?.label} — مسار`,
description: `كل أدلة مسار ${meta?.label} في أنور بآي: ${meta?.hint}. أدلة عملية بالعربية بخطوات قابلة للتطبيق.`, description: `كل محتوى مسار ${meta?.label} في أنور پآي: ${meta?.hint}. محتوى عملي بالعربية بخطوات قابلة للتطبيق.`,
alternates: { canonical: `/topics/${topic}` }, alternates: { canonical: `/topics/${topic}` },
} }
} }
@@ -90,10 +91,10 @@ export default async function TopicPage({ params }: Args) {
</div> </div>
</section> </section>
<section className="topic-list" aria-label={`أدلة ${meta.label}`}> <section className="topic-list" aria-label={`محتوى ${meta.label}`}>
<div className="wrap narrow"> <div className="wrap narrow">
{posts.length === 0 && ( {posts.length === 0 && (
<p className="muted">لا أدلة منشورة في هذا المسار بعد. تابعنا للقادم قريباً.</p> <p className="muted">لا يوجد محتوى منشور في هذا المسار بعد. تابعنا للقادم قريباً.</p>
)} )}
<div className="lib-grid"> <div className="lib-grid">
{posts.map(post => { {posts.map(post => {
@@ -112,7 +113,7 @@ export default async function TopicPage({ params }: Args) {
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2> <h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
<p className="guide-card-dek">{post.dek}</p> <p className="guide-card-dek">{post.dek}</p>
<div className="guide-card-foot"> <div className="guide-card-foot">
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span> <span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{accessLabel(post.access)}</span>
{post.views != null && <span className="engagement">{formatNumber(post.views)} قراءة</span>} {post.views != null && <span className="engagement">{formatNumber(post.views)} قراءة</span>}
{locked && <span className="badge badge-vip">يتطلب عضوية</span>} {locked && <span className="badge badge-vip">يتطلب عضوية</span>}
</div> </div>
+6 -2
View File
@@ -8,7 +8,11 @@ import VerifyActions from './verify-actions'
import '../styles/email-verification.css' import '../styles/email-verification.css'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { title: 'تأكيد البريد الإلكتروني' } export const metadata = {
title: 'تأكيد البريد الإلكتروني',
description: 'صفحة تأكيد البريد الإلكتروني للحسابات القائمة في أنور پآي: مراجعة حالة التأكيد والخطوات التالية. قراءة المحتوى العام لا تتطلب حسابًا.',
robots: { index: false, follow: false },
}
type Props = { searchParams: Promise<{ invalid?: string }> } type Props = { searchParams: Promise<{ invalid?: string }> }
@@ -48,7 +52,7 @@ export default async function VerifyEmailPage({ searchParams }: Props) {
<p className="status" role="status">بريدك الإلكتروني مؤكَّد بالفعل ✓ — تابع إلى الإعداد.</p> <p className="status" role="status">بريدك الإلكتروني مؤكَّد بالفعل ✓ — تابع إلى الإعداد.</p>
)} )}
<ol className="steps" aria-label="خطوات تأكيد البريد"> <ol className="steps" aria-label="خطوات تأكيد البريد">
<li><span className="step-num">01</span><span>افتح رسالة أنور بآي في صندوق الوارد.</span></li> <li><span className="step-num">01</span><span>افتح رسالة أنور پآي في صندوق الوارد.</span></li>
<li><span className="step-num">02</span><span>اضغط رابط تأكيد البريد الإلكتروني داخل الرسالة.</span></li> <li><span className="step-num">02</span><span>اضغط رابط تأكيد البريد الإلكتروني داخل الرسالة.</span></li>
<li><span className="step-num">03</span><span>افحص مجلد الرسائل غير المرغوبة (Spam).</span></li> <li><span className="step-num">03</span><span>افحص مجلد الرسائل غير المرغوبة (Spam).</span></li>
<li><span className="step-num">04</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li> <li><span className="step-num">04</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
@@ -183,7 +183,7 @@ export default function OnboardingWizard({ viewerId }: { viewerId: number | null
<p className="eyebrow">نقطة البداية المقترحة</p> <p className="eyebrow">نقطة البداية المقترحة</p>
<h3 id="recommendationTitle">{rec[0]}</h3> <h3 id="recommendationTitle">{rec[0]}</h3>
<p id="recommendationCopy">{rec[1]}</p> <p id="recommendationCopy">{rec[1]}</p>
<Link className="btn btn-primary" href={rec[2]} id="startGuide" data-od-id="start-first-guide">ابدأ الدليل الأول</Link> <Link className="btn btn-primary" href={rec[2]} id="startGuide" data-od-id="start-first-guide">ابدأ المحتوى الأول</Link>
</article> </article>
<div className="actions"> <div className="actions">
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 2 }))}>تعديل الاختيارات</button> <button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 2 }))}>تعديل الاختيارات</button>
+3 -5
View File
@@ -5,7 +5,6 @@ import { getPayload } from 'payload'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { FILES_DIR } from '@/collections/Files' import { FILES_DIR } from '@/collections/Files'
import { getViewer } from '@/lib/viewer' import { getViewer } from '@/lib/viewer'
import { freeEarlyAccess } from '@/lib/launch'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -27,10 +26,9 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
} }
if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 }) if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
// L-02: all files open during free early access (owner curates file // No approved public-file relationship exists here. Free launch must not
// access in the admin; ALL vip files download while billing is off). // expose every private upload by ID; explicitly free files stay public.
// Viewer checks apply only when billing is enabled. if (doc.access === 'vip') {
if (doc.access === 'vip' && !freeEarlyAccess()) {
const viewer = await getViewer() const viewer = await getViewer()
if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 }) if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 })
if (viewer.role !== 'vip' && viewer.role !== 'admin') { if (viewer.role !== 'vip' && viewer.role !== 'admin') {
+5 -57
View File
@@ -1,63 +1,11 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { sendMail } from '@/lib/mail'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // Delivery is not verified. Do not collect addresses or claim subscription.
export async function POST() {
/* POST /api/notify — L-01 launch-notification list. return NextResponse.json(
Idempotent: an existing email returns 200 { ok: true, alreadySubscribed: true } { error: 'التسجيل في إشعارات البريد متوقف حالياً. للتواصل: hello@anwarpy.ai' },
(no duplicate row, no re-send). A fresh email is stored and a confirmation { status: 503 },
is sent; if the mail transport is not wired the response is an honest 503
(the row is kept — no fake success). */
export async function POST(req: Request) {
let body: { email?: string } | null = null
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'صيغة الطلب غير صحيحة.' }, { status: 400 })
}
const email = (body?.email ?? '').trim().toLowerCase()
if (!EMAIL_RE.test(email)) {
return NextResponse.json({ error: 'أدخل بريداً إلكترونياً صحيحاً.' }, { status: 400 })
}
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'launch-notifications',
where: { email: { equals: email } },
limit: 1,
overrideAccess: true,
})
if (docs.length > 0) {
return NextResponse.json({ ok: true, alreadySubscribed: true })
}
try {
await payload.create({
collection: 'launch-notifications',
data: { email, source: 'subscription' },
})
} catch (err) {
// Lost a unique-constraint race against a concurrent signup → idempotent OK.
const message = err instanceof Error ? err.message : ''
if (/duplicate key|unique constraint/i.test(message)) {
return NextResponse.json({ ok: true, alreadySubscribed: true })
}
console.error('launch notification create failed:', err)
return NextResponse.json({ error: 'تعذر حفظ البريد، حاول مرة أخرى.' }, { status: 500 })
}
const { sent } = await sendMail(
email,
'سنعلمك عند تفعيل العضوية — أنور بي آي',
'مرحباً بك في أنور بي آي.\n\nسجّلنا بريدك في قائمة الإشعار، وسنراسلك قبل تفعيل العضوية بأيام.\n\nخلال الوصول المبكر كل الأدلة المنشورة متاحة مجاناً، ولن يُنشأ لك اشتراك أو يُحصَّل أي مبلغ.',
) )
if (!sent) {
return NextResponse.json({ error: 'خدمة البريد غير مربوطة بعد.' }, { status: 503 })
}
return NextResponse.json({ ok: true })
} }
@@ -41,7 +41,7 @@ export async function POST(_request: Request, ctx: { params: Promise<{ slug: str
// Mirror the UI paywall: commenting on a VIP post requires entitlement. // Mirror the UI paywall: commenting on a VIP post requires entitlement.
if (post.access === 'vip' && role !== 'vip' && role !== 'admin') { if (post.access === 'vip' && role !== 'vip' && role !== 'admin') {
return NextResponse.json({ error: 'التعليق على هذا الدليل متاح لأعضاء العضوية.' }, { status: 403 }) return NextResponse.json({ error: 'التعليق على هذا المحتوى متاح لأعضاء العضوية.' }, { status: 403 })
} }
// Naive per-user rate limit: 1 comment / 60 s, any post, counts pending. // Naive per-user rate limit: 1 comment / 60 s, any post, counts pending.
+2 -3
View File
@@ -13,9 +13,8 @@ export async function POST() {
const priceId = process.env.STRIPE_PRICE_ID const priceId = process.env.STRIPE_PRICE_ID
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000' const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
// L-01: checkout is disabled server-side during free early access — // Free-launch policy is code-locked: Stripe configuration alone cannot
// 503, and never a Checkout session in this mode. Paid transition = // create a checkout session. Re-enabling requires separate operator review.
// LAUNCH_MODE=paid + Stripe keys; this route re-enables with no code change.
if (!billingEnabled()) { if (!billingEnabled()) {
return NextResponse.json({ error: 'الدفع غير مفعّل خلال فترة الوصول المبكر.' }, { status: 503 }) return NextResponse.json({ error: 'الدفع غير مفعّل خلال فترة الوصول المبكر.' }, { status: 503 })
} }
+8 -10
View File
@@ -1,15 +1,13 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import { Readex_Pro } from 'next/font/google' import localFont from 'next/font/local'
import './globals.css' import './globals.css'
import './polish.css' import './polish.css'
import './design.css' import './design.css'
import './color-trial.css' import './color-trial.css'
/* Readex Pro: a modern Arabic/Latin face drawn for screens and interfaces. /* Bundle Readex Pro's Arabic/Latin variable font so builds never fetch Google Fonts. */
Self-hosted at build time by next/font. */ const sans = localFont({
const sans = Readex_Pro({ src: [{ path: '../fonts/ReadexPro.ttf', weight: '300 700', style: 'normal' }],
subsets: ['arabic', 'latin'],
weight: ['300', '400', '500', '600', '700'],
display: 'swap', display: 'swap',
variable: '--font-sans', variable: '--font-sans',
}) })
@@ -19,12 +17,12 @@ export const viewport: Viewport = { colorScheme: 'only light', themeColor: '#fff
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'), metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'),
title: { default: 'أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية', template: '%s — أنور بآي' }, title: { default: 'أنور پآي — محتوى الذكاء الاصطناعي العملي بالعربية', template: '%s — أنور پآي' },
description: 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.', description: 'محتوى عربي عملي عن الذكاء الاصطناعي: تشغيل النماذج محليًا، وبناء الوكلاء، والأتمتة وRAG. استكشف تجارب أنور پآي وشروحات الأدوات بخطوات قابلة للتطبيق.',
alternates: { canonical: './' }, alternates: { canonical: './' },
openGraph: { openGraph: {
type: 'website', type: 'website',
siteName: 'أنور بآي', siteName: 'أنور پآي',
locale: 'ar', locale: 'ar',
images: [{ url: '/og-default.png', width: 1200, height: 630 }], images: [{ url: '/og-default.png', width: 1200, height: 630 }],
}, },
@@ -40,7 +38,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head> </head>
<body> <body>
{children} {children}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebSite', name: 'أنور بآي', url: siteUrl, inLanguage: 'ar' }) }} /> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebSite', name: 'أنور پآي', url: siteUrl, inLanguage: 'ar' }) }} />
</body> </body>
</html> </html>
) )
+3 -3
View File
@@ -20,13 +20,13 @@ export async function GET(): Promise<Response> {
renderIndexSections(docs, !early), renderIndexSections(docs, !early),
'---', '---',
'', '',
'# الأدلة الكاملة', '# المحتوى الكامل',
'', '',
] ]
for (const doc of docs) { for (const doc of docs) {
const vipOnly = !early && doc.access === 'vip' const vipOnly = !early && doc.access === 'vip'
const suffix = vipOnly ? ' (دليل للأعضاء)' : '' const suffix = vipOnly ? ' (محتوى للأعضاء)' : ''
parts.push(`## ${doc.title}${suffix}`, '') parts.push(`## ${doc.title}${suffix}`, '')
parts.push(`URL: /posts/${doc.slug}`, '') parts.push(`URL: /posts/${doc.slug}`, '')
if (doc.dek) parts.push(doc.dek, '') if (doc.dek) parts.push(doc.dek, '')
@@ -34,7 +34,7 @@ export async function GET(): Promise<Response> {
parts.push(`حُدّث: ${formatDate(doc.updatedAt)}`, '') parts.push(`حُدّث: ${formatDate(doc.updatedAt)}`, '')
} }
if (vipOnly) { if (vipOnly) {
parts.push('هذا الدليل متاح للأعضاء فقط. العضوية: /subscription', '') parts.push('هذا المحتوى متاح للأعضاء فقط. العضوية: /subscription', '')
} else if (typeof doc.content === 'object' && doc.content !== null) { } else if (typeof doc.content === 'object' && doc.content !== null) {
parts.push(lexicalToPlain(doc.content), '') parts.push(lexicalToPlain(doc.content), '')
} }
+4 -3
View File
@@ -121,7 +121,7 @@ const Posts: CollectionConfig = {
label: 'نوع المحتوى', label: 'نوع المحتوى',
options: [ options: [
{ label: 'ملاحظة تقنية', value: 'note' }, { label: 'ملاحظة تقنية', value: 'note' },
{ label: 'دليل عملي', value: 'tutorial' }, { label: 'محتوى عملي', value: 'tutorial' },
{ label: 'تجربة موثقة', value: 'case_study' }, { label: 'تجربة موثقة', value: 'case_study' },
{ label: 'مقارنة', value: 'comparison' }, { label: 'مقارنة', value: 'comparison' },
], ],
@@ -143,10 +143,11 @@ const Posts: CollectionConfig = {
{ name: 'readingMinutes', type: 'number', required: true, defaultValue: 5 }, { name: 'readingMinutes', type: 'number', required: true, defaultValue: 5 },
{ name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } }, { name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
{ {
// Private traceability + agent-pipeline dedup key (L-10). Never exposed: // Private traceability + agent-pipeline dedup key; internal Local API
// pickFields strips it on ingest and public queries never select it. // callers may explicitly override access, public REST/GraphQL may not.
name: 'sourceId', name: 'sourceId',
type: 'text', type: 'text',
access: { read: () => false },
index: true, index: true,
admin: { description: 'معرّف المصدر (خاص — للتتبّع ومنع التكرار)' }, admin: { description: 'معرّف المصدر (خاص — للتتبّع ومنع التكرار)' },
}, },
+9 -6
View File
@@ -3,7 +3,7 @@ import { promisify } from 'util'
import type { AccessArgs, CollectionConfig } from 'payload' import type { AccessArgs, CollectionConfig } from 'payload'
import { sendVerificationEmail } from '@/lib/mail' import { sendVerificationEmail } from '@/lib/mail'
const isAdmin = (args: AccessArgs) => const isAdmin = (args: Pick<AccessArgs, 'req'>) =>
(args.req.user as { role?: string } | null | undefined)?.role === 'admin' (args.req.user as { role?: string } | null | undefined)?.role === 'admin'
export const ROLES = ['free', 'vip', 'admin'] as const export const ROLES = ['free', 'vip', 'admin'] as const
@@ -20,10 +20,10 @@ const Users: CollectionConfig = {
// Only self and admins can read full user docs // Only self and admins can read full user docs
read: ({ req, id }) => read: ({ req, id }) =>
req.user?.role === 'admin' || (id != null && req.user?.id === id), req.user?.role === 'admin' || (id != null && req.user?.id === id),
// Public open for self-signup; beforeChange forces role 'free' for non-admins // Public registration is disabled. Only administrators create accounts.
create: () => true, create: isAdmin,
update: ({ req, id }) => update: ({ req, id }) =>
req.user?.role === 'admin' || req.user?.id === id, req.user?.role === 'admin' || (req.user != null && id != null && String(req.user.id) === String(id)),
delete: isAdmin, delete: isAdmin,
}, },
hooks: { hooks: {
@@ -161,17 +161,19 @@ const Users: CollectionConfig = {
label: 'Notification prefs', label: 'Notification prefs',
admin: { description: 'weekly/trackUpdates/replies switches (VIP onboarding)' }, admin: { description: 'weekly/trackUpdates/replies switches (VIP onboarding)' },
}, },
{ name: 'emailVerified', type: 'checkbox', defaultValue: false, label: 'Email verified' }, { name: 'emailVerified', type: 'checkbox', defaultValue: false, label: 'Email verified', access: { create: isAdmin, update: isAdmin } },
{ {
name: 'verificationToken', name: 'verificationToken',
type: 'text', type: 'text',
access: { create: isAdmin, update: isAdmin, read: () => false },
hidden: true, hidden: true,
admin: { readOnly: true, description: 'Email verification token (hashed at rest not required for this flow)' }, admin: { readOnly: true, description: 'Email verification token (hashed at rest not required for this flow)' },
}, },
{ name: 'onboarded', type: 'checkbox', defaultValue: false, label: 'Onboarding completed' }, { name: 'onboarded', type: 'checkbox', defaultValue: false, label: 'Onboarding completed', access: { create: isAdmin } },
{ {
name: 'role', name: 'role',
type: 'select', type: 'select',
access: { create: isAdmin, update: isAdmin },
required: true, required: true,
defaultValue: 'free', defaultValue: 'free',
options: [ options: [
@@ -183,6 +185,7 @@ const Users: CollectionConfig = {
{ {
name: 'stripeCustomerId', name: 'stripeCustomerId',
type: 'text', type: 'text',
access: { create: isAdmin, update: isAdmin },
label: 'Stripe Customer ID', label: 'Stripe Customer ID',
admin: { readOnly: true, position: 'sidebar' }, admin: { readOnly: true, position: 'sidebar' },
}, },
+44 -44
View File
@@ -1,44 +1,44 @@
import Link from 'next/link' import Link from 'next/link'
/* Footer — brand column + three link columns. */ /* Footer — brand column + three link columns. */
export default function Footer() { export default function Footer() {
return ( return (
<footer className="pagefoot site-footer" data-od-id="footer"> <footer className="pagefoot site-footer" data-od-id="footer">
<div className="container"> <div className="container">
<div className="footer-grid"> <div className="footer-grid">
<div> <div>
<Link className="logo" href="/"> <Link className="logo" href="/">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} /> <img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
أنور بآي أنور پآي
</Link> </Link>
<p>محتوى عربي عملي لتعلّم الذكاء الاصطناعي والوكلاء.</p> <p>محتوى عربي عملي لتعلّم الذكاء الاصطناعي والوكلاء.</p>
</div> </div>
<div className="footer-col"> <div className="footer-col">
<strong>استكشف</strong> <strong>استكشف</strong>
<Link href="/articles">المكتبة</Link> <Link href="/articles">المكتبة</Link>
<Link href="/#topics">المسارات</Link> <Link href="/#topics">المسارات</Link>
<a href="/articles?access=free">الأدلة المجانية</a> <a href="/articles?access=free">المحتوى المجاني</a>
</div> </div>
<div className="footer-col"> <div className="footer-col">
<strong>العضوية</strong> <strong>الوصول المبكر</strong>
<Link href="/subscription">الأسعار والمزايا</Link> <Link href="/subscription">الوصول المجاني والخطة المستقبلية</Link>
<Link href="/signup">إنشاء حساب</Link> <Link href="/articles">اقرأ دون حساب</Link>
<Link href="/login">تسجيل الدخول</Link> <Link href="/login">تسجيل الدخول</Link>
</div> </div>
<div className="footer-col"> <div className="footer-col">
<strong>عن المنصة</strong> <strong>عن المنصة</strong>
<Link href="/about">عن الفريق</Link> <Link href="/about">عن الفريق</Link>
<a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>
<Link href="/terms">الشروط</Link> <Link href="/terms">الشروط</Link>
<Link href="/privacy">الخصوصية</Link> <Link href="/privacy">الخصوصية</Link>
</div> </div>
</div> </div>
<div className="footer-bottom"> <div className="footer-bottom">
<span>© {new Date().getFullYear()} أنور بآي</span> <span>© {new Date().getFullYear()} أنور پآي</span>
<span>العربية أولاً · تعلّم بوضوح</span> <span>العربية أولاً · تعلّم بوضوح</span>
</div> </div>
</div> </div>
</footer> </footer>
) )
} }
+1 -1
View File
@@ -4,6 +4,6 @@ export type NavLink = { href: string; label: string }
export const SITE_LINKS: NavLink[] = [ export const SITE_LINKS: NavLink[] = [
{ href: '/articles', label: 'المكتبة' }, { href: '/articles', label: 'المكتبة' },
{ href: '/#topics', label: 'المسارات' }, { href: '/#topics', label: 'المسارات' },
{ href: '/subscription', label: 'العضوية' }, { href: '/subscription', label: 'الوصول المبكر' },
{ href: '/about', label: 'عن الفريق' }, { href: '/about', label: 'عن الفريق' },
] ]
+2 -2
View File
@@ -68,12 +68,12 @@ export default function SiteMenu({
return ( return (
<aside className="menu-panel" id={menuId} aria-hidden="true" data-od-id="site-menu"> <aside className="menu-panel" id={menuId} aria-hidden="true" data-od-id="site-menu">
<button className="menu-scrim" type="button" aria-label="إغلاق القائمة" data-menu-close></button> <button className="menu-scrim" type="button" aria-label="إغلاق القائمة" data-menu-close></button>
<div className="menu-drawer" role="dialog" aria-modal="true" aria-label="قائمة أنور بآي"> <div className="menu-drawer" role="dialog" aria-modal="true" aria-label="قائمة أنور پآي">
<div className="menu-head"> <div className="menu-head">
<Link className="logo" href="/"> <Link className="logo" href="/">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} /> <img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
أنور بآي أنور پآي
</Link> </Link>
<button className="icon-btn" type="button" aria-label="إغلاق القائمة" data-menu-close>×</button> <button className="icon-btn" type="button" aria-label="إغلاق القائمة" data-menu-close>×</button>
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ export default function Topnav({ viewer }: { viewer?: { email: string } | null }
<Link className="logo" href="/" data-od-id="brand"> <Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} /> <img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
أنور بآي أنور پآي
</Link> </Link>
<NavLinks links={SITE_LINKS} /> <NavLinks links={SITE_LINKS} />
<div className="nav-tools"> <div className="nav-tools">
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { useState } from 'react'
import { youtubeVideoId } from '../lib/youtube'
export default function YouTubePlayer({ url }: { url: string }) {
const [loaded, setLoaded] = useState(false)
const id = youtubeVideoId(url)
return (
<figure style={{ margin: '1.5rem 0' }}>
<div style={{ aspectRatio: '16 / 9', width: '100%', background: '#111827', borderRadius: '0.5rem', overflow: 'hidden' }}>
{loaded && id ? (
<iframe
src={`https://www.youtube-nocookie.com/embed/${id}`}
title="مشغل فيديو YouTube"
referrerPolicy="strict-origin-when-cross-origin"
allow="encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
ref={frame => { frame?.focus() }}
tabIndex={0}
style={{ display: 'block', width: '100%', height: '100%', border: 0 }}
/>
) : (
<button type="button" onClick={() => setLoaded(true)} style={{ width: '100%', height: '100%', background: 'transparent', color: '#fff', border: 0, cursor: 'pointer', font: 'inherit' }}>
تشغيل فيديو YouTube
</button>
)}
</div>
<figcaption>
<a href={url} target="_blank" rel="noopener noreferrer" className="text-link">شاهد الفيديو على YouTube</a>
</figcaption>
</figure>
)
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Readex Pro Project Authors (https://github.com/ThomasJockin/readexpro), with Reserved Font Name “RevReading Lexend”.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
/** Only uploaded raster image paths can become article figures. */
export function articleImageUrl(value: string): string | null {
// Validate the raw spelling before URL parsing can normalize traversal.
if (/[\s\u0000-\u001f\u007f\\%?#]/.test(value)) return null
const match = /^(?:https:\/\/([^/]+))?(\/api\/media\/file\/[A-Za-z0-9_-]+\.(?:png|jpg|jpeg|webp))$/.exec(value)
if (!match) return null
const path = match[2]
if (!match[1]) return path
try {
const url = new URL(value)
if (url.username || url.password) return null
if (url.origin === 'https://anwarpy.ai') return path
const site = new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://anwarpy.ai')
if (site.protocol !== 'https:' || site.username || site.password) return null
return url.origin === site.origin ? path : null
} catch {
return null
}
}
+6 -6
View File
@@ -13,12 +13,12 @@ export const formatNumber = (value: number | null | undefined) => numberFormat.f
/* Single source for the displayed membership price. */ /* Single source for the displayed membership price. */
export const MEMBERSHIP_PRICE = '$10' export const MEMBERSHIP_PRICE = '$10'
/* Arabic count agreement for «دليل»: 1 دليل، 2 دليلان، 3–10 أدلة، 11+ دليلاً. */ /* Count UI content items while preserving the existing formatter API. */
export function guidesLabel(n: number): string { export function guidesLabel(n: number): string {
if (n === 0) return 'لا أدلة' if (n === 0) return 'لا محتوى'
if (n === 1) return 'دليل واحد' if (n === 1) return 'عنصر محتوى واحد'
if (n === 2) return 'دليلان' if (n === 2) return 'عنصران من المحتوى'
return `${formatNumber(n)} ${n <= 10 ? 'أدلة' : 'دليلاً'}` return `${formatNumber(n)} ${n <= 10 ? 'عناصر محتوى' : 'عنصر محتوى'}`
} }
export function minutesLabel(n: number | null | undefined): string { export function minutesLabel(n: number | null | undefined): string {
@@ -32,7 +32,7 @@ export function minutesLabel(n: number | null | undefined): string {
export function contentTypeLabel(value: string | null | undefined): string | null { export function contentTypeLabel(value: string | null | undefined): string | null {
switch (value) { switch (value) {
case 'note': return 'ملاحظة تقنية' case 'note': return 'ملاحظة تقنية'
case 'tutorial': return 'دليل عملي' case 'tutorial': return 'محتوى عملي'
case 'case_study': return 'تجربة موثقة' case 'case_study': return 'تجربة موثقة'
case 'comparison': return 'مقارنة' case 'comparison': return 'مقارنة'
default: return null default: return null
+18 -13
View File
@@ -1,17 +1,22 @@
/* Single source of launch policy (L-01/L-02). /* Adopted free-launch policy. Changing environment variables or passing a
review date cannot activate billing or a paywall. A paid transition requires
Free early access is a server-side policy, never a data rewrite: the a separately reviewed code change, operator approval and payment/access QA.
intended VIP tier stays in the DB (`access: 'vip'`) and only becomes Intended tiers remain in the database; publication/private checks still apply. */
enforced again when billing turns on. Rollback / paid transition = export const LAUNCH_POLICY = {
set LAUNCH_MODE=paid + the Stripe env vars and redeploy. No timers, mode: 'free_early_access',
no date logic — idempotent by construction. */ effectiveDate: '2026-10-10',
export const billingEnabled = (): boolean => operator: 'Anwarpy',
process.env.LAUNCH_MODE === 'paid' && supportEmail: 'hello@anwarpy.ai',
Boolean(process.env.STRIPE_SECRET_KEY) && billingEnabled: false,
Boolean(process.env.STRIPE_WEBHOOK_SECRET) notificationSignupEnabled: false,
summary: 'يمكنك قراءة المحتوى المنشور خلال الوصول المبكر مجاناً دون حساب أو بطاقة دفع. نراجع العضوية بعد نحو أسبوعين من الإطلاق؛ هذه نقطة مراجعة وليست موعد حجب. قد تمتد الفترة، ولن يُفعّل الدفع أو الحجب إلا بقرار يدوي بعد الاختبارات وإعلان التغيير. لن يُنشأ اشتراك أو يُحصّل مبلغ تلقائياً.',
} as const
export const billingEnabled = (): boolean => LAUNCH_POLICY.billingEnabled
export const freeEarlyAccess = (): boolean => !billingEnabled() export const freeEarlyAccess = (): boolean => !billingEnabled()
export const accessLabel = (tier: string): string => tier === 'vip'
? 'مجاني الآن · للعضوية مستقبلاً'
: 'مجاني'
/* Configurable Arabic reading rate (words/minute); revisit after reader // Configurable Arabic reading rate, used by the existing publication hook.
feedback. Used by the Posts beforeChange hook and the backfill script. */
export const READING_WORDS_PER_MINUTE = 160 export const READING_WORDS_PER_MINUTE = 160
+3 -3
View File
@@ -41,8 +41,8 @@ export async function fetchIndexDocs(withContent = false): Promise<IndexDoc[]> {
return docs as unknown as IndexDoc[] return docs as unknown as IndexDoc[]
} }
export const SITE_TITLE = 'أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية' export const SITE_TITLE = 'أنور پآي — محتوى الذكاء الاصطناعي العملي بالعربية'
export const SITE_INTRO = 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.' export const SITE_INTRO = 'محتواك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.'
/** Topic-grouped markdown index lines; a post can appear under several topics. */ /** Topic-grouped markdown index lines; a post can appear under several topics. */
export function renderIndexSections(docs: IndexDoc[], markVip: boolean): string { export function renderIndexSections(docs: IndexDoc[], markVip: boolean): string {
@@ -52,7 +52,7 @@ export function renderIndexSections(docs: IndexDoc[], markVip: boolean): string
if (items.length === 0) continue if (items.length === 0) continue
lines.push(`## ${topic.label}`, '') lines.push(`## ${topic.label}`, '')
for (const doc of items) { for (const doc of items) {
const suffix = markVip && doc.access === 'vip' ? ' (دليل للأعضاء)' : '' const suffix = markVip && doc.access === 'vip' ? ' (محتوى للأعضاء)' : ''
lines.push(`- [${doc.title}${suffix}](/posts/${doc.slug}): ${doc.dek}`) lines.push(`- [${doc.title}${suffix}](/posts/${doc.slug}): ${doc.dek}`)
} }
lines.push('') lines.push('')
+1 -1
View File
@@ -7,7 +7,7 @@ export const TOPICS: { key: TopicKey; label: string; hint: string }[] = [
{ key: 'agents', label: 'الوكلاء', hint: 'تخطيط · حلقات · سلامة' }, { key: 'agents', label: 'الوكلاء', hint: 'تخطيط · حلقات · سلامة' },
{ key: 'models', label: 'النماذج', hint: 'اختيار · تقييم · استرجاع' }, { key: 'models', label: 'النماذج', hint: 'اختيار · تقييم · استرجاع' },
{ key: 'automation', label: 'الأتمتة', hint: 'أدوات · أعمال · إنتاج' }, { key: 'automation', label: 'الأتمتة', hint: 'أدوات · أعمال · إنتاج' },
{ key: 'foundations', label: 'الأساسيات', hint: 'أدلة للمبتدئين' }, { key: 'foundations', label: 'الأساسيات', hint: 'محتوى للمبتدئين' },
] ]
const CATEGORY_TOPIC: Record<string, TopicKey> = { const CATEGORY_TOPIC: Record<string, TopicKey> = {
+20
View File
@@ -0,0 +1,20 @@
/** Only recognized HTTPS YouTube URLs can become players. */
export function youtubeVideoId(value: string): string | null {
// Check raw input before URL parsing can normalize whitespace, slashes or dot paths.
if (typeof value !== 'string' || /[\s\\\u0000-\u001f\u007f]|%(?:0[0-9a-f]|1[0-9a-f]|7f)/i.test(value)) return null
const match = /^https:\/\/(youtu\.be|(?:www\.)?youtube\.com)(?::443)?(\/[^?#]*)(?:\?[^#]*)?(?:#[^]*)?$/.exec(value)
if (!match) return null
try {
const url = new URL(value)
if (url.protocol !== 'https:' || url.username || url.password || url.port) return null
let id: string | null = null
if (match[1] === 'youtu.be') {
id = match[2].slice(1)
} else if (match[2] === '/watch' && url.searchParams.getAll('v').length === 1) {
id = url.searchParams.get('v')
}
return id && /^[A-Za-z0-9_-]{11}$/.test(id) ? id : null
} catch {
return null
}
}
+143
View File
@@ -0,0 +1,143 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require(process.env.TEST_TYPESCRIPT || 'typescript')
const root = path.join(__dirname, '..')
function load(file, mocks = {}, globals = {}) {
const source = fs.readFileSync(path.join(root, file), 'utf8')
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText
const module = { exports: {} }
const jsx = (type, props) => ({ type: typeof type === 'string' ? type : 'component', ...props })
const requireMock = id => {
if (id in mocks) return mocks[id]
if (id === 'react/jsx-runtime') return { jsx, jsxs: jsx, Fragment: 'fragment' }
if (id === '@/lib/viewer') return load('src/lib/viewer.ts', mocks)
if (id === '@/lib/launch') return load('src/lib/launch.ts')
if (id.startsWith('node:') || ['crypto', 'util'].includes(id)) return require(id)
if (id.endsWith('.css')) return {}
return { __esModule: true, default: () => null }
}
vm.runInNewContext(output, { module, exports: module.exports, require: requireMock, process, console, ...globals }, { filename: file })
return module.exports
}
const response = { NextResponse: { json: (body, options = {}) => ({ body, status: options.status || 200 }) } }
const noIO = { getPayload: () => { throw new Error('Unexpected database access') } }
test('free launch cannot be switched to billing by Stripe environment or date', async () => {
const old = { ...process.env }
try {
for (const mode of [undefined, 'free', 'paid']) {
if (mode) process.env.LAUNCH_MODE = mode; else delete process.env.LAUNCH_MODE
process.env.STRIPE_SECRET_KEY = 'test-present'; process.env.STRIPE_WEBHOOK_SECRET = 'test-present'; process.env.STRIPE_PRICE_ID = 'test-present'
const policy = load('src/lib/launch.ts')
assert.equal(policy.billingEnabled(), false, `billing must stay disabled for env mode ${mode}`)
assert.equal(policy.freeEarlyAccess(), true)
const result = await load('src/app/api/subscribe/route.ts', { 'next/server': response, payload: noIO }).POST()
assert.equal(result.status, 503); assert.ok(result.body.error); assert.equal(result.body.url, undefined)
}
} finally { process.env = old }
})
test('unverified notification signup rejects without storing email or sending mail', async () => {
const result = await load('src/app/api/notify/route.ts', { 'next/server': response, payload: noIO }).POST(new Request('https://example.test/api/notify', { method: 'POST', body: JSON.stringify({ email: 'reader@example.test' }) }))
assert.equal(result.status, 503); assert.ok(result.body.error); assert.equal(result.body.ok, undefined)
})
test('policies and public entry points describe current free reading without signup or notification form', async () => {
for (const name of ['terms', 'privacy']) {
const page = load(`src/app/(frontend)/${name}/page.tsx`).default()
const text = JSON.stringify(page)
assert.doesNotMatch(text, /مسودة|يجب إضافة|قبل الإطلاق يجب|تجريبية للواجهة/)
assert.match(text, /2026-10-10/); assert.match(text, /Anwarpy/); assert.match(text, /hello@anwarpy.ai/)
}
const privacy = JSON.stringify(load('src/app/(frontend)/privacy/page.tsx').default())
assert.match(privacy, /Cloudflare/); assert.match(privacy, /سنة واحدة/); assert.match(privacy, /تلقائي/)
const subscription = await load('src/app/(frontend)/subscription/page.tsx', {
payload: { getPayload: async () => ({ auth: async () => ({ user: null }), find: async () => ({ docs: [] }) }) },
'next/headers': { headers: async () => ({}) },
}).default()
const rendered = JSON.stringify(subscription)
assert.doesNotMatch(rendered, /المقدمة فقط|حساب مجاني|شهرياً|تُضاف أدلة جديدة كل شهر/)
assert.match(rendered, /دون حساب/); assert.match(rendered, /مستقبلاً/)
const source = fs.readFileSync(path.join(root, 'src/app/(frontend)/subscription/page.tsx'), 'utf8')
assert.doesNotMatch(source, /NotifyForm|SubscribeButton/)
const footer = JSON.stringify(load('src/components/footer.tsx').default())
assert.doesNotMatch(footer, /\/signup/)
})
test('frontend layout displays the exact trial banner on every frontend route', () => {
const layout = 'src/app/(frontend)/layout.tsx'
assert.ok(fs.existsSync(path.join(root, layout)), 'shared frontend layout is required')
const tree = JSON.stringify(load(layout).default({ children: 'page content' }))
assert.match(tree, /اطلاق تجريبي/); assert.match(tree, /دون حساب/); assert.match(tree, /تلقائياً/)
assert.match(tree, /aria-label/)
})
test('missing download payload never reports success and valid payload creates a file', () => {
for (const key of ['missing.md', 'agent-state.md']) {
const button = { dataset: { dl: key }, textContent: 'تنزيل', addEventListener: (event, fn) => { button.click = fn } }
const blobs = []; let clicked = 0
const document = { getElementById: () => null, querySelectorAll: () => [button], body: { append: () => {} }, createElement: () => ({ click: () => clicked++, remove: () => {} }) }
const react = { useEffect: fn => fn(), useState: value => [value, () => {}] }
load('src/app/(frontend)/posts/[slug]/post-extras.tsx', { react }, {
document, Blob: class { constructor(body) { blobs.push(body) } }, URL: { createObjectURL: () => 'blob:test', revokeObjectURL: () => {} }, setTimeout: () => {},
}).default({ slug: 'fixture', loggedIn: false })
button.click({ currentTarget: button })
if (key === 'missing.md') { assert.notEqual(button.textContent, 'تم التنزيل ✓'); assert.equal(clicked, 0); assert.equal(blobs.length, 0) }
else { assert.equal(clicked, 1); assert.ok(blobs[0][0].length > 20); assert.equal(button.textContent, 'تم التنزيل ✓') }
}
})
test('home and library entry points label intended membership as future, never require signup for reading', () => {
for (const file of ['src/app/(frontend)/page.tsx', 'src/app/(frontend)/articles/page.tsx', 'src/app/(frontend)/topics/[topic]/page.tsx']) {
const source = fs.readFileSync(path.join(root, file), 'utf8')
assert.match(source, /accessLabel/)
assert.doesNotMatch(source, /العضوية تفتح الأدلة|للأعضاء، وتُضاف/)
}
assert.match(fs.readFileSync(path.join(root, 'src/components/site-links.ts'), 'utf8'), /الوصول المبكر/)
assert.doesNotMatch(fs.readFileSync(path.join(root, 'src/app/(frontend)/login/page.tsx'), 'utf8'), /\/signup/)
})
test('private VIP files remain protected during free launch', async () => {
let diskRead = false
const result = await load('src/app/api/files/[id]/route.ts', {
'next/server': response,
payload: { getPayload: async () => ({ findByID: async () => ({ id: 1, access: 'vip', filename: 'private.md', mimeType: 'text/plain' }) }) },
'@/lib/viewer': { getViewer: async () => null },
'@/collections/Files': { FILES_DIR: '/not-used' },
'node:fs/promises': { readFile: async () => { diskRead = true; return Buffer.from('private') } },
}).GET({}, { params: Promise.resolve({ id: '1' }) })
assert.equal(result.status, 401); assert.equal(diskRead, false)
})
test('source traceability is never readable and users remain admin-created only', () => {
const posts = load('src/collections/Posts.ts').default
const sourceId = posts.fields.find(field => field.name === 'sourceId')
assert.equal(typeof sourceId.access?.read, 'function')
assert.equal(sourceId.access.read({ req: { user: null } }), false)
assert.equal(sourceId.access.read({ req: { user: { role: 'admin' } } }), false)
assert.equal(posts.access.read({ req: { user: null } }).status.equals, 'published')
const users = load('src/collections/Users.ts').default
assert.equal(users.access.create({ req: { user: null } }), false)
assert.equal(users.access.create({ req: { user: { role: 'free' } } }), false)
assert.equal(users.access.create({ req: { user: { role: 'admin' } } }), true)
})
test('unused notification and checkout controls also fail closed if reused', () => {
const react = { useState: value => [value, () => {}] }
const notify = JSON.stringify(load('src/app/(frontend)/subscription/notify-form.tsx', { react }).default())
assert.doesNotMatch(notify, /\"type\":\"input\"|\"type\":\"form\"/)
const checkout = JSON.stringify(load('src/app/(frontend)/subscription/subscribe-button.tsx', { react }).default({ loggedIn: false }))
assert.doesNotMatch(checkout, /\/signup|اشترك/); assert.match(checkout, /\/articles/)
assert.doesNotMatch(fs.readFileSync(path.join(root, 'src/app/(frontend)/account-settings/page.tsx'), 'utf8'), /\/signup/)
})
test('all source TypeScript and TSX files transpile without syntax diagnostics', () => {
let count = 0
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
const result = ts.transpileModule(fs.readFileSync(full, 'utf8'), { fileName: full, reportDiagnostics: true, compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } })
const errors = (result.diagnostics || []).filter(d => d.category === ts.DiagnosticCategory.Error)
assert.equal(errors.length, 0, `${full}: ${errors.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')).join('; ')}`)
count++
}
}
}
walk(path.join(root, 'src')); assert.ok(count > 50); console.log(`Syntax transpile checked ${count} source files (not full type/build validation)`)
})
module.exports = { load, response, noIO }