Author SHA1 Message Date
ahmed 0f075626d1 Merge approved live VPS reconciliation
Deploy GitHub Pages / deploy (push) Waiting to run
Reconcile deployed application source and ops; preserve secret exclusions. Local build, type checking, launch tests and independent review passed.
2026-10-13 18:05:31 +03:00
ahmed 6385d60dff fix: reconcile approved live VPS source and operations 2026-10-13 18:04:15 +03:00
ahmed 36f508df75 Merge pull request 'Revert to next 15.4.11 (payload peer ceiling) + record CVE risk acceptance' (#6) from fix/next-pin-cve-acceptance into main
Deploy GitHub Pages / deploy (push) Canceled after 0s
2026-10-13 06:02:51 +03:00
AHMED 26d662ed7c fix: revert to next 15.4.11 (payload peer ceiling), record CVE risk acceptance
@payloadcms/next@3.90.2 peer-requires next <15.5.0; 15.5.27 was an
unsupported combo. Owner chose the pin + documented risk (SECURITY.md
§8). Transitive overrides (undici/sharp/postcss/sass) stay.
Gate skipped: parallel session WIP still in tree.
2026-10-13 06:02:15 +03:00
ahmed e35d908672 Merge pull request 'Procoder governance + dependency CVE sweep (next 15.5.27)' (#5) from chore/procoder-governance into main
Deploy GitHub Pages / deploy (push) Canceled after 0s
2026-10-13 05:56:33 +03:00
59 changed files with 2542 additions and 4916 deletions
+12 -12
View File
@@ -4,18 +4,18 @@
## 1. Stack (pinned 2026-10-11)
| Layer | Choice | Version |
| ----------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Framework | Next.js App Router (server components first) | next@15.5.27 (CVE-driven bump from 15.4.11, owner-approved 2026-10-13) |
| 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) | — |
| 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
+21 -11
View File
@@ -12,18 +12,19 @@
## 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 |
| 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).
@@ -38,7 +39,7 @@ Rules:
## 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.
- **`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).
@@ -62,3 +63,12 @@ Rules:
- 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.
+45
View File
@@ -0,0 +1,45 @@
# Live VPS source reconciliation
## Source of truth
This reconciliation imports the already deployed application source from `/root/anwarpi` into `web/`. The owner explicitly confirmed that the VPS copy, including its package manifest and lockfile, is authoritative. It does not deploy or restart the website.
Included features:
- Admin-only user creation and protected administrative user fields.
- Free early-access reading with registration, checkout and notification signup disabled.
- The exact trial banner, current policy pages and support contact.
- Arabic branding «أنور پآي» and site-wide «محتوى» labels.
- SEO titles/descriptions, utility-page noindex and absolute Article image URLs.
- Most-read homepage cards based on the published collection's cumulative views.
- Click-to-load privacy-enhanced YouTube embeds and same-origin inline raster images.
- Bundled Readex Pro font with its OFL license.
- The deployed daily backup script and cron definition under `ops/`.
## Deliberate exclusions
Do not import `.env` files, secrets, database dumps, backups, uploads/media, private files, build output, dependency directories or TypeScript build caches into Git. Published article records remain in the production CMS database; they are not application-source files.
Generated `payload-types.ts` remains generated/ignored. The repository's existing removal of a hardcoded publishing key from `web/docs/agent-publish-api.md` is retained rather than copying that key back from an older live document.
## Dependencies and security
The deployed `web/package.json` and `web/package-lock.json` are imported as requested. This replaces repository-only dependency/tooling edits and CVE overrides that were not on the live VPS. This release is a source reconciliation, **not a security upgrade**, and does not claim to resolve dependency advisories. Keep the existing recorded Next.js risk acceptance visible and evaluate upgrades separately.
## Verification
Before publication, the reconciled application passed:
- `npm ci --ignore-scripts --no-audit --no-fund`.
- `npm run test:launch`: 10 tests passed, 0 failed.
- `npm run generate:types`.
- `npx tsc --noEmit`.
- `npm run build`: Next.js production build completed successfully.
Added-line credential/injection screening found no literal credentials. Linting is skipped by the deployed build configuration; these results do not claim a separate lint pass or a remote CI run. The independent pre-publication review is recorded in the reconciliation's private evidence, not treated as a substitute for the commands above.
## Operational files
`ops/daily_backup.py` is the deployed `/root/anwarpy-daily-backup.py`; `ops/anwarpy-backup.cron` is the deployed cron definition. Their paths and container names reflect the current installation. Merely merging these files does not install cron or run a backup.
Backup archives include runtime configuration and potentially sensitive data. Keep their destination root-only and never publish archive contents. This workflow did not read or change production data, invoke a backup, apply migrations, or deploy a replacement image.
+3
View File
@@ -0,0 +1,3 @@
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
15 2 * * * root /usr/bin/python3 /root/anwarpy-daily-backup.py >> /root/anwarpy-backups/daily.log 2>&1
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/python3
"""Bounded daily backup. Never modifies application data or schema."""
from pathlib import Path
import subprocess, json, datetime, shutil, os
os.umask(0o077)
root=Path('/root/anwarpy-backups');root.mkdir(mode=0o700,exist_ok=True)
now=datetime.datetime.now(datetime.timezone.utc);folder=root/('daily-'+now.strftime('%Y%m%dT%H%M%SZ'));folder.mkdir(mode=0o700)
try:
with (folder/'database.dump').open('wb') as out:
subprocess.run(['docker','exec','anwarpi-db-1','pg_dump','-U','anwarpi','-d','anwarpi','-Fc'],stdout=out,check=True)
subprocess.run(['tar','-czf',str(folder/'source-config.tar.gz'),'-C','/root','anwarpi'],check=True)
info=json.loads(subprocess.check_output(['docker','inspect','anwarpi-web-1'],text=True))[0]
for mount in info['Mounts']:
if mount['Destination'] in ['/app/media','/app/files-private']:
subprocess.run(['tar','-czf',str(folder/(mount['Destination'].split('/')[-1]+'.tar.gz')),'-C',mount['Source'],'.'],check=True)
subprocess.run(['docker','cp',str(folder/'database.dump'),'anwarpi-db-1:/var/lib/postgresql/daily-archive-check.dump'],check=True)
archive=subprocess.check_output(['docker','exec','anwarpi-db-1','pg_restore','--list','/var/lib/postgresql/daily-archive-check.dump'],text=True)
assert 'TABLE DATA' in archive
record={'status':'ok','finished_at':now.isoformat(),'backup':str(folder),'image':info['Image'],'archive_list_valid':True}
(folder/'verification.json').write_text(json.dumps(record))
for old in root.glob('daily-*'):
if old.is_dir() and old!=folder and now.timestamp()-old.stat().st_mtime>30*86400:shutil.rmtree(old)
except Exception as error:
record={'status':'failed','finished_at':now.isoformat(),'error_type':type(error).__name__}
(root/'health.json').write_text(json.dumps(record));print(json.dumps(record));raise
(root/'health.json').write_text(json.dumps(record));print(json.dumps(record))
+3
View File
@@ -15,6 +15,9 @@ services:
retries: 10
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:
+902 -3655
View File
File diff suppressed because it is too large Load Diff
+4 -15
View File
@@ -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",
@@ -12,36 +13,24 @@
"covers": "node scripts/generate-covers.mjs"
},
"dependencies": {
"@clerk/nextjs": "^7.9.14",
"@payloadcms/db-postgres": "^3.60.0",
"@payloadcms/next": "^3.60.0",
"@payloadcms/richtext-lexical": "^3.60.0",
"next": "^15.5.27",
"next": "15.4.11",
"payload": "^3.60.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sharp": "^0.35.5",
"sharp": "^0.34.0",
"stripe": "^18.5.0"
},
"devDependencies": {
"@playwright/test": "^1.64.0",
"@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"eslint": "^10.12.0",
"prettier": "^3.9.10",
"typescript": "^5.9.0",
"typescript-eslint": "^8.71.2",
"vitest": "^5.0.4"
"typescript": "^5.9.0"
},
"engines": {
"node": ">=20.9.0"
},
"overrides": {
"undici": "^8.11.2",
"sharp": "^0.35.5",
"postcss": "^8.5.6",
"sass": "^1.79.0"
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

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