Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6385d60dff | ||
|
|
36f508df75 | ||
|
|
26d662ed7c | ||
|
|
e35d908672 | ||
|
|
c0db8f6165 | ||
|
|
fd4e69a27c | ||
|
|
1ccb031dba | ||
|
|
7be9ffd3ed | ||
|
|
74b57bfee4 | ||
|
|
638a59018a | ||
|
|
0161924088 | ||
|
|
82414e8a93 | ||
|
|
165efc3269 |
@@ -12,3 +12,11 @@ screenshot-*.png
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Local AI coding hosts (procoder init); .procoder/ stays trackable.
|
||||
/skills/procoder/
|
||||
|
||||
# procoder state (regenerable)
|
||||
.procoder/state/
|
||||
.procoder/index/
|
||||
.lycheecache
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[git]
|
||||
default_branch_policy = "report" # rulebook allows docs/evidence commits to main
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"skills"
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# AGENT.md — AnwarPi for AI agents
|
||||
|
||||
Orientation doc for AI agents (coding assistants, autonomous maintainers, content
|
||||
pipelines) working on this repository. Read this before changing anything.
|
||||
|
||||
## What this is
|
||||
|
||||
**AnwarPi (أنور بي آي)** — an Arabic-first, RTL platform for learning practical
|
||||
AI at **https://anwarpy.ai**. Free guides for everyone, VIP-gated deep-dives, a
|
||||
searchable library/catalog, moderated comments, member accounts, and a Stripe
|
||||
subscription that is fully wired but public-facing "coming soon" until keys are
|
||||
set. Content is also published programmatically by an external agent pipeline
|
||||
through a key-authenticated API.
|
||||
|
||||
Canonical domain: `anwarpy.ai` (`anwarpi.ai` is legacy). Owner: Ahmed
|
||||
(ahmedalkhair.sa@gmail.com). Owner constraint: **never change site flow/UX
|
||||
without explicit approval** (e.g. no CAPTCHA, no aggressive limits).
|
||||
|
||||
## Stack
|
||||
|
||||
| Component | Choice |
|
||||
|---|---|
|
||||
| Framework | Next.js 15 (App Router, `output: 'standalone'`), React 19 |
|
||||
| CMS/Auth | Payload 3.90 (`@payloadcms/next`), Lexical editor |
|
||||
| Database | PostgreSQL 16 via `@payloadcms/db-postgres` |
|
||||
| Styling | Plain CSS files per page (see `web/src/app/(frontend)/styles/`), dark-first brand (see `brand-spec.md`) |
|
||||
| Email | Cloudflare Email Routing (inbound) + Cloudflare Email Sending REST (outbound) — **not SMTP** (docs/adr/0003) |
|
||||
| Payments | Stripe SDK, webhook at `/api/stripe/webhook` |
|
||||
| Edge | Traefik 2/3 with Docker provider (labels in `web/docker-compose.vps.yml`) |
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
web/ ← the entire application (deploy unit)
|
||||
src/
|
||||
payload.config.ts ← Payload config: collections, postgres adapter
|
||||
seed.ts ← `npm run seed`
|
||||
middleware.ts ← Next middleware: auth rate limits (F2)
|
||||
collections/ ← Users, Posts, Media, Files, PostLikes, Comments
|
||||
migrations/ ← Payload migrations (`payload migrate` runs on boot)
|
||||
lib/ ← viewer.ts (session helper), mail.ts, format.ts, topics.ts, agent-*
|
||||
app/
|
||||
(frontend)/ ← public Arabic site (page.tsx per route, styles/ css)
|
||||
(payload)/ ← Payload admin + API catch-all
|
||||
api/ ← custom REST routes (posts, auth, subscribe, stripe, agent, files)
|
||||
public/ ← brand/, covers/, og-default.png
|
||||
Dockerfile ← standalone build; CMD runs `npx payload migrate` first
|
||||
docker-compose.vps.yml ← prod: web + postgres, Traefik labels (rate limits)
|
||||
GLOSSARY.md ← canonical domain vocabulary — USE THESE TERMS
|
||||
docs/adr/ ← architecture decision records — do not contradict
|
||||
brand-spec.md ← visual identity: dark blue-black, silver beams, cyan-teal accent
|
||||
```
|
||||
|
||||
## Data model (Payload collections)
|
||||
|
||||
- **Users** — auth collection. `role: 'free' | 'vip' | 'admin'` (server-forced),
|
||||
`emailVerified`, `onboarded`, `termsVersion`. Login lockout: 5 attempts /
|
||||
15 min; admin accounts never lock (hook clears lock before login).
|
||||
- **Posts** — guides. `access: 'free' | 'vip'`, `slug`, category (وكلاء، نماذج،
|
||||
استرجاع، سلامة، أدوات، أعمال), cover image (1200×630), Lexical body, likes/views.
|
||||
- **Comments** — moderated: `pending → approved | rejected`. Only `approved` is
|
||||
public. Authors cannot edit/delete their own comments (moderated model,
|
||||
docs/adr/0002). Display name is a snapshot of `fullName` at submit time.
|
||||
- **Media / Files** — uploads (files served via `/api/files/[id]`).
|
||||
- **PostLikes** — one like per member per post.
|
||||
|
||||
## Trust model (critical — do not break)
|
||||
|
||||
1. **Roles are server-forced.** `POST /api/users` and any client write cannot
|
||||
set `role`, `emailVerified`, `stripeCustomerId`, or `verificationToken` —
|
||||
the `beforeChange` hook in `Users.ts` deletes them for untrusted operations.
|
||||
Only `req.user.role === 'admin'` or `context.systemActivation === true`
|
||||
(payment activation from server code) counts as trusted.
|
||||
2. **Entitlement is derived, never stored on the user**: viewer may read a
|
||||
post's body iff `post.access === 'free' || role in ('vip','admin')`. Gating
|
||||
happens server-side (locked body is stripped from API responses).
|
||||
3. **Comments require verification** (`emailVerified`), admins exempt.
|
||||
4. Client requests can never mint VIP (free `/payment-success` visits redirect
|
||||
to `/subscription`; docs/adr/0004, 0005).
|
||||
|
||||
## Auth & rate limiting
|
||||
|
||||
- Passwords: Payload local auth → **PBKDF2-SHA256, 600 000 iterations, 32
|
||||
bytes** (NOT bcrypt — verified in payload's `generatePasswordSaltHash`).
|
||||
- `web/src/middleware.ts` (F2): per-IP in-memory buckets —
|
||||
`POST /api/users` 5/h, `POST /api/users/login` 10/15min,
|
||||
`POST /api/users/forgot-password` 5/h. Over limit → 429 JSON
|
||||
`{"error":"محاولات كثيرة جدًا. حاول بعد قليل."}` + `Retry-After`.
|
||||
**In-memory is correct for the single-container deployment**; moving to
|
||||
horizontal scaling requires a shared store (Redis) — do not silently assume
|
||||
one replica.
|
||||
- Timing equalization (F7): `Users.ts` `beforeOperation` login hook runs a
|
||||
dummy PBKDF2 (same params, salt `anwarpy-dummy-salt-v1`) when no user
|
||||
matches, so ghost emails are not enumerable by response time. Keep this when
|
||||
touching the hook.
|
||||
- Traefik edge (F1, VPS only): pages `pagelimit` 60/min burst 120 +
|
||||
`connlimit` 50 in-flight per IP; API `apilimit` 90/min burst 180.
|
||||
- `Strict-Transport-Security: max-age=63072000; includeSubDomains` on all
|
||||
routes (`web/next.config.mjs`). Do not add `preload`.
|
||||
|
||||
## Key routes
|
||||
|
||||
Frontend: `/` (home), `/articles`, `/catalog`, `/posts/[slug]` (gated body,
|
||||
engagement, comments), `/signup`, `/login`, `/forgot-password`,
|
||||
`/verify-email`, `/subscription`, `/payment-success`, `/payment-cancelled`,
|
||||
`/vip-onboarding`, `/onboarding`, `/account-settings`, `/about`, `/privacy`,
|
||||
`/terms`. Admin UI: `/admin`.
|
||||
|
||||
Custom API (see `web/src/app/api/`): `posts` (list) + `posts/[slug]` (detail —
|
||||
strips locked bodies), `posts/[slug]/{like,view,comments}`,
|
||||
`auth/{verify-email,resend-verification}`, `users` REST is Payload's own,
|
||||
`subscribe`, `stripe/webhook` (returns 503 until `STRIPE_SECRET_KEY` +
|
||||
`STRIPE_WEBHOOK_SECRET` exist; verifies `stripe-signature` once configured),
|
||||
`agent/posts` (POST/PUT with `x-agent-key` header — external publishing), and
|
||||
`files/[id]`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
`DATABASE_URI`, `PAYLOAD_SECRET`, `NEXT_PUBLIC_SITE_URL`, `AGENT_API_KEY`,
|
||||
`MAIL_FROM`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, plus optional
|
||||
`STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET`. Prod values live in
|
||||
`/root/anwarpi/.env.production` on the VPS — never commit `.env*`.
|
||||
|
||||
## Development & verification
|
||||
|
||||
```bash
|
||||
cd web && npm install
|
||||
npm run dev # localhost:3000, /admin for CMS
|
||||
npm run build # MUST exit 0 — typescript.ignoreBuildErrors is false
|
||||
npm run seed
|
||||
```
|
||||
|
||||
- Regenerates `payload-types.ts` automatically on build
|
||||
(`payload generate:types`).
|
||||
- Schema changes require a migration (`payload migrate:create`); the container
|
||||
CMD runs `payload migrate` on every deploy — no manual DB steps.
|
||||
- Auth endpoints are rate-limited in dev too; restart the dev server to reset
|
||||
buckets when testing.
|
||||
|
||||
## Deployment
|
||||
|
||||
Single VPS `147.79.102.201`, app at `/root/anwarpi`, managed by
|
||||
`docker compose -f docker-compose.vps.yml` (web + postgres + Traefik proxy
|
||||
network). Update flow: rsync `web/` → `/root/anwarpi/` excluding
|
||||
`node_modules .next .git media files-private '.env*' '*.tsbuildinfo'`, then
|
||||
`docker compose -f docker-compose.vps.yml up -d --build` (label changes force
|
||||
recreate; migration runs on boot). Traefik labels only affect the VPS.
|
||||
|
||||
## House rules
|
||||
|
||||
- Follow the **reuse ladder**: existing helper → stdlib → platform feature →
|
||||
installed dependency → then write the complete version.
|
||||
- **Do not add UX changes** (new limits, captchas, gates) without owner
|
||||
approval; thresholds are deliberately generous for humans.
|
||||
- New decisions → add an ADR in `docs/adr/` (next number, `NNNN-slug.md`).
|
||||
- Use GLOSSARY terms exactly (عضوية/entitlement, مفتوح/unlocked, موثّق/
|
||||
verified, كتابة موثوقة/trusted write).
|
||||
- **Pentest context**: the 2026-10-09 report items F1–F9 are closed; see PR #1
|
||||
and release v1.0.0 on Gitea for what was done and verified. Do not
|
||||
re-implement.
|
||||
@@ -1,4 +1,5 @@
|
||||
<!-- 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
|
||||
|
||||
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
|
||||
|
||||
Before writing new code, stop at the first rung that holds:
|
||||
|
||||
1. A helper, util, or pattern already in this repo.
|
||||
2. The standard library.
|
||||
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
|
||||
skill suite (reviews, ship, QA, evals). This digest is generated — edit
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,49 @@
|
||||
# GLOSSARY — أنور بي آي
|
||||
|
||||
Implementation-free vocabulary shared across code, QA, and product talk.
|
||||
|
||||
## العضوية / Entitlement
|
||||
|
||||
A user's paid access level. Server-derived from `users.role`: `vip` or `admin` counts as entitled; `free` does not. Never cached client-side as a boolean.
|
||||
|
||||
## مفتوح / Unlocked
|
||||
|
||||
Whether the current viewer may read a given post's full body: `post.access === 'free' || entitled`. Derived per request; never stored on the user.
|
||||
|
||||
## تعليق / Comment
|
||||
|
||||
A moderated note by an authenticated member on a published post. Lifecycle `pending → approved | rejected`; only `approved` is publicly visible; only admins move status; authors cannot edit or delete their own comments (moderated model). Authors see their own `pending` comments on the post page tagged «قيد المراجعة».
|
||||
|
||||
## اسم العرض / Display name
|
||||
|
||||
The public author label of a comment: a snapshot of `users.fullName` taken at submit time, or the fixed word «عضو» when absent. Never the email.
|
||||
|
||||
## الهوية / Identity
|
||||
|
||||
- **Canonical domain: `anwarpy.ai`** — the Cloudflare zone (`anwarpi.ai` references are legacy being replaced; see repo history).
|
||||
- **Contact inbox: `hello@anwarpy.ai`** — receives via Cloudflare Email Routing, forwards to ahmedalkhair.sa@gmail.com.
|
||||
- **Sender: `noreply@anwarpy.ai`** — sends via Cloudflare Email Sending (REST).
|
||||
|
||||
## الاشتراك قريبًا / Coming soon
|
||||
|
||||
The public state of the subscribe flow while billing is disabled. From the first paint the UI renders a disabled «قريباً» button (when `LAUNCH_MODE` disables billing there is no active «اشترك الآن» that fails late); if a request is still attempted while Stripe keys are absent, `POST /api/subscribe` returns `{ comingSoon: true }` with no `url`, and the button shows an in-page retry state — **not a redirect** to `/payment-success` and no VIP is minted there either way. No VIP is granted without a Stripe-verified session.
|
||||
|
||||
## الوصول المبكر المجاني / Free early access
|
||||
|
||||
The launch policy (`LAUNCH_MODE=free_early_access`, the compose default): every approved **published** post is readable and its approved files downloadable without an account, regardless of the post's intended tier. Billing stays off; nothing is charged or auto-subscribed. The mode has no date expiry — the ~two-week checkpoint is a review date, not a cutoff.
|
||||
|
||||
## الطبقة المقصودة / Intended tier
|
||||
|
||||
The post's `access` field (`free` | `vip`) — what the content is meant to be once billing is enabled. The launch never rewrites it: a VIP-designated post opened by early access keeps `access: 'vip'` so rollback is instant and cards/comparisons stay truthful.
|
||||
|
||||
## الوصول الفعلي / Effective access
|
||||
|
||||
The decision actually enforced at request time: `status: 'published'` **and** (`access: 'free'` **or** free early access). One policy (`src/lib/launch.ts`) feeds pages, `/api/posts/[slug]`, the file gate, and the llms exports, so visible labels and served bytes can't disagree. A draft is never readable in any mode.
|
||||
|
||||
## كتابة موثوقة / Trusted write
|
||||
|
||||
An operation on a member record performed by server code (email verification, payment activation, billing webhook, seed), as opposed to a client request. Client requests can never set a member's privilege, verification, or billing fields.
|
||||
|
||||
## موثّق / Verified
|
||||
|
||||
Whether the member confirmed their email address. Required to comment; admins are exempt; set only by server code, never by client requests.
|
||||
@@ -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).
|
||||
@@ -1,37 +1,180 @@
|
||||
# أنور بي آي
|
||||
<div align="center">
|
||||
|
||||
منصة عربية ثابتة لتعلّم الذكاء الاصطناعي، تتضمن محتوى مجانيًا ومقالات VIP وتجربة اشتراك وتهيئة للمشترك الجديد. الواجهة متجاوبة مع الهاتف واللوحي وسطح المكتب.
|
||||
<img src="web/public/brand/icon-256.png" alt="شعار أنور بي آي" width="120"/>
|
||||
|
||||
## الصفحات الرئيسية
|
||||
# أنور بي آي — AnwarPi
|
||||
|
||||
- الصفحة الرئيسية والكتالوج التعليمي
|
||||
- مقال مجاني ومعاينة مقال VIP ومقال VIP كامل
|
||||
- الاشتراك وتهيئة مشترك VIP جديد
|
||||
- تسجيل الدخول واستعادة كلمة المرور وإعدادات الحساب
|
||||
- سياسة الخصوصية
|
||||
**محتوى عربي عملي لتعلّم الذكاء الاصطناعي، بلا ضجيج.**
|
||||
|
||||
## التشغيل محليًا
|
||||
Arabic-first platform for learning AI: free guides, VIP deep-dives, a searchable library, and moderated community — built with **Next.js 15 + Payload 3 + PostgreSQL** behind Traefik.
|
||||
|
||||
لا يحتاج المشروع إلى تثبيت حزم. شغّل خادمًا محليًا من مجلد المشروع:
|
||||
**[anwarpy.ai](https://anwarpy.ai)** · [دليل الوكيل الآلي (AGENT.md)](AGENT.md) · [مسرد المصطلحات](GLOSSARY.md) · [قرارات التصميم](docs/adr)
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8000
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ لقطات من الموقع
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>الصفحة الرئيسية</strong></td>
|
||||
<td align="center"><strong>المكتبة</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/home.png" alt="الصفحة الرئيسية — أنور بي آي" width="470"/></td>
|
||||
<td><img src="docs/screenshots/articles.png" alt="المكتبة — أنور بي آي" width="470"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>صفحة دليل</strong></td>
|
||||
<td align="center"><strong>العضوية</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/post.png" alt="صفحة دليل — أنور بي آي" width="470"/></td>
|
||||
<td><img src="docs/screenshots/subscription.png" alt="العضوية — أنور بي آي" width="470"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## ما هو أنور بي آي؟
|
||||
|
||||
منصة عربية لتعلّم الذكاء الاصطناعي بشكل عملي: أدلة مجانية للجميع، وأدلة متعمقة لأعضاء **VIP**، ومكتبة قابلة للبحث والتصفية، وتعليقات مُراقَّبة، ومسارات تعلّم (كتالوج). الواجهة عربية بالكامل (RTL) وتعمل على الهاتف واللوحي وسطح المكتب.
|
||||
|
||||
## ✨ المميزات
|
||||
|
||||
- 📚 **المكتبة** — كل الأدلة مع بحث وتصفية حسب التصنيف (وكلاء، نماذج، استرجاع، سلامة، أدوات، أعمال).
|
||||
- 🔓 **بوابة محتوى** — الأدلة المجانية كاملة؛ أدلة VIP تظهر بمعاينة مقفلة حتى يصبح القارئ مشتركًا.
|
||||
- 👤 **حسابات** — تسجيل، دخول، استعادة كلمة المرور، تحقق البريد، وإعدادات حساب كاملة.
|
||||
- 💬 **تعليقات مُراقَّبة** — دورة حياة `pending → approved`؛ يظهر العلني المعتمَد فقط.
|
||||
- 🗺️ **الكتالوج والمسارات** — رحلة تعلّم مرتّبة حسب المستوى (مبتدئ/متوسط/متقدم).
|
||||
- 🤖 **نشر آلي** — API للوكلاء الخارجيين (`/api/agent/posts` بمفتاح `x-agent-key`) لنشر الأدلة من خط إدارة المحتوى.
|
||||
- 💳 **عضوية VIP** — Stripe جاهز؛ حتى تفعيل المفاتيح يعمل زر الاشتراك بوضع «قريبًا».
|
||||
- 🌓 **هوية بصرية داكنة** — أشعة فضية ولمسة سماوية؛ انظر `brand-spec.md`.
|
||||
|
||||
## 🔄 كيف يعمل الموقع؟ (شرح التدفق)
|
||||
|
||||
### تدفق الزائر
|
||||
|
||||
```mermaid
|
||||
flowchart RL
|
||||
Z[الزائر] --> A[الصفحة الرئيسية]
|
||||
A --> B[المكتبة / الكتالوج]
|
||||
B --> C{نوع الدليل؟}
|
||||
C -->|مجاني| D[قراءة كاملة + إعجاب وتعليق]
|
||||
C -->|VIP| E[معاينة مقفلة]
|
||||
E --> F[إنشاء حساب / دخول]
|
||||
F --> G{مشترك VIP؟}
|
||||
G -->|نعم| D
|
||||
G -->|لا| H[صفحة العضوية]
|
||||
H --> I[تهيئة مشترك VIP جديد]
|
||||
I --> D
|
||||
```
|
||||
|
||||
ثم افتح `http://localhost:8000`.
|
||||
1. **تصفّح** — الزائر يفتح الرئيسية، يتصفح المكتبة أو الكتالوج، ثم يفتح دليلًا.
|
||||
2. **قراءة** — الدليل المجاني يُقرأ كاملًا. دليل VIP يظهر جزءًا منه فقط مع طبقة قفل.
|
||||
3. **حساب** — التسجيل يمنح دور `free` فقط (الخادم يجبر ذلك؛ طلب العميل لا يستطيع ترقية نفسه). بعد التحقق من البريد يمكن التعليق.
|
||||
4. **ترقية** — صفحة العضوية تقود لـ Stripe؛ بعد الدفع الناجح يُفعَّل دور `vip` عبر مسار موثوق من الخادم، ثم معالجة تهيئة VIP الجديد.
|
||||
|
||||
## التحقق قبل النشر
|
||||
### تدفق المحتوى (نشر الأدلة)
|
||||
|
||||
```bash
|
||||
python3 scripts/check_site.py
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T[مواضيع المالك في AppFlowy] --> P[خط النشر الآلي]
|
||||
P -->|POST/PUT مع x-agent-key| A[API الوكلاء]
|
||||
A --> V[التحقق والتغطية الافتراضية للغلاف]
|
||||
V --> D[(PostgreSQL)]
|
||||
D --> S[anwarpy.ai]
|
||||
```
|
||||
|
||||
يتحقق الفحص من صفحات HTML والروابط والأصول المحلية وبيانات العرض وسياسة أمان المحتوى.
|
||||
- يملك المالك قائمة مواضيع؛ يعمل خط النشر FIFO (دليل واحد لكل تشغيل، الاثنين/الأربعاء/الجمعة).
|
||||
- كل دليل يُوسم `free` أو `vip`؛ غير الموسوم = مجاني.
|
||||
- الأغلفة 1200×630، ولكل تصنيف غلاف افتراضي احتياطي.
|
||||
|
||||
## النشر
|
||||
### تدفق الطلب عبر الخادم
|
||||
|
||||
المستودع الرئيسي مستضاف على Gitea، والفرع الجاهز للنشر هو `main`. يحتوي المشروع أيضًا على مسار عمل GitHub Pages عند نسخ المستودع إلى GitHub وتفعيل Pages من GitHub Actions.
|
||||
```
|
||||
المتصفح → Traefik (حدود المعدل: pagelimit / apilimit / connlimit)
|
||||
→ Next.js 15 (الصفحات + /api)
|
||||
→ Payload 3 (التحكم بالوصول + الخطافات)
|
||||
→ PostgreSQL 16
|
||||
```
|
||||
|
||||
## حدود النسخة الحالية
|
||||
## 🏗️ البنية التقنية
|
||||
|
||||
الموقع نموذج واجهة ثابتة. تسجيل الدخول واستعادة كلمة المرور والدفع والعضوية وحماية محتوى VIP محاكاة داخل المتصفح وليست حماية إنتاجية. لا تستخدمه لجمع بيانات أو مدفوعات حقيقية قبل إضافة مصادقة وتفويض ودفع من جهة الخادم.
|
||||
| الطبقة | التقنية |
|
||||
|---|---|
|
||||
| الواجهة | Next.js 15 (App Router) + React 19، عربية RTL |
|
||||
| لوحة التحكم | Payload 3 (`/admin`) مع محرر Lexical |
|
||||
| قاعدة البيانات | PostgreSQL 16 عبر `@payloadcms/db-postgres` |
|
||||
| المصادقة | جلسات Payload، تجزئة PBKDF2-SHA256 600k تكرار |
|
||||
| الوسيط | `web/src/middleware.ts` — حدود معدل لمسارات المصادقة |
|
||||
| الحاوية | Docker Compose + Traefik (TLS + حدود معدل الحافة) |
|
||||
| البريد | Cloudflare (توجيه + إرسال REST) |
|
||||
| المدفوعات | Stripe (جاهز، بوضع «قريبًا» حتى المفاتيح) |
|
||||
|
||||
## 🚀 التشغيل محليًا
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
```
|
||||
|
||||
أنشئ `web/.env`:
|
||||
|
||||
```env
|
||||
DATABASE_URI=postgres://anwarpi:anwarpi@127.0.0.1:5432/anwarpi
|
||||
PAYLOAD_SECRET=<سري عشوائي طويل>
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
AGENT_API_KEY=<مفتاح نشر الوكلاء>
|
||||
MAIL_FROM=noreply@anwarpy.ai
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev # http://localhost:3000 — لوحة التحكم على /admin
|
||||
npm run build # تحقق قبل النشر
|
||||
npm run seed # تعبئة بيانات تجريبية
|
||||
```
|
||||
|
||||
## 📡 أهم مسارات الـ API
|
||||
|
||||
| المسار | الوصف |
|
||||
|---|---|
|
||||
| `POST /api/users` | تسجيل (حد 5/ساعة لكل IP) |
|
||||
| `POST /api/users/login` | دخول (حد 10/15دقيقة) |
|
||||
| `POST /api/users/forgot-password` | استعادة (حد 5/ساعة) |
|
||||
| `GET/PATCH /api/users/me` | حسابي |
|
||||
| `GET /api/posts` و `GET /api/posts/[slug]` | الأدلة (المحتوى المقفل يُقتطع حسب الدور) |
|
||||
| `POST /api/posts/[slug]/like` / `view` / `comments` | تفاعلات |
|
||||
| `POST/PUT /api/agent/posts` | نشر آلي — يتطلب ترويسة `x-agent-key` |
|
||||
| `POST /api/subscribe` | اشتراك Stripe (يعيد `comingSoon` حتى المفاتيح) |
|
||||
| `POST /api/stripe/webhook` | ويبهوك Stripe — يتحقق من التوقيع |
|
||||
|
||||
## 🔐 الأمان
|
||||
|
||||
- **حدود المعدل** (وسيط Next): تسجيل 5/ساعة، دخول 10/15دقيقة، استعادة 5/ساعة — لكل IP، استجابة 429 مع `Retry-After` ورسالة عامة.
|
||||
- **حدود الحافة** (Traefik): 60 طلب/دقيقة للصفحات (انفجار 120) و50 طلبًا جاريًا لكل IP، و`apilimit` للـ API.
|
||||
- **قاعدة الكتابة الموثوقة**: طلبات العميل لا تستطيع تعيين الدور أو التحقق أو حقول الفواتير — تُحذف في خطاف `beforeChange` (`verificationToken`, `stripeCustomerId`, `role`, `emailVerified`).
|
||||
- **HSTS** `max-age=63072000; includeSubDomains` على كل المسارات.
|
||||
- **تكافؤ زمن الدخول**: البريد غير الموجود يستهلك نفس كلفة PBKDF2 (منع تعداد المستخدمين بالزمن).
|
||||
- **التعليقات مُراقَّبة** ولا تُنشر قبل اعتماد المشرف.
|
||||
|
||||
## 📚 الوثائق
|
||||
|
||||
| الملف | المحتوى |
|
||||
|---|---|
|
||||
| [AGENT.md](AGENT.md) | شرح التطبيق والبنية للوكلاء الآليين (AI agents) |
|
||||
| [GLOSSARY.md](GLOSSARY.md) | مسرد المصطلحات الموحّد (عضوية، مفتوح، موثّق…) |
|
||||
| [docs/adr](docs/adr) | قرارات التصميم (ADRs) |
|
||||
| [brand-spec.md](brand-spec.md) | الهوية البصرية والألوان |
|
||||
| [web/docker-compose.vps.yml](web/docker-compose.vps.yml) | تكوين النشر على الـ VPS |
|
||||
|
||||
## 🚢 النشر
|
||||
|
||||
```bash
|
||||
rsync -az --exclude node_modules --exclude .next --exclude .git \
|
||||
--exclude media --exclude files-private --exclude '.env*' --exclude '*.tsbuildinfo' \
|
||||
web/ root@<VPS>:/root/anwarpi/
|
||||
|
||||
ssh root@<VPS> "cd /root/anwarpi && docker compose -f docker-compose.vps.yml up -d --build"
|
||||
```
|
||||
|
||||
أمر التشغيل داخل الحاوية ينفّذ `payload migrate` تلقائيًا قبل الإقلاع.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,21 @@
|
||||
# ADR 0001 — Comments endpoint lives at /api/posts/[slug]/comments, not /api/comments
|
||||
|
||||
Date: 2026-10-03
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The comments API needs a route. The conventional-looking path `/api/comments` collides with Payload: it auto-mounts REST CRUD for every collection at `/api/<slug>`. A `comments` collection therefore already serves `/api/comments` for the admin panel, and moderation (approve/reject) runs through that panel. An app-router route at the same path would shadow and break the panel's CRUD.
|
||||
|
||||
## Decision
|
||||
|
||||
Comments are created at `POST /api/posts/[slug]/comments` (`src/app/api/posts/[slug]/comments/route.ts`), matching the existing `/api/posts/[slug]/like` pattern. Admin moderation keeps using Payload's own REST/admin panel.
|
||||
|
||||
## Tests for a real ADR
|
||||
|
||||
- **Hard to reverse**: the endpoint is a public contract consumed by the client form; moving it breaks every deployed client.
|
||||
- **Surprising**: the collision with Payload's auto-mounted REST is non-obvious — a developer would naturally reach for `/api/comments`.
|
||||
- **Real trade-off**: a shorter conventional path vs. a working admin moderation panel.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR 0002 — Moderated comments with server-forced author
|
||||
|
||||
Date: 2026-10-03
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Comments are user-generated content on a public site. Risks: identity spoofing (a request forging `author`), spam (no gate), and orphaned identity data (nested user lookups through restrictive read access). QA-D04 required a real backend for comments.
|
||||
|
||||
## Decision
|
||||
|
||||
- Comments default to `status: 'pending'`; only `approved` are publicly visible. The read access rule returns `{ status: { equals: 'approved' } }` for non-admins.
|
||||
- The author is forced server-side on create, twice: a `beforeChange` hook on the collection and the custom route — REST-created comments can never spoof identity.
|
||||
- `authorName` is denormalized at submit time (snapshot of `fullName`, else «عضو») to avoid nested user-read access issues when rendering; the email is never exposed.
|
||||
- Approval/rejection is admin-only, done in the Payload admin panel. Authors cannot edit or delete their comments (moderated model).
|
||||
- Authors see their own pending comments rendered on the post page tagged «قيد المراجعة» (via `overrideAccess` query filtered to their own author id), so a submitted comment does not "vanish".
|
||||
|
||||
## Tests for a real ADR
|
||||
|
||||
- **Hard to reverse**: the display-name snapshot and status lifecycle are visible in every rendered comment and in the DB.
|
||||
- **Surprising**: forcing author in two places (hook + route) and `overrideAccess` for the pending query are non-obvious.
|
||||
- **Real trade-off**: moderation latency for users vs. spam safety; denormalization vs. live join through restricted user reads.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR 0003 — Cloudflare Email Sending REST, not SMTP
|
||||
|
||||
Date: 2026-10-03
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The site needs exactly one transactional email: the verification mail sent by `POST /api/auth/resend-verification` via `sendVerificationEmail(to, token)` in `src/lib/mail.ts`. The first implementation used nodemailer over generic SMTP. Cloudflare Email Service (the mail provider chosen for anwarpy.ai) has **no SMTP interface** — it exposes a REST endpoint only — so the nodemailer path could never be activated on the target host.
|
||||
|
||||
## Decision
|
||||
|
||||
- `src/lib/mail.ts` sends via `POST https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/email/sending/send` using `fetch`, authenticated with `Authorization: Bearer ${CLOUDFLARE_API_TOKEN}`.
|
||||
- The `from` field uses the REST shape `{ address, name }` (key is `address`, **not** `email`); default address `noreply@anwarpy.ai` (override: `MAIL_FROM`).
|
||||
- Active only when `CLOUDFLARE_API_TOKEN` **and** `CLOUDFLARE_ACCOUNT_ID` are set; otherwise `{ sent: false }` (existing degrade-gracefully contract — the resend route answers 503 with a clear message).
|
||||
- Non-OK response or fetch throw: `console.error` the error body, return `{ sent: false }`. No retry logic (single-shot; the resend route re-invokes).
|
||||
- nodemailer is uninstalled; the exported signature `sendVerificationEmail(to: string, token: string): Promise<{ sent: boolean }>` is unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The API token needs the Email Sending permission; the sender domain must be onboarded in Cloudflare (SPF/DKIM records created via `wrangler email sending dns get`).
|
||||
- `smtpConfigured()` and all `SMTP_*` env vars are gone; anything expecting them must be updated when mail features grow.
|
||||
- Adding more email types later means extending this one module — it is the single mail boundary.
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR 0004 — Coming-soon subscribe on public launch (no simulated VIP)
|
||||
|
||||
Date: 2026-10-03
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Until Stripe keys exist, the subscribe flow had two self-grant holes that are fatal on a public site:
|
||||
|
||||
1. `POST /api/subscribe` (no keys) returned `{ url: '/payment-success?simulated=1' }`.
|
||||
2. `/payment-success` without a `session_id` set `activate = true` — i.e. any logged-in user visiting the URL became `role: 'vip'` for free.
|
||||
|
||||
On the public internet this lets anyone self-grant VIP.
|
||||
|
||||
## Decision
|
||||
|
||||
- With no Stripe keys, `POST /api/subscribe` returns `{ comingSoon: true }` and no `url`; `SubscribeButton` already renders a disabled «قريباً» button whenever the response has no `url`.
|
||||
- `/payment-success` no longer activates anything without a session: the `else if (!sessionId) { activate = true }` branch is deleted and `simulated` is removed from the page props. After the activation block, free users (`role` not `vip`/`admin`) are redirected to `/subscription`. VIP/admin users pass through to the success copy.
|
||||
- The real Stripe path is unchanged: `session_id` present + `STRIPE_SECRET_KEY` set → session verified with Stripe → VIP activated.
|
||||
- When Stripe keys land, the same pages switch to real Checkout without further flow changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No revenue until Stripe is configured; VIP can still be granted manually by an admin in `/admin`.
|
||||
- Anyone with a saved `?simulated=1` link gets redirected — the query param is simply ignored now.
|
||||
@@ -0,0 +1,47 @@
|
||||
# ADR 0005 — Member trust model (server-only privilege/verification/billing fields)
|
||||
|
||||
Date: 2026-10-09
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The 2026-10-08 pentest (findings #1, #3, #4, #6) confirmed that anonymous or self-authenticated
|
||||
client requests could write privileged fields on `users`:
|
||||
|
||||
1. `POST /api/users` accepted client-supplied `emailVerified`, `role`, `onboarded`,
|
||||
`stripeCustomerId`, `verificationToken` — e.g. instant self-verified admin.
|
||||
2. Self-`PATCH /api/users/{id}` accepted the same fields on update.
|
||||
3. Per-account login lockout (no time-box) let an attacker lock the admin out of the site
|
||||
indefinitely with wrong-password floods.
|
||||
|
||||
## Decision
|
||||
|
||||
- `role`, `emailVerified`, `verificationToken`, `stripeCustomerId`, `onboarded` are stripped from
|
||||
every **untrusted write** in `Users.beforeChange` (`web/src/collections/Users.ts`). Untrusted =
|
||||
no authenticated admin **and** no `context.systemActivation === true`. On create, the hook forces
|
||||
`role: 'free'`, `emailVerified: false`, `onboarded: false`.
|
||||
- Server-side writers opt back in with `context: { systemActivation: true }` on their local-API
|
||||
`payload.update`/`create` calls. Enumerated writers: `verify-email`, `resend-verification`,
|
||||
`subscribe`, `stripe/webhook` (×2), `payment-success`, `seed`. **Omitting the context silently
|
||||
no-ops the write (the field is deleted) — that is the intended failure mode**, so a missing
|
||||
context is caught in QA rather than leaking privilege.
|
||||
- Login lockout is per-account, time-boxed: 5 failed attempts → 15-minute lock (`auth:
|
||||
{ maxLoginAttempts: 5, lockTime: 15 * 60 * 1000 }`).
|
||||
- `role === 'admin'` accounts are **exempt from lockout** (owner decision 2026-10-09): a
|
||||
`beforeOperation` hook on `login` pre-clears `lockUntil`/`loginAttempts` via
|
||||
`db.updateOne` (which, like Payload's own `incrementLoginAttempts`, bypasses hooks). Free/VIP
|
||||
accounts keep the normal 5/15-min lockout.
|
||||
- Commenting requires a verified email; admins are exempt (see the 403 gate in
|
||||
`api/posts/[slug]/comments`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every future server-side writer of these fields MUST pass `context.systemActivation`; a writer
|
||||
that forgets it silently loses its write.
|
||||
- The admin password has **no brute-force throttle** — compensate with a strong password. If that
|
||||
posture changes, delete the `beforeOperation` hook and the account falls back to 5/15-min lockout.
|
||||
- Client-sent profile fields (`fullName`, `termsVersion`, `interest`, `level`, `goal`, `prefs`) stay
|
||||
writable; only privilege/verification/billing fields are protected.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Marketing-skills audit — anwarpy.ai (2026-10-10)
|
||||
|
||||
> Copy of the local audit of 2026-10-10 (source: agent workspace `marketing-skills-audit-findings.md`), committed for the launch PR. Items marked ❌ were fixed the same day in the launch/early-access branch; see `docs/launch/2026-10-10-launch-readiness.md` for per-item evidence.
|
||||
|
||||
Audited against installed playbooks: paywalls, signup, ai-seo, programmatic-seo, aso (`.agents/skills/`). Evidence: repo `web/src` (file:line) + live production HTML. Verdicts: ✅ compliant · ⚠️ partial · ❌ gap.
|
||||
|
||||
## Paywalls (skill: paywalls v2.0)
|
||||
|
||||
**Current state — technically solid.** Gating = `users.role` (free/vip/admin); VIP body has field-level read ACL (`collections/Posts.ts:57-71`) + page-level select — never served to non-VIP. Teaser paragraphs required for VIP posts (`Posts.ts:39-51`) with fade-out mask; paywall box on locked posts has headline, price-in-CTA («اشترك بـ $10 شهرياً», `lib/format.ts:11`), objection line («تلغي في أي وقت»), login secondary. Per-file byte gate on attachments (`/api/files/[id]`). Stripe: Checkout → `/payment-success` re-verification + webhook activation; deletion webhook downgrades. No dark patterns anywhere.
|
||||
|
||||
**Gaps**
|
||||
- ❌ Value demonstration: locked H2 titles never shown (`posts/[slug]/page.tsx:202` extracts TOC only when unlocked) — paywall promises «الأقسام العملية» generically.
|
||||
- ❌ Social proof absent on paywall and subscription (subscription shows guide counts only). No fabricated quotes allowed; needs owner-provided testimonial or real aggregate.
|
||||
- ❌ No FAQ link or free-escape-hatch inside the paywall box (FAQ lives only on /subscription).
|
||||
- ❌ Coming-soon discovered after failed click (`subscription/subscribe-button.tsx:16-27`): glossary says disabled «قريباً» button; actual renders active «اشترك الآن» and reveals coming-soon only after POST fails.
|
||||
- ❌ No measurement (paywall impression/CTA events) — blocked on analytics-tooling owner decision.
|
||||
- ⚠️ Pricing: monthly $10 only; skill suggests annual toggle — owner policy.
|
||||
- 🧹 Dead code: `styles/post-locked.css`, `styles/post-unlocked.css` imported nowhere (live styles in globals.css:168-190, design.css:271-287).
|
||||
|
||||
## Signup (skill: signup v2.0)
|
||||
|
||||
**Current state.** 5 fields (الاسم required, email, password ≥8 with show-toggle + hint, تأكيد كلمة المرور, terms checkbox) + hidden `termsVersion`. Native constraint validation with Arabic custom messages; paste allowed; visible labels; single column mobile. Post-submit: auto-login → `/verify-email` (can explore unverified; only commenting gated, 403 with Arabic copy) → `/onboarding` preserving `anwarpi:signup-next`. `safeNext` redirect hygiene. Rate limits: 5/h signup, 10/15min login per-IP (`middleware.ts`), Payload lockout 5/15min (admin exempt, PBKDF2 timing-equalizer).
|
||||
|
||||
**Gaps**
|
||||
- ❌ **Verification email is never sent at signup** — `sendVerificationEmail` has a single call site (`api/auth/resend-verification/route.ts`); `Users.ts` has no afterChange hook. The verify page claims «أرسلنا رابط التأكيد إلى:» falsely until the user clicks resend. Highest-impact fix.
|
||||
- ❌ 429 body mis-parsed: signup reads only `err?.errors?.[0]?.message` (`signup/page.tsx:44-47`), so middleware's `{error}` JSON is ignored → rate-limited users see «قد يكون البريد مستخدماً بالفعل» (wrong). Login shows one generic message for all failures (`login/page.tsx:29-31`). Retry-After never surfaced.
|
||||
- ❌ No spam-folder step and no change-email affordance on `/verify-email` (skill post-submit checklist).
|
||||
- ❌ Field count above skill's ≤3 guidance (confirm-password cuttable; name kept for comment display names).
|
||||
- ❌ No «مجاناً — بلا بطاقة ائتمانية» / privacy microcopy near CTA; no inline duplicate-email error with login link; no email-typo suggestion; static (not live) password hint.
|
||||
- ❌ Social auth absent (Google/Apple) — needs owner (OAuth apps).
|
||||
- ✅ Already compliant: single email field, no placeholder-only labels, value-story panel, terms timestamped (`termsAcceptedAt`).
|
||||
|
||||
## AI-SEO (skill: ai-seo v2.0)
|
||||
|
||||
**Current state — good SSR baseline.** All pages force-dynamic server components (content in initial HTML); `lang="ar" dir="rtl"`; self-canonical everywhere; title template; sitemap (published posts + lastmod, 8 static paths); robots with disallow list + sitemap pointer; WebSite JSON-LD (home) and Article JSON-LD (posts); answer-first seed content; public per-post JSON at `/api/posts/[slug]`.
|
||||
|
||||
**Gaps**
|
||||
- ❌ No `llms.txt` / `llms-full.txt`.
|
||||
- ❌ No named AI-crawler robots policy (implied `*` only); `/api` disallow hides the agent-readable post JSON.
|
||||
- ❌ Article pages emit **no og:image** — `generateMetadata` (`posts/[slug]/page.tsx:16-33`) defines `openGraph` which *replaces* the root OG object (no siteName/locale/images), though `post.cover.url` exists.
|
||||
- ❌ Draft leak: the metadata query lacks the `status: 'published'` filter the page query has → draft titles/deks render in metadata of 404 pages.
|
||||
- ❌ Article JSON-LD missing `image`, `dateModified`, `description`, `mainEntityOfPage`, `publisher`.
|
||||
- ❌ FAQPage schema absent despite real Q&A (`subscription/page.tsx:16-21` FAQ_ITEMS); BreadcrumbList absent despite breadcrumb UI (`posts/[slug]/page.tsx:179-183`); no Organization schema.
|
||||
- ❌ Lexical renderer has no `table` node case (~`page.tsx:373-387`) — comparison tables in content fall through to `<p>`.
|
||||
- ⚠️ Freshness: sitemap uses updatedAt but the page shows only publishedAt — add «حُدّث» when they differ.
|
||||
- ✅ Answer-first style present; single H1; h2 extraction; agent-accessible free JSON exists.
|
||||
|
||||
## Programmatic SEO (skill: programmatic-seo)
|
||||
|
||||
**Current state.** Indexable surface = `/`, `/articles`, `/posts/[slug]`, 7 static pages. All category/topic navigation is client-side DOM filtering on ONE URL (`articles-client.tsx:11-19, 62-70`) — clicks never update the URL; `?topic=` states share one canonical. `/catalog` permanently redirects to /articles; `catalog-filters.tsx` + `catalog-deck.tsx` exported nowhere (dead).
|
||||
|
||||
**Data available in Postgres** (Posts): title, slug, dek, category (6 values), level (3), access, status, publishedAt/updatedAt, readingMinutes, views, likes, cover, attachments. Public taxonomy already exists: `lib/topics.ts` — 4 topics (`agents` الوكلاء, `models` النماذج, `automation` الأتمتة, `foundations` الأساسيات) mapped from the 6 CMS categories via `topicsFor(post)` + `isTopic()`.
|
||||
|
||||
**Decision (owner-confirmed 2026-10-10): build 4 topic hub pages `/topics/[topic]`** — server-rendered directories with unique intros, counts, breadcrumbs + BreadcrumbList, sitemap entries, internal links from home/articles/post breadcrumbs. Later candidates (not now): topic×level personas (gate on ≥3 posts/combo), templates directory, glossary (needs new field). Comparisons/locations/translations: no data, N/A.
|
||||
|
||||
## ASO (transferable items only — site, not an app)
|
||||
|
||||
- ❌ Home title is brand-only «أنور بآي» — unknown brands need keywords: «أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية».
|
||||
- ❌ Only root + posts have descriptions; give /articles, /subscription, /about their own Arabic descriptions.
|
||||
- ❌ One static OG image for everything → per-post `cover` as og:image (same fix as ai-seo list).
|
||||
- ❌ Surface «حُدّث» freshness date on articles.
|
||||
- ✅ Localization correct (ar locale, RTL, og:locale). Ratings/screenshot fields: N/A.
|
||||
|
||||
## Needs owner (not in this pass)
|
||||
|
||||
1. Social auth providers (Google/Apple or magic links) — OAuth apps + keys.
|
||||
2. Analytics/experimentation tooling (paywall impressions, CTA CTR, form drop-off) — vendor + privacy stance; code ready once chosen.
|
||||
3. Annual plan / money-back guarantee — pricing policy.
|
||||
4. Testimonial content — must be real; nothing fabricated.
|
||||
5. Brand spelling inconsistency: site uses «أنور بآي» everywhere (title, og:site_name, JSON-LD); docs use «أنور بي آي». Pick one canonical spelling.
|
||||
6. Policy: allow free users to comment on VIP posts (currently hard-403) — easy conversion lever if allowed.
|
||||
7. Sitemap `limit: 500` with no pagination — fine at 13 posts; revisit at scale. /login + /signup kept in sitemap.
|
||||
8. `GLOSSARY.md` says free users are *redirected* from /payment-success; actual behavior is an in-page retry state — doc will be aligned to code (no redirect; no VIP minting either way).
|
||||
@@ -0,0 +1,347 @@
|
||||
# Anwarpy.ai launch readiness and developer handoff
|
||||
|
||||
Prepared for Ahmed and the developer team. Basis: public-site audit on 10 October 2026, refreshed membership/terms/privacy pages, Ahmed's confirmation that Stripe is unfinished, and the installed launch skill's readiness and site-QA framework.
|
||||
|
||||
## 1. Decision
|
||||
|
||||
**Paid/VIP launch: NO-GO at present. Free early-access launch: CONDITIONAL GO after the launch-blocking checks below pass.**
|
||||
|
||||
Stripe does not have to delay a useful free content launch. Launch the complete experience of reading useful Arabic posts and approved downloads first. Run an approximately two-week learning period; treat its end as a review checkpoint, not an automatic paywall date. Continue free early access if Stripe or access control remains unready. Do not advertise an exact paid-start date until approved.
|
||||
|
||||
This document is a proposed implementation specification, not evidence that fixes have been applied. No website, Stripe account, source notebook or production database was modified during preparation. Ticket owners are recommended roles; individual assignees and calendar dates remain to be assigned.
|
||||
|
||||
### User decisions incorporated
|
||||
|
||||
- Blog content originates primarily in AppFlowy and Obsidian notes.
|
||||
- An AI agent will help turn approved notes into blog posts.
|
||||
- Stripe integration is unfinished.
|
||||
- Desired initial period: about two weeks with free access, followed by VIP gating after Stripe is ready.
|
||||
|
||||
### Important correction to the earlier audit
|
||||
|
||||
Short notes are a legitimate content type. A 150–300-word note need not become a long tutorial to be useful. The problem is a mismatch between short content and labels promising a complete implementation or nine/ten minutes of reading. Add explicit content types and honest estimates. Expand only posts intended to be implementation guides, not every note.
|
||||
|
||||
## 2. Evidence and status
|
||||
|
||||
Confirmed in the earlier public audit:
|
||||
- All 22 unique sitemap URLs returned 200, including 14 posts: eight public full posts and six VIP previews.
|
||||
- Public text and membership price were present in initial HTML.
|
||||
- Canonicals, Arabic/RTL settings, sitemap and robots.txt exist.
|
||||
- Article and WebSite JSON-LD were observed in initial HTML. Schema is not absent.
|
||||
- Homepage/library titles are generic; eight non-post pages share a generic description.
|
||||
- Login and signup are in the sitemap; no noindex meta was observed in their initial HTML.
|
||||
- www serves a 200 homepage while canonical points to the apex domain.
|
||||
- First-agent and RAG article bodies were approximately 165 and 154 whitespace-delimited words, despite nine/ten-minute reading estimates.
|
||||
- No n8n mention was found in public text of the 14 posts. Paid bodies were not inspected.
|
||||
- Some free posts display download controls, while the paid comparison frames working files as a membership benefit. Actual download behavior was not tested.
|
||||
|
||||
Rechecked for this handoff:
|
||||
- /subscription still advertises $10 monthly, subscription CTAs and Stripe payment.
|
||||
- /terms still declares itself a prelaunch draft and calls the price experimental.
|
||||
- /privacy still contains prelaunch placeholders for actual providers, retention and effective dates.
|
||||
|
||||
Not verified; developer must test:
|
||||
- Full reading/download access when launch-free mode is applied.
|
||||
- Account signup, email verification, password reset, save/comment flows.
|
||||
- Mobile rendering, RTL behavior, browser interactions, accessible controls.
|
||||
- Checkout, webhooks, entitlements, cancellation and renewal.
|
||||
- Paid-body protection in APIs, page source, hydration payloads, search, feeds, assets and caches.
|
||||
- Restore procedures, monitoring, deployed security controls.
|
||||
- Search Console index status, field performance, analytics and actual AI citations.
|
||||
|
||||
The audit browser failed to start and PageSpeed returned 429. These failures do not establish website defects. Public metadata checks do not certify application behavior.
|
||||
|
||||
## 3. Recommended launch policy
|
||||
|
||||
### Free launch mode
|
||||
|
||||
Recommended initial settings, to adapt to the actual application rather than copy as existing field names:
|
||||
|
||||
```yaml
|
||||
launch_mode: free_early_access
|
||||
billing_enabled: false
|
||||
vip_enforcement_enabled: false
|
||||
review_checkpoint: approximately_two_weeks_after_actual_launch
|
||||
paid_transition: manual_owner_approval_only
|
||||
reading_requires_account: false
|
||||
newsletter_opt_in_required: true
|
||||
```
|
||||
|
||||
Open approved published content, including posts designated for future VIP access, during the early-access period. Keep drafts, private source notes, internal comments and unsafe attachments private. Guest reading should work without an account; accounts can remain optional for tested features. If signup is not ready, disable its CTA instead of blocking reading.
|
||||
|
||||
**Do not create Stripe trial subscriptions to implement this temporary editorial launch.** A site-level free-access policy is simpler and avoids accidentally collecting cards, issuing charges or making every reader a billing customer.
|
||||
|
||||
### Preserve editorial classification
|
||||
|
||||
Store the intended tier independently from effective access. A post can be designated VIP for the future while temporarily open now. Recommended conceptual fields:
|
||||
|
||||
```yaml
|
||||
publication_status: draft | review | published | archived
|
||||
content_type: note | tutorial | case_study | comparison
|
||||
intended_tier: free | vip
|
||||
early_access_eligible: true | false
|
||||
source_reference: private_traceability_record
|
||||
author_id: approved_real_author
|
||||
review_status: pending | approved
|
||||
```
|
||||
|
||||
The global launch override should apply only to approved published posts and approved associated assets. Never rewrite every VIP post to a permanent free tier, grant permanent VIP user roles, or expose raw CMS records in a public launch feed.
|
||||
|
||||
### What happens after the two-week checkpoint?
|
||||
|
||||
- If Stripe is not ready: continue free mode, update messaging and set a new review checkpoint.
|
||||
- If Stripe is ready but tests fail: continue free mode; repair failures before gating.
|
||||
- If tests pass: notify readers, specify the future gated set, have Ahmed approve the transition, then enable billing and gating in a controlled rollout.
|
||||
- Posts designated permanently free remain free.
|
||||
- Posts explicitly labeled as temporary VIP early-access may become previews. Preserve their URLs and public summaries.
|
||||
- Already downloaded public files cannot reliably be recalled. Do not promise otherwise.
|
||||
- Readers are never automatically subscribed or charged. Joining an update list is not consent to recurring billing.
|
||||
|
||||
Recommended trust-preserving alternative: keep the strongest launch tutorials free permanently and monetize new advanced workflows and files. This is an option for Ahmed to choose, not an assumed change to his requested VIP plan.
|
||||
|
||||
## 4. P0 blockers for the free launch
|
||||
|
||||
### L-01 — Replace premature paid messaging
|
||||
|
||||
**Owner:** developer + Ahmed. **Evidence:** /subscription, homepage membership section and VIP CTAs currently promise purchasing through Stripe.
|
||||
|
||||
**Change:** Replace purchase CTAs across home, membership, cards, article gates, menus and account pages. Retain /subscription URL but present early-access status and an optional, functional notification signup. Disable checkout initiation server-side as well as hiding buttons. Never publish a fake working purchase flow.
|
||||
|
||||
Suggested Arabic copy:
|
||||
|
||||
> وصول مبكر مجاني
|
||||
>
|
||||
> يمكنك قراءة المحتوى المنشور خلال فترة الوصول المبكر دون بطاقة دفع. نراجع إطلاق العضوية بعد نحو أسبوعين، وقد تمتد الفترة إذا لم تكتمل خدمة الدفع. بعض المواد محددة للعضوية مستقبلاً، وسنوضح ذلك قبل تفعيلها. لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.
|
||||
|
||||
For an intended-VIP post:
|
||||
|
||||
> متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.
|
||||
|
||||
Use only when this is the actual implemented access policy. Do not put an unapproved fixed end date in the UI. Remove '$10 subscribe now' and completed-payment claims until payment is genuinely enabled. A future-price preview, if retained, must be labeled not currently purchasable and subject to owner confirmation.
|
||||
|
||||
**Acceptance:** Search all rendered templates and emails for obsolete purchase claims; direct checkout calls fail safely with an explicit unavailable response; no card data is requested; /subscription remains a useful page rather than 404.
|
||||
|
||||
### L-02 — Implement consistent launch-free access
|
||||
|
||||
**Owner:** backend developer. **Evidence:** six posts currently show public membership previews; desired open period is new policy.
|
||||
|
||||
**Change:** One server-side policy function controls article bodies and downloads. It checks publication/review status, actual launch mode, intended tier, and valid entitlement when paid mode is eventually enabled. Frontend labels consume the same effective-access decision.
|
||||
|
||||
**Acceptance:** Guest can read every approved launch-open post from its canonical URL, library and related links. Approved downloads work. Draft/private/unreviewed posts remain inaccessible. Original intended tier survives opening and rollback. Repeated config changes are idempotent. Date expiry alone cannot activate a paywall.
|
||||
|
||||
### L-03 — Finalize free-launch terms and privacy
|
||||
|
||||
**Owner:** Ahmed + qualified reviewer + developer. **Evidence:** /terms and /privacy are publicly marked drafts with unfinished instructions.
|
||||
|
||||
**Change:** Document the actual free service, content license, whether future VIP materials are temporary-access, contact channel, real data collection, providers, retention, deletion process and effective date. Distinguish operational email from optional marketing. Remove paid billing provisions that do not apply now or label future features accurately. Do not invent legal entity, governing law, retention duration or compliance claims.
|
||||
|
||||
**Acceptance:** Final pages contain facts approved by the operator, no editorial TODO instructions, and a real effective date. Their account and analytics descriptions match the deployed behavior. If analytics requires consent in the applicable context, blocking and withdrawal work. Legal text is professionally reviewed as appropriate; this document is not legal advice.
|
||||
|
||||
### L-04 — Test the complete reading and download journey
|
||||
|
||||
**Owner:** QA/developer. **Status:** unverified, not a known failure.
|
||||
|
||||
**Change:** Test home → library → each launch article → approved file download on desktop and mobile, including Safari and Telegram in-app browser. Check filters, navigation, pagination if any, table/code readability, Arabic text mixed with English commands, and contact links.
|
||||
|
||||
**Acceptance:** No placeholder/empty files; names and contents match promises; links and images resolve; keyboard controls and labels work; no horizontal page overflow. Code blocks may scroll independently. For meaningful diagrams use descriptive alt; decorative card covers may have empty alt. Store a browser/version test log and example screenshots.
|
||||
|
||||
### L-05 — Protect private notes and publishing permissions
|
||||
|
||||
**Owner:** developer + editorial operator. **Status:** new requirement for the publishing pipeline, not an observed leak.
|
||||
|
||||
**Change:** Separate public artifacts from AppFlowy/Obsidian sources. Restrict the agent to explicitly approved notes and CMS permissions. Scan text, images and downloads for credentials, private URLs, customer data, internal names and unlicensed assets. Sanitize Markdown/HTML, block executable content and unsafe URLs.
|
||||
|
||||
**Acceptance:** Deliberately seeded private markers in test inputs never reach public output. Source notes remain unchanged. An agent cannot publish another author's private note, alter a tier, modify billing, or inject unsafe HTML. Logs contain identifiers and outcomes rather than secrets/full private source text.
|
||||
|
||||
### L-06 — Reliable feedback, monitoring and rollback
|
||||
|
||||
**Owner:** developer + operator. **Status:** unverified.
|
||||
|
||||
**Change:** Ensure at least one monitored support/feedback route works. Add uptime/error visibility, persistence/backups and a tested restore. Test optional signup/reset flows before promoting them; disable unfinished features rather than presenting stubs.
|
||||
|
||||
**Acceptance:** A test feedback request reaches the actual destination and confirmation is truthful. A restore/rollback rehearsal recovers public content and configuration. Transactional notices reach the intended test mailbox without leaking data. Operator and emergency contact are named in the runbook.
|
||||
|
||||
## 5. P1 work during the early-access period
|
||||
|
||||
### L-07 — Content labels and reading estimates
|
||||
|
||||
**Owner:** editorial + developer. Add visible 'ملاحظة تقنية', 'دليل عملي', 'تجربة موثقة' or equivalent content-type labels. Compute reading estimate from article body using a configurable rate validated with Arabic readers; exclude navigation/comments and treat exercise time separately. Do not force a minimum long reading time.
|
||||
|
||||
**Acceptance:** Short notes are labeled honestly; tutorials show prerequisites and usable steps. The first-agent and RAG labels/estimates match actual content. No arbitrary padding to increase word count.
|
||||
|
||||
### L-08 — Metadata and domain hygiene
|
||||
|
||||
**Owner:** developer + editorial. Write individual descriptions for home/library/early-access/about. Improve discoverability-oriented titles without adding unsupported topics. Review noindex for login/signup, remove utility pages from sitemap, retain crawl visibility so noindex can be read. Permanently redirect www to apex while preserving path/query. Test nonexisting paths remain 404 and HTTPS canonical consistency.
|
||||
|
||||
**Acceptance:** Public posts remain indexable, private/draft pages do not enter sitemap, sitemap URLs return canonical 200s, metadata matches actual article and access status. Never ship staging noindex to public content.
|
||||
|
||||
### L-09 — Author, evidence and structured data
|
||||
|
||||
**Owner:** editorial + developer. Display real author/reviewer and sources. Enhance Article/BlogPosting fields with author profile, image, dates and canonical identity where correct. Do not invent credentials, reviews or performance data. Validate initial and rendered markup with suitable validators. Preserve existing useful schema.
|
||||
|
||||
**Acceptance:** Visible author and schema agree. Update date reflects real substantive updates. Early-access availability and future paywall fields match actual current access; no false paid Offer while billing is off. Validators report understood results; no promise of a special Google AI ranking boost.
|
||||
|
||||
### L-10 — Publishing workflow and deduplication
|
||||
|
||||
**Owner:** developer + editorial. Implement the companion agent prompt. Map proposed metadata to real CMS fields, create drafts first, review evidence and redactions, approve a specific revision, then publish and read it back. Use stable source IDs plus revision/hash to prevent duplicate posts; existing slugs should not change silently.
|
||||
|
||||
**Acceptance:** Same source retried produces no duplicate. Missing-source and failed-test inputs stop safely. Live output is verified against the approved draft. Retries after an ambiguous publish first search/read the expected record. Previous revisions can be restored. No note is marked published until verification succeeds.
|
||||
|
||||
### L-11 — Measure the launch
|
||||
|
||||
**Owner:** operator + developer. Configure privacy-respecting analytics and search ownership. Suggested events: article_view, meaningful_read, download_success, signup_confirmed, notification_opt_in_confirmed, feedback_received. Fire confirmations on real success, not button clicks. Define meaningful_read rather than using a page-open event as consumption.
|
||||
|
||||
**Acceptance:** Test event evidence exists, duplicate analytics installation is avoided, consent is respected, no source notes/emails/credentials appear in event parameters. Verify sitemap and priority URL inspection in Search Console; a search-tool miss is not proof of nonindexation.
|
||||
|
||||
### L-12 — Content and launch distribution
|
||||
|
||||
**Owner:** Ahmed/editorial. Select an initial set of useful notes and at least one demonstrated implementation if advertised as an implementation library. Publish a specific n8n guide only when an approved working example exists. Keep related links useful; differentiate overlapping beginner posts.
|
||||
|
||||
Use the launch skill's owned/rented/borrowed channel mix proportionally:
|
||||
- Owned: website and opt-in email updates.
|
||||
- Rented: one LinkedIn announcement and a real demonstration where the audience is present.
|
||||
- Borrowed: ask relevant collaborators for feedback/share only with permission.
|
||||
|
||||
**Acceptance:** Announcement links to an actually accessible post, accurately states free early access, makes no fabricated testimonial or availability claim, and has a monitored feedback path. Product Hunt, affiliate programs and paid ads are optional, not prerequisites for this content launch.
|
||||
|
||||
## 6. Stripe/VIP gate — required only before paid launch
|
||||
|
||||
Do not block the initial free reading experience on these. Do block purchases and paid enforcement until complete.
|
||||
|
||||
### S-01 — Business and account readiness
|
||||
|
||||
Operator checks actual Stripe entity eligibility, verification, settlement/payout setup and enabled capabilities for the business jurisdiction. Do not assume an account is eligible because customers are Saudi or a domain is .sa. No workaround through an unsupported entity. Resolve applicable billing/tax requirements with qualified advice. Do not promise a completion timeline based on this document.
|
||||
|
||||
### S-02 — Product, price and checkout
|
||||
|
||||
Create/verify the approved subscription product and immutable price reference in the correct environment. Confirm currency, monthly interval, actual amount, tax treatment and displayed totals. Use a hosted Checkout subscription flow rather than handling raw card details. Server selects the allowed price, amount and user; reject client-supplied substitutions. Map customer/subscription to the authenticated app user using trusted server-side data.
|
||||
|
||||
**Tests:** correct plan, tampered price, wrong customer, repeated checkout, cancelled checkout, authentication-required payment and declined payment. Returning to a success URL never grants VIP by itself. Early free access is not retroactively billed.
|
||||
|
||||
### S-03 — Verified webhook processing
|
||||
|
||||
Use official signature verification with the raw body and the proper environment-specific signing secret. Persist event IDs, process duplicates safely and reconcile subscription state instead of assuming delivery order. Durably record/enqueue work before acknowledging; maintain retry/dead-letter visibility. Use narrowly selected relevant events such as checkout completion, invoice paid/payment failure and subscription changes/end as required by the chosen integration; account for delayed payment methods if enabled.
|
||||
|
||||
**Tests:** invalid signature, duplicate delivery, out-of-order event, network interruption, delayed event, update/cancellation and recovery. A duplicate event neither duplicates entitlements nor creates additional charges. Test mode never activates production VIP rights.
|
||||
|
||||
### S-04 — Server-side entitlements and lifecycle policy
|
||||
|
||||
Define explicit access rules for active, incomplete, unpaid, past-due, canceled and any genuinely supported trial states. Document grace periods; do not invent one silently. Maintain paid-through timestamps and cancellation-at-period-end behavior consistent with promises. Reconcile against Stripe after missed events; do not revoke valid existing access merely because of a transient retrieval error. New unverifiable purchases stay pending and are not granted based on UI redirects.
|
||||
|
||||
**Tests:** paid user receives correct access; nonpayer cannot gain VIP by changing frontend storage; scheduled cancellation retains access through paid end and expires correctly; renewal extends once; failed renewal follows the approved policy; reversal/refund/dispute handling matches business policy.
|
||||
|
||||
### S-05 — Prevent full-content leaks
|
||||
|
||||
Paid gating must exclude restricted bodies/assets for unauthorized users from server HTML, page hydration/RSC payloads, APIs, GraphQL/REST responses, RSS/search feeds, public files, alternate Markdown and optional llms exports. Do not send full bodies and blur/hide them with CSS. Protect VIP downloads server-side and use scoped delivery where needed.
|
||||
|
||||
Separate guest/free/member caching; invalidate public copies of newly restricted content when switching. Previously public copies on third-party caches cannot be fully recalled.
|
||||
|
||||
**Tests:** guest/free user sees a useful public intro but never the restricted payload; valid member can read/download; expired member is denied; direct asset/API attempts do not bypass checks. If a crawler sampling policy is used, follow applicable search guidance without returning special promotional content or disabling authentication for arbitrary bot strings.
|
||||
|
||||
### S-06 — Customer self-service and notices
|
||||
|
||||
Provide a tested billing-management path for invoices, payment-method updates and cancellation. The app must generate a portal session for the logged-in user's own customer, not arbitrary IDs. Verify actual supported portal language; do not promise Arabic availability without testing. Keep Arabic app guidance around an English portal if needed. Confirm receipt/support contacts, statement details, renewal/cancellation notices and refund policy.
|
||||
|
||||
### S-07 — Sandbox acceptance and production preparation
|
||||
|
||||
Use Stripe sandbox/test resources and official test methods for success, failures, authentication, renewals and time-based lifecycle scenarios. Do not use real card data or arbitrary real purchases for testing. Record environment, SDK/API versions, relevant object/event IDs, expected result, actual result and pass/fail, with secrets removed. Test signing-secret rotation and failure visibility. Production keys and price IDs are stored securely and never inserted into source notes, prompts or repositories.
|
||||
|
||||
### S-08 — Owner-approved transition and rollback
|
||||
|
||||
Before switch, take an access/content snapshot, confirm notification copy and approve a list of specific intended-VIP posts/assets. Keep canonical URLs; retain public introductions and meaningful metadata. Synchronize all counters, cards, comparison tables and policies. Invalidate relevant caches and verify guest/member journeys immediately.
|
||||
|
||||
Rollback should disable new purchases and revert the faulty restriction configuration without deleting Stripe records, canceling paid customers or erasing paid entitlements. Repair paid readers' access quickly and reconcile outstanding events. A kill switch is not authorization to give all private content away.
|
||||
|
||||
## 7. Approximate two-week operating plan
|
||||
|
||||
Relative timing only; actual launch date is not yet set.
|
||||
|
||||
- Before day 1: close L-01 through L-06, approve content/access labels, and complete the free-launch QA gate.
|
||||
- First few days: publish one useful launch post, invite a small set of readers, gather specific feedback, fix broken journeys before wider announcement.
|
||||
- First week: ship reviewed notes and at least one evidence-backed implementation if available; correct metadata/reading estimates; test notifications and source pipeline.
|
||||
- Second week: review read/download/feedback evidence, publish improvements, and complete Stripe sandbox work in parallel.
|
||||
- Around the two-week checkpoint: inspect the free experience and Stripe/VIP test matrix. Either extend early access honestly or schedule an owner-approved paid transition. Do not activate gating solely because a calendar timer expired.
|
||||
|
||||
No automatic schedule or campaign was created by this document.
|
||||
|
||||
## 8. Developer signoff checklist
|
||||
|
||||
### Free launch GO only when
|
||||
|
||||
- [ ] L-01: paid claims and backend purchase paths are disabled consistently.
|
||||
- [ ] L-02: all approved launch-open content/assets work for intended readers; private content stays private.
|
||||
- [ ] L-03: real free-launch terms/privacy/access expectations are approved and published.
|
||||
- [ ] L-04: desktop/mobile/in-app reading and download QA passes.
|
||||
- [ ] L-05: note redaction, safe rendering and agent permissions pass negative tests.
|
||||
- [ ] L-06: feedback, monitoring, backup/restore and rollback work.
|
||||
- [ ] Ahmed approves the precise launch-open set and announcement.
|
||||
|
||||
### Paid launch GO only when
|
||||
|
||||
- [ ] S-01 to S-08 pass with recorded evidence.
|
||||
- [ ] Actual billing currency, amount, tax and refund handling are approved.
|
||||
- [ ] Guest, free, active member and expired member access matrices pass.
|
||||
- [ ] No restricted data appears in anonymous responses/feeds/assets/caches.
|
||||
- [ ] Readers receive honest notice of the transition and no automatic charge.
|
||||
- [ ] Ahmed signs off the transition; operator and rollback owner are assigned.
|
||||
|
||||
For each item record: ticket ID; assignee; environment; PR/deployment ID; test steps; expected/actual result; evidence link; status; remaining risk; reviewer. A checkbox without evidence is not release verification.
|
||||
|
||||
## 9. Decisions Ahmed/developer must settle
|
||||
|
||||
1. Which existing intended-VIP posts/assets are temporarily opened? Recommended: approved published VIP materials only, not raw notes or drafts.
|
||||
2. Which launch tutorials remain permanently free? Recommended: retain core introductory tutorials for discovery.
|
||||
3. What must require an account? Recommended: guest reading; account only for tested save/comment functions.
|
||||
4. Is an email-update feature ready? If not, omit the CTA rather than deploying a fake signup.
|
||||
5. What notice period will be used before restricting temporary VIP early-access materials? Agree and implement; no arbitrary period is treated as approved here.
|
||||
6. Who approves posts and launch switches? Until explicitly delegated, human approval is required for a specific article revision and for paid-mode changes.
|
||||
7. What are the actual retention periods, providers, legal entity and billing jurisdiction? Operator supplies facts; the agent must not invent them.
|
||||
|
||||
## 10. Reference links
|
||||
|
||||
Website evidence:
|
||||
- https://anwarpy.ai/
|
||||
- https://anwarpy.ai/articles
|
||||
- https://anwarpy.ai/subscription
|
||||
- https://anwarpy.ai/terms
|
||||
- https://anwarpy.ai/privacy
|
||||
- https://anwarpy.ai/posts/first-arabic-agent
|
||||
- https://anwarpy.ai/posts/simple-arabic-rag
|
||||
- https://anwarpy.ai/posts/dograh-voice-agent-evaluation
|
||||
- https://anwarpy.ai/sitemap.xml
|
||||
- https://anwarpy.ai/robots.txt
|
||||
|
||||
Stripe primary guidance consulted:
|
||||
- https://docs.stripe.com/billing/subscriptions/webhooks
|
||||
- https://docs.stripe.com/webhooks
|
||||
- https://docs.stripe.com/testing
|
||||
- https://docs.stripe.com/customer-management
|
||||
|
||||
Companion document: anwarpy-blog-publishing-agent-prompt.md. The prompt controls content generation and approval, not billing activation.
|
||||
|
||||
## ملخص عربي
|
||||
|
||||
الإطلاق المدفوع غير جاهز الآن لأن ربط Stripe لم يكتمل. يمكن إطلاق قراءة مجانية مبكرة بعد إصلاح رسائل الاشتراك والسياسات، وفتح المواد المعتمدة فقط، واختبار القراءة والتنزيل وحماية الملاحظات الخاصة. الأسبوعان نقطة مراجعة وليسا موعد حجب تلقائي. الملاحظات القصيرة مقبولة إذا سُمّيت ملاحظات وكان وقت القراءة صادقاً. لا تتحول المواد إلى VIP إلا بعد نجاح اختبارات الدفع والصلاحيات وإبلاغ القراء وموافقة أحمد، ولا يُنشأ اشتراك أو يُحصّل مبلغ تلقائياً.
|
||||
|
||||
---
|
||||
|
||||
## 11. Evidence — free-launch items executed 2026-10-10
|
||||
|
||||
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-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. **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-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`. **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** (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` 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 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-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-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).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Owner inputs required for L-03 — /terms and /privacy
|
||||
|
||||
Ahmed must supply the facts below before `/terms` or `/privacy` are edited. The agent will not invent any of them (checklist §9.7). Once provided, the pages get real text, an effective date, and go into the launch checklist evidence.
|
||||
|
||||
## Legal facts (checklist L-03 + §9)
|
||||
|
||||
1. **Legal entity** — registered company name and form, or an explicit statement that the site is operated as a sole proprietorship (and under what name).
|
||||
2. **Governing law / jurisdiction** — the law that governs the terms and the courts or venue for disputes.
|
||||
3. **Retention periods per data type** — accounts and hashed passwords, verification/reset tokens, comments, saved posts, launch-notification emails, server logs. Include "until account deletion" where that is the real answer.
|
||||
4. **Full provider list** — as currently known: Cloudflare (DNS/proxy, Email Routing + Email Sending), VPS host for 147.79.102.201, Postgres (self-hosted on that VPS). Confirm or correct; note anything that processes personal data outside these.
|
||||
5. **Data-deletion process** — how a user requests deletion (email address or in-app), what is deleted vs retained, and the timeframe.
|
||||
6. **Contact channel** — the address that appears in the pages and must actually reach Ahmed (currently the footer `mailto:hello@anwarpy.ai`).
|
||||
7. **Effective date** — the date the final terms/privacy go live (this becomes the published "آخر تحديث").
|
||||
|
||||
## Checklist §9 decisions (needed to finalize the same pages)
|
||||
|
||||
1. Which existing intended-VIP posts/assets are temporarily opened? (Recommended: approved published VIP materials only.)
|
||||
2. Which launch tutorials remain permanently free? (Recommended: core introductory tutorials.)
|
||||
3. What requires an account? (Recommended: guest reading; account only for save/comment.)
|
||||
4. Is an email-update feature ready? (The notify list exists; confirm it may be advertised.)
|
||||
5. Notice period before restricting temporary VIP early-access materials.
|
||||
6. Who approves posts and launch switches (Ahmed until delegated)?
|
||||
7. Actual retention periods, providers, legal entity and billing jurisdiction (see items 1–7 above).
|
||||
|
||||
## Operational blocker found 2026-10-10 (also affects the pages' email claims)
|
||||
|
||||
Cloudflare Email Sending is **disabled** on the account (API test returned `10203 email.sending_disabled`). Before any page or flow promises email delivery: enable Email Sending in the Cloudflare dashboard (Account → Email), then re-test the transport. The footer mailto link is unaffected (it opens the visitor's own mail client).
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -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
|
||||
@@ -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))
|
||||
@@ -15,6 +15,9 @@ services:
|
||||
retries: 10
|
||||
|
||||
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:
|
||||
context: .
|
||||
args:
|
||||
@@ -30,17 +33,29 @@ services:
|
||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
|
||||
AGENT_API_KEY: ${AGENT_API_KEY}
|
||||
NEXT_PUBLIC_SITE_URL: https://anwarpy.ai
|
||||
LAUNCH_MODE: ${LAUNCH_MODE:-free_early_access}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID}
|
||||
MAIL_FROM: noreply@anwarpy.ai
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi.entrypoints=websecure
|
||||
- traefik.http.routers.anwarpi.tls.certresolver=mytlschallenge
|
||||
- traefik.http.routers.anwarpi.middlewares=pagelimit@docker,connlimit@docker
|
||||
- traefik.http.services.anwarpi.loadbalancer.server.port=3000
|
||||
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && (Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`))
|
||||
# 39: canonical host is the apex. www hits this router first (priority
|
||||
# above anwarpi-api's 100) and is 301-redirected before any rate limit.
|
||||
- traefik.http.routers.anwarpi-www.rule=Host(`www.anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi-www.entrypoints=websecure
|
||||
- traefik.http.routers.anwarpi-www.tls.certresolver=mytlschallenge
|
||||
- traefik.http.routers.anwarpi-www.priority=110
|
||||
- traefik.http.routers.anwarpi-www.service=anwarpi
|
||||
- traefik.http.routers.anwarpi-www.middlewares=www-redirect
|
||||
- traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\.anwarpy\.ai/(.*)
|
||||
- traefik.http.middlewares.www-redirect.redirectregex.replacement=https://anwarpy.ai/$$1
|
||||
- traefik.http.middlewares.www-redirect.redirectregex.permanent=true
|
||||
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && Host(`anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi-api.entrypoints=websecure
|
||||
- traefik.http.routers.anwarpi-api.tls=true
|
||||
- traefik.http.routers.anwarpi-api.tls.certresolver=mytlschallenge
|
||||
|
||||
@@ -24,7 +24,7 @@ Publish and manage blog posts programmatically. Auth: send the agent key in the
|
||||
## Create + publish a post with an image
|
||||
|
||||
```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)
|
||||
python3 - <<'EOF' > /tmp/post.json
|
||||
@@ -54,11 +54,20 @@ Response `201`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 113, "title": "دليلي الجديد", "slug": "my-new-guide",
|
||||
"status": "published", "access": "vip", "category": "agents",
|
||||
"id": 113,
|
||||
"title": "دليلي الجديد",
|
||||
"slug": "my-new-guide",
|
||||
"status": "published",
|
||||
"access": "vip",
|
||||
"category": "agents",
|
||||
"cover": { "id": 113, "url": "/api/media/file/my-cover.jpg" },
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:launch": "node --test tests/free-launch.test.cjs",
|
||||
"dev": "next dev",
|
||||
"build": "payload generate:types && next build",
|
||||
"start": "next start",
|
||||
|
||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Backfill `readingMinutes` on every post from its body (L-07).
|
||||
* No schema change — safe to re-run any time.
|
||||
*
|
||||
* Usage: cd web && npx tsx scripts/backfill-reading-minutes.ts
|
||||
*/
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { countLexicalWords } from '../src/lib/lexical'
|
||||
import { READING_WORDS_PER_MINUTE } from '../src/lib/launch'
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
let updated = 0
|
||||
for (const post of docs) {
|
||||
const words = countLexicalWords(post.content)
|
||||
const readingMinutes = Math.max(1, Math.round(words / READING_WORDS_PER_MINUTE))
|
||||
if (readingMinutes !== post.readingMinutes) {
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: { readingMinutes },
|
||||
overrideAccess: true,
|
||||
context: { systemBackfill: true },
|
||||
})
|
||||
payload.logger.info(`backfill: ${post.slug} ${post.readingMinutes} → ${readingMinutes} (${words} words)`)
|
||||
updated += 1
|
||||
}
|
||||
}
|
||||
|
||||
payload.logger.info(`backfill complete: ${docs.length} scanned, ${updated} updated`)
|
||||
// Postgres pool keeps the loop alive — release it so the script exits.
|
||||
await payload.db.destroy?.()
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# anwarpy.ai — Postgres backup (L-06)
|
||||
#
|
||||
# Run ON THE VPS (needs the `proxy`/compose network up):
|
||||
# ./scripts/backup-db.sh
|
||||
#
|
||||
# Cron (nightly 03:15, keep dir writable):
|
||||
# 15 3 * * * cd /root/anwarpi && ./scripts/backup-db.sh >> backups/backup.log 2>&1
|
||||
#
|
||||
# Restore a dump into the running db container:
|
||||
# gunzip -c backups/anwarpi-YYYYMMDD-HHMMSS.sql.gz | \
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi anwarpi
|
||||
#
|
||||
# Restore-rehearsal (verify a backup is loadable BEFORE you need it):
|
||||
# gunzip -c backups/<file>.sql.gz | \
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -c "CREATE DATABASE anwarpi_restore_test;"
|
||||
# gunzip -c backups/<file>.sql.gz | \
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -d anwarpi_restore_test -q
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -d anwarpi_restore_test -tAc \
|
||||
# "SELECT (SELECT count(*) FROM posts) AS posts, (SELECT count(*) FROM users) AS users;"
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -c "DROP DATABASE anwarpi_restore_test;"
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mkdir -p backups
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
OUT="backups/anwarpi-${STAMP}.sql.gz"
|
||||
|
||||
docker compose -f docker-compose.vps.yml exec -T db pg_dump -U anwarpi anwarpi | gzip > "$OUT"
|
||||
echo "wrote $OUT ($(du -h "$OUT" | cut -f1))"
|
||||
@@ -3,7 +3,11 @@ import Footer from '@/components/footer'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'عن الفريق' }
|
||||
export const metadata = {
|
||||
title: 'عن الفريق: تجارب الذكاء الاصطناعي والبنية التحتية',
|
||||
description:
|
||||
'فريق أنور پآي: عرب يكتبون محتوى ذكاء اصطناعي عملي — تجارب موثقة، خطوات قابلة للتطبيق، وملفات عمل حقيقية بلا حشو تسويقي.',
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const viewer = await getViewer()
|
||||
@@ -15,7 +19,7 @@ export default async function AboutPage() {
|
||||
<div className="wrap about-hero-grid">
|
||||
<div>
|
||||
<h1 id="about-title">نكتب عن تجاربنا، ونشارك ما جرّبناه عمليًا</h1>
|
||||
<p className="lede">أنور بآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى أدلة عربية يمكن تنفيذها في عملك.</p>
|
||||
<p className="lede">أنور پآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى محتوى عربي يمكن تطبيقه في عملك.</p>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="about-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
@@ -50,7 +54,7 @@ export default async function AboutPage() {
|
||||
|
||||
<section className="about-principles" aria-labelledby="principles-title">
|
||||
<div className="wrap principles-grid">
|
||||
<h2 id="principles-title">كيف نكتب كل دليل</h2>
|
||||
<h2 id="principles-title">كيف نكتب كل عنصر محتوى</h2>
|
||||
<dl className="principles">
|
||||
<div><dt>التطبيق قبل الاستعراض</dt><dd>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</dd></div>
|
||||
<div><dt>نشرح ما تحت الغطاء</dt><dd>نفضّل الأدوات المفتوحة والتشغيل الذي يمنحك فهماً وتحكماً أكبر في بياناتك ومواردك.</dd></div>
|
||||
@@ -61,7 +65,7 @@ export default async function AboutPage() {
|
||||
|
||||
<section className="about-contact">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
userId: number
|
||||
fullName: string
|
||||
role: string
|
||||
billingEnabled: boolean
|
||||
}
|
||||
|
||||
type Status = { ok: boolean; text: string } | null
|
||||
@@ -25,7 +26,7 @@ const TABS = [
|
||||
{ id: 'membership', label: 'العضوية والفوترة' },
|
||||
]
|
||||
|
||||
export default function AccountSettingsClient({ email, userId, fullName, role }: Props) {
|
||||
export default function AccountSettingsClient({ email, userId, fullName, role, billingEnabled }: Props) {
|
||||
const router = useRouter()
|
||||
const [tab, setTab] = useState('profile')
|
||||
const [name, setName] = useState(fullName)
|
||||
@@ -150,14 +151,14 @@ export default function AccountSettingsClient({ email, userId, fullName, role }:
|
||||
|
||||
<section className={`panel${tab === 'prefs' ? ' active' : ''}`} id="panel-prefs" role="tabpanel" aria-labelledby="tab-prefs" data-od-id="panel-prefs">
|
||||
<h2>التفضيلات</h2>
|
||||
<p className="panel-sub">رسائل أنور بآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p>
|
||||
<p className="panel-sub">رسائل أنور پآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
@@ -172,13 +173,15 @@ export default function AccountSettingsClient({ email, userId, fullName, role }:
|
||||
<span className={`state-chip${entitled ? '' : ' cancelled'}`} id="planState">{entitled ? 'نشطة' : role === 'admin' ? 'إداري' : 'مجاني'}</span>
|
||||
</div>
|
||||
<div className="plan-details">
|
||||
<div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
|
||||
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : 'الأدلة المجانية ومعاينة الأدلة'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">ستُربط عبر Stripe</dd></dl>
|
||||
{billingEnabled
|
||||
? <div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></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 id="payMethod">{billingEnabled ? 'ستُربط عبر Stripe' : 'لا يوجد — لن يُحصَّل أي مبلغ خلال الوصول المبكر'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
|
||||
</div>
|
||||
<div className="membership-actions" id="membershipActions">
|
||||
{!entitled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
|
||||
{!entitled && billingEnabled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
|
||||
</div>
|
||||
</article>
|
||||
<div className="cancel-zone" id="cancelZone" data-od-id="cancel-zone">
|
||||
|
||||
@@ -4,11 +4,16 @@ import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import AccountSettingsClient from './client'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { billingEnabled } from '@/lib/launch'
|
||||
import configPromise from '@payload-config'
|
||||
import '../styles/account-settings.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'إعدادات الحساب' }
|
||||
export const metadata = {
|
||||
title: 'إعدادات الحساب',
|
||||
description: 'إدارة حسابك القائم في أنور پآي: بيانات الحساب وتفضيلات الرسائل والميزات المتاحة. يلزم تسجيل الدخول، ولا تحتاج إلى حساب لقراءة المحتوى العام.',
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
@@ -20,7 +25,7 @@ export default async function AccountSettingsPage() {
|
||||
{user ? (
|
||||
<main id="content">
|
||||
<section className="page-head" data-od-id="settings-header"><div className="container">
|
||||
<p className="crumb">حسابك في أنور بآي</p>
|
||||
<p className="crumb">حسابك في أنور پآي</p>
|
||||
<h1 data-od-id="settings-title">إعدادات الحساب</h1>
|
||||
<p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? formatDate(String(user.createdAt)) : 'اليوم'}</p>
|
||||
</div></section>
|
||||
@@ -30,6 +35,7 @@ export default async function AccountSettingsPage() {
|
||||
userId={Number(user.id)}
|
||||
fullName={(user.fullName as string) || ''}
|
||||
role={((user.role as string) || 'free')}
|
||||
billingEnabled={billingEnabled()}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
@@ -40,7 +46,7 @@ export default async function AccountSettingsPage() {
|
||||
<p>من هنا تعدّل اسمك وكلمة المرور وتفضيلات الرسائل، وتدير عضويتك.</p>
|
||||
<div className="actions">
|
||||
<a className="btn btn-primary" href="/login?next=%2Faccount-settings">تسجيل الدخول</a>
|
||||
<a className="btn btn-quiet" href="/signup">إنشاء حساب مجاني</a>
|
||||
<a className="btn btn-quiet" href="/articles">اقرأ مجاناً دون حساب</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import { accessLabel } from '@/lib/launch'
|
||||
import Footer from '@/components/footer'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
@@ -6,10 +7,14 @@ import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import ArticlesClient from './articles-client'
|
||||
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
|
||||
import { formatNumber, guidesLabel, minutesLabel } from '@/lib/format'
|
||||
import { formatNumber, guidesLabel, minutesLabel, contentTypeLabel } from '@/lib/format'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'المكتبة' }
|
||||
export const metadata = {
|
||||
title: 'مكتبة الذكاء الاصطناعي: نماذج ووكلاء وأدوات',
|
||||
description:
|
||||
'محتوى الذكاء الاصطناعي العملي بالعربية: وكلاء، نماذج، استرجاع، سلامة، أدوات، وأعمال — بخطوات قابلة للتطبيق وتجارب موثقة وملفات عمل.',
|
||||
}
|
||||
|
||||
type PostDoc = {
|
||||
id: number
|
||||
@@ -24,6 +29,7 @@ type PostDoc = {
|
||||
likes: number
|
||||
publishedAt: string
|
||||
cover?: { url?: string | null } | number | null
|
||||
contentType?: string | null
|
||||
}
|
||||
|
||||
type Viewer = { id: number; email: string; role: string; interest: string | null; level: string | null } | null
|
||||
@@ -40,6 +46,7 @@ export default async function ArticlesPage() {
|
||||
select: {
|
||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||
readingMinutes: true, views: true, likes: true, publishedAt: true, cover: true,
|
||||
contentType: true,
|
||||
},
|
||||
}),
|
||||
payload.auth({ headers: await headers() }),
|
||||
@@ -87,7 +94,7 @@ export default async function ArticlesPage() {
|
||||
<section className="lib-head" aria-labelledby="library-title">
|
||||
<div className="wrap">
|
||||
<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">
|
||||
<label className="lib-search">
|
||||
<span className="sr-only">ابحث في العناوين والوصف</span>
|
||||
@@ -109,14 +116,17 @@ export default async function ArticlesPage() {
|
||||
<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="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>
|
||||
</section>
|
||||
|
||||
<section className="lib-results" aria-label="الأدلة">
|
||||
<section className="lib-results" aria-label="المحتوى">
|
||||
<div className="wrap">
|
||||
<nav className="topic-strip" aria-label="تصفّح المسارات">
|
||||
{TOPICS.map(topic => <Link key={topic.key} href={`/topics/${topic.key}`}>{topic.label}</Link>)}
|
||||
</nav>
|
||||
<p className="results-count" id="resultCount" aria-live="polite">{guidesLabel(posts.length)}</p>
|
||||
<div className="lib-grid" id="postList">
|
||||
{posts.map(post => {
|
||||
@@ -139,6 +149,7 @@ export default async function ArticlesPage() {
|
||||
<div className="guide-card-body">
|
||||
<p className="meta-row">
|
||||
<span>{post.category}</span>
|
||||
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||
<span>{minutesLabel(post.readingMinutes)}</span>
|
||||
<span>{post.level}</span>
|
||||
{meta._match && <span className="for-you">يناسب مستواك</span>}
|
||||
@@ -146,7 +157,7 @@ export default async function ArticlesPage() {
|
||||
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
|
||||
<p className="guide-card-dek">{post.dek}</p>
|
||||
<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 data-views>{formatNumber(post.views ?? 0)} قراءة</span>
|
||||
<button className="like" type="button" data-like={post.slug} aria-pressed={likedSlugs.has(post.slug)} aria-label="أعجبني">
|
||||
@@ -161,7 +172,7 @@ export default async function ArticlesPage() {
|
||||
})}
|
||||
</div>
|
||||
<div className="empty" id="emptyState">
|
||||
<p>لا يوجد دليل يطابق هذا البحث.</p>
|
||||
<p>لا يوجد محتوى يطابق هذا البحث.</p>
|
||||
<p>جرّب كلمة أقصر أو اختر «كل المسارات».</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export type DeckSlide = {
|
||||
|
||||
const V1_SLIDES: DeckSlide[] = [
|
||||
{
|
||||
kicker: 'دليل حصري · المستوى المتوسط',
|
||||
kicker: 'محتوى حصري · المستوى المتوسط',
|
||||
index: '01',
|
||||
title: 'ابنِ وكيلك الأول دون فوضى',
|
||||
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
/* QA-07: /catalog was a stale duplicate library — /articles is canonical.
|
||||
Deck/filter components in this folder are still used by the homepage. */
|
||||
/* QA-07: /catalog was a stale duplicate library — /articles is canonical. */
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function CatalogPage() {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
/* QA-06: page-specific title (the page itself is a client component). */
|
||||
export const metadata: Metadata = { title: 'استعادة كلمة المرور' }
|
||||
/* QA-06: page-specific title (the page itself is a client component).
|
||||
Utility pages stay out of search indexes (step 38). */
|
||||
export const metadata: Metadata = {
|
||||
title: 'استعادة كلمة المرور',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
/* QA-06: page-specific title (the page itself is a client component). */
|
||||
export const metadata: Metadata = { title: 'تسجيل الدخول' }
|
||||
/* QA-06: page-specific title (the page itself is a client component).
|
||||
Utility pages stay out of search indexes (step 38). */
|
||||
export const metadata: Metadata = {
|
||||
title: 'تسجيل الدخول',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
|
||||
@@ -41,6 +41,23 @@ export default function LoginPage() {
|
||||
router.push(safeNext(new URLSearchParams(window.location.search).get('next')) ?? '/account-settings')
|
||||
return
|
||||
}
|
||||
const err = await res.json().catch(() => null) as { error?: string; errors?: { message?: string }[] } | null
|
||||
// Rate-limit / middleware shape first: { error: string } + optional Retry-After.
|
||||
if (typeof err?.error === 'string') {
|
||||
const retryAfterSec = Number(res.headers.get('Retry-After'))
|
||||
setStatus(
|
||||
res.headers.get('Retry-After') && Number.isFinite(retryAfterSec)
|
||||
? `${err.error} حاول بعد ${Math.max(1, Math.ceil(retryAfterSec / 60))} دقيقة.`
|
||||
: err.error,
|
||||
)
|
||||
return
|
||||
}
|
||||
const message = err?.errors?.[0]?.message
|
||||
if (message && /locked/i.test(message)) {
|
||||
// Payload lockout: maxLoginAttempts 5 / lockTime 15min (Users.ts).
|
||||
setStatus('تم تجاوز عدد المحاولات المسموح. حاول بعد ١٥ دقيقة.')
|
||||
return
|
||||
}
|
||||
setStatus('تعذّر تسجيل الدخول — تحقّق من البريد وكلمة المرور.')
|
||||
} catch {
|
||||
setStatus('تعذّر الاتصال. حاول تسجيل الدخول مرة أخرى.')
|
||||
@@ -57,7 +74,7 @@ export default function LoginPage() {
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
<h1>أكمل من حيث توقفت.</h1>
|
||||
<p>سجّل الدخول لمتابعة قراءة الأدلة والمشاركة في النقاشات.</p>
|
||||
<p>القراءة مجانية دون حساب. تسجيل الدخول لأصحاب الحسابات القائمة والميزات المتاحة.</p>
|
||||
</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">
|
||||
@@ -83,7 +100,7 @@ export default function LoginPage() {
|
||||
<div className="status" id="loginStatus" role="status">{status}</div>
|
||||
<div className="divider">أو</div>
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -123,20 +123,20 @@ export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
|
||||
<h2>ابدأ مجاناً أو افتح محتوى العضوية الكامل.</h2>
|
||||
<p className="intro" id="summaryCopy">{summary}</p>
|
||||
<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">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">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">03</span><div><h3>نقطة بداية مخصصة</h3><p>إعداد العضوية يرتّب محتواك الأول ورسائلك بناءً على هدفك.</p></div></article>
|
||||
</div>
|
||||
<div className="upgrade-note">
|
||||
<strong>الحساب المجاني لا يختفي.</strong>
|
||||
<p>يمكنك قراءة الأدلة المجانية وحفظها، والترقية عندما تحتاج الأدلة الحصرية.</p>
|
||||
<p>يمكنك قراءة المحتوى المجاني وحفظه، والترقية عندما تحتاج المحتوى الحصري.</p>
|
||||
</div>
|
||||
<div className="actions" data-od-id="final-actions">
|
||||
{nextPath ? (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,13 +6,14 @@ import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
|
||||
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import { freeEarlyAccess, accessLabel, LAUNCH_POLICY } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* Editorial picks for «ابدأ من هنا». */
|
||||
const START_HERE = ['first-arabic-agent', 'choosing-your-model', 'prototype-to-production', 'simple-arabic-rag', 'safe-agent-guardrails', 'agent-loop-stop-condition']
|
||||
/* Demo seed articles remain in the library, but never in the popular section. */
|
||||
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() {
|
||||
const viewer = await getViewer()
|
||||
@@ -42,10 +43,33 @@ export default async function HomePage() {
|
||||
.slice(0, 4)
|
||||
.map(({ d }) => d)
|
||||
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 (
|
||||
<div id="site" className="site-page v2 v2-home">
|
||||
{/* Organization structured data (home page only; WebSite LD lives in layout). */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'أنور پآي',
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai',
|
||||
logo: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'}/brand/icon-256.png`,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<section className="home-hero">
|
||||
@@ -53,15 +77,15 @@ export default async function HomePage() {
|
||||
<div className="home-hero-copy">
|
||||
<p className="kicker">مكتبة عربية للذكاء الاصطناعي العملي</p>
|
||||
<h1>افهم الذكاء الاصطناعي، ثم ابنِ به.</h1>
|
||||
<p className="lede">أدلة عربية عملية عن الوكلاء والنماذج والأتمتة، يكتبها مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p>
|
||||
<p className="lede">محتوى عربي عملي عن الوكلاء والنماذج والأتمتة، يكتبه مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{hero.length > 0 && (
|
||||
<aside className="hero-panel" aria-labelledby="new-title">
|
||||
<h2 id="new-title">أحدث الأدلة</h2>
|
||||
<h2 id="new-title">أحدث المحتوى</h2>
|
||||
<ul role="list">
|
||||
{hero.map(d => {
|
||||
const coverUrl = typeof d.cover === 'object' ? d.cover?.url ?? null : null
|
||||
@@ -70,13 +94,13 @@ export default async function HomePage() {
|
||||
<Link href={`/posts/${d.slug}`}>
|
||||
{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="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>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<Link className="text-link" href="/articles">كل الأدلة</Link>
|
||||
<Link className="text-link" href="/articles">كل المحتوى</Link>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
@@ -86,12 +110,12 @@ export default async function HomePage() {
|
||||
<div className="wrap">
|
||||
<header className="section-head">
|
||||
<h2 id="paths-title">اختر مساراً حسب ما تريد بناءه</h2>
|
||||
<p>كل دليل ينتمي إلى مسار، وأدلة المبتدئين تظهر أيضاً في الأساسيات.</p>
|
||||
<p>ينتمي كل عنصر محتوى إلى مسار، ويظهر محتوى المبتدئين أيضاً في الأساسيات.</p>
|
||||
</header>
|
||||
<ol className="paths" role="list">
|
||||
{TOPICS.map(topic => (
|
||||
<li key={topic.key}>
|
||||
<Link href={`/articles?topic=${topic.key}`} className="path">
|
||||
<Link href={`/topics/${topic.key}`} className="path">
|
||||
<span className="path-name">{topic.label}</span>
|
||||
<span className="path-hint">{topic.hint}</span>
|
||||
<span className="path-count">{guidesLabel(counts[topic.key])}</span>
|
||||
@@ -106,8 +130,8 @@ export default async function HomePage() {
|
||||
<section className="home-start" aria-labelledby="start-title">
|
||||
<div className="wrap">
|
||||
<header className="section-head">
|
||||
<h2 id="start-title">ابدأ من هنا</h2>
|
||||
<Link className="text-link" href="/articles">كل الأدلة ({formatNumber(docs.length)})</Link>
|
||||
<h2 id="start-title">الأكثر قراءة</h2>
|
||||
<Link className="text-link" href="/articles">كل المحتوى ({formatNumber(docs.length)})</Link>
|
||||
</header>
|
||||
<ul className="card-grid" role="list">
|
||||
{startHere.map(post => {
|
||||
@@ -116,10 +140,10 @@ export default async function HomePage() {
|
||||
<li key={post.id}>
|
||||
<article className="guide">
|
||||
{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>
|
||||
<p className="guide-dek">{post.dek}</p>
|
||||
<span className="guide-link" aria-hidden="true">اقرأ الدليل</span>
|
||||
<span className="guide-link" aria-hidden="true">اقرأ المحتوى</span>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
@@ -132,17 +156,24 @@ export default async function HomePage() {
|
||||
<section className="home-membership" id="membership" aria-labelledby="membership-title">
|
||||
<div className="wrap membership-panel">
|
||||
<div>
|
||||
<h2 id="membership-title">العضوية تفتح الأدلة المتقدمة وملفات العمل.</h2>
|
||||
<p>الأدلة المجانية كاملة دائماً. العضوية تضيف الأدلة المعمّقة، القوالب الجاهزة للتنزيل، والنقاش مع الفريق.</p>
|
||||
<h2 id="membership-title">اقرأ مجاناً الآن؛ العضوية خطة مستقبلية.</h2>
|
||||
<p>{LAUNCH_POLICY.summary}</p>
|
||||
</div>
|
||||
<div className="membership-offer">
|
||||
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
|
||||
{freeEarlyAccess() ? (
|
||||
<>
|
||||
<p className="price price-early">وصول مبكر مجاني</p>
|
||||
<p className="early-line">يمكنك قراءة المحتوى المنشور خلال الوصول المبكر دون بطاقة دفع.</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
|
||||
)}
|
||||
<ul role="list">
|
||||
<li>{guidesLabel(docs.length - freeCount)} للأعضاء، وتُضاف أدلة جديدة باستمرار</li>
|
||||
<li>{guidesLabel(docs.length - freeCount)} مصنفة للعضوية مستقبلاً ومفتوحة الآن</li>
|
||||
<li>قوالب وقوائم فحص قابلة للتنزيل</li>
|
||||
<li>التعليق والنقاش على كل دليل</li>
|
||||
<li>القراءة دون حساب؛ التسجيل العام متوقف</li>
|
||||
</ul>
|
||||
<Link className="btn btn-primary" href="/subscription">اطّلع على العضوية</Link>
|
||||
<Link className="btn btn-primary" href="/subscription">تفاصيل الوصول المبكر</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,11 @@ import Footer from '@/components/footer'
|
||||
import Link from 'next/link'
|
||||
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. */
|
||||
export default function PaymentCancelledPage() {
|
||||
@@ -24,7 +28,7 @@ export default function PaymentCancelledPage() {
|
||||
</div>
|
||||
<div className="actions">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -63,8 +63,8 @@ export default async function PaymentSuccessPage({ searchParams }: Props) {
|
||||
{vipNow ? (
|
||||
<>
|
||||
<p className="eyebrow">تم الدفع · العضوية فعّالة</p>
|
||||
<h1 data-od-id="success-title">مرحباً بك في عضوية أنور بآي.</h1>
|
||||
<p className="lead">تم تفعيل وصولك إلى الأدلة الحصرية وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p>
|
||||
<h1 data-od-id="success-title">مرحباً بك في عضوية أنور پآي.</h1>
|
||||
<p className="lead">تم تفعيل وصولك إلى المحتوى الحصري وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p>
|
||||
<dl className="summary" data-od-id="payment-summary">
|
||||
<div className="summary-row"><dt>الحالة</dt><dd>عضوية فعّالة</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>
|
||||
<div className="next-step" data-od-id="next-step">
|
||||
<h2 data-od-id="next-step-title">جهّز مسارك الأول</h2>
|
||||
<p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بدليل واحد يناسبك.</p>
|
||||
<p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بعنصر محتوى واحد يناسبك.</p>
|
||||
<div className="actions">
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -3,12 +3,18 @@ import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import configPromise from '@payload-config'
|
||||
import Topnav from '@/components/topnav'
|
||||
import { accessLabel } from '@/lib/launch'
|
||||
import Footer from '@/components/footer'
|
||||
import PostExtras from './post-extras'
|
||||
import { LikeButton, ViewPing } from './engagement'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import { TOPICS, topicsFor } from '@/lib/topics'
|
||||
import { formatDate, formatNumber, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import { formatDate, formatNumber, minutesLabel, contentTypeLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import { extractH2s, innerText, type Node } from '@/lib/lexical'
|
||||
import { youtubeVideoId } from '@/lib/youtube'
|
||||
import { articleImageUrl } from '@/lib/article-image'
|
||||
import YouTubePlayer from '@/components/youtube-player'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -16,21 +22,36 @@ export const dynamic = 'force-dynamic'
|
||||
export async function generateMetadata({ params }: Args) {
|
||||
const { slug } = await params
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
// Drafts must never leak title/description into metadata.
|
||||
const res = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
select: { title: true, dek: true, publishedAt: true },
|
||||
select: { title: true, dek: true, publishedAt: true, updatedAt: true, cover: true },
|
||||
})
|
||||
const post = res.docs[0] as unknown as { title: string; dek: string; publishedAt?: string | null } | undefined
|
||||
return post
|
||||
? {
|
||||
title: post.title,
|
||||
description: post.dek,
|
||||
openGraph: { type: 'article', title: post.title, description: post.dek, publishedTime: post.publishedAt ?? undefined },
|
||||
}
|
||||
: { title: 'أنور بآي' }
|
||||
const post = res.docs[0] as unknown as
|
||||
| { title: string; dek: string; publishedAt?: string | null; updatedAt?: string | null; cover?: { url?: string | null } | number }
|
||||
| undefined
|
||||
if (!post) return { title: 'أنور پآي' }
|
||||
|
||||
const ogImage =
|
||||
post.cover && typeof post.cover === 'object' && post.cover.url ? post.cover.url : '/og-default.png'
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.dek,
|
||||
openGraph: {
|
||||
// Next replaces (not merges) the layout-level openGraph — redeclare fully.
|
||||
type: 'article',
|
||||
siteName: 'أنور پآي',
|
||||
locale: 'ar',
|
||||
title: post.title,
|
||||
description: post.dek,
|
||||
publishedTime: post.publishedAt ?? undefined,
|
||||
images: [{ url: ogImage, width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: { card: 'summary_large_image', title: post.title, description: post.dek, images: [ogImage] },
|
||||
}
|
||||
}
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> }
|
||||
@@ -47,10 +68,13 @@ type PostDoc = {
|
||||
likes?: number | null
|
||||
readingMinutes: number
|
||||
publishedAt?: string | null
|
||||
updatedAt?: string | null
|
||||
teaser?: string | null
|
||||
content?: unknown
|
||||
cover?: { url?: string | null }
|
||||
attachments?: (number | { id: number })[]
|
||||
contentType?: string | null
|
||||
authorName?: string | null
|
||||
}
|
||||
|
||||
async function isLikedByViewer(postId: number, viewerId?: number): Promise<boolean> {
|
||||
@@ -83,8 +107,9 @@ export default async function PostPage({ params }: Args) {
|
||||
select: {
|
||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||
views: true, likes: true,
|
||||
readingMinutes: true, publishedAt: true, teaser: true, cover: true,
|
||||
readingMinutes: true, publishedAt: true, updatedAt: true, teaser: true, cover: true,
|
||||
content: true, attachments: true,
|
||||
contentType: true, authorName: true,
|
||||
},
|
||||
})
|
||||
const post = res.docs[0] as unknown as PostDoc | undefined
|
||||
@@ -95,7 +120,10 @@ export default async function PostPage({ params }: Args) {
|
||||
const role = viewer?.role ?? null
|
||||
const viewerId = viewer?.id
|
||||
const viewerLevel = viewer?.level ?? null
|
||||
const unlocked = post.access === 'free' || role === 'vip' || role === 'admin'
|
||||
// L-02: free early access unlocks every published post (the query above
|
||||
// already filters status: 'published'); paid mode keeps the tier gate.
|
||||
const earlyAccessNotice = freeEarlyAccess() && post.access === 'vip'
|
||||
const unlocked = freeEarlyAccess() || post.access === 'free' || role === 'vip' || role === 'admin'
|
||||
const postTopics = topicsFor(post)
|
||||
// The downloadable templates are agent-workflow files; only offer them on agent guides.
|
||||
const hasAgentFiles = postTopics.includes('agents')
|
||||
@@ -162,16 +190,54 @@ export default async function PostPage({ params }: Args) {
|
||||
})
|
||||
: null
|
||||
|
||||
// TOC from content h2s (server-extracted from the Lexical tree)
|
||||
const toc = unlocked ? extractH2s(post.content) : []
|
||||
// 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)
|
||||
|
||||
const topic = postTopics[0]
|
||||
const topicLabel = TOPICS.find(t => t.key === topic)?.label ?? post.category
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-article page-posts">
|
||||
<ViewPing slug={post.slug} />
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: post.title, datePublished: post.publishedAt ?? undefined, author: { '@type': 'Organization', name: 'أنور بآي' }, inLanguage: 'ar' }) }} />
|
||||
{/* L-09: honest Article + Breadcrumb structured data. JSON-LD array = multiple objects in one script. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify([
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: post.title,
|
||||
description: post.dek,
|
||||
image: typeof post.cover === 'object' && post.cover?.url ? new URL(post.cover.url, siteUrl).toString() : undefined,
|
||||
datePublished: post.publishedAt ?? undefined,
|
||||
dateModified: post.updatedAt ?? post.publishedAt ?? undefined,
|
||||
author: post.authorName
|
||||
? { '@type': 'Person', name: post.authorName }
|
||||
: { '@type': 'Organization', name: 'أنور پآي' },
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'أنور پآي',
|
||||
logo: { '@type': 'ImageObject', url: `${siteUrl}/brand/icon-256.png` },
|
||||
},
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': `${siteUrl}/posts/${post.slug}` },
|
||||
isAccessibleForFree: post.access === 'free' || freeEarlyAccess(),
|
||||
inLanguage: 'ar',
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@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">
|
||||
@@ -187,12 +253,17 @@ export default async function PostPage({ params }: Args) {
|
||||
<div className="art-meta">
|
||||
<span className={`badge${post.access === 'vip' ? ' badge-vip' : ''}`}>
|
||||
{post.access === 'vip' && <LockIcon />}
|
||||
{post.access === 'free' ? 'مجاني بالكامل' : unlocked ? 'للأعضاء' : 'معاينة مجانية'}
|
||||
{unlocked ? accessLabel(post.access) : 'معاينة مجانية'}
|
||||
</span>
|
||||
<span>{post.category}</span>
|
||||
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||
{post.authorName && <span>بقلم {post.authorName}</span>}
|
||||
<span>{minutesLabel(post.readingMinutes)} للقراءة</span>
|
||||
<span>{post.level ?? 'مبتدئ'}</span>
|
||||
{post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>}
|
||||
{post.updatedAt && post.publishedAt && new Date(post.updatedAt) > new Date(post.publishedAt) && (
|
||||
<span>· حُدّث {formatDate(post.updatedAt)}</span>
|
||||
)}
|
||||
{(post.level ?? 'مبتدئ') === viewerLevel && <span className="for-you">يناسب مستواك</span>}
|
||||
</div>
|
||||
<div className="art-actions">
|
||||
@@ -211,7 +282,14 @@ export default async function PostPage({ params }: Args) {
|
||||
<div className="wrap art-layout">
|
||||
<article className="art-body">
|
||||
{unlocked ? (
|
||||
<RichText data={post.content} />
|
||||
<>
|
||||
{earlyAccessNotice && (
|
||||
<section className="early-notice" aria-label="إشعار الوصول المبكر">
|
||||
<p>متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.</p>
|
||||
</section>
|
||||
)}
|
||||
<RichText data={post.content} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => (
|
||||
@@ -220,21 +298,27 @@ export default async function PostPage({ params }: Args) {
|
||||
: <p key={i} className={i === 0 ? 'intro' : undefined}>{para}</p>
|
||||
))}
|
||||
<section className="paywall" aria-labelledby="paywall-title">
|
||||
<h2 id="paywall-title">بقية هذا الدليل للأعضاء</h2>
|
||||
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل الأدلة الحصرية الأخرى.</p>
|
||||
<h2 id="paywall-title">بقية هذا المحتوى للأعضاء</h2>
|
||||
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل المحتوى الحصري الآخر.</p>
|
||||
{toc.length > 0 && (
|
||||
<div className="paywall-preview">
|
||||
<p className="aside-title">ماذا ينتظرك داخل هذا المحتوى:</p>
|
||||
<ul role="list">{toc.map((t, i) => <li key={i}><LockIcon />{t}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="paywall-actions">
|
||||
<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>
|
||||
<p className="fine">تلغي في أي وقت من صفحة حسابك.</p>
|
||||
<p className="fine">تلغي في أي وقت من صفحة حسابك. <Link href="/subscription#faq">أسئلة قبل الاشتراك</Link> · <Link href="/articles?access=free">أكمل بالمحتوى المجاني</Link></p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
<aside className="art-aside" aria-label="داخل هذا الدليل">
|
||||
<aside className="art-aside" aria-label="داخل هذا المحتوى">
|
||||
{unlocked && toc.length > 0 && (
|
||||
<nav className="toc" aria-label="فهرس الدليل">
|
||||
<p className="aside-title">في هذا الدليل</p>
|
||||
<nav className="toc" aria-label="فهرس المحتوى">
|
||||
<p className="aside-title">في هذا المحتوى</p>
|
||||
<ol>{toc.map((t, i) => <li key={i}><a href={`#sec-${i}`}>{t}</a></li>)}</ol>
|
||||
</nav>
|
||||
)}
|
||||
@@ -260,17 +344,17 @@ export default async function PostPage({ params }: Args) {
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<div className="files">
|
||||
<p className="aside-title">ملفات هذا الدليل</p>
|
||||
<p className="aside-title">ملفات هذا المحتوى</p>
|
||||
<ul>
|
||||
{attachments.map(item => {
|
||||
// Per-file gate mirrors GET /api/files/[id]: free streams to
|
||||
// anyone; vip needs a vip/admin viewer (post access is
|
||||
// irrelevant — a vip file on a free post stays locked).
|
||||
const canDownload = item.access === 'free' || role === 'vip' || role === 'admin'
|
||||
const accessLabel = item.access === 'free' ? 'مجاني' : 'للأعضاء'
|
||||
const fileAccessLabel = item.access === 'free' ? 'مجاني' : 'ملف مقيد للأعضاء'
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<span>{item.label || item.filename} <span className="file-access">· {accessLabel}</span></span>
|
||||
<span>{item.label || item.filename} <span className="file-access">· {fileAccessLabel}</span></span>
|
||||
{canDownload
|
||||
? <a className="dl-btn" href={`/api/files/${item.id}`} download>تنزيل</a>
|
||||
: <span className="dl-locked"><LockIcon />للأعضاء</span>}
|
||||
@@ -301,7 +385,7 @@ export default async function PostPage({ params }: Args) {
|
||||
</article>
|
||||
))}
|
||||
{comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && (
|
||||
<p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا الدليل.</p>
|
||||
<p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا المحتوى.</p>
|
||||
)}
|
||||
</div>
|
||||
<PostExtras slug={post.slug} loggedIn={role !== null} />
|
||||
@@ -317,7 +401,7 @@ export default async function PostPage({ params }: Args) {
|
||||
{related.map(item => (
|
||||
<li key={item.id}>
|
||||
<Link href={`/posts/${item.slug}`} className="related-item">
|
||||
<span className="meta-row"><span>{item.category}</span><span>{minutesLabel(item.readingMinutes)}</span><span>{item.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></span>
|
||||
<span className="meta-row"><span>{item.category}</span><span>{minutesLabel(item.readingMinutes)}</span><span>{accessLabel(item.access)}</span></span>
|
||||
<span className="related-title">{item.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -343,7 +427,7 @@ function SaveButton() {
|
||||
}
|
||||
|
||||
// Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules.
|
||||
type Node = { type?: string; text?: string; children?: Node[]; tag?: string; url?: string; listType?: string; code?: string }
|
||||
// `Node`, `innerText` and `extractH2s` live in lib/lexical (shared with llms routes).
|
||||
|
||||
function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] {
|
||||
return (nodes || []).map((n, i) => {
|
||||
@@ -354,6 +438,26 @@ function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): Rea
|
||||
}
|
||||
if (n.type === 'linebreak') return <br key={key} />
|
||||
if (n.type === 'link') return <a key={key} href={n.url || '#'} className="text-link">{renderChildren(n.children, counter)}</a>
|
||||
// Only a paragraph made of exactly one recognized link is media.
|
||||
// Keep inline/multiple links ordinary; never interpret authored HTML.
|
||||
if (n.type === 'paragraph' && n.children?.length === 1) {
|
||||
const link = n.children[0]
|
||||
if (link.type === 'link' && link.url && youtubeVideoId(link.url)) {
|
||||
return <YouTubePlayer key={key} url={link.url} />
|
||||
}
|
||||
if (link.type === 'link' && link.url && link.children?.every(child => child.type === 'text')) {
|
||||
const src = articleImageUrl(link.url)
|
||||
const caption = innerText(link.children)
|
||||
if (src && caption.trim()) {
|
||||
return (
|
||||
<figure key={key} style={{ maxWidth: '100%', marginInline: 0 }}>
|
||||
<img src={src} alt={caption} loading="lazy" decoding="async" style={{ display: 'block', width: '100%', maxWidth: '100%', height: 'auto' }} />
|
||||
<figcaption>{caption}</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const inner = renderChildren(n.children, counter)
|
||||
switch (n.type) {
|
||||
case 'heading': {
|
||||
@@ -370,27 +474,19 @@ function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): Rea
|
||||
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 innerText(nodes?: Node[]): string {
|
||||
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
|
||||
}
|
||||
|
||||
function extractH2s(data: unknown): string[] {
|
||||
try {
|
||||
const root = (data as { root?: { children?: Node[] } })?.root
|
||||
if (!root?.children) return []
|
||||
return root.children
|
||||
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
|
||||
.map(n => innerText(n.children))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function RichText({ data }: { data: unknown }) {
|
||||
if (!data || typeof data !== 'object' || !('root' in data)) return null
|
||||
const rootUnknown = data.root
|
||||
|
||||
@@ -77,6 +77,10 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
|
||||
const dlHandler = (e: Event) => {
|
||||
const b = e.currentTarget as HTMLButtonElement
|
||||
const file = DL_FILES[b.dataset.dl ?? '']
|
||||
if (!file) {
|
||||
b.textContent = 'الملف غير متاح'
|
||||
return
|
||||
}
|
||||
if (file) {
|
||||
const blob = new Blob([file.body], { type: file.type })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -138,9 +142,9 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
|
||||
if (!loggedIn) {
|
||||
return (
|
||||
<div className="comment-gate">
|
||||
<p>سجّل الدخول لتشارك سؤالك أو تجربتك مع هذا الدليل.</p>
|
||||
<p>سجّل الدخول لتشارك سؤالك أو تجربتك مع هذا المحتوى.</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { LAUNCH_POLICY } from '@/lib/launch'
|
||||
import '../styles/privacy.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'الخصوصية' }
|
||||
export const metadata = {
|
||||
title: 'سياسة خصوصية Anwarpy',
|
||||
description: 'سياسة خصوصية أنور پآي: تعرّف على بيانات الزيارة والحسابات والتواصل، وكيف تُستخدم وتُحفظ، ومدة الاحتفاظ بها، وطريقة طلب الوصول والتصحيح والحذف.',
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div id="site" className="site-page page-privacy">
|
||||
<Topnav />
|
||||
<main id="content">
|
||||
<section className="hero" data-od-id="privacy-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">الخصوصية · مسودة ما قبل الإطلاق</p>
|
||||
<h1 data-od-id="privacy-title">بياناتك ليست مادة للمحتوى.</h1>
|
||||
<p className="lead">توضح هذه المسودة الحد الأدنى المقترح لجمع البيانات واستخدامها داخل أنور بآي.</p>
|
||||
<p className="notice" role="note">هذه سياسة تجريبية للواجهة وليست نصاً قانونياً نهائياً. يجب مراجعتها واعتمادها قبل نشر الموقع.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container policy" data-od-id="privacy-content">
|
||||
<section data-od-id="privacy-data"><h2>ما الذي نجمعه؟</h2><p>عند تشغيل الحسابات فعلياً قد نحتاج إلى الاسم والبريد الإلكتروني، وبيانات العضوية والفوترة التي يوفرها مزوّد الدفع. لا ينبغي تخزين بيانات البطاقة كاملة داخل المنصة.</p></section>
|
||||
<section data-od-id="privacy-use"><h2>كيف تُستخدم البيانات؟</h2><ul><li>تسجيل الدخول وحفظ التقدّم والتفضيلات.</li><li>إدارة العضوية وإرسال الرسائل التي اختارها المستخدم.</li><li>حماية المنصة وتشخيص الأعطال وتحسين تجربة القراءة.</li></ul></section>
|
||||
<section data-od-id="privacy-sharing"><h2>المشاركة والاحتفاظ</h2><p>لا تُباع البيانات الشخصية. قبل الإطلاق يجب تسمية مزوّدي الاستضافة والبريد والدفع والتحليلات، وتحديد مدة الاحتفاظ بكل نوع من البيانات بوضوح.</p></section>
|
||||
<section data-od-id="privacy-rights"><h2>حقوقك</h2><p>يمكنك طلب نسخة من بياناتك أو تصحيحها أو حذفها بعد تفعيل الحسابات. أرسل طلبك إلى <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>.</p></section>
|
||||
<section data-od-id="privacy-updates"><h2>التحديثات</h2><p>يجب إضافة تاريخ السريان وملخص أي تغيير جوهري عند اعتماد النسخة القانونية النهائية.</p></section>
|
||||
<section className="hero"><div className="container">
|
||||
<p className="eyebrow">سياسات الوصول المبكر المجاني</p>
|
||||
<h1>سياسة خصوصية Anwarpy</h1>
|
||||
<p className="lead">تاريخ السريان: <time dateTime={LAUNCH_POLICY.effectiveDate}>{LAUNCH_POLICY.effectiveDate}</time></p>
|
||||
<p>{LAUNCH_POLICY.summary}</p>
|
||||
</div></section>
|
||||
<div className="container policy">
|
||||
<section><h2>المشغّل والنطاق</h2><p>يدير Anwarpy هذا الموقع. القراءة مجانية ولا تتطلب حساباً، وإنشاء الحسابات العامة متوقف. تتناول هذه السياسة بيانات زيارة الموقع والحسابات القائمة والتواصل، لا الملاحظات الخاصة التي لم تنشر.</p></section>
|
||||
<section><h2>البيانات التي تعالجها الخدمة</h2><p>قد تتضمن الحسابات القائمة الاسم والبريد وكلمة مرور محفوظة بصيغة تجزئة، الدور وحالة التحقق وتفضيلات المستخدم واهتماماته، وسجلات قبول الشروط. ترتبط التعليقات والإعجابات بالحساب عند استخدامها. تسجل الخدمة عدادات قراءة المنشورات، وقد تعالج الاستضافة وCloudflare عنوان IP ومعلومات الطلب والمتصفح وسجلات الأخطاء لأغراض التشغيل والأمان. ما ترسله إلى بريد الدعم، بما فيه عنوانك ومضمون الرسالة، يعالج للرد على طلبك.</p></section>
|
||||
<section><h2>الاستخدام والتخزين في المتصفح</h2><p>نستخدم البيانات لتسجيل الدخول وتشغيل الميزات المتاحة ومراجعة التعليقات وحماية الخدمة وتشخيص الأعطال والرد على الطلبات. قد يستخدم تسجيل الدخول ملفات ارتباط للجلسة، وتستخدم بعض الواجهات التخزين المحلي للتفضيلات والعرض؛ يمكنك مسحه من إعدادات المتصفح وقد تفقد معه تلك التفضيلات. لا نعلن عن أداة تحليلات تسويقية أو تتبع إعلاني غير متحقق منها.</p></section>
|
||||
<section><h2>الاستضافة والبريد والأطراف الأخرى</h2><p>يعمل الموقع على خادم خاص افتراضي (VPS). تمر الطلبات عبر Cloudflare بوصفه وسيطاً للشبكة والحماية. يستخدم تكامل البريد في الشيفرة Cloudflare Email Sending API للرسائل التشغيلية حين يكون مهيأً؛ لا نفترض أن كل رسالة سلمت. لا نجمع بيانات البطاقة ولا ننشئ اشتراكات Stripe في هذا الإطلاق. لا نبيع البيانات الشخصية. يقتصر وصول المزوّدين والمسؤولين إلى ما يلزم للتشغيل والحماية ومعالجة طلبك، وقد يجري النقل خارج بلدك وفق مواقع البنية التحتية؛ لا ندعي إقامة بيانات في بلد محدد.</p></section>
|
||||
<section><h2>البريد التشغيلي والتسويق</h2><p>الرسائل المتعلقة بحساب قائم أو طلب دعم رسائل تشغيلية وليست موافقة على التسويق. التسجيل في إشعارات العضوية متوقف إلى حين التحقق من التسليم والموافقة الاختيارية؛ لا نطلب بريدك للقراءة ولا نسجلك تلقائياً في قائمة. يمكنك التواصل مع <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> دون الاشتراك في أي خدمة مدفوعة.</p></section>
|
||||
<section><h2>الاحتفاظ والحذف</h2><p>السياسة المعتمدة هي الاحتفاظ بالبيانات الشخصية لمدة أقصاها سنة واحدة من جمعها، ثم حذفها أو إزالة ارتباطها بالهوية. يمكن حذفها قبل ذلك بطلب منك أو عند انتفاء الحاجة إليها. الاستثناء هو ما يلزم للاحتفاظ به لالتزام قانوني واجب أو نزاع قائم أو تحقيق أمني محدد؛ يقتصر على البيانات اللازمة ويزال عند انتهاء السبب. قد تبقى نسخ احتياطية معزولة حتى دورة استبدالها، ضمن الحد السنوي ما لم ينطبق استثناء. هذه سياسة يتولى المشغّل تنفيذها؛ لا ندعي وجود حذف تلقائي مجدول أو نظام تطهير مختبر. النسخ التي نشرها أو نزلها طرف آخر لا يمكن استرجاعها عن بُعد.</p></section>
|
||||
<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 />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = { title: 'إنشاء حساب' }
|
||||
export const metadata: Metadata = {
|
||||
title: 'إنشاء حساب',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function SignupLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
|
||||
@@ -1,98 +1,23 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import Topnav from '@/components/topnav'
|
||||
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'
|
||||
|
||||
/* Signup — markup from signup.html, wired to real Payload registration
|
||||
(create + auto-login) instead of the simulated client-only flow. */
|
||||
// Account creation is administrator-only until protected signup is approved.
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [nextPath, setNextPath] = useState<string | null>(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)
|
||||
const confirm = form.elements.namedItem('confirmPassword') as HTMLInputElement
|
||||
confirm.setCustomValidity(data.get('password') === data.get('confirmPassword') ? '' : 'كلمتا المرور غير متطابقتان.')
|
||||
if (!form.checkValidity()) {
|
||||
setStatus(data.get('password') !== data.get('confirmPassword') ? 'كلمتا المرور غير متطابقتان.' : 'تحقق من جميع الحقول المطلوبة.')
|
||||
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)
|
||||
setStatus(err?.errors?.[0]?.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 (
|
||||
<div id="site" className="site-page page-signup">
|
||||
<Topnav />
|
||||
<main id="content" className="auth">
|
||||
<section className="auth-story">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
<h1>ابدأ بحساب مجاني.</h1>
|
||||
<p>الحساب المجاني يفتح كل الأدلة المجانية كاملة والتعليق عليها. يمكنك الاشتراك في العضوية لاحقاً إن احتجتها.</p>
|
||||
<h1>التسجيل الجديد متوقف مؤقتاً.</h1>
|
||||
<p>يمكنك قراءة المحتوى المتاح خلال الوصول المبكر دون إنشاء حساب.</p>
|
||||
</section>
|
||||
<section className="auth-form-wrap" data-od-id="signup-form-section">
|
||||
<form className="auth-form" id="signupForm" method="post" action="/api/users" noValidate onSubmit={onSubmit} data-od-id="signup-form">
|
||||
<p className="eyebrow">حساب جديد</p>
|
||||
<h2 data-od-id="signup-title">إنشاء حساب</h2>
|
||||
<p className="lead">أدخل بياناتك الأساسية للبدء.</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 onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} /></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('')} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className="fine" id="passwordHint">ثمانية أحرف على الأقل.</span></div>
|
||||
<div className="field"><label htmlFor="confirmPassword">تأكيد كلمة المرور</label><div className="input-wrap"><input className="input" id="confirmPassword" name="confirmPassword" type={showConfirm ? 'text' : 'password'} autoComplete="new-password" minLength={8} required onInvalid={e => e.currentTarget.setCustomValidity('أعد إدخال كلمة المرور.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="confirmPassword" onClick={() => setShowConfirm(v => !v)}>{showConfirm ? 'إخفاء' : 'إظهار'}</button></div></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>
|
||||
<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 className="auth-form-wrap">
|
||||
<h2>لديك حساب بالفعل؟</h2>
|
||||
<p>تسجيل الدخول للحسابات الحالية متاح. أوقفنا إنشاء الحسابات الجديدة مؤقتاً إلى حين استكمال حماية التسجيل.</p>
|
||||
<p><Link href="/login" className="btn btn-primary">تسجيل الدخول</Link></p>
|
||||
<p><Link href="/articles">تصفح المكتبة دون حساب</Link></p>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
.page-account-settings .plan-details dt{font:12px/1.6 var(--font-mono);color:var(--muted)}
|
||||
.page-account-settings .plan-details dd{margin:4px 0 0;font-size:16px}
|
||||
.page-account-settings .price-tag{font-family:var(--font-display);font-size:44px;line-height:1}
|
||||
.page-account-settings .price-tag-early{font-size:24px;color:var(--accent,var(--fg))}
|
||||
.page-account-settings .price-tag small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:6px}
|
||||
.page-account-settings .membership-actions{display:flex;gap:14px;flex-wrap:wrap;padding-top:22px;border-top:1px solid var(--border)}
|
||||
.page-account-settings .cancel-zone{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
|
||||
|
||||
@@ -5,4 +5,9 @@
|
||||
.page-verify-email .verification{min-height:calc(100vh - 70px);display:grid;place-items:center;padding-block:clamp(54px,9vw,110px)}.page-verify-email .card{width:min(700px,100%);background:color-mix(in oklch,var(--surface) 92%,transparent);border:1px solid var(--border);padding:clamp(28px,6vw,64px)}.page-verify-email .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-verify-email .card h1{font-size:clamp(42px,7vw,72px);line-height:1.08;margin:0;max-width:11ch}.page-verify-email .lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:54ch;margin:22px 0 0}.page-verify-email .email{display:block;color:var(--fg);font:700 16px/1.6 var(--font-mono);direction:ltr;text-align:right;overflow-wrap:anywhere;margin-top:8px}.page-verify-email .steps{list-style:none;margin:36px 0;padding:0;border-top:1px solid var(--fg)}.page-verify-email .steps li{display:grid;grid-template-columns:34px 1fr;gap:14px;padding:17px 0;border-bottom:1px solid var(--border)}.page-verify-email .step-num{font:12px/1.8 var(--font-mono);color:var(--accent)}.page-verify-email .actions{display:flex;gap:12px;flex-wrap:wrap}.page-verify-email .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.page-verify-email .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-verify-email .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-verify-email .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-verify-email .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.page-verify-email .status{min-height:27px;color:var(--muted);font-size:14px;margin:16px 0 0}.page-verify-email .fine{color:var(--muted);font-size:12px;margin:24px 0 0;padding-top:20px;border-top:1px solid var(--border)}
|
||||
@media(max-width:620px){.page-verify-email .nav-inner{gap:10px}.page-verify-email .logo{font-size:20px}.page-verify-email .nav-return{font-size:13px}.page-verify-email .verification{align-items:start;padding-block:34px}.page-verify-email .card{padding:28px 20px}.page-verify-email .actions{align-items:stretch}.page-verify-email .actions .btn{width:100%}}
|
||||
@media(prefers-reduced-motion:reduce){.page-verify-email *{transition-duration:.01ms!important}}
|
||||
.page-verify-email .change-email{margin-top:18px}
|
||||
.page-verify-email .change-email summary{cursor:pointer;font-size:13px;color:var(--muted)}
|
||||
.page-verify-email .change-email summary:hover{color:var(--fg)}
|
||||
.page-verify-email .change-email-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:center}
|
||||
.page-verify-email .change-email-row .input{min-height:48px;border:1px solid color-mix(in oklch,var(--border) 72%,var(--fg));background:var(--surface);color:var(--fg);padding:0 14px;min-width:min(300px,100%)}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -6,3 +6,8 @@
|
||||
@media(max-width:920px){.page-signup .auth{grid-template-columns:1fr}.page-signup .auth-story{min-height:390px;padding:58px var(--gutter)}.page-signup .auth-form-wrap{padding:58px var(--gutter);justify-content:center}.page-signup .story-foot{margin-top:48px}}
|
||||
@media(max-width:620px){.page-signup .nav-inner{gap:12px}.page-signup .nav-return{font-size:13px}.page-signup .auth-story{min-height:auto}.page-signup .auth-story h1{font-size:46px}.page-signup .auth-form-wrap{padding-block:46px}.page-signup .story-list{margin-top:38px}}
|
||||
@media(prefers-reduced-motion:reduce){.page-signup *{transition-duration:.01ms!important}}
|
||||
.page-signup .fine.ok{color:var(--success,#2e9e6b)}
|
||||
.page-signup .field-error{margin:2px 0 0;font-size:13px;color:var(--error,#b4443c)}
|
||||
.page-signup .field-error a{color:inherit;text-decoration:underline}
|
||||
.page-signup .typo-suggest{justify-self:start;margin-top:4px;padding:0;border:0;background:none;color:var(--accent);font:inherit;font-size:13px;cursor:pointer;text-decoration:underline}
|
||||
.page-signup .signup-reassure{margin-top:12px}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Disabled until delivery and explicit opt-in are verified. No email collection.
|
||||
export default function NotifyForm() {
|
||||
return <p>التسجيل في إشعارات البريد متوقف حالياً. للتواصل: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p>
|
||||
}
|
||||
@@ -1,132 +1,47 @@
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import SubscribeButton from './subscribe-button'
|
||||
import Faq from './faq'
|
||||
import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import type { Viewer } from '@/lib/viewer'
|
||||
import { LAUNCH_POLICY } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'العضوية' }
|
||||
|
||||
/* Member-library samples — real VIP guides, read from the CMS at request time. */
|
||||
const SAMPLES = [
|
||||
{ slug: 'agent-loop-stop-condition', cover: 'تشريح وكيل ناجح', variant: '', output: 'قالب حالة الوكيل + قائمة فحص قبل التشغيل' },
|
||||
{ slug: 'systematic-model-eval', cover: 'قِس قبل أن تبدّل', variant: 'guide-cover-light', output: 'مجموعة صغيرة من اختبارات التقييم قابلة لإعادة التشغيل' },
|
||||
{ slug: 'prototype-to-production', cover: 'من التجربة إلى الإنتاج', variant: '', output: 'قائمة قرارات ما قبل الإطلاق' },
|
||||
]
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{ q: 'هل أستطيع الإلغاء في أي وقت؟', a: 'نعم. يتوقف التجديد التالي، ويستمر وصولك حتى نهاية الفترة المدفوعة.' },
|
||||
{ q: 'هل المحتوى مناسب للمبتدئ؟', a: 'كل دليل يوضح مستواه. ابدأ بالأدلة المجانية في مسار الأساسيات، ثم انتقل إلى الوكلاء والأتمتة.' },
|
||||
{ q: 'هل توجد شهادة إكمال؟', a: 'لا حالياً. تركيز أنور بآي على التطبيق والملفات العملية، لا على شهادات الحضور.' },
|
||||
{ q: 'هل يمكنني استخدام الملفات في عملي؟', a: 'نعم للاستخدام الشخصي والمهني داخل مشاريعك. إعادة بيع الملفات أو نشرها كما هي غير مشمول.' },
|
||||
]
|
||||
export const metadata = {
|
||||
title: 'الوصول المبكر المجاني والعضوية المستقبلية',
|
||||
description: 'اقرأ المحتوى المنشور دون حساب أو بطاقة دفع. العضوية خطة مستقبلية غير متاحة للشراء، ولا يتحول الوصول أو الدفع تلقائياً.',
|
||||
}
|
||||
|
||||
export default async function SubscriptionPage() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
const role = (user?.role as string) || null
|
||||
const entitled = role === 'vip' || role === 'admin'
|
||||
const viewer: Viewer = user ? { email: user.email, role: role ?? 'free' } : null
|
||||
const { docs: published } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
depth: 0,
|
||||
limit: 200,
|
||||
select: { title: true, slug: true, dek: true, category: true, readingMinutes: true, access: true },
|
||||
})
|
||||
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 }] : []
|
||||
})
|
||||
|
||||
const viewer = await getViewer()
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-membership">
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<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>
|
||||
<p className="plan-note">تلغي متى شئت، ويبقى حسابك المجاني ومحفوظاتك.</p>
|
||||
<div className="plan-cta">
|
||||
{entitled ? (
|
||||
<a className="btn btn-primary" href="/account-settings">عضويتك فعّالة، أدر حسابك</a>
|
||||
) : (
|
||||
<SubscribeButton loggedIn={Boolean(user)} />
|
||||
)}
|
||||
</div>
|
||||
<p className="fine">الدفع عبر 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" aria-labelledby="faq-title">
|
||||
<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>
|
||||
<section className="mem-hero"><div className="wrap narrow">
|
||||
<p className="kicker">الوصول الحالي</p>
|
||||
<h1>وصول مبكر مجاني، دون حساب أو بطاقة دفع.</h1>
|
||||
<p className="lede">{LAUNCH_POLICY.summary}</p>
|
||||
<Link className="btn btn-primary" href="/articles">ابدأ القراءة</Link>
|
||||
</div></section>
|
||||
<section className="mem-compare"><div className="wrap narrow">
|
||||
<h2>ما المتاح الآن وما المخطط مستقبلاً؟</h2>
|
||||
<div className="table-wrap"><table>
|
||||
<thead><tr><th scope="col">الميزة</th><th scope="col">الوصول المجاني الآن</th><th scope="col">العضوية مستقبلاً · غير متاحة للشراء</th></tr></thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<tr><th scope="row">الحسابات والتفاعل</th><td>التسجيل العام متوقف؛ الميزات القائمة لأصحاب الحسابات حسب صلاحياتهم</td><td>لا نعد بميزات قبل اختبارها</td></tr>
|
||||
<tr><th scope="row">الدفع</th><td>لا بطاقة، لا اشتراك، لا رسوم</td><td>السعر والفوترة والإلغاء يعلن عنها قبل إتاحة الشراء</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
<section className="mem-faq"><div className="wrap narrow">
|
||||
<h2>هل تنتهي الفترة تلقائياً بعد أسبوعين؟</h2>
|
||||
<p>لا. الأسبوعان نقطة مراجعة فقط. لا يغيّر التاريخ الوصول ولا ينشئ اشتراكاً، وقد يستمر الإطلاق المجاني.</p>
|
||||
<h2>كيف أتواصل؟</h2>
|
||||
<p>إشعارات البريد غير متاحة للتسجيل حالياً. للدعم والملاحظات وطلبات حذف البيانات: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p>
|
||||
<p><Link href="/terms">شروط الاستخدام</Link> · <Link href="/privacy">الخصوصية</Link></p>
|
||||
</div></section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -1,45 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [comingSoon, setComingSoon] = 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
|
||||
}
|
||||
}
|
||||
// Stripe pending mode (no keys configured) or failure
|
||||
setComingSoon(true)
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
if (comingSoon) {
|
||||
return <p className="plan-soon" role="status">الدفع الإلكتروني متاح قريباً. راسلنا على <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> لتفعيل عضويتك يدوياً.</p>
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
// Fail closed even if an older template reuses this control without comingSoon.
|
||||
export default function SubscribeButton(_props: { loggedIn: boolean; comingSoon?: boolean }) {
|
||||
return <Link className="btn btn-primary" href="/articles">اقرأ مجاناً دون حساب</Link>
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { LAUNCH_POLICY } from '@/lib/launch'
|
||||
import '../styles/privacy.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'شروط الاستخدام' }
|
||||
export const metadata = {
|
||||
title: 'شروط استخدام Anwarpy',
|
||||
description: 'شروط استخدام أنور پآي خلال الإطلاق التجريبي: القراءة المجانية، وحقوق المحتوى والملفات، والاستخدام المقبول، والحسابات والتواصل، وشروط أي تغيير مستقبلي في الوصول.',
|
||||
}
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div id="site" className="site-page page-terms">
|
||||
<Topnav />
|
||||
<main id="content">
|
||||
<section className="hero" data-od-id="terms-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">شروط الاستخدام · مسودة ما قبل الإطلاق</p>
|
||||
<h1 data-od-id="terms-title">شروط استخدام أنور بآي.</h1>
|
||||
<p className="lead">توضح هذه المسودة القواعد الأساسية لاستخدام مكتبة أنور بآي والاشتراك فيها.</p>
|
||||
<p className="notice" role="note">هذه مسودة للمراجعة القانونية وليست نصاً قانونياً نهائياً. يجب اعتماد النسخة النهائية قبل نشر الموقع.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container policy" data-od-id="terms-content">
|
||||
<section data-od-id="terms-service"><h2>ما هي الخدمة؟</h2><p>أنور بآي مكتبة محتوى عربية تقدم أدلة ومسارات تعليمية، بعضها مجاني وبعضها متاح لمشتركي العضوية. يمنحك الاشتراك وصولاً شخصياً إلى المحتوى المدفوع طوال فترة الاشتراك الفعّالة.</p></section>
|
||||
<section data-od-id="terms-account"><h2>حسابك</h2><p>أنت مسؤول عن دقة بيانات التسجيل والاحتفاظ بكلمة المرور في سرية، وعن أي نشاط يجري عبر حسابك. يمكنك حذف حسابك في أي وقت بمراسلة <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>، وسنحذف بياناتك وفق سياسة الخصوصية.</p></section>
|
||||
<section data-od-id="terms-billing"><h2>الاشتراك والدفع</h2><p>العضوية بـ <bdi dir="ltr">$10</bdi> شهرياً وتُدفع عبر مزوّد الدفع (Stripe). يمكنك الإلغاء في أي وقت ويتوقف التجديد التالي فوراً مع بقاء وصولك حتى نهاية الفترة المدفوعة. السعر الحالي سعر تجريبي للمعاينة وقد يتغير قبل الإطلاق مع إشعار مسبق.</p></section>
|
||||
<section data-od-id="terms-content"><h2>المحتوى والملكية</h2><p>حقوق المحتوى والملفات محفوظة لأصحابها. حصولك على الاشتراك يمنحك حق الاستخدام الشخصي والمهني داخل مشاريعك، ولا يشمل إعادة نشر المحتوى أو بيعه أو توزيعه كما هو.</p></section>
|
||||
<section data-od-id="terms-conduct"><h2>الاستخدام المقبول</h2><p>لا يجوز استخدام الخدمة لاستخراج البيانات آلياً أو إعادة نشر المحتوى على نطاق واسع أو أي استخدام يضر بالمنصة أو بمستخدميها. قد نوقف الحسابات المخالفة بعد تنبيه صاحبها.</p></section>
|
||||
<section data-od-id="terms-disclaimer"><h2>إخلاء المسؤولية</h2><p>المحتوى تعليمي ويُقدَّم «كما هو» دون ضمانات لنتائج محددة. أنت مسؤول عن قراراتك ومشاريعك، وينبغي اختبار أي قالب أو ملف قبل استخدامه في بيئة إنتاج.</p></section>
|
||||
<section data-od-id="terms-changes"><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 className="hero"><div className="container">
|
||||
<p className="eyebrow">سياسات الوصول المبكر المجاني</p>
|
||||
<h1>شروط استخدام Anwarpy</h1>
|
||||
<p className="lead">تاريخ السريان: <time dateTime={LAUNCH_POLICY.effectiveDate}>{LAUNCH_POLICY.effectiveDate}</time></p>
|
||||
<p>{LAUNCH_POLICY.summary}</p>
|
||||
</div></section>
|
||||
<div className="container policy">
|
||||
<section><h2>الخدمة والمشغّل</h2><p>يدير Anwarpy مكتبة عربية للمحتوى التعليمي. القراءة العامة للمحتوى المنشور مجانية خلال الوصول المبكر، دون حساب أو بطاقة دفع. المسودات والملاحظات الخاصة والملفات غير المعتمدة ليست جزءاً من هذا الوصول.</p></section>
|
||||
<section><h2>الوصول المبكر والعضوية المستقبلية</h2><p>بعض المواد مصنفة للعضوية مستقبلاً لكنها مفتوحة للقراءة الآن. نراجع الإطلاق المدفوع بعد نحو أسبوعين من الإطلاق الفعلي؛ المراجعة ليست موعد انتهاء تلقائياً وقد يستمر الوصول المجاني. أي تغيير في الوصول يحتاج موافقة المشغّل واختبارات الدفع والصلاحيات وإعلاناً على الموقع قبل تطبيقه. لا يوجد اشتراك مدفوع متاح الآن ولا تجديد أو رسوم تلقائية. الأسعار وشروط الفوترة والإلغاء المستقبلية ستعلن قبل إتاحة الشراء؛ القراءة أو التواصل لا يمثلان موافقة على الدفع.</p></section>
|
||||
<section><h2>الحسابات والتواصل</h2><p>إنشاء الحسابات العامة متوقف، والحسابات ينشئها المسؤول فقط. يستطيع أصحاب الحسابات القائمة تسجيل الدخول للميزات المتاحة؛ لا يلزم ذلك للقراءة. حافظ على سرية بيانات الدخول ولا تنتحل هوية غيرك. للاستفسار أو طلب حذف بيانات حساب قائم: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p></section>
|
||||
<section><h2>المحتوى والملفات</h2><p>حقوق المحتوى لأصحابها. يمكنك قراءة المحتوى واستخدام الملفات المتاحة في مشاريعك الشخصية والمهنية، ما لم يذكر الملف ترخيصاً مختلفاً أو حقوقاً لطرف آخر. لا يشمل ذلك إعادة بيع المواد أو إعادة نشرها كما هي. الملفات التي نزلت أثناء الوصول المجاني لا يمكن سحب نسخها منك عن بُعد؛ وقد يتغير إتاحة تنزيلها لاحقاً بعد إعلان.</p></section>
|
||||
<section><h2>الاستخدام المقبول</h2><p>لا تحاول الوصول إلى بيانات خاصة أو تجاوز الصلاحيات أو نشر برمجيات ضارة أو محتوى مسيء أو إغراق الخدمة. يجوز تقييد النشاط الضار وإيقاف الحساب المخالف لحماية الخدمة، دون المساس بحقوق يقررها القانون.</p></section>
|
||||
<section><h2>طبيعة المحتوى</h2><p>المحتوى تعليمي وليس ضماناً للنتائج أو بديلاً عن استشارة متخصصة. اختبر التعليمات والقوالب في بيئة آمنة قبل الإنتاج، وراجع التكاليف والصلاحيات وحقوق الأطراف الأخرى. لا نضمن استمرار كل مادة أو خلوها من الخطأ، ولا يستبعد هذا حقوقاً إلزامية للمستخدم.</p></section>
|
||||
<section><h2>التحديثات وطلبات المساعدة</h2><p>ننشر التغييرات الجوهرية وتاريخ سريانها على الموقع. لا نعتمد على إشعارات بريدية غير مختبرة لتغيير الوصول. قناة الدعم وطلبات البيانات هي <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>؛ لا يمثل مراسلتها اشتراكاً في التسويق.</p></section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { headers } from 'next/headers'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS, topicsFor, isTopic } from '@/lib/topics'
|
||||
import { guidesLabel, minutesLabel, formatNumber, contentTypeLabel } from '@/lib/format'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import Topnav from '@/components/topnav'
|
||||
import { accessLabel } from '@/lib/launch'
|
||||
import Footer from '@/components/footer'
|
||||
|
||||
type Args = { params: Promise<{ topic: string }> }
|
||||
|
||||
type HubPost = {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
dek: string
|
||||
access: 'free' | 'vip'
|
||||
category: string
|
||||
level: string
|
||||
readingMinutes: number
|
||||
views: number
|
||||
cover?: { url?: string | null } | number | null
|
||||
contentType?: string | null
|
||||
}
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
const { topic } = await params
|
||||
if (!isTopic(topic)) return { title: 'أنور پآي' }
|
||||
const meta = TOPICS.find(t => t.key === topic)
|
||||
return {
|
||||
title: `${meta?.label} — مسار`,
|
||||
description: `كل محتوى مسار ${meta?.label} في أنور پآي: ${meta?.hint}. محتوى عملي بالعربية بخطوات قابلة للتطبيق.`,
|
||||
alternates: { canonical: `/topics/${topic}` },
|
||||
}
|
||||
}
|
||||
|
||||
export default async function TopicPage({ params }: Args) {
|
||||
const { topic } = await params
|
||||
if (!isTopic(topic)) notFound()
|
||||
const meta = TOPICS.find(t => t.key === topic)!
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const [res, auth] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
depth: 1,
|
||||
limit: 200,
|
||||
sort: '-publishedAt',
|
||||
select: {
|
||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||
readingMinutes: true, views: true, cover: true, contentType: true,
|
||||
},
|
||||
}),
|
||||
payload.auth({ headers: await headers() }),
|
||||
])
|
||||
const role = (auth.user?.role as string) || null
|
||||
// Topic membership is derived (category + level), same rule as the library/home.
|
||||
const posts = (res.docs as unknown as (HubPost & { likes?: number })[]).filter(post => topicsFor(post).includes(topic))
|
||||
const totalMinutes = posts.reduce((sum, post) => sum + (post.readingMinutes ?? 0), 0)
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 page-topic">
|
||||
{/* L-09: Breadcrumb structured data mirrors the visible trail. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'الرئيسية', item: SITE_URL },
|
||||
{ '@type': 'ListItem', position: 2, name: meta.label, item: `${SITE_URL}/topics/${topic}` },
|
||||
],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Topnav viewer={auth.user ? { email: String(auth.user.email) } : null} />
|
||||
<main id="content">
|
||||
<section className="topic-hero">
|
||||
<div className="wrap narrow">
|
||||
<p className="crumbs" dir="rtl"><Link href="/">الرئيسية</Link> <span aria-hidden="true">/</span> <span>{meta.label}</span></p>
|
||||
<h1>{meta.label}</h1>
|
||||
<p className="topic-hint">{meta.hint} · {guidesLabel(posts.length)} · {minutesLabel(totalMinutes)} قراءة</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="topic-list" aria-label={`محتوى ${meta.label}`}>
|
||||
<div className="wrap narrow">
|
||||
{posts.length === 0 && (
|
||||
<p className="muted">لا يوجد محتوى منشور في هذا المسار بعد. تابعنا للقادم قريباً.</p>
|
||||
)}
|
||||
<div className="lib-grid">
|
||||
{posts.map(post => {
|
||||
const coverUrl = typeof post.cover === 'object' ? post.cover?.url ?? null : null
|
||||
const locked = post.access === 'vip' && role !== 'vip' && role !== 'admin' && !freeEarlyAccess()
|
||||
return (
|
||||
<article className="post guide-card" key={post.id}>
|
||||
{coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />}
|
||||
<div className="guide-card-body">
|
||||
<p className="meta-row">
|
||||
<span>{post.category}</span>
|
||||
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||
<span>{minutesLabel(post.readingMinutes)}</span>
|
||||
<span>{post.level}</span>
|
||||
</p>
|
||||
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
|
||||
<p className="guide-card-dek">{post.dek}</p>
|
||||
<div className="guide-card-foot">
|
||||
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{accessLabel(post.access)}</span>
|
||||
{post.views != null && <span className="engagement">{formatNumber(post.views)} قراءة</span>}
|
||||
{locked && <span className="badge badge-vip">يتطلب عضوية</span>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="topic-other">
|
||||
مسارات أخرى:{' '}
|
||||
{TOPICS.filter(t => t.key !== topic).map((t, i) => (
|
||||
<span key={t.key}>{i > 0 && ' · '}<Link href={`/topics/${t.key}`}>{t.label}</Link></span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import VerifyActions from './verify-actions'
|
||||
import '../styles/email-verification.css'
|
||||
|
||||
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 }> }
|
||||
|
||||
@@ -19,12 +23,14 @@ export default async function VerifyEmailPage({ searchParams }: Props) {
|
||||
const { invalid } = await searchParams
|
||||
let email: string | null = null
|
||||
let verified = false
|
||||
let userId: number | null = null
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (user) {
|
||||
email = user.email
|
||||
verified = !!user.emailVerified
|
||||
userId = Number(user.id)
|
||||
}
|
||||
} catch {
|
||||
/* no db / no session — page still renders */
|
||||
@@ -46,11 +52,12 @@ export default async function VerifyEmailPage({ searchParams }: Props) {
|
||||
<p className="status" role="status">بريدك الإلكتروني مؤكَّد بالفعل ✓ — تابع إلى الإعداد.</p>
|
||||
)}
|
||||
<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">03</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
|
||||
<li><span className="step-num">03</span><span>افحص مجلد الرسائل غير المرغوبة (Spam).</span></li>
|
||||
<li><span className="step-num">04</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
|
||||
</ol>
|
||||
<VerifyActions verified={verified} />
|
||||
<VerifyActions verified={verified} email={email} userId={userId} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/* Resend button + status — real /api/auth/resend-verification endpoint.
|
||||
Without SMTP configured the API answers 503 with a clear message. */
|
||||
export default function VerifyActions({ verified }: { verified: boolean }) {
|
||||
Without SMTP configured the API answers 503 with a clear message.
|
||||
«البريد غير صحيح؟» — collapsed change-email affordance: PATCH /api/users/:id
|
||||
(self-update allowed in Users.ts access) then resend; 400 → duplicate-email
|
||||
copy. Hidden without a session (no id → nothing to patch). */
|
||||
export default function VerifyActions({ verified, email, userId }: { verified: boolean; email: string | null; userId: number | null }) {
|
||||
const [status, setStatus] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (email) {
|
||||
setNewEmail(email)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = sessionStorage.getItem('anwarpi:signup-email')
|
||||
if (saved) setNewEmail(saved)
|
||||
} catch { /* private mode */ }
|
||||
}, [email])
|
||||
|
||||
async function changeEmail() {
|
||||
if (busy || !userId) return
|
||||
setBusy(true)
|
||||
setStatus('جارٍ التحديث…')
|
||||
try {
|
||||
const patchRes = await fetch(`/api/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: newEmail.trim() }),
|
||||
})
|
||||
if (!patchRes.ok) {
|
||||
setStatus(patchRes.status === 400 ? 'هذا البريد مسجَّل لدينا.' : 'تعذّر تحديث البريد، حاول مرة أخرى.')
|
||||
return
|
||||
}
|
||||
const resendRes = await fetch('/api/auth/resend-verification', { method: 'POST' })
|
||||
const data = await resendRes.json().catch(() => null) as { error?: string; already?: boolean } | null
|
||||
setStatus(resendRes.ok || data?.already ? 'أرسلنا رسالة تأكيد جديدة إلى بريدك المحدَّث.' : data?.error || 'تم تحديث البريد لكن تعذّر الإرسال الآن.')
|
||||
} catch {
|
||||
setStatus('تعذّر الاتصال، حاول مرة أخرى.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,7 +59,7 @@ export default function VerifyActions({ verified }: { verified: boolean }) {
|
||||
button.disabled = true
|
||||
setStatus('جارٍ الإرسال…')
|
||||
const res = await fetch('/api/auth/resend-verification', { method: 'POST' })
|
||||
const data = await res.json().catch(() => null)
|
||||
const data = await res.json().catch(() => null) as { error?: string; already?: boolean } | null
|
||||
if (res.ok || data?.already) {
|
||||
setStatus('أرسلنا الرسالة من جديد — افحص صندوق الوارد.')
|
||||
} else {
|
||||
@@ -33,6 +73,17 @@ export default function VerifyActions({ verified }: { verified: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
<p className="status" id="resendStatus" role="status" aria-live="polite">{status}</p>
|
||||
{userId && !verified && (
|
||||
<details className="change-email" data-od-id="change-email">
|
||||
<summary>البريد غير صحيح؟</summary>
|
||||
<div className="change-email-row">
|
||||
<input className="input" type="email" inputMode="email" dir="ltr" value={newEmail} onChange={e => setNewEmail(e.target.value)} aria-label="البريد الإلكتروني الصحيح" />
|
||||
<button className="btn btn-secondary" type="button" onClick={changeEmail} disabled={busy || newEmail.trim() === ''}>
|
||||
{busy ? 'جارٍ التحديث…' : 'حدّث البريد وأعد الإرسال'}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function OnboardingWizard({ viewerId }: { viewerId: number | null
|
||||
<p className="eyebrow">نقطة البداية المقترحة</p>
|
||||
<h3 id="recommendationTitle">{rec[0]}</h3>
|
||||
<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>
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 2 }))}>تعديل الاختيارات</button>
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { Media } from '@payload-types'
|
||||
import configPromise from '@payload-config'
|
||||
import { resolveCoverImage } from '@/lib/agent-media'
|
||||
import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files'
|
||||
import { scanAgentPayload } from '@/lib/secret-scan'
|
||||
import { lexicalToText } from '@/lib/lexical'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -20,9 +22,11 @@ function validKey(req: NextRequest) {
|
||||
return req.headers.get('x-agent-key') === key
|
||||
}
|
||||
|
||||
/** Fields the agent is allowed to write; status is NEVER included. */
|
||||
/** Fields the agent is allowed to write; status is NEVER included.
|
||||
* `access`/`readingMinutes` are admin-only (tier changes and reading time
|
||||
* are admin/agent-policy decisions — the hook computes honest minutes). */
|
||||
function pickFields(body: Body): Body {
|
||||
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
|
||||
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'cover'] as const
|
||||
const out: Body = {}
|
||||
for (const field of allowed) {
|
||||
if (field in body) out[field] = body[field]
|
||||
@@ -58,6 +62,21 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// L-10 dedup first: a retried ingest with the same sourceId surfaces the
|
||||
// existing post (200) instead of a misleading slug 409.
|
||||
if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') {
|
||||
const dupe = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { sourceId: { equals: body.sourceId } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const dup = dupe.docs[0] as { id: number; slug: string; status: string } | undefined
|
||||
if (dup) {
|
||||
return NextResponse.json({ id: dup.id, slug: dup.slug, status: dup.status, deduplicated: true }, { status: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
// Slug conflict → 409
|
||||
const existing = await payload.find({
|
||||
collection: 'posts',
|
||||
@@ -69,6 +88,20 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const data = pickFields(body)
|
||||
if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') data.sourceId = body.sourceId
|
||||
|
||||
// L-05: pattern names only — matched values are never stored or echoed.
|
||||
// A string content (wrong shape) is scanned raw too — lexicalToText of a
|
||||
// string returns '' and would silently skip the scan.
|
||||
const secretPatterns = scanAgentPayload([
|
||||
data.title as string,
|
||||
data.dek as string,
|
||||
data.teaser as string,
|
||||
typeof data.content === 'string' ? data.content : lexicalToText(data.content),
|
||||
])
|
||||
if (secretPatterns.length > 0) {
|
||||
return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 })
|
||||
}
|
||||
|
||||
// Per-post work files (inline base64) → stored as gated `files` documents.
|
||||
// Like coverImage, `attachments` is an input transform, not a raw field.
|
||||
@@ -99,7 +132,14 @@ export async function POST(req: NextRequest) {
|
||||
// directly (unpublished content requires a deliberate admin action otherwise).
|
||||
const publish = body.publish === true
|
||||
if (publish && !data.publishedAt) data.publishedAt = new Date().toISOString()
|
||||
if (!data.content || typeof data.content !== 'object') {
|
||||
if (typeof data.content === 'string') {
|
||||
// Wrong shape — never store it and never silently drop the agent's text.
|
||||
return NextResponse.json(
|
||||
{ error: 'content must be Lexical JSON ({ root: { children: [...] } }), not a markdown/plain string' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (!data.content) {
|
||||
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later
|
||||
data.content = {
|
||||
root: {
|
||||
@@ -159,6 +199,16 @@ export async function PUT(req: NextRequest) {
|
||||
const data = pickFields(body)
|
||||
delete (data as Body).status // agent can never publish/unpublish
|
||||
|
||||
const secretPatterns = scanAgentPayload([
|
||||
data.title as string,
|
||||
data.dek as string,
|
||||
data.teaser as string,
|
||||
typeof data.content === 'string' ? data.content : lexicalToText(data.content),
|
||||
])
|
||||
if (secretPatterns.length > 0) {
|
||||
return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 })
|
||||
}
|
||||
|
||||
const doc = await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
|
||||
@@ -26,6 +26,8 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
|
||||
}
|
||||
if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
|
||||
|
||||
// No approved public-file relationship exists here. Free launch must not
|
||||
// expose every private upload by ID; explicitly free files stay public.
|
||||
if (doc.access === 'vip') {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 })
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Delivery is not verified. Do not collect addresses or claim subscription.
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{ error: 'التسجيل في إشعارات البريد متوقف حالياً. للتواصل: hello@anwarpy.ai' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { REST_DELETE, REST_GET, REST_PATCH, REST_PUT } from '@payloadcms/next/routes'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -35,7 +36,10 @@ export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
})
|
||||
const post = docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (post.access === 'vip') {
|
||||
// L-02: in free early access, published VIP posts serve the full body
|
||||
// (mirrors the page + field-ACL policy); the tier gate applies only when
|
||||
// billing is enabled.
|
||||
if (post.access === 'vip' && !freeEarlyAccess()) {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Payload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import Stripe from 'stripe'
|
||||
import configPromise from '@payload-config'
|
||||
import { billingEnabled } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -12,15 +13,16 @@ export async function POST() {
|
||||
const priceId = process.env.STRIPE_PRICE_ID
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
|
||||
|
||||
// Stripe pending mode: no keys yet → subscription is «قريباً» (coming soon).
|
||||
// No VIP is granted without a Stripe-verified session; adding
|
||||
// STRIPE_SECRET_KEY + STRIPE_PRICE_ID switches to real Checkout with the
|
||||
// same success/cancel pages.
|
||||
// Free-launch policy is code-locked: Stripe configuration alone cannot
|
||||
// create a checkout session. Re-enabling requires separate operator review.
|
||||
if (!billingEnabled()) {
|
||||
return NextResponse.json({ error: 'الدفع غير مفعّل خلال فترة الوصول المبكر.' }, { status: 503 })
|
||||
}
|
||||
|
||||
// Config-error guard for paid mode (TS narrowing; billingEnabled() already
|
||||
// guarantees the secret) — never create a session with a missing price.
|
||||
if (!secret || !priceId) {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
return NextResponse.json({ comingSoon: true })
|
||||
return NextResponse.json({ error: 'الدفع غير مكتمل الإعداد حالياً.' }, { status: 503 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
@@ -207,6 +207,8 @@ body { background: var(--ink); color: var(--mist); }
|
||||
#site .membership-offer { border: 1px solid var(--seam); border-top: 2px solid var(--champagne); border-radius: var(--r-md); padding: 28px; background: var(--basalt); display: grid; gap: 18px; }
|
||||
#site .membership-offer .price { font: 600 40px/1 var(--font-display); color: var(--mist); display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
#site .membership-offer .price span { font: 400 15px/1.5 var(--font-body); color: var(--stone); }
|
||||
#site .membership-offer .price-early { color: var(--champagne); }
|
||||
#site .membership-offer .early-line { color: var(--stone); font-size: 15.5px; margin: -8px 0 0; }
|
||||
#site .membership-offer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||
#site .membership-offer li { display: flex; gap: 12px; align-items: baseline; color: var(--mist); font-size: 15.5px; }
|
||||
#site .membership-offer li::before { content: ""; flex: none; width: 9px; height: 8px; background: var(--champagne); clip-path: polygon(50% 0, 100% 100%, 0 100%); transform: translateY(-1px); }
|
||||
@@ -277,7 +279,22 @@ body { background: var(--ink); color: var(--mist); }
|
||||
#site .art-body li + li { margin-top: .4em; }
|
||||
#site .art-body li::marker { color: var(--champagne); }
|
||||
#site .art-body blockquote { margin-inline: 0; padding: 4px 20px; border-inline-start: 2px solid var(--champagne); color: var(--mist); font-size: 19px; }
|
||||
#site .topic-hero { padding-block: clamp(40px, 6vw, 72px) 8px; }
|
||||
#site .topic-hero h1 { font-size: clamp(28px, 3vw, 40px); margin: 0 0 6px; }
|
||||
#site .topic-hero .crumbs { display: flex; gap: 8px; color: var(--stone); font-size: 14px; margin: 0 0 18px; }
|
||||
#site .topic-hero .crumbs a { color: var(--champagne); }
|
||||
#site .topic-hint { color: var(--mist); margin: 0; }
|
||||
#site .topic-list { padding-block: 32px 72px; }
|
||||
#site .topic-other { margin-top: 40px; color: var(--stone); font-size: 15px; }
|
||||
#site .topic-other a { color: var(--champagne); }
|
||||
#site .topic-strip { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 22px; }
|
||||
#site .topic-strip a { display: inline-block; padding: 6px 16px; border: 1px solid var(--seam); border-radius: 999px; color: var(--mist); font-size: 14.5px; }
|
||||
#site .topic-strip a:hover { border-color: var(--champagne); color: var(--champagne); }
|
||||
#site .art-body pre { direction: ltr; text-align: left; background: var(--ink-2); border: 1px solid var(--seam); border-radius: var(--r-md); padding: 20px 22px; overflow-x: auto; font: 14.5px/1.75 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
|
||||
#site .art-body .post-table { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
#site .art-body .post-table table { width: 100%; border-collapse: collapse; font-size: 16px; line-height: 1.8; }
|
||||
#site .art-body .post-table th, #site .art-body .post-table td { border: 1px solid var(--seam); padding: 10px 14px; text-align: start; vertical-align: top; }
|
||||
#site .art-body .post-table th { background: var(--ink-2); font-weight: 600; color: var(--mist); }
|
||||
#site .art-body pre code { background: none; padding: 0; white-space: pre; }
|
||||
#site .art-body hr { border: 0; height: 1px; background: var(--seam); margin-block: 2.4em; }
|
||||
#site .art-body .intro { font-size: 21px; line-height: 1.9; color: var(--mist); }
|
||||
@@ -285,6 +302,11 @@ body { background: var(--ink); color: var(--mist); }
|
||||
#site .paywall h2 { font-size: clamp(24px, 2.4vw, 30px); margin: 0 0 12px; }
|
||||
#site .paywall p { color: var(--stone); font-size: 16.5px; }
|
||||
#site .paywall-actions { display: flex; flex-wrap: wrap; gap: 12px; margin: 24px 0 14px; }
|
||||
#site .paywall-preview ul { list-style: none; margin: 0 0 6px; padding: 0; display: grid; gap: 8px; }
|
||||
#site .paywall-preview li { display: flex; gap: 10px; align-items: baseline; color: var(--mist); font-size: 15.5px; }
|
||||
#site .paywall-preview li svg { flex: none; width: 14px; height: 14px; transform: translateY(2px); }
|
||||
#site .early-notice { margin: 0 0 28px; padding: 14px 18px; border: 1px solid var(--seam); border-inline-start: 2px solid var(--champagne); border-radius: var(--r-md); background: var(--basalt); }
|
||||
#site .early-notice p { color: var(--stone); font-size: 16.5px; margin: 0; }
|
||||
#site .art-aside { position: sticky; top: 104px; display: grid; gap: 28px; }
|
||||
#site .aside-title { font: 600 15px/1.6 var(--font-body); color: var(--mist); margin-bottom: 10px; }
|
||||
#site .toc ol { list-style: none; margin: 0; padding: 0; border-inline-start: 1px solid var(--seam); }
|
||||
@@ -324,6 +346,13 @@ body { background: var(--ink); color: var(--mist); }
|
||||
MEMBERSHIP
|
||||
================================================================== */
|
||||
#site.v2-membership .mem-hero { padding-block: clamp(56px, 8vw, 104px); border-bottom: 1px solid var(--seam); }
|
||||
#site.v2-membership .early-access { padding-top: clamp(32px, 5vw, 56px); }
|
||||
#site.v2-membership .early-access .wrap > p { color: var(--stone); max-width: 70ch; }
|
||||
#site.v2-membership .early-access h2 { font-size: clamp(22px, 2.4vw, 30px); margin-bottom: 10px; }
|
||||
#site .notify-form { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; align-items: center; }
|
||||
#site .notify-form input { min-height: 48px; padding: 0 14px; border: 1px solid var(--seam); border-radius: var(--r-sm, 10px); background: var(--bg); color: var(--fg); font: inherit; min-width: min(320px, 100%); }
|
||||
#site .notify-form .notify-error { color: var(--error, #b4443c); width: 100%; margin: 0; }
|
||||
#site .notify-done { color: var(--success, var(--champagne)); font-weight: 600; margin-top: 18px; }
|
||||
#site.v2-membership .mem-hero-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 1fr); gap: clamp(32px, 6vw, 88px); align-items: start; }
|
||||
#site.v2-membership .mem-hero h1 { font-size: clamp(36px, 4.6vw, 60px); max-width: 15ch; }
|
||||
#site.v2-membership .mem-hero .lede { margin-top: 22px; }
|
||||
@@ -341,6 +370,7 @@ body { background: var(--ink); color: var(--mist); }
|
||||
#site .plan-cta .btn { width: 100%; }
|
||||
#site .plan-soon { padding: 14px 16px; border: 1px dashed var(--seam-2); border-radius: var(--r-sm); color: var(--stone); font-size: 14.5px; }
|
||||
#site .plan-soon a { color: var(--mist); text-decoration: underline; text-underline-offset: 4px; }
|
||||
#site .plan-soon-wrap { display: grid; gap: 10px; justify-items: start; }
|
||||
#site.v2-membership .mem-samples { padding-block: clamp(64px, 8vw, 104px); background: var(--ink-2); border-bottom: 1px solid var(--seam); }
|
||||
#site .sample-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px 24px; }
|
||||
#site .sample { display: grid; gap: 8px; align-content: start; height: 100%; }
|
||||
@@ -605,6 +635,7 @@ body, #site { background: var(--ink); color: var(--mist); }
|
||||
#site.v2-article .art-head { background: var(--ink-2); }
|
||||
#site .art-body pre { background: #0f172a; color: #e2e8f0; border-color: #0f172a; }
|
||||
#site .paywall { background: #f2faf7; border-color: #cfe9e1; border-top-color: var(--champagne); }
|
||||
#site .early-notice { background: #f2faf7; border-color: #cfe9e1; border-inline-start-color: var(--champagne); }
|
||||
#site .comment, #site .comment-gate { background: #fff; }
|
||||
#site .art-related { background: var(--ink-2); }
|
||||
#site :is(.related-item, .sample) { background: #fff; }
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Readex_Pro } from 'next/font/google'
|
||||
import localFont from 'next/font/local'
|
||||
import './globals.css'
|
||||
import './polish.css'
|
||||
import './design.css'
|
||||
import './color-trial.css'
|
||||
|
||||
/* Readex Pro: a modern Arabic/Latin face drawn for screens and interfaces.
|
||||
Self-hosted at build time by next/font. */
|
||||
const sans = Readex_Pro({
|
||||
subsets: ['arabic', 'latin'],
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
/* Bundle Readex Pro's Arabic/Latin variable font so builds never fetch Google Fonts. */
|
||||
const sans = localFont({
|
||||
src: [{ path: '../fonts/ReadexPro.ttf', weight: '300 700', style: 'normal' }],
|
||||
display: 'swap',
|
||||
variable: '--font-sans',
|
||||
})
|
||||
@@ -19,12 +17,12 @@ export const viewport: Viewport = { colorScheme: 'only light', themeColor: '#fff
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'),
|
||||
title: { default: 'أنور بآي', template: '%s — أنور بآي' },
|
||||
description: 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.',
|
||||
title: { default: 'أنور پآي — محتوى الذكاء الاصطناعي العملي بالعربية', template: '%s — أنور پآي' },
|
||||
description: 'محتوى عربي عملي عن الذكاء الاصطناعي: تشغيل النماذج محليًا، وبناء الوكلاء، والأتمتة وRAG. استكشف تجارب أنور پآي وشروحات الأدوات بخطوات قابلة للتطبيق.',
|
||||
alternates: { canonical: './' },
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: 'أنور بآي',
|
||||
siteName: 'أنور پآي',
|
||||
locale: 'ar',
|
||||
images: [{ url: '/og-default.png', width: 1200, height: 630 }],
|
||||
},
|
||||
@@ -40,7 +38,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</head>
|
||||
<body>
|
||||
{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>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SITE_INTRO, SITE_TITLE, fetchIndexDocs, renderIndexSections, type IndexDoc } from '@/lib/llms'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { lexicalToPlain } from '@/lib/lexical'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const early = freeEarlyAccess()
|
||||
// Public read path: the content field's read ACL already decides body
|
||||
// availability — with early access every published body passes; with billing
|
||||
// on, VIP bodies come back undefined and are listed members-only.
|
||||
const docs = await fetchIndexDocs(true) as (IndexDoc & { content?: unknown })[]
|
||||
|
||||
const parts: string[] = [
|
||||
`# ${SITE_TITLE}`,
|
||||
'',
|
||||
`> ${SITE_INTRO}`,
|
||||
'',
|
||||
renderIndexSections(docs, !early),
|
||||
'---',
|
||||
'',
|
||||
'# المحتوى الكامل',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const doc of docs) {
|
||||
const vipOnly = !early && doc.access === 'vip'
|
||||
const suffix = vipOnly ? ' (محتوى للأعضاء)' : ''
|
||||
parts.push(`## ${doc.title}${suffix}`, '')
|
||||
parts.push(`URL: /posts/${doc.slug}`, '')
|
||||
if (doc.dek) parts.push(doc.dek, '')
|
||||
if (doc.updatedAt && doc.publishedAt && doc.updatedAt !== doc.publishedAt) {
|
||||
parts.push(`حُدّث: ${formatDate(doc.updatedAt)}`, '')
|
||||
}
|
||||
if (vipOnly) {
|
||||
parts.push('هذا المحتوى متاح للأعضاء فقط. العضوية: /subscription', '')
|
||||
} else if (typeof doc.content === 'object' && doc.content !== null) {
|
||||
parts.push(lexicalToPlain(doc.content), '')
|
||||
}
|
||||
parts.push('---', '')
|
||||
}
|
||||
|
||||
return new Response(parts.join('\n'), { headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SITE_INTRO, SITE_TITLE, fetchIndexDocs, renderIndexSections } from '@/lib/llms'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const docs = await fetchIndexDocs()
|
||||
const body = [
|
||||
`# ${SITE_TITLE}`,
|
||||
'',
|
||||
`> ${SITE_INTRO}`,
|
||||
'',
|
||||
`النسخة الكاملة: /llms-full.txt`,
|
||||
'',
|
||||
renderIndexSections(docs, false),
|
||||
].join('\n')
|
||||
return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
||||
}
|
||||
@@ -1,12 +1,35 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
/* Owner decision: allow all named search/AI crawlers explicitly. The `*`
|
||||
group keeps its `/api` disallow; the named groups opt back into the
|
||||
public content APIs and the llms.txt feeds. */
|
||||
const AI_BOTS = [
|
||||
'GPTBot',
|
||||
'OAI-SearchBot',
|
||||
'ChatGPT-User',
|
||||
'ClaudeBot',
|
||||
'Claude-User',
|
||||
'Claude-SearchBot',
|
||||
'PerplexityBot',
|
||||
'Google-Extended',
|
||||
'Applebot',
|
||||
'Bingbot',
|
||||
]
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin', '/api', '/account-settings', '/onboarding', '/vip-onboarding', '/verify-email', '/payment-success', '/payment-cancelled', '/forgot-password'],
|
||||
}],
|
||||
rules: [
|
||||
...AI_BOTS.map(userAgent => ({
|
||||
userAgent,
|
||||
allow: ['/', '/api/posts', '/llms.txt', '/llms-full.txt'],
|
||||
})),
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin', '/api', '/account-settings', '/onboarding', '/vip-onboarding', '/verify-email', '/payment-success', '/payment-cancelled', '/forgot-password'],
|
||||
},
|
||||
],
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS } from '@/lib/topics'
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
@@ -14,7 +15,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
})
|
||||
const staticPaths = ['', '/articles', '/subscription', '/terms', '/privacy', '/about', '/login', '/signup']
|
||||
const staticPaths = ['', '/articles', '/subscription', '/terms', '/privacy', '/about',
|
||||
// Topic hubs are indexable; /login and /signup are noindex (step 38).
|
||||
...TOPICS.map(topic => `/topics/${topic.key}`)]
|
||||
return [
|
||||
...staticPaths.map(p => ({ url: `${base}${p}` })),
|
||||
...posts.docs.map(p => ({ url: `${base}/posts/${p.slug}`, lastModified: p.updatedAt ? new Date(String(p.updatedAt)) : undefined })),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
|
||||
const isAdmin = (args: AccessArgs) =>
|
||||
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
|
||||
|
||||
/* L-01 early-access notification list — real, minimal. The /api/notify route
|
||||
is the primary writer (duplicate check + confirmation email); direct REST
|
||||
create stays open (`create: () => true`) so the list never silently drops
|
||||
a signup, but reads/updates/deletes are admin-only. */
|
||||
const LaunchNotifications: CollectionConfig = {
|
||||
slug: 'launch-notifications',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'source', 'createdAt'],
|
||||
},
|
||||
access: {
|
||||
create: () => true,
|
||||
read: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
admin: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'email', type: 'text', required: true, unique: true, index: true },
|
||||
{
|
||||
name: 'source',
|
||||
type: 'select',
|
||||
defaultValue: 'subscription',
|
||||
options: [{ label: 'صفحة العضوية', value: 'subscription' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default LaunchNotifications
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
import { freeEarlyAccess, READING_WORDS_PER_MINUTE } from '@/lib/launch'
|
||||
import { countLexicalWords } from '@/lib/lexical'
|
||||
|
||||
const isAdmin = (args: AccessArgs) =>
|
||||
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
|
||||
@@ -7,6 +9,19 @@ export const CATEGORIES = ['وكلاء', 'نماذج', 'استرجاع', 'سلا
|
||||
|
||||
const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
hooks: {
|
||||
// L-07: honest reading estimates. Recomputed from the body on every
|
||||
// write — agent/admin-supplied `readingMinutes` is always overridden.
|
||||
beforeChange: [
|
||||
({ data, originalDoc }) => {
|
||||
const words =
|
||||
data.content !== undefined
|
||||
? countLexicalWords(data.content)
|
||||
: countLexicalWords(originalDoc?.content)
|
||||
return { ...data, readingMinutes: Math.max(1, Math.round(words / READING_WORDS_PER_MINUTE)) }
|
||||
},
|
||||
],
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'category', 'access', 'status', 'publishedAt'],
|
||||
@@ -61,8 +76,13 @@ const Posts: CollectionConfig = {
|
||||
access: {
|
||||
// Never serve VIP body to non-VIP clients — defense in depth
|
||||
// (the paywall page also never queries it for them).
|
||||
read: ((args: { doc?: { access?: string }; req: { user?: { role?: string } | null } }) => {
|
||||
read: ((args: { doc?: { access?: string; status?: string }; req: { user?: { role?: string } | null } }) => {
|
||||
if (!args.doc) return true
|
||||
// L-02: during free early access, published posts serve their body
|
||||
// to everyone — Payload REST must not contradict the page. The
|
||||
// field-permission `doc` arg is the full document incl. `status`
|
||||
// (verified: payload/dist/fields/hooks/afterRead/promise.js:226).
|
||||
if (freeEarlyAccess() && args.doc.status === 'published') return true
|
||||
if (args.doc.access !== 'vip') return true
|
||||
return args.req.user?.role === 'vip' || args.req.user?.role === 'admin'
|
||||
}) as never, // field-permission signature is structurally compatible; TS can't infer it here
|
||||
@@ -95,11 +115,42 @@ const Posts: CollectionConfig = {
|
||||
{ label: 'متقدم', value: 'متقدم' },
|
||||
],
|
||||
},
|
||||
{ name: 'views', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
|
||||
{
|
||||
name: 'contentType',
|
||||
type: 'select',
|
||||
label: 'نوع المحتوى',
|
||||
options: [
|
||||
{ label: 'ملاحظة تقنية', value: 'note' },
|
||||
{ label: 'محتوى عملي', value: 'tutorial' },
|
||||
{ label: 'تجربة موثقة', value: 'case_study' },
|
||||
{ label: 'مقارنة', value: 'comparison' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// L-09: real author display — never invent values; empty = no byline.
|
||||
name: 'authorName',
|
||||
type: 'text',
|
||||
label: 'اسم الكاتب',
|
||||
},
|
||||
{
|
||||
name: 'views',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
admin: { readOnly: true, position: 'sidebar' },
|
||||
},
|
||||
{ name: 'likes', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
|
||||
{ name: 'cover', type: 'upload', relationTo: 'media', required: true },
|
||||
{ name: 'readingMinutes', type: 'number', required: true, defaultValue: 5 },
|
||||
{ name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
|
||||
{
|
||||
// Private traceability + agent-pipeline dedup key; internal Local API
|
||||
// callers may explicitly override access, public REST/GraphQL may not.
|
||||
name: 'sourceId',
|
||||
type: 'text',
|
||||
access: { read: () => false },
|
||||
index: true,
|
||||
admin: { description: 'معرّف المصدر (خاص — للتتبّع ومنع التكرار)' },
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { pbkdf2 } from 'crypto'
|
||||
import { pbkdf2, randomBytes } from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
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'
|
||||
|
||||
export const ROLES = ['free', 'vip', 'admin'] as const
|
||||
@@ -19,10 +20,10 @@ const Users: CollectionConfig = {
|
||||
// Only self and admins can read full user docs
|
||||
read: ({ req, id }) =>
|
||||
req.user?.role === 'admin' || (id != null && req.user?.id === id),
|
||||
// Public open for self-signup; beforeChange forces role 'free' for non-admins
|
||||
create: () => true,
|
||||
// Public registration is disabled. Only administrators create accounts.
|
||||
create: isAdmin,
|
||||
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,
|
||||
},
|
||||
hooks: {
|
||||
@@ -88,6 +89,31 @@ const Users: CollectionConfig = {
|
||||
return data
|
||||
},
|
||||
],
|
||||
// L-06 signup trust: the verify-email page claims «أرسلنا رابط التأكيد» —
|
||||
// make that true at signup instead of only after clicking resend. Mirror
|
||||
// api/auth/resend-verification exactly: fresh token → internal update
|
||||
// (systemActivation bypasses the beforeChange token strip) → email.
|
||||
// Mail env absent → sendVerificationEmail returns { sent: false } and the
|
||||
// hook skips silently; the resend route keeps the honest-503 path.
|
||||
afterChange: [
|
||||
async ({ doc, operation, req }) => {
|
||||
if (operation !== 'create' || !doc?.email) return
|
||||
try {
|
||||
const token = randomBytes(32).toString('hex')
|
||||
await req.payload.update({
|
||||
collection: 'users',
|
||||
id: doc.id,
|
||||
data: { verificationToken: token },
|
||||
overrideAccess: true,
|
||||
context: { systemActivation: true },
|
||||
req,
|
||||
})
|
||||
await sendVerificationEmail(doc.email, token)
|
||||
} catch (err) {
|
||||
console.error('signup verification email failed:', err)
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
@@ -135,17 +161,19 @@ const Users: CollectionConfig = {
|
||||
label: 'Notification prefs',
|
||||
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',
|
||||
type: 'text',
|
||||
access: { create: isAdmin, update: isAdmin, read: () => false },
|
||||
hidden: true,
|
||||
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',
|
||||
type: 'select',
|
||||
access: { create: isAdmin, update: isAdmin },
|
||||
required: true,
|
||||
defaultValue: 'free',
|
||||
options: [
|
||||
@@ -157,6 +185,7 @@ const Users: CollectionConfig = {
|
||||
{
|
||||
name: 'stripeCustomerId',
|
||||
type: 'text',
|
||||
access: { create: isAdmin, update: isAdmin },
|
||||
label: 'Stripe Customer ID',
|
||||
admin: { readOnly: true, position: 'sidebar' },
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Footer() {
|
||||
<Link className="logo" href="/">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
|
||||
أنور بآي
|
||||
أنور پآي
|
||||
</Link>
|
||||
<p>محتوى عربي عملي لتعلّم الذكاء الاصطناعي والوكلاء.</p>
|
||||
</div>
|
||||
@@ -18,12 +18,12 @@ export default function Footer() {
|
||||
<strong>استكشف</strong>
|
||||
<Link href="/articles">المكتبة</Link>
|
||||
<Link href="/#topics">المسارات</Link>
|
||||
<a href="/articles?access=free">الأدلة المجانية</a>
|
||||
<a href="/articles?access=free">المحتوى المجاني</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<strong>العضوية</strong>
|
||||
<Link href="/subscription">الأسعار والمزايا</Link>
|
||||
<Link href="/signup">إنشاء حساب</Link>
|
||||
<strong>الوصول المبكر</strong>
|
||||
<Link href="/subscription">الوصول المجاني والخطة المستقبلية</Link>
|
||||
<Link href="/articles">اقرأ دون حساب</Link>
|
||||
<Link href="/login">تسجيل الدخول</Link>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
@@ -35,7 +35,7 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>© {new Date().getFullYear()} أنور بآي</span>
|
||||
<span>© {new Date().getFullYear()} أنور پآي</span>
|
||||
<span>العربية أولاً · تعلّم بوضوح</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,6 @@ export type NavLink = { href: string; label: string }
|
||||
export const SITE_LINKS: NavLink[] = [
|
||||
{ href: '/articles', label: 'المكتبة' },
|
||||
{ href: '/#topics', label: 'المسارات' },
|
||||
{ href: '/subscription', label: 'العضوية' },
|
||||
{ href: '/subscription', label: 'الوصول المبكر' },
|
||||
{ href: '/about', label: 'عن الفريق' },
|
||||
]
|
||||
|
||||
@@ -68,12 +68,12 @@ export default function SiteMenu({
|
||||
return (
|
||||
<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>
|
||||
<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">
|
||||
<Link className="logo" href="/">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
|
||||
أنور بآي
|
||||
أنور پآي
|
||||
</Link>
|
||||
<button className="icon-btn" type="button" aria-label="إغلاق القائمة" data-menu-close>×</button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function Topnav({ viewer }: { viewer?: { email: string } | null }
|
||||
<Link className="logo" href="/" data-od-id="brand">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
|
||||
أنور بآي
|
||||
أنور پآي
|
||||
</Link>
|
||||
<NavLinks links={SITE_LINKS} />
|
||||
<div className="nav-tools">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ export const formatNumber = (value: number | null | undefined) => numberFormat.f
|
||||
/* Single source for the displayed membership price. */
|
||||
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 {
|
||||
if (n === 0) return 'لا أدلة'
|
||||
if (n === 1) return 'دليل واحد'
|
||||
if (n === 2) return 'دليلان'
|
||||
return `${formatNumber(n)} ${n <= 10 ? 'أدلة' : 'دليلاً'}`
|
||||
if (n === 0) return 'لا محتوى'
|
||||
if (n === 1) return 'عنصر محتوى واحد'
|
||||
if (n === 2) return 'عنصران من المحتوى'
|
||||
return `${formatNumber(n)} ${n <= 10 ? 'عناصر محتوى' : 'عنصر محتوى'}`
|
||||
}
|
||||
|
||||
export function minutesLabel(n: number | null | undefined): string {
|
||||
@@ -27,3 +27,14 @@ export function minutesLabel(n: number | null | undefined): string {
|
||||
if (value === 2) return 'دقيقتان'
|
||||
return `${formatNumber(value)} ${value % 100 >= 3 && value % 100 <= 10 ? 'دقائق' : 'دقيقة'}`
|
||||
}
|
||||
|
||||
/* Content-type badge labels (L-09). Null for unset/unknown — no byline invented. */
|
||||
export function contentTypeLabel(value: string | null | undefined): string | null {
|
||||
switch (value) {
|
||||
case 'note': return 'ملاحظة تقنية'
|
||||
case 'tutorial': return 'محتوى عملي'
|
||||
case 'case_study': return 'تجربة موثقة'
|
||||
case 'comparison': return 'مقارنة'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Adopted free-launch policy. Changing environment variables or passing a
|
||||
review date cannot activate billing or a paywall. A paid transition requires
|
||||
a separately reviewed code change, operator approval and payment/access QA.
|
||||
Intended tiers remain in the database; publication/private checks still apply. */
|
||||
export const LAUNCH_POLICY = {
|
||||
mode: 'free_early_access',
|
||||
effectiveDate: '2026-10-10',
|
||||
operator: 'Anwarpy',
|
||||
supportEmail: 'hello@anwarpy.ai',
|
||||
billingEnabled: false,
|
||||
notificationSignupEnabled: false,
|
||||
summary: 'يمكنك قراءة المحتوى المنشور خلال الوصول المبكر مجاناً دون حساب أو بطاقة دفع. نراجع العضوية بعد نحو أسبوعين من الإطلاق؛ هذه نقطة مراجعة وليست موعد حجب. قد تمتد الفترة، ولن يُفعّل الدفع أو الحجب إلا بقرار يدوي بعد الاختبارات وإعلان التغيير. لن يُنشأ اشتراك أو يُحصّل مبلغ تلقائياً.',
|
||||
} as const
|
||||
|
||||
export const billingEnabled = (): boolean => LAUNCH_POLICY.billingEnabled
|
||||
export const freeEarlyAccess = (): boolean => !billingEnabled()
|
||||
export const accessLabel = (tier: string): string => tier === 'vip'
|
||||
? 'مجاني الآن · للعضوية مستقبلاً'
|
||||
: 'مجاني'
|
||||
|
||||
// Configurable Arabic reading rate, used by the existing publication hook.
|
||||
export const READING_WORDS_PER_MINUTE = 160
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Helpers over serialized Lexical richText JSON (Payload's editor state).
|
||||
* A serialized tree is `{ root: { children: [...] } }`; text lives on
|
||||
* `{ text }` nodes, nesting happens via `{ children }`.
|
||||
*/
|
||||
|
||||
export type Node = {
|
||||
type?: string
|
||||
text?: string
|
||||
children?: Node[]
|
||||
tag?: string
|
||||
url?: string
|
||||
listType?: string
|
||||
code?: string
|
||||
headerState?: number
|
||||
}
|
||||
|
||||
/** Root `children` of a serialized tree, narrowed at runtime. */
|
||||
function rootChildren(data: unknown): Node[] | null {
|
||||
if (data === null || typeof data !== 'object' || !('root' in data)) return null
|
||||
const root: unknown = data.root // unknown via `in` narrowing — no shape assumed
|
||||
if (root === null || typeof root !== 'object' || !('children' in root)) return null
|
||||
const children: unknown = root.children
|
||||
if (!Array.isArray(children)) return null
|
||||
// Boundary: the array is the editor's serialized node list; every access
|
||||
// below goes through Node's all-optional contract, so nothing unchecked.
|
||||
const nodes: Node[] = children
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** Concatenated text of every text node (secret scanning, llms extraction). */
|
||||
export function lexicalToText(content: unknown): string {
|
||||
let out = ''
|
||||
const walk = (node: unknown): void => {
|
||||
if (node === null || typeof node !== 'object') return
|
||||
if ('text' in node && typeof node.text === 'string') out += node.text + '\n'
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) walk(child)
|
||||
}
|
||||
}
|
||||
// Serialized trees wrap nodes in `root` — walk it, not the wrapper.
|
||||
if (content !== null && typeof content === 'object' && 'root' in content) {
|
||||
walk((content as { root: unknown }).root)
|
||||
} else {
|
||||
walk(content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Count whitespace-delimited words across all text nodes of a Lexical tree. */
|
||||
export function countLexicalWords(content: unknown): number {
|
||||
let words = 0
|
||||
const walk = (node: unknown): void => {
|
||||
if (node === null || typeof node !== 'object') return
|
||||
if ('text' in node && typeof node.text === 'string') {
|
||||
words += node.text.trim().split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) walk(child)
|
||||
}
|
||||
}
|
||||
// Same root unwrap as lexicalToText.
|
||||
if (content !== null && typeof content === 'object' && 'root' in content) {
|
||||
walk((content as { root: unknown }).root)
|
||||
} else {
|
||||
walk(content)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
export function innerText(nodes?: Node[]): string {
|
||||
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
|
||||
}
|
||||
|
||||
/** H2 headings — TOC and the paid-mode paywall preview. */
|
||||
export function extractH2s(data: unknown): string[] {
|
||||
const children = rootChildren(data)
|
||||
if (!children) return []
|
||||
return children
|
||||
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
|
||||
.map(n => innerText(n.children))
|
||||
}
|
||||
|
||||
function tagLevel(tag?: string): number {
|
||||
const n = Number.parseInt((tag ?? 'h2').replace('h', ''), 10)
|
||||
return Number.isFinite(n) && n >= 1 && n <= 6 ? n : 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text rendering for llms-full.txt: headings → `## `, paragraphs and
|
||||
* list items → lines, code → fenced blocks, table rows → `| a | b |`.
|
||||
*/
|
||||
export function lexicalToPlain(data: unknown): string {
|
||||
const children = rootChildren(data)
|
||||
if (!children) return ''
|
||||
const lines: string[] = []
|
||||
for (const n of children) {
|
||||
switch (n.type) {
|
||||
case 'heading':
|
||||
lines.push(`${'#'.repeat(tagLevel(n.tag))} ${innerText(n.children)}`.trim())
|
||||
break
|
||||
case 'paragraph':
|
||||
lines.push(innerText(n.children).trim())
|
||||
break
|
||||
case 'list':
|
||||
for (const item of n.children || []) lines.push(`- ${innerText(item.children).trim()}`)
|
||||
break
|
||||
case 'listitem':
|
||||
lines.push(`- ${innerText(n.children).trim()}`)
|
||||
break
|
||||
case 'code':
|
||||
lines.push('```\n' + (n.code ?? innerText(n.children)) + '\n```')
|
||||
break
|
||||
case 'table':
|
||||
for (const row of n.children || []) {
|
||||
const cells = (row.children || []).map(cell => innerText(cell.children).trim().replace(/\|/g, '\\|'))
|
||||
lines.push(`| ${cells.join(' | ')} |`)
|
||||
}
|
||||
break
|
||||
case 'quote':
|
||||
lines.push(`> ${innerText(n.children).trim()}`)
|
||||
break
|
||||
case 'horizontalrule':
|
||||
lines.push('---')
|
||||
break
|
||||
default: {
|
||||
const text = innerText(n.children).trim()
|
||||
if (text) lines.push(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.filter(line => line !== '').join('\n\n')
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS, topicsFor, type TopicKey } from '@/lib/topics'
|
||||
|
||||
export type IndexDoc = {
|
||||
id: number | string
|
||||
title: string
|
||||
slug: string
|
||||
dek: string
|
||||
access: 'free' | 'vip'
|
||||
category: string
|
||||
level: string
|
||||
publishedAt: string | null
|
||||
updatedAt: string | null
|
||||
/** Present only when requested via fetchIndexDocs(true). */
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
/** Published posts, public read path — no overrideAccess (drafts stay hidden,
|
||||
* and the content field's own read ACL decides body availability). */
|
||||
export async function fetchIndexDocs(withContent = false): Promise<IndexDoc[]> {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
depth: 0,
|
||||
limit: 500,
|
||||
sort: '-publishedAt',
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
dek: true,
|
||||
access: true,
|
||||
category: true,
|
||||
level: true,
|
||||
publishedAt: true,
|
||||
updatedAt: true,
|
||||
...(withContent ? { content: true } : {}),
|
||||
},
|
||||
})
|
||||
return docs as unknown as IndexDoc[]
|
||||
}
|
||||
|
||||
export const SITE_TITLE = 'أنور پآي — محتوى الذكاء الاصطناعي العملي بالعربية'
|
||||
export const SITE_INTRO = 'محتواك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.'
|
||||
|
||||
/** Topic-grouped markdown index lines; a post can appear under several topics. */
|
||||
export function renderIndexSections(docs: IndexDoc[], markVip: boolean): string {
|
||||
const lines: string[] = []
|
||||
for (const topic of TOPICS) {
|
||||
const items = docs.filter(doc => topicsFor(doc).includes(topic.key))
|
||||
if (items.length === 0) continue
|
||||
lines.push(`## ${topic.label}`, '')
|
||||
for (const doc of items) {
|
||||
const suffix = markVip && doc.access === 'vip' ? ' (محتوى للأعضاء)' : ''
|
||||
lines.push(`- [${doc.title}${suffix}](/posts/${doc.slug}): ${doc.dek}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -1,3 +1,42 @@
|
||||
/* Generic text-only send over the same Cloudflare Email Sending REST call —
|
||||
used by /api/notify for the launch-notification confirmation. Single-shot,
|
||||
honest { sent: false } on any failure; no retry. */
|
||||
export async function sendMail(to: string, subject: string, text: string): Promise<{ sent: boolean }> {
|
||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
if (!apiToken || !accountId) return { sent: false }
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/email/sending/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to,
|
||||
from: { address: process.env.MAIL_FROM ?? 'noreply@anwarpy.ai', name: 'أنور بي آي' },
|
||||
subject,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('cloudflare email sending failed:', res.status, await res.text())
|
||||
return { sent: false }
|
||||
}
|
||||
const json = (await res.json()) as { success?: boolean }
|
||||
if (!json.success) {
|
||||
console.error('cloudflare email sending rejected:', JSON.stringify(json))
|
||||
return { sent: false }
|
||||
}
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
console.error('cloudflare email sending threw:', err)
|
||||
return { sent: false }
|
||||
}
|
||||
}
|
||||
|
||||
/* Cloudflare Email Sending (REST) — activates only when CLOUDFLARE_API_TOKEN
|
||||
and CLOUDFLARE_ACCOUNT_ID are set. Without them, sendVerificationEmail
|
||||
returns { sent: false } and the UI degrades to a clear status message; the
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* L-05: reject agent content that looks like a leaked credential before it
|
||||
* is stored or published. `scanForSecrets` returns matched PATTERN NAMES
|
||||
* only — never the matched values (those must not be echoed anywhere).
|
||||
*/
|
||||
|
||||
const PATTERNS: readonly (readonly [string, RegExp])[] = [
|
||||
['aws_access_key', /AKIA[0-9A-Z]{16}/],
|
||||
['pem_private_key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
||||
['openai_api_key', /sk-[A-Za-z0-9]{20,}/],
|
||||
['github_token', /ghp_[A-Za-z0-9]{36}/],
|
||||
['generic_credential', /(?:password|passwd|api[_-]?key|secret)\s*[:=]\s*\S+/i],
|
||||
] as const
|
||||
|
||||
export function scanForSecrets(text: string): string[] {
|
||||
const names: string[] = []
|
||||
for (const [name, re] of PATTERNS) {
|
||||
if (re.test(text)) names.push(name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** Scan the strings an agent can set; returns pattern names across all targets. */
|
||||
export function scanAgentPayload(targets: (string | undefined | null)[]): string[] {
|
||||
const names = new Set<string>()
|
||||
for (const target of targets) {
|
||||
if (typeof target !== 'string') continue
|
||||
for (const name of scanForSecrets(target)) names.add(name)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||