135 changed files with 5273 additions and 8198 deletions
+4
View File
@@ -12,3 +12,7 @@ screenshot-*.png
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
# Python runtime files
__pycache__/
*.pyc
-160
View File
@@ -1,160 +0,0 @@
# AGENT.md — AnwarPi for AI agents
Orientation doc for AI agents (coding assistants, autonomous maintainers, content
pipelines) working on this repository. Read this before changing anything.
## What this is
**AnwarPi (أنور بي آي)** — an Arabic-first, RTL platform for learning practical
AI at **https://anwarpy.ai**. Free guides for everyone, VIP-gated deep-dives, a
searchable library/catalog, moderated comments, member accounts, and a Stripe
subscription that is fully wired but public-facing "coming soon" until keys are
set. Content is also published programmatically by an external agent pipeline
through a key-authenticated API.
Canonical domain: `anwarpy.ai` (`anwarpi.ai` is legacy). Owner: Ahmed
(ahmedalkhair.sa@gmail.com). Owner constraint: **never change site flow/UX
without explicit approval** (e.g. no CAPTCHA, no aggressive limits).
## Stack
| Component | Choice |
|---|---|
| Framework | Next.js 15 (App Router, `output: 'standalone'`), React 19 |
| CMS/Auth | Payload 3.90 (`@payloadcms/next`), Lexical editor |
| Database | PostgreSQL 16 via `@payloadcms/db-postgres` |
| Styling | Plain CSS files per page (see `web/src/app/(frontend)/styles/`), dark-first brand (see `brand-spec.md`) |
| Email | Cloudflare Email Routing (inbound) + Cloudflare Email Sending REST (outbound) — **not SMTP** (docs/adr/0003) |
| Payments | Stripe SDK, webhook at `/api/stripe/webhook` |
| Edge | Traefik 2/3 with Docker provider (labels in `web/docker-compose.vps.yml`) |
## Repo layout
```
web/ ← the entire application (deploy unit)
src/
payload.config.ts ← Payload config: collections, postgres adapter
seed.ts ← `npm run seed`
middleware.ts ← Next middleware: auth rate limits (F2)
collections/ ← Users, Posts, Media, Files, PostLikes, Comments
migrations/ ← Payload migrations (`payload migrate` runs on boot)
lib/ ← viewer.ts (session helper), mail.ts, format.ts, topics.ts, agent-*
app/
(frontend)/ ← public Arabic site (page.tsx per route, styles/ css)
(payload)/ ← Payload admin + API catch-all
api/ ← custom REST routes (posts, auth, subscribe, stripe, agent, files)
public/ ← brand/, covers/, og-default.png
Dockerfile ← standalone build; CMD runs `npx payload migrate` first
docker-compose.vps.yml ← prod: web + postgres, Traefik labels (rate limits)
GLOSSARY.md ← canonical domain vocabulary — USE THESE TERMS
docs/adr/ ← architecture decision records — do not contradict
brand-spec.md ← visual identity: dark blue-black, silver beams, cyan-teal accent
```
## Data model (Payload collections)
- **Users** — auth collection. `role: 'free' | 'vip' | 'admin'` (server-forced),
`emailVerified`, `onboarded`, `termsVersion`. Login lockout: 5 attempts /
15 min; admin accounts never lock (hook clears lock before login).
- **Posts** — guides. `access: 'free' | 'vip'`, `slug`, category (وكلاء، نماذج،
استرجاع، سلامة، أدوات، أعمال), cover image (1200×630), Lexical body, likes/views.
- **Comments** — moderated: `pending → approved | rejected`. Only `approved` is
public. Authors cannot edit/delete their own comments (moderated model,
docs/adr/0002). Display name is a snapshot of `fullName` at submit time.
- **Media / Files** — uploads (files served via `/api/files/[id]`).
- **PostLikes** — one like per member per post.
## Trust model (critical — do not break)
1. **Roles are server-forced.** `POST /api/users` and any client write cannot
set `role`, `emailVerified`, `stripeCustomerId`, or `verificationToken` —
the `beforeChange` hook in `Users.ts` deletes them for untrusted operations.
Only `req.user.role === 'admin'` or `context.systemActivation === true`
(payment activation from server code) counts as trusted.
2. **Entitlement is derived, never stored on the user**: viewer may read a
post's body iff `post.access === 'free' || role in ('vip','admin')`. Gating
happens server-side (locked body is stripped from API responses).
3. **Comments require verification** (`emailVerified`), admins exempt.
4. Client requests can never mint VIP (free `/payment-success` visits redirect
to `/subscription`; docs/adr/0004, 0005).
## Auth & rate limiting
- Passwords: Payload local auth → **PBKDF2-SHA256, 600 000 iterations, 32
bytes** (NOT bcrypt — verified in payload's `generatePasswordSaltHash`).
- `web/src/middleware.ts` (F2): per-IP in-memory buckets —
`POST /api/users` 5/h, `POST /api/users/login` 10/15min,
`POST /api/users/forgot-password` 5/h. Over limit → 429 JSON
`{"error":"محاولات كثيرة جدًا. حاول بعد قليل."}` + `Retry-After`.
**In-memory is correct for the single-container deployment**; moving to
horizontal scaling requires a shared store (Redis) — do not silently assume
one replica.
- Timing equalization (F7): `Users.ts` `beforeOperation` login hook runs a
dummy PBKDF2 (same params, salt `anwarpy-dummy-salt-v1`) when no user
matches, so ghost emails are not enumerable by response time. Keep this when
touching the hook.
- Traefik edge (F1, VPS only): pages `pagelimit` 60/min burst 120 +
`connlimit` 50 in-flight per IP; API `apilimit` 90/min burst 180.
- `Strict-Transport-Security: max-age=63072000; includeSubDomains` on all
routes (`web/next.config.mjs`). Do not add `preload`.
## Key routes
Frontend: `/` (home), `/articles`, `/catalog`, `/posts/[slug]` (gated body,
engagement, comments), `/signup`, `/login`, `/forgot-password`,
`/verify-email`, `/subscription`, `/payment-success`, `/payment-cancelled`,
`/vip-onboarding`, `/onboarding`, `/account-settings`, `/about`, `/privacy`,
`/terms`. Admin UI: `/admin`.
Custom API (see `web/src/app/api/`): `posts` (list) + `posts/[slug]` (detail —
strips locked bodies), `posts/[slug]/{like,view,comments}`,
`auth/{verify-email,resend-verification}`, `users` REST is Payload's own,
`subscribe`, `stripe/webhook` (returns 503 until `STRIPE_SECRET_KEY` +
`STRIPE_WEBHOOK_SECRET` exist; verifies `stripe-signature` once configured),
`agent/posts` (POST/PUT with `x-agent-key` header — external publishing), and
`files/[id]`.
## Environment variables
`DATABASE_URI`, `PAYLOAD_SECRET`, `NEXT_PUBLIC_SITE_URL`, `AGENT_API_KEY`,
`MAIL_FROM`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, plus optional
`STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET`. Prod values live in
`/root/anwarpi/.env.production` on the VPS — never commit `.env*`.
## Development & verification
```bash
cd web && npm install
npm run dev # localhost:3000, /admin for CMS
npm run build # MUST exit 0 — typescript.ignoreBuildErrors is false
npm run seed
```
- Regenerates `payload-types.ts` automatically on build
(`payload generate:types`).
- Schema changes require a migration (`payload migrate:create`); the container
CMD runs `payload migrate` on every deploy — no manual DB steps.
- Auth endpoints are rate-limited in dev too; restart the dev server to reset
buckets when testing.
## Deployment
Single VPS `147.79.102.201`, app at `/root/anwarpi`, managed by
`docker compose -f docker-compose.vps.yml` (web + postgres + Traefik proxy
network). Update flow: rsync `web/` → `/root/anwarpi/` excluding
`node_modules .next .git media files-private '.env*' '*.tsbuildinfo'`, then
`docker compose -f docker-compose.vps.yml up -d --build` (label changes force
recreate; migration runs on boot). Traefik labels only affect the VPS.
## House rules
- Follow the **reuse ladder**: existing helper → stdlib → platform feature →
installed dependency → then write the complete version.
- **Do not add UX changes** (new limits, captchas, gates) without owner
approval; thresholds are deliberately generous for humans.
- New decisions → add an ADR in `docs/adr/` (next number, `NNNN-slug.md`).
- Use GLOSSARY terms exactly (عضوية/entitlement, مفتوح/unlocked, موثّق/
verified, كتابة موثوقة/trusted write).
- **Pentest context**: the 2026-10-09 report items F1–F9 are closed; see PR #1
and release v1.0.0 on Gitea for what was done and verified. Do not
re-implement.
-37
View File
@@ -1,37 +0,0 @@
# GLOSSARY — أنور بي آي
Implementation-free vocabulary shared across code, QA, and product talk.
## العضوية / Entitlement
A user's paid access level. Server-derived from `users.role`: `vip` or `admin` counts as entitled; `free` does not. Never cached client-side as a boolean.
## مفتوح / Unlocked
Whether the current viewer may read a given post's full body: `post.access === 'free' || entitled`. Derived per request; never stored on the user.
## تعليق / Comment
A moderated note by an authenticated member on a published post. Lifecycle `pending → approved | rejected`; only `approved` is publicly visible; only admins move status; authors cannot edit or delete their own comments (moderated model). Authors see their own `pending` comments on the post page tagged «قيد المراجعة».
## اسم العرض / Display name
The public author label of a comment: a snapshot of `users.fullName` taken at submit time, or the fixed word «عضو» when absent. Never the email.
## الهوية / Identity
- **Canonical domain: `anwarpy.ai`** — the Cloudflare zone (`anwarpi.ai` references are legacy being replaced; see repo history).
- **Contact inbox: `hello@anwarpy.ai`** — receives via Cloudflare Email Routing, forwards to ahmedalkhair.sa@gmail.com.
- **Sender: `noreply@anwarpy.ai`** — sends via Cloudflare Email Sending (REST).
## الاشتراك قريبًا / Coming soon
The public state of the subscribe flow until Stripe keys exist: `POST /api/subscribe` returns `{ comingSoon: true }` and no `url`; the UI renders a disabled «قريباً» button. No VIP is granted without a Stripe-verified session — neither via the subscribe API nor via `/payment-success` (free users are redirected to `/subscription`).
## كتابة موثوقة / Trusted write
An operation on a member record performed by server code (email verification, payment activation, billing webhook, seed), as opposed to a client request. Client requests can never set a member's privilege, verification, or billing fields.
## موثّق / Verified
Whether the member confirmed their email address. Required to comment; admins are exempt; set only by server code, never by client requests.
+19 -162
View File
@@ -1,180 +1,37 @@
<div align="center"> # أنور بي آي
<img src="web/public/brand/icon-256.png" alt="شعار أنور بي آي" width="120"/> منصة عربية ثابتة لتعلّم الذكاء الاصطناعي، تتضمن محتوى مجانيًا ومقالات VIP وتجربة اشتراك وتهيئة للمشترك الجديد. الواجهة متجاوبة مع الهاتف واللوحي وسطح المكتب.
# أنور بي آي — AnwarPi ## الصفحات الرئيسية
**محتوى عربي عملي لتعلّم الذكاء الاصطناعي، بلا ضجيج.** - الصفحة الرئيسية والكتالوج التعليمي
- مقال مجاني ومعاينة مقال VIP ومقال VIP كامل
- الاشتراك وتهيئة مشترك VIP جديد
- تسجيل الدخول واستعادة كلمة المرور وإعدادات الحساب
- سياسة الخصوصية
Arabic-first platform for learning AI: free guides, VIP deep-dives, a searchable library, and moderated community — built with **Next.js 15 + Payload 3 + PostgreSQL** behind Traefik. ## التشغيل محليًا
**[anwarpy.ai](https://anwarpy.ai)** · [دليل الوكيل الآلي (AGENT.md)](AGENT.md) · [مسرد المصطلحات](GLOSSARY.md) · [قرارات التصميم](docs/adr) لا يحتاج المشروع إلى تثبيت حزم. شغّل خادمًا محليًا من مجلد المشروع:
</div>
---
## 🖼️ لقطات من الموقع
<table>
<tr>
<td align="center"><strong>الصفحة الرئيسية</strong></td>
<td align="center"><strong>المكتبة</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/home.png" alt="الصفحة الرئيسية — أنور بي آي" width="470"/></td>
<td><img src="docs/screenshots/articles.png" alt="المكتبة — أنور بي آي" width="470"/></td>
</tr>
<tr>
<td align="center"><strong>صفحة دليل</strong></td>
<td align="center"><strong>العضوية</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/post.png" alt="صفحة دليل — أنور بي آي" width="470"/></td>
<td><img src="docs/screenshots/subscription.png" alt="العضوية — أنور بي آي" width="470"/></td>
</tr>
</table>
## ما هو أنور بي آي؟
منصة عربية لتعلّم الذكاء الاصطناعي بشكل عملي: أدلة مجانية للجميع، وأدلة متعمقة لأعضاء **VIP**، ومكتبة قابلة للبحث والتصفية، وتعليقات مُراقَّبة، ومسارات تعلّم (كتالوج). الواجهة عربية بالكامل (RTL) وتعمل على الهاتف واللوحي وسطح المكتب.
## ✨ المميزات
- 📚 **المكتبة** — كل الأدلة مع بحث وتصفية حسب التصنيف (وكلاء، نماذج، استرجاع، سلامة، أدوات، أعمال).
- 🔓 **بوابة محتوى** — الأدلة المجانية كاملة؛ أدلة VIP تظهر بمعاينة مقفلة حتى يصبح القارئ مشتركًا.
- 👤 **حسابات** — تسجيل، دخول، استعادة كلمة المرور، تحقق البريد، وإعدادات حساب كاملة.
- 💬 **تعليقات مُراقَّبة** — دورة حياة `pending → approved`؛ يظهر العلني المعتمَد فقط.
- 🗺️ **الكتالوج والمسارات** — رحلة تعلّم مرتّبة حسب المستوى (مبتدئ/متوسط/متقدم).
- 🤖 **نشر آلي** — API للوكلاء الخارجيين (`/api/agent/posts` بمفتاح `x-agent-key`) لنشر الأدلة من خط إدارة المحتوى.
- 💳 **عضوية VIP** — Stripe جاهز؛ حتى تفعيل المفاتيح يعمل زر الاشتراك بوضع «قريبًا».
- 🌓 **هوية بصرية داكنة** — أشعة فضية ولمسة سماوية؛ انظر `brand-spec.md`.
## 🔄 كيف يعمل الموقع؟ (شرح التدفق)
### تدفق الزائر
```mermaid
flowchart RL
Z[الزائر] --> A[الصفحة الرئيسية]
A --> B[المكتبة / الكتالوج]
B --> C{نوع الدليل؟}
C -->|مجاني| D[قراءة كاملة + إعجاب وتعليق]
C -->|VIP| E[معاينة مقفلة]
E --> F[إنشاء حساب / دخول]
F --> G{مشترك VIP؟}
G -->|نعم| D
G -->|لا| H[صفحة العضوية]
H --> I[تهيئة مشترك VIP جديد]
I --> D
```
1. **تصفّح** — الزائر يفتح الرئيسية، يتصفح المكتبة أو الكتالوج، ثم يفتح دليلًا.
2. **قراءة** — الدليل المجاني يُقرأ كاملًا. دليل VIP يظهر جزءًا منه فقط مع طبقة قفل.
3. **حساب** — التسجيل يمنح دور `free` فقط (الخادم يجبر ذلك؛ طلب العميل لا يستطيع ترقية نفسه). بعد التحقق من البريد يمكن التعليق.
4. **ترقية** — صفحة العضوية تقود لـ Stripe؛ بعد الدفع الناجح يُفعَّل دور `vip` عبر مسار موثوق من الخادم، ثم معالجة تهيئة VIP الجديد.
### تدفق المحتوى (نشر الأدلة)
```mermaid
flowchart LR
T[مواضيع المالك في AppFlowy] --> P[خط النشر الآلي]
P -->|POST/PUT مع x-agent-key| A[API الوكلاء]
A --> V[التحقق والتغطية الافتراضية للغلاف]
V --> D[(PostgreSQL)]
D --> S[anwarpy.ai]
```
- يملك المالك قائمة مواضيع؛ يعمل خط النشر FIFO (دليل واحد لكل تشغيل، الاثنين/الأربعاء/الجمعة).
- كل دليل يُوسم `free` أو `vip`؛ غير الموسوم = مجاني.
- الأغلفة ‏1200×630، ولكل تصنيف غلاف افتراضي احتياطي.
### تدفق الطلب عبر الخادم
```
المتصفح → Traefik (حدود المعدل: pagelimit / apilimit / connlimit)
→ Next.js 15 (الصفحات + /api)
→ Payload 3 (التحكم بالوصول + الخطافات)
→ PostgreSQL 16
```
## 🏗️ البنية التقنية
| الطبقة | التقنية |
|---|---|
| الواجهة | Next.js 15 (App Router) + React 19، عربية RTL |
| لوحة التحكم | Payload 3 (`/admin`) مع محرر Lexical |
| قاعدة البيانات | PostgreSQL 16 عبر `@payloadcms/db-postgres` |
| المصادقة | جلسات Payload، تجزئة PBKDF2-SHA256 ‏600k تكرار |
| الوسيط | `web/src/middleware.ts` — حدود معدل لمسارات المصادقة |
| الحاوية | Docker Compose + Traefik (TLS + حدود معدل الحافة) |
| البريد | Cloudflare (توجيه + إرسال REST) |
| المدفوعات | Stripe (جاهز، بوضع «قريبًا» حتى المفاتيح) |
## 🚀 التشغيل محليًا
```bash ```bash
cd web python3 -m http.server 8000
npm install
``` ```
أنشئ `web/.env`: ثم افتح `http://localhost:8000`.
```env ## التحقق قبل النشر
DATABASE_URI=postgres://anwarpi:anwarpi@127.0.0.1:5432/anwarpi
PAYLOAD_SECRET=<سري عشوائي طويل>
NEXT_PUBLIC_SITE_URL=http://localhost:3000
AGENT_API_KEY=<مفتاح نشر الوكلاء>
MAIL_FROM=noreply@anwarpy.ai
```
```bash ```bash
npm run dev # http://localhost:3000 — لوحة التحكم على /admin python3 scripts/check_site.py
npm run build # تحقق قبل النشر
npm run seed # تعبئة بيانات تجريبية
``` ```
## 📡 أهم مسارات الـ API يتحقق الفحص من صفحات HTML والروابط والأصول المحلية وبيانات العرض وسياسة أمان المحتوى.
| المسار | الوصف | ## النشر
|---|---|
| `POST /api/users` | تسجيل (حد ‏5/ساعة لكل IP) |
| `POST /api/users/login` | دخول (حد ‏10/15دقيقة) |
| `POST /api/users/forgot-password` | استعادة (حد ‏5/ساعة) |
| `GET/PATCH /api/users/me` | حسابي |
| `GET /api/posts` و `GET /api/posts/[slug]` | الأدلة (المحتوى المقفل يُقتطع حسب الدور) |
| `POST /api/posts/[slug]/like` / `view` / `comments` | تفاعلات |
| `POST/PUT /api/agent/posts` | نشر آلي — يتطلب ترويسة `x-agent-key` |
| `POST /api/subscribe` | اشتراك Stripe (يعيد `comingSoon` حتى المفاتيح) |
| `POST /api/stripe/webhook` | ويب‌هوك Stripe — يتحقق من التوقيع |
## 🔐 الأمان المستودع الرئيسي مستضاف على Gitea، والفرع الجاهز للنشر هو `main`. يحتوي المشروع أيضًا على مسار عمل GitHub Pages عند نسخ المستودع إلى GitHub وتفعيل Pages من GitHub Actions.
- **حدود المعدل** (وسيط Next): تسجيل ‏5/ساعة، دخول ‏10/15دقيقة، استعادة ‏5/ساعة — لكل IP، استجابة 429 مع `Retry-After` ورسالة عامة. ## حدود النسخة الحالية
- **حدود الحافة** (Traefik): ‏60 طلب/دقيقة للصفحات (انفجار 120) و‏50 طلبًا جاريًا لكل IP، و`apilimit` للـ API.
- **قاعدة الكتابة الموثوقة**: طلبات العميل لا تستطيع تعيين الدور أو التحقق أو حقول الفواتير — تُحذف في خطاف `beforeChange` (`verificationToken`, `stripeCustomerId`, `role`, `emailVerified`).
- **HSTS** `max-age=63072000; includeSubDomains` على كل المسارات.
- **تكافؤ زمن الدخول**: البريد غير الموجود يستهلك نفس كلفة PBKDF2 (منع تعداد المستخدمين بالزمن).
- **التعليقات مُراقَّبة** ولا تُنشر قبل اعتماد المشرف.
## 📚 الوثائق الموقع نموذج واجهة ثابتة. تسجيل الدخول واستعادة كلمة المرور والدفع والعضوية وحماية محتوى VIP محاكاة داخل المتصفح وليست حماية إنتاجية. لا تستخدمه لجمع بيانات أو مدفوعات حقيقية قبل إضافة مصادقة وتفويض ودفع من جهة الخادم.
| الملف | المحتوى |
|---|---|
| [AGENT.md](AGENT.md) | شرح التطبيق والبنية للوكلاء الآليين (AI agents) |
| [GLOSSARY.md](GLOSSARY.md) | مسرد المصطلحات الموحّد (عضوية، مفتوح، موثّق…) |
| [docs/adr](docs/adr) | قرارات التصميم (ADRs) |
| [brand-spec.md](brand-spec.md) | الهوية البصرية والألوان |
| [web/docker-compose.vps.yml](web/docker-compose.vps.yml) | تكوين النشر على الـ VPS |
## 🚢 النشر
```bash
rsync -az --exclude node_modules --exclude .next --exclude .git \
--exclude media --exclude files-private --exclude '.env*' --exclude '*.tsbuildinfo' \
web/ root@<VPS>:/root/anwarpi/
ssh root@<VPS> "cd /root/anwarpi && docker compose -f docker-compose.vps.yml up -d --build"
```
أمر التشغيل داخل الحاوية ينفّذ `payload migrate` تلقائيًا قبل الإقلاع.
+6 -24
View File
@@ -106,9 +106,6 @@
.deck-frame { position: relative; min-height: 470px; border: 1px solid var(--fg); background: var(--surface); overflow: hidden; } .deck-frame { position: relative; min-height: 470px; border: 1px solid var(--fg); background: var(--surface); overflow: hidden; }
.deck-slide { position: absolute; inset: 0; padding: clamp(28px, 4vw, 52px); display: none; grid-template-rows: auto 1fr auto; } .deck-slide { position: absolute; inset: 0; padding: clamp(28px, 4vw, 52px); display: none; grid-template-rows: auto 1fr auto; }
.deck-slide.active { display: grid; } .deck-slide.active { display: grid; }
.deck-slide-link { position: absolute; inset: 0; z-index: 2; cursor: pointer; }
.deck-slide-link:focus-visible { outline-offset: -8px; }
.deck-slide:hover .deck-slide-body h2 { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 8px; }
.deck-slide::after { content: ""; position: absolute; inset-inline-start: 0; bottom: 0; width: 100%; height: 4px; background: var(--fg); } .deck-slide::after { content: ""; position: absolute; inset-inline-start: 0; bottom: 0; width: 100%; height: 4px; background: var(--fg); }
.deck-slide-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .deck-slide-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.deck-slide-kicker { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; } .deck-slide-kicker { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; }
@@ -215,10 +212,9 @@
<div class="container topnav-inner"> <div class="container topnav-inner">
<a class="logo" href="#top" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a> <a class="logo" href="#top" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a>
<nav aria-label="التنقل الرئيسي"> <nav aria-label="التنقل الرئيسي">
<a href="articles.html" data-od-id="path-0-0-1-0">المكتبة</a> <a href="#catalog">المكتبة</a>
<a href="#topics">المسارات</a> <a href="#topics">المسارات</a>
<a href="#membership">العضوية</a> <a href="#membership">العضوية</a>
<a href="about.html" data-od-id="nav-about-team">عن الفريق</a>
</nav> </nav>
<button class="nav-action" data-open-membership>تسجيل الدخول</button> <button class="nav-action" data-open-membership>تسجيل الدخول</button>
</div> </div>
@@ -241,19 +237,16 @@
<div class="deck" data-od-id="featured-deck" aria-label="مختارات مميزة"> <div class="deck" data-od-id="featured-deck" aria-label="مختارات مميزة">
<div class="deck-frame" aria-live="polite"> <div class="deck-frame" aria-live="polite">
<article class="deck-slide active" data-slide="0"> <article class="deck-slide active" data-slide="0">
<a class="deck-slide-link" href="articles.html?topic=agents" aria-label="افتح قسم مقالات الوكلاء في المكتبة"></a>
<div class="deck-slide-top"><span class="deck-slide-kicker">دليل حصري · المستوى المتوسط</span><span class="deck-slide-index num">01</span></div> <div class="deck-slide-top"><span class="deck-slide-kicker">دليل حصري · المستوى المتوسط</span><span class="deck-slide-index num">01</span></div>
<div class="deck-slide-body"><h2>ابنِ وكيلك الأول دون فوضى</h2><p>خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.</p></div> <div class="deck-slide-body"><h2>ابنِ وكيلك الأول دون فوضى</h2><p>خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.</p></div>
<div class="deck-slide-foot"><span class="deck-slide-format"><span class="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</span><span class="meta"><bdi dir="ltr">AGENTIC AI</bdi></span></div> <div class="deck-slide-foot"><span class="deck-slide-format"><span class="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</span><span class="meta"><bdi dir="ltr">AGENTIC AI</bdi></span></div>
</article> </article>
<article class="deck-slide" data-slide="1"> <article class="deck-slide" data-slide="1">
<a class="deck-slide-link" href="articles.html?topic=models" aria-label="افتح قسم مقالات النماذج في المكتبة"></a>
<div class="deck-slide-top"><span class="deck-slide-kicker">مقال تطبيقي · للمبتدئين</span><span class="deck-slide-index num">02</span></div> <div class="deck-slide-top"><span class="deck-slide-kicker">مقال تطبيقي · للمبتدئين</span><span class="deck-slide-index num">02</span></div>
<div class="deck-slide-body"><h2>كيف تختار نموذجاً لمهمتك؟</h2><p>مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.</p></div> <div class="deck-slide-body"><h2>كيف تختار نموذجاً لمهمتك؟</h2><p>مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.</p></div>
<div class="deck-slide-foot"><span class="deck-slide-format">قراءة · 12 دقيقة</span><span class="meta">النماذج</span></div> <div class="deck-slide-foot"><span class="deck-slide-format">قراءة · 12 دقيقة</span><span class="meta">النماذج</span></div>
</article> </article>
<article class="deck-slide" data-slide="2"> <article class="deck-slide" data-slide="2">
<a class="deck-slide-link" href="articles.html?topic=models" aria-label="افتح قسم مقالات RAG في المكتبة"></a>
<div class="deck-slide-top"><span class="deck-slide-kicker">مسار متقدم · للأعضاء</span><span class="deck-slide-index num">03</span></div> <div class="deck-slide-top"><span class="deck-slide-kicker">مسار متقدم · للأعضاء</span><span class="deck-slide-index num">03</span></div>
<div class="deck-slide-body"><h2>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</h2><p>صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.</p></div> <div class="deck-slide-body"><h2>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</h2><p>صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.</p></div>
<div class="deck-slide-foot"><span class="deck-slide-format"><span class="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</span><span class="meta"><bdi dir="ltr">RAG</bdi></span></div> <div class="deck-slide-foot"><span class="deck-slide-format"><span class="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</span><span class="meta"><bdi dir="ltr">RAG</bdi></span></div>
@@ -281,10 +274,10 @@
<p class="lead">تصفح المحتوى حسب ما تريد بناءه، لا حسب المصطلحات التي تحفظها.</p> <p class="lead">تصفح المحتوى حسب ما تريد بناءه، لا حسب المصطلحات التي تحفظها.</p>
</div> </div>
<div class="topic-grid"> <div class="topic-grid">
<a class="topic" href="articles.html?topic=agents" data-od-id="topic-agents"><strong>الوكلاء</strong><span>تخطيط · أدوات · ذاكرة</span></a> <a class="topic" href="#catalog" data-topic-link="agents" data-od-id="topic-agents"><strong>الوكلاء</strong><span>تخطيط · أدوات · ذاكرة</span></a>
<a class="topic" href="articles.html?topic=models" data-od-id="topic-models"><strong>النماذج</strong><span>اختيار · تقييم · تشغيل</span></a> <a class="topic" href="#catalog" data-topic-link="models" data-od-id="topic-models"><strong>النماذج</strong><span>اختيار · تقييم · تشغيل</span></a>
<a class="topic" href="articles.html?topic=automation" data-od-id="topic-automation"><strong>الأتمتة</strong><span>سير عمل · ربط · مراقبة</span></a> <a class="topic" href="#catalog" data-topic-link="automation" data-od-id="topic-automation"><strong>الأتمتة</strong><span>سير عمل · ربط · مراقبة</span></a>
<a class="topic" href="articles.html?topic=foundations" data-od-id="topic-foundations"><strong>الأساسيات</strong><span>مفاهيم · تطبيق · أمان</span></a> <a class="topic" href="#catalog" data-topic-link="foundations" data-od-id="topic-foundations"><strong>الأساسيات</strong><span>مفاهيم · تطبيق · أمان</span></a>
</div> </div>
</div> </div>
</section> </section>
@@ -376,18 +369,7 @@
</main> </main>
<footer class="pagefoot" data-od-id="footer"> <footer class="pagefoot" data-od-id="footer">
<div class="container row-between"> <div class="container row-between"><span>© أنور بي آي 2026 · تعلّم بوضوح</span><span class="meta">العربية أولاً · خصوصيتك محفوظة</span></div>
<span>© أنور بي آي 2026 · تعلّم بوضوح</span>
<nav aria-label="روابط الموقع" style="display:flex; flex-wrap:wrap; align-items:center; gap:8px 24px;">
<a href="arabic-ai-learning-catalog-v2.html" style="min-height:44px; display:inline-flex; align-items:center;">الرئيسية</a>
<a href="articles.html" style="min-height:44px; display:inline-flex; align-items:center;">المكتبة</a>
<a href="subscription.html" style="min-height:44px; display:inline-flex; align-items:center;">الاشتراك</a>
<a href="about.html" style="min-height:44px; display:inline-flex; align-items:center;">عن الفريق</a>
<a href="privacy.html" style="min-height:44px; display:inline-flex; align-items:center;">الخصوصية</a>
<a href="login.html" style="min-height:44px; display:inline-flex; align-items:center;">تسجيل الدخول</a>
</nav>
<span class="meta">العربية أولاً · خصوصيتك محفوظة</span>
</div>
</footer> </footer>
<div class="modal" id="previewModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-hidden="true" data-od-id="preview-dialog"> <div class="modal" id="previewModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-hidden="true" data-od-id="preview-dialog">
-233
View File
@@ -1,233 +0,0 @@
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src 'none'; worker-src 'none'" />
<meta name="description" content="جميع مقالات وأدلة أنور بي آي، مع تصفية حسب الفئة ونوع الوصول." />
<title>المكتبة — أنور بي آي</title>
<style>
:root {
--bg: oklch(16% 0.03 225);
--surface: oklch(22% 0.04 220);
--fg: oklch(96% 0.015 210);
--muted: oklch(74% 0.03 215);
--border: oklch(36% 0.05 215);
--accent: oklch(78% 0.17 190);
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
--fg-soft: color-mix(in oklch, var(--fg) 6%, transparent);
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--container: 1180px;
--gutter: 32px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.7 var(--font-body); text-rendering: optimizeLegibility; }
a { color: inherit; text-decoration: none; }
button { color: inherit; font: inherit; cursor: pointer; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); text-wrap: balance; }
p { text-wrap: pretty; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
.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); }
.topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
.logo { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 23px; font-weight: 700; }
.logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; }
.topnav nav { display: flex; align-items: center; gap: 28px; }
.topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); border-bottom: 1px solid transparent; }
.topnav nav a:hover, .topnav nav a[aria-current="page"] { color: var(--fg); border-color: var(--accent); }
.nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); }
.nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
.hero { padding-block: clamp(64px, 9vw, 112px); border-bottom: 1px solid var(--border); }
.eyebrow { margin: 0 0 18px; color: var(--accent); font: 11px/1.5 var(--font-mono); letter-spacing: .06em; }
h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; letter-spacing: -.025em; }
.lead { max-width: 56ch; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2vw, 22px); }
.library { padding-block: clamp(52px, 8vw, 88px); }
.filter-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-bottom: 44px; padding-block: 22px; border-block: 1px solid var(--border); }
.filter-group { min-width: 0; }
.filter-label { display: block; margin-bottom: 10px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
.filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: none; }
.filters::-webkit-scrollbar { display: none; }
.filter { flex: none; min-height: 44px; padding: 9px 15px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
.filter:hover { color: var(--fg); border-color: var(--fg); }
.filter[aria-pressed="true"] { background: var(--fg); color: var(--surface); border-color: var(--fg); }
.results-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
h2 { font-size: clamp(30px, 4vw, 46px); line-height: 1.15; }
.count { color: var(--muted); font: 12px/1.5 var(--font-mono); }
.posts { border-top: 1px solid var(--fg); }
.post { display: grid; grid-template-columns: 138px minmax(0, 1fr) 130px; gap: 28px; align-items: start; padding-block: 28px; border-bottom: 1px solid var(--border); }
.post[hidden] { display: none; }
.post-meta { color: var(--muted); font: 12px/1.8 var(--font-mono); }
.post-meta span { display: block; }
.post h3 { font-size: clamp(22px, 2.5vw, 30px); line-height: 1.3; }
.post p { max-width: 62ch; margin: 9px 0 0; color: var(--muted); }
.access { justify-self: end; display: inline-flex; align-items: center; min-height: 34px; padding: 5px 11px; border: 1px solid var(--border); color: var(--muted); font: 11px/1.5 var(--font-mono); }
.access.free { border-color: var(--accent); color: var(--accent); }
.post-link { display: inline-flex; align-items: center; min-height: 44px; margin-top: 14px; font-weight: 700; border-bottom: 1px solid transparent; }
.post-link:hover { color: var(--accent); border-color: var(--accent); }
.soon { color: var(--muted); cursor: default; }
.empty { display: none; padding: 42px 0; color: var(--muted); }
.empty.show { display: block; }
.pagefoot { padding-block: 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.footer-row { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
@media (max-width: 920px) {
:root { --gutter: 22px; }
.topnav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
.logo, .nav-action { margin-bottom: 10px; }
.topnav nav { order: 3; flex: 0 0 calc(100% + (var(--gutter) * 2)); gap: 6px; margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter); overflow-x: auto; border-top: 1px solid var(--border); scrollbar-width: none; }
.topnav nav::-webkit-scrollbar { display: none; }
.topnav nav a { flex: none; min-height: 52px; padding-inline: 12px; white-space: nowrap; }
.filter-panel { grid-template-columns: 1fr; gap: 20px; }
.post { grid-template-columns: 112px minmax(0, 1fr); }
.access { justify-self: start; grid-column: 2; }
}
@media (max-width: 620px) {
:root { --gutter: 18px; }
.nav-action { padding-inline: 12px; }
h1 { font-size: clamp(42px, 13vw, 58px); }
.results-head { align-items: start; flex-direction: column; }
.post { grid-template-columns: 1fr; gap: 12px; }
.access { grid-column: 1; }
.post-link { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
}
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
</style>
<link rel="stylesheet" href="assets/anwarpi.css?v=5" />
<script src="assets/anwarpi.js?v=4" defer></script>
</head>
<body>
<header class="topnav" data-od-id="topnav">
<div class="container topnav-inner">
<a class="logo" href="arabic-ai-learning-catalog-v2.html" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352" />أنور بي آي</a>
<nav aria-label="التنقل الرئيسي">
<a href="articles.html" aria-current="page" data-od-id="nav-library">المكتبة</a>
<a href="arabic-ai-learning-catalog-v2.html#topics">المسارات</a>
<a href="subscription.html">العضوية</a>
</nav>
<a class="nav-action" href="login.html">تسجيل الدخول</a>
</div>
</header>
<main id="content">
<section class="hero" data-od-id="library-hero">
<div class="container">
<p class="eyebrow">مكتبة أنور بي آي</p>
<h1 data-od-id="library-title">كل الأدلة في مكان واحد.</h1>
<p class="lead">صفِّ المحتوى حسب الفئة ونوع الوصول، ثم افتح المقال المجاني أو معاينة محتوى العضوية.</p>
</div>
</section>
<section class="library" data-od-id="article-library">
<div class="container">
<div class="filter-panel" data-od-id="library-filters">
<div class="filter-group" role="group" aria-label="تصفية حسب الفئة">
<span class="filter-label">الفئة</span>
<div class="filters">
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="all" aria-pressed="true">الكل</button>
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="agents" aria-pressed="false">الوكلاء</button>
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="models" aria-pressed="false">النماذج</button>
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="automation" aria-pressed="false">الأتمتة</button>
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="foundations" aria-pressed="false">الأساسيات</button>
</div>
</div>
<div class="filter-group" role="group" aria-label="تصفية حسب نوع الوصول">
<span class="filter-label">نوع الوصول</span>
<div class="filters">
<button class="filter" type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
<button class="filter" type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
<button class="filter" type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">VIP</button>
</div>
</div>
</div>
<div class="results-head">
<h2 data-od-id="posts-heading">جميع المواد</h2>
<span class="count" id="resultCount" aria-live="polite">٦ مواد</span>
</div>
<div class="posts" id="postList">
<article class="post" data-topic="agents" data-access="vip" data-od-id="post-agent-loop">
<div class="post-meta"><span>الوكلاء</span><span>١٨ دقيقة</span></div>
<div><h3>البنية التي تمنع وكيلك من الدوران في حلقة</h3><p>حدود واضحة بين التخطيط والتنفيذ والتحقق، مع نموذج بسيط لسجل الحالة.</p><a class="post-link" href="post-locked.html">شاهد المعاينة ←</a></div>
<span class="access">VIP · معاينة</span>
</article>
<article class="post" data-topic="models" data-access="free" data-od-id="post-model-selection">
<div class="post-meta"><span>النماذج</span><span>١٢ دقيقة</span></div>
<div><h3>لا تبدأ باسم النموذج؛ ابدأ بطبيعة المهمة</h3><p>مصفوفة قرار عملية توازن بين الدقة، السياق، السرعة، والكلفة.</p><a class="post-link" href="article-agent-guide.html">ابدأ القراءة ←</a></div>
<span class="access free">مجاني · كامل</span>
</article>
<article class="post" data-topic="automation" data-access="vip" data-od-id="post-automation-observability">
<div class="post-meta"><span>الأتمتة</span><span>٢٤ دقيقة</span></div>
<div><h3>كيف تكتشف فشل سير العمل قبل المستخدم؟</h3><p>إشارات قليلة تكفي لمراقبة التكلفة، التأخير، ونقاط الانقطاع.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
<span class="access">VIP</span>
</article>
<article class="post" data-topic="foundations" data-access="free" data-od-id="post-prompt-context">
<div class="post-meta"><span>الأساسيات</span><span>٩ دقائق</span></div>
<div><h3>لماذا لا يصلح تحسين الصياغة بياناتك الرديئة؟</h3><p>افصل بين التعليمات، المعرفة، وذاكرة المحادثة لتحصل على نتيجة مستقرة.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
<span class="access free">مجاني</span>
</article>
<article class="post" data-topic="agents" data-access="vip" data-od-id="post-tool-safety">
<div class="post-meta"><span>الوكلاء</span><span>١٥ دقيقة</span></div>
<div><h3>قبل أن تمنح الوكيل صلاحية التنفيذ</h3><p>حدود الصلاحيات، التأكيد البشري، وسجل تدقيق يمكنك الرجوع إليه.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
<span class="access">VIP</span>
</article>
<article class="post" data-topic="models" data-access="vip" data-od-id="post-rag-evaluation">
<div class="post-meta"><span>النماذج</span><span>٤ أجزاء</span></div>
<div><h3>اختبر الاسترجاع والإجابة كلّاً على حدة</h3><p>منهج تقييم يفصل فشل المصدر عن فشل النموذج ويقودك إلى إصلاح صحيح.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
<span class="access">VIP</span>
</article>
</div>
<p class="empty" id="emptyState">لا توجد مواد تطابق هذين الفلترين.</p>
</div>
</section>
</main>
<footer class="pagefoot" data-od-id="footer">
<div class="container footer-row"><span>© أنور بي آي ٢٠٢٦ · تعلّم بوضوح</span><a href="privacy.html">الخصوصية</a></div>
</footer>
<script>
(() => {
const state = { topic: 'all', access: 'all' };
const buttons = [...document.querySelectorAll('[data-filter-kind]')];
const posts = [...document.querySelectorAll('.post')];
const count = document.getElementById('resultCount');
const empty = document.getElementById('emptyState');
const arabicCounts = ['٠', '١', '٢', '٣', '٤', '٥', '٦'];
function applyFilters() {
let visible = 0;
posts.forEach(post => {
const topicMatch = state.topic === 'all' || post.dataset.topic === state.topic;
const accessMatch = state.access === 'all' || post.dataset.access === state.access;
post.hidden = !(topicMatch && accessMatch);
if (!post.hidden) visible += 1;
});
count.textContent = `${arabicCounts[visible]} ${visible === 1 ? 'مادة' : 'مواد'}`;
empty.classList.toggle('show', visible === 0);
}
buttons.forEach(button => button.addEventListener('click', () => {
const kind = button.dataset.filterKind;
state[kind] = button.dataset.filterValue;
buttons.filter(item => item.dataset.filterKind === kind).forEach(item => {
item.setAttribute('aria-pressed', String(item === button));
});
applyFilters();
}));
applyFilters();
})();
</script>
</body>
</html>
-308
View File
@@ -1,308 +0,0 @@
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src 'none'; worker-src 'none'" />
<meta name="description" content="جميع مقالات وأدلة أنور بي آي، مع تصفية حسب الفئة ونوع الوصول." />
<title>المكتبة — أنور بي آي</title>
<style>
:root {
--bg: oklch(16% 0.03 225);
--surface: oklch(22% 0.04 220);
--fg: oklch(96% 0.015 210);
--muted: oklch(74% 0.03 215);
--border: oklch(36% 0.05 215);
--accent: oklch(78% 0.17 190);
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
--fg-soft: color-mix(in oklch, var(--fg) 6%, transparent);
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--container: 1180px;
--gutter: 32px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.7 var(--font-body); text-rendering: optimizeLegibility; }
a { color: inherit; text-decoration: none; }
button { color: inherit; font: inherit; cursor: pointer; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); text-wrap: balance; }
p { text-wrap: pretty; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
.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); }
.topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
.logo { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 23px; font-weight: 700; }
.logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; }
.topnav nav { display: flex; align-items: center; gap: 28px; }
.topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); border-bottom: 1px solid transparent; }
.topnav nav a:hover, .topnav nav a[aria-current="page"] { color: var(--fg); border-color: var(--accent); }
.nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); }
.nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
.hero { padding-block: clamp(56px, 8vw, 96px); border-bottom: 1px solid var(--border); }
.hero-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(360px, 1.1fr); gap: clamp(36px, 7vw, 88px); align-items: end; }
.eyebrow { margin: 0 0 18px; color: var(--accent); font: 11px/1.5 var(--font-mono); letter-spacing: .06em; }
h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; letter-spacing: -.025em; }
.lead { max-width: 56ch; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2vw, 22px); }
.weekly { display: block; padding: clamp(24px, 4vw, 40px); background: var(--surface); border: 1px solid var(--border); border-top-color: var(--accent); }
.weekly:hover { border-color: var(--fg); background: color-mix(in oklch, var(--surface) 88%, var(--fg)); }
.weekly-meta { display: flex; justify-content: space-between; gap: 16px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
.weekly h2 { margin-top: 42px; font-size: clamp(28px, 4vw, 44px); line-height: 1.18; }
.weekly p { margin: 14px 0 30px; color: var(--muted); }
.weekly-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); }
.library { padding-block: clamp(52px, 8vw, 88px); }
.library-tools { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 16px; margin-bottom: 22px; }
.control { width: 100%; min-height: 48px; padding: 11px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--fg); font: inherit; }
.control::placeholder { color: var(--muted); }
.control:hover { border-color: var(--fg); }
.control:focus { outline: 3px solid var(--accent-soft); border-color: var(--accent); }
.filter-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-bottom: 44px; padding-block: 22px; border-block: 1px solid var(--border); }
.filter-group { min-width: 0; }
.filter-label { display: block; margin-bottom: 10px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
.filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: none; }
.filters::-webkit-scrollbar { display: none; }
.filter { flex: none; min-height: 44px; padding: 9px 15px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
.filter:hover { color: var(--fg); border-color: var(--fg); }
.filter[aria-pressed="true"] { background: var(--fg); color: var(--surface); border-color: var(--fg); }
.results-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
h2 { font-size: clamp(30px, 4vw, 46px); line-height: 1.15; }
.count { color: var(--muted); font: 12px/1.5 var(--font-mono); }
.posts { border-top: 1px solid var(--fg); }
.post { display: grid; grid-template-columns: 138px minmax(0, 1fr) 168px; gap: 28px; align-items: start; padding-block: 28px; border-bottom: 1px solid var(--border); }
.post[hidden] { display: none; }
.post-meta { color: var(--muted); font: 12px/1.8 var(--font-mono); }
.post-meta span { display: block; }
.post h3 { font-size: clamp(22px, 2.5vw, 30px); line-height: 1.3; }
.post p { max-width: 62ch; margin: 9px 0 0; color: var(--muted); }
.post-side { justify-self: stretch; display: grid; gap: 12px; }
.access { justify-self: end; display: inline-flex; align-items: center; min-height: 34px; padding: 5px 11px; border: 1px solid var(--border); color: var(--muted); font: 11px/1.5 var(--font-mono); }
.access.free { border-color: var(--fg); color: var(--fg); }
.engagement { display: flex; justify-content: flex-end; gap: 12px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
.like { min-height: 44px; padding: 0 10px; border: 1px solid transparent; background: transparent; color: var(--muted); }
.like:hover, .like[aria-pressed="true"] { color: var(--fg); border-color: var(--border); }
.post-link { display: inline-flex; align-items: center; min-height: 44px; margin-top: 14px; font-weight: 700; border-bottom: 1px solid transparent; }
.post-link:hover { color: var(--accent); border-color: var(--accent); }
.soon { color: var(--muted); cursor: default; }
.empty { display: none; padding: 42px 0; color: var(--muted); }
.empty.show { display: block; }
.pagefoot { padding-block: 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.footer-row { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
@media (max-width: 920px) {
:root { --gutter: 22px; }
.topnav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
.logo, .nav-action { margin-bottom: 10px; }
.topnav nav { order: 3; flex: 0 0 calc(100% + (var(--gutter) * 2)); gap: 6px; margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter); overflow-x: auto; border-top: 1px solid var(--border); scrollbar-width: none; }
.topnav nav::-webkit-scrollbar { display: none; }
.topnav nav a { flex: none; min-height: 52px; padding-inline: 12px; white-space: nowrap; }
.hero-grid, .filter-panel { grid-template-columns: 1fr; gap: 20px; }
.weekly { margin-top: 8px; }
.post { grid-template-columns: 112px minmax(0, 1fr); }
.post-side { justify-self: start; grid-column: 2; }
.access { justify-self: start; }
.engagement { justify-content: flex-start; }
}
@media (max-width: 620px) {
:root { --gutter: 18px; }
.nav-action { padding-inline: 12px; }
h1 { font-size: clamp(42px, 13vw, 58px); }
.library-tools { grid-template-columns: 1fr; }
.results-head { align-items: start; flex-direction: column; }
.post { grid-template-columns: 1fr; gap: 12px; }
.post-side { grid-column: 1; }
.post-link { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
}
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
</style>
<link rel="stylesheet" href="assets/anwarpi.css?v=5" />
<script src="assets/anwarpi.js?v=4" defer></script>
</head>
<body>
<header class="topnav" data-od-id="topnav">
<div class="container topnav-inner">
<a class="logo" href="arabic-ai-learning-catalog-v2.html" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352" />أنور بي آي</a>
<nav aria-label="التنقل الرئيسي">
<a href="articles.html" aria-current="page" data-od-id="nav-library">المكتبة</a>
<a href="arabic-ai-learning-catalog-v2.html#topics">المسارات</a>
<a href="subscription.html">العضوية</a>
<a href="about.html">عن الفريق</a>
</nav>
<a class="nav-action" href="login.html">تسجيل الدخول</a>
</div>
</header>
<main id="content">
<section class="hero" data-od-id="library-hero">
<div class="container hero-grid">
<div>
<p class="eyebrow">مكتبة أنور بي آي</p>
<h1 data-od-id="library-title">كل المقالات. بلا ضياع.</h1>
<p class="lead">ابحث، اختر مسارك، ورتّب النتائج حسب الأحدث أو التفاعل المسجل على جهازك.</p>
</div>
<a class="weekly" href="article-agent-guide.html" data-od-id="weekly-post">
<div class="weekly-meta"><span>مقال هذا الأسبوع</span><span>مجاني · ١٢ دقيقة</span></div>
<h2>لا تبدأ باسم النموذج؛ ابدأ بطبيعة المهمة</h2>
<p>مصفوفة قرار عملية توازن بين الدقة، السياق، السرعة، والكلفة.</p>
<span class="weekly-link">اقرأ المقال ←</span>
</a>
</div>
</section>
<section class="library" data-od-id="article-library">
<div class="container">
<div class="library-tools" data-od-id="library-search-sort">
<label><span class="filter-label">ابحث في العناوين والوصف</span><input class="control" id="postSearch" type="search" autocomplete="off" placeholder="مثال: الوكلاء، RAG، اختيار النموذج" /></label>
<label><span class="filter-label">الترتيب</span><select class="control" id="postSort"><option value="latest">الأحدث أولاً</option><option value="views">الأكثر مشاهدة على هذا الجهاز</option><option value="likes">الأكثر إعجاباً على هذا الجهاز</option></select></label>
</div>
<div class="filter-panel" data-od-id="library-filters">
<div class="filter-group" role="group" aria-label="تصفية حسب المسار">
<span class="filter-label">المسار / الفئة</span>
<div class="filters">
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="all" aria-pressed="true">الكل</button>
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="agents" aria-pressed="false">الوكلاء</button>
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="models" aria-pressed="false">النماذج</button>
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="automation" aria-pressed="false">الأتمتة</button>
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="foundations" aria-pressed="false">الأساسيات</button>
</div>
</div>
<div class="filter-group" role="group" aria-label="تصفية حسب نوع الوصول">
<span class="filter-label">نوع الوصول</span>
<div class="filters">
<button class="filter" type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
<button class="filter" type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
<button class="filter" type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">VIP</button>
</div>
</div>
</div>
<div class="results-head">
<h2 data-od-id="posts-heading">جميع المواد</h2>
<span class="count" id="resultCount" aria-live="polite">٦ مواد</span>
</div>
<div class="posts" id="postList">
<article class="post" data-topic="agents" data-access="vip" data-order="6" data-id="agent-loop" data-od-id="post-agent-loop">
<div class="post-meta"><span>الوكلاء</span><span>١٨ دقيقة</span></div>
<div><h3>البنية التي تمنع وكيلك من الدوران في حلقة</h3><p>حدود واضحة بين التخطيط والتنفيذ والتحقق، مع نموذج بسيط لسجل الحالة.</p><a class="post-link" href="post-locked.html">شاهد المعاينة ←</a></div>
<div class="post-side"><span class="access">VIP · معاينة</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
</article>
<article class="post" data-topic="models" data-access="free" data-order="5" data-id="model-selection" data-od-id="post-model-selection">
<div class="post-meta"><span>النماذج</span><span>١٢ دقيقة</span></div>
<div><h3>لا تبدأ باسم النموذج؛ ابدأ بطبيعة المهمة</h3><p>مصفوفة قرار عملية توازن بين الدقة، السياق، السرعة، والكلفة.</p><a class="post-link" href="article-agent-guide.html">ابدأ القراءة ←</a></div>
<div class="post-side"><span class="access free">مجاني · كامل</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
</article>
<article class="post" data-topic="automation" data-access="vip" data-order="4" data-id="automation-observability" data-od-id="post-automation-observability">
<div class="post-meta"><span>الأتمتة</span><span>٢٤ دقيقة</span></div>
<div><h3>كيف تكتشف فشل سير العمل قبل المستخدم؟</h3><p>إشارات قليلة تكفي لمراقبة التكلفة، التأخير، ونقاط الانقطاع.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
<div class="post-side"><span class="access">VIP</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
</article>
<article class="post" data-topic="foundations" data-access="free" data-order="3" data-id="prompt-context" data-od-id="post-prompt-context">
<div class="post-meta"><span>الأساسيات</span><span>٩ دقائق</span></div>
<div><h3>لماذا لا يصلح تحسين الصياغة بياناتك الرديئة؟</h3><p>افصل بين التعليمات، المعرفة، وذاكرة المحادثة لتحصل على نتيجة مستقرة.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
<div class="post-side"><span class="access free">مجاني</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
</article>
<article class="post" data-topic="agents" data-access="vip" data-order="2" data-id="tool-safety" data-od-id="post-tool-safety">
<div class="post-meta"><span>الوكلاء</span><span>١٥ دقيقة</span></div>
<div><h3>قبل أن تمنح الوكيل صلاحية التنفيذ</h3><p>حدود الصلاحيات، التأكيد البشري، وسجل تدقيق يمكنك الرجوع إليه.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
<div class="post-side"><span class="access">VIP</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
</article>
<article class="post" data-topic="models" data-access="vip" data-order="1" data-id="rag-evaluation" data-od-id="post-rag-evaluation">
<div class="post-meta"><span>النماذج</span><span>٤ أجزاء</span></div>
<div><h3>اختبر الاسترجاع والإجابة كلّاً على حدة</h3><p>منهج تقييم يفصل فشل المصدر عن فشل النموذج ويقودك إلى إصلاح صحيح.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
<div class="post-side"><span class="access">VIP</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
</article>
</div>
<p class="empty" id="emptyState">لا توجد مواد تطابق البحث والفلاتر الحالية.</p>
</div>
</section>
</main>
<footer class="pagefoot" data-od-id="footer">
<div class="container footer-row"><span>© أنور بي آي ٢٠٢٦ · تعلّم بوضوح</span><a href="privacy.html">الخصوصية</a></div>
</footer>
<script>
(() => {
const params = new URLSearchParams(location.search);
const allowedTopics = ['all', 'agents', 'models', 'automation', 'foundations'];
const state = { topic: allowedTopics.includes(params.get('topic')) ? params.get('topic') : 'all', access: 'all', query: '', sort: 'latest' };
const buttons = [...document.querySelectorAll('[data-filter-kind]')];
const posts = [...document.querySelectorAll('.post')];
const list = document.getElementById('postList');
const count = document.getElementById('resultCount');
const empty = document.getElementById('emptyState');
const search = document.getElementById('postSearch');
const sort = document.getElementById('postSort');
const formatNumber = value => new Intl.NumberFormat('ar').format(value);
const readMetric = (post, metric) => Number(localStorage.getItem(`anwarpi:${metric}:${post.dataset.id}`) || 0);
function normalize(value) {
return value.toLocaleLowerCase('ar').normalize('NFKD').replace(/[\u064B-\u065F\u0670]/g, '').replace(/[إأآ]/g, 'ا').replace(/ى/g, 'ي');
}
function paintMetrics(post) {
const views = readMetric(post, 'views');
const liked = localStorage.getItem(`anwarpi:liked:${post.dataset.id}`) === '1';
post.querySelector('[data-views]').textContent = `${formatNumber(views)} مشاهدة`;
const like = post.querySelector('[data-like]');
like.setAttribute('aria-pressed', String(liked));
like.querySelector('span').textContent = formatNumber(liked ? 1 : 0);
}
function applyFilters() {
const ordered = [...posts].sort((a, b) => {
if (state.sort === 'views') return readMetric(b, 'views') - readMetric(a, 'views') || Number(b.dataset.order) - Number(a.dataset.order);
if (state.sort === 'likes') return Number(localStorage.getItem(`anwarpi:liked:${b.dataset.id}`) === '1') - Number(localStorage.getItem(`anwarpi:liked:${a.dataset.id}`) === '1') || Number(b.dataset.order) - Number(a.dataset.order);
return Number(b.dataset.order) - Number(a.dataset.order);
});
ordered.forEach(post => list.appendChild(post));
let visible = 0;
posts.forEach(post => {
const topicMatch = state.topic === 'all' || post.dataset.topic === state.topic;
const accessMatch = state.access === 'all' || post.dataset.access === state.access;
const queryMatch = !state.query || normalize(post.textContent).includes(normalize(state.query));
post.hidden = !(topicMatch && accessMatch && queryMatch);
if (!post.hidden) visible += 1;
});
count.textContent = `${formatNumber(visible)} ${visible === 1 ? 'مادة' : 'مواد'}`;
empty.classList.toggle('show', visible === 0);
}
buttons.forEach(button => button.addEventListener('click', () => {
const kind = button.dataset.filterKind;
state[kind] = button.dataset.filterValue;
buttons.filter(item => item.dataset.filterKind === kind).forEach(item => {
item.setAttribute('aria-pressed', String(item === button));
});
applyFilters();
}));
search.addEventListener('input', () => { state.query = search.value.trim(); applyFilters(); });
sort.addEventListener('change', () => { state.sort = sort.value; applyFilters(); });
posts.forEach(post => {
paintMetrics(post);
post.querySelector('[data-like]').addEventListener('click', () => {
const key = `anwarpi:liked:${post.dataset.id}`;
localStorage.setItem(key, localStorage.getItem(key) === '1' ? '0' : '1');
paintMetrics(post);
applyFilters();
});
post.querySelectorAll('a.post-link').forEach(link => link.addEventListener('click', () => {
localStorage.setItem(`anwarpi:views:${post.dataset.id}`, String(readMetric(post, 'views') + 1));
}));
});
buttons.filter(button => button.dataset.filterKind === 'topic').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.filterValue === state.topic)));
applyFilters();
})();
</script>
</body>
</html>
+1 -4
View File
@@ -197,15 +197,12 @@
"article-agent-guide.html": "nav a[href='article-agent-guide.html']", "article-agent-guide.html": "nav a[href='article-agent-guide.html']",
"post-locked.html": "nav a[href='article-agent-guide.html']", "post-locked.html": "nav a[href='article-agent-guide.html']",
"post-unlocked.html": "nav a[href='article-agent-guide.html']", "post-unlocked.html": "nav a[href='article-agent-guide.html']",
"articles.html": "nav a[href='articles.html']",
"subscription.html": "nav a[href='#exclusive-guides']", "subscription.html": "nav a[href='#exclusive-guides']",
"vip-onboarding.html": "nav a[href='subscription.html']", "vip-onboarding.html": "nav a[href='subscription.html']",
"account-settings.html": ".nav-account", "account-settings.html": ".nav-account",
"login.html": ".logo", "login.html": ".logo",
"signup.html": ".logo",
"forgot-password.html": ".logo", "forgot-password.html": ".logo",
"privacy.html": "nav a[href='privacy.html']", "privacy.html": "nav a[href='privacy.html']"
"about.html": "nav a[href='about.html']"
}[page]; }[page];
var active = selector && topnav.querySelector(selector); var active = selector && topnav.querySelector(selector);
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import base64
import hashlib
from pathlib import Path
import random
import re
from PIL import Image, ImageDraw, ImageFilter
from publication_meta import CATEGORIES
WIDTH = 1200
HEIGHT = 630
MAX_BYTES = 5 * 1024 * 1024
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_PALETTES = {
"وكلاء": ((17, 25, 48), (82, 214, 186)),
"نماذج": ((20, 22, 48), (129, 140, 248)),
"استرجاع": ((13, 31, 43), (56, 189, 248)),
"سلامة": ((37, 21, 35), (244, 114, 182)),
"أدوات": ((16, 30, 35), (245, 174, 65)),
"أعمال": ((31, 24, 20), (249, 115, 80)),
}
def _art(slug: str, category: str) -> Image.Image:
seed = int.from_bytes(hashlib.sha256(f"{slug}|{category}".encode()).digest()[:8], "big")
rng = random.Random(seed)
background, accent = _PALETTES[category]
image = Image.new("RGB", (WIDTH, HEIGHT), background)
glow = Image.new("RGBA", image.size, (0, 0, 0, 0))
glow_draw = ImageDraw.Draw(glow, "RGBA")
for _ in range(5):
x = rng.randint(80, WIDTH - 80)
y = rng.randint(60, HEIGHT - 60)
radius = rng.randint(130, 320)
glow_draw.ellipse(
(x - radius, y - radius, x + radius, y + radius),
fill=(*accent, rng.randint(18, 42)),
)
glow = glow.filter(ImageFilter.GaussianBlur(70))
image = Image.alpha_composite(image.convert("RGBA"), glow)
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay, "RGBA")
points = [(rng.randint(110, WIDTH - 110), rng.randint(90, HEIGHT - 90)) for _ in range(12)]
for index, start in enumerate(points):
end = points[(index * 5 + 3) % len(points)]
draw.line((start, end), fill=(*accent, 95), width=3)
for index, (x, y) in enumerate(points):
radius = 8 + (index % 4) * 4
draw.ellipse(
(x - radius, y - radius, x + radius, y + radius),
fill=(*accent, 220),
outline=(235, 241, 247, 180),
width=2,
)
left = 68
top = 54
right = WIDTH - 68
bottom = HEIGHT - 54
draw.rounded_rectangle((left, top, right, bottom), radius=32, outline=(*accent, 120), width=3)
draw.line((left + 35, bottom - 55, right - 35, bottom - 55), fill=(*accent, 155), width=4)
return Image.alpha_composite(image, overlay).convert("RGB")
def generate_cover(slug: str, category: str, output_dir: Path) -> dict:
if not SLUG_RE.fullmatch(slug):
raise ValueError("slug must be lowercase ASCII kebab-case")
if category not in CATEGORIES:
raise ValueError("category is invalid")
output_dir.mkdir(parents=True, exist_ok=True)
output = output_dir / f"{slug}.jpg"
temporary = output.with_suffix(".jpg.tmp")
image = _art(slug, category)
image.save(temporary, format="JPEG", quality=91, optimize=True, progressive=True)
if temporary.stat().st_size > MAX_BYTES:
temporary.unlink(missing_ok=True)
raise ValueError("generated cover exceeds 5MB")
with Image.open(temporary) as check:
if check.size != (WIDTH, HEIGHT) or check.format != "JPEG" or check.mode != "RGB":
temporary.unlink(missing_ok=True)
raise ValueError("generated cover failed validation")
temporary.replace(output)
encoded = base64.b64encode(output.read_bytes()).decode("ascii")
return {
"path": output,
"coverImage": {"filename": output.name, "dataBase64": encoded},
}
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Iterable, Mapping, Optional
from publication_meta import clean, parse_meta
class MarkerError(ValueError):
pass
@dataclass(frozen=True)
class PageMarker:
block_id: str
raw: str
meta: dict[str, str]
def find_marker(blocks: Iterable[Mapping[str, object]]) -> Optional[PageMarker]:
valid: list[PageMarker] = []
near_misses: list[str] = []
for block in blocks:
block_id = str(block.get("id", "unknown"))
raw_value = block.get("text", "")
raw = raw_value if isinstance(raw_value, str) else ""
normalized = clean(raw)
if re.search(r"anwarpy", normalized, re.IGNORECASE) is None:
continue
if not normalized.startswith("anwarpy:"):
near_misses.append(block_id)
continue
try:
meta = parse_meta(normalized)
except ValueError:
near_misses.append(block_id)
continue
valid.append(PageMarker(block_id=block_id, raw=raw, meta=meta))
if len(valid) > 1:
ids = ", ".join(marker.block_id for marker in valid)
raise MarkerError(f"multiple anwarpy markers found in blocks: {ids}")
if valid and near_misses:
ids = ", ".join(near_misses)
raise MarkerError(f"ambiguous anwarpy markers; near-miss blocks: {ids}")
if near_misses:
ids = ", ".join(near_misses)
raise MarkerError(f"near-miss anwarpy marker in blocks: {ids}")
return valid[0] if valid else None
+1
View File
@@ -0,0 +1 @@
anwarpy.ai
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
from datetime import datetime
import re
import unicodedata
from urllib.parse import urlsplit
CATEGORIES = ("وكلاء", "نماذج", "استرجاع", "سلامة", "أدوات", "أعمال")
ACCESSES = ("free", "vip")
STATUSES = ("pending", "publishing", "published", "blocked", "republish")
BLOCKED_REASONS = ("sanitizer", "generation", "article_schema", "cover", "video")
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_INVISIBLE = re.compile(r"[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]")
_NO_BREAK = re.compile(r"[\u00a0\u202f]")
_FIELD_ORDER = (
"status",
"access",
"category",
"queued_at",
"slug",
"published_at",
"url",
"blocked_reason",
)
_REQUIRED = {
"pending": {"status", "access", "category", "queued_at"},
"publishing": {"status", "access", "category", "queued_at", "slug"},
"published": {"status", "access", "category", "queued_at", "slug", "published_at", "url"},
"blocked": {"status", "access", "category", "queued_at", "blocked_reason"},
"republish": {"status", "access", "category", "queued_at", "slug", "published_at", "url"},
}
def clean(line: str) -> str:
line = _INVISIBLE.sub(" ", line)
line = _NO_BREAK.sub(" ", line)
return unicodedata.normalize("NFC", line).strip()
def _aware_datetime(value: str, field: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"{field} must be a valid ISO-8601 datetime") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError(f"{field} must include a timezone offset")
return parsed
def _validate_url(value: str, slug: str) -> None:
parsed = urlsplit(value)
expected_path = f"/posts/{slug}"
if (
parsed.scheme != "https"
or parsed.netloc != "anwarpy.ai"
or parsed.path != expected_path
or parsed.query
or parsed.fragment
):
raise ValueError(f"url must equal https://anwarpy.ai{expected_path}")
def parse_meta(raw: str) -> dict[str, str]:
line = clean(raw)
if not line.startswith("anwarpy:"):
raise ValueError("marker must start with exact prefix anwarpy:")
body = line.removeprefix("anwarpy:").strip()
if not body:
raise ValueError("marker fields are required")
meta: dict[str, str] = {}
for part in body.split("|"):
segment = clean(part)
if "=" not in segment:
raise ValueError(f"invalid marker field: {segment}")
key, value = (clean(item) for item in segment.split("=", 1))
if key not in _FIELD_ORDER:
raise ValueError(f"unknown marker field: {key}")
if key in meta:
raise ValueError(f"duplicate marker field: {key}")
if not value:
raise ValueError(f"empty marker field: {key}")
meta[key] = value
status = meta.get("status")
if status not in STATUSES:
raise ValueError("status is invalid")
required = _REQUIRED[status]
missing = required - meta.keys()
if missing:
raise ValueError(f"missing required fields: {', '.join(sorted(missing))}")
forbidden = meta.keys() - required
if forbidden:
raise ValueError(f"fields not allowed for {status}: {', '.join(sorted(forbidden))}")
if meta["access"] not in ACCESSES:
raise ValueError("access is invalid")
if meta["category"] not in CATEGORIES:
raise ValueError("category is invalid")
_aware_datetime(meta["queued_at"], "queued_at")
slug = meta.get("slug")
if slug is not None and not SLUG_RE.fullmatch(slug):
raise ValueError("slug must be lowercase ASCII kebab-case")
if "published_at" in meta:
_aware_datetime(meta["published_at"], "published_at")
if "url" in meta:
_validate_url(meta["url"], meta["slug"])
if "blocked_reason" in meta and meta["blocked_reason"] not in BLOCKED_REASONS:
raise ValueError("blocked_reason is invalid")
return meta
def render_meta(meta: dict[str, str]) -> str:
validated = parse_meta(
"anwarpy: " + " | ".join(f"{key}={value}" for key, value in meta.items())
)
return "anwarpy: " + " | ".join(
f"{key}={validated[key]}" for key in _FIELD_ORDER if key in validated
)
def same_meta(expected_raw: str, actual_raw: str) -> bool:
return parse_meta(expected_raw) == parse_meta(actual_raw)
def mark_publishing(raw: str, slug: str) -> str:
meta = parse_meta(raw)
if meta["status"] not in ("pending", "republish"):
raise ValueError("only pending or republish markers can be claimed")
normalized_slug = clean(slug)
if meta["status"] == "republish" and normalized_slug != meta["slug"]:
raise ValueError("republish slug cannot change")
meta = {
"status": "publishing",
"access": meta["access"],
"category": meta["category"],
"queued_at": meta["queued_at"],
"slug": normalized_slug,
}
return render_meta(meta)
def mark_published(raw: str, published_at: str, url: str) -> str:
meta = parse_meta(raw)
if meta["status"] not in ("publishing", "republish"):
raise ValueError("only publishing or republish markers can become published")
meta["status"] = "published"
meta["published_at"] = clean(published_at)
meta["url"] = clean(url)
return render_meta(meta)
def mark_blocked(raw: str, reason: str) -> str:
meta = parse_meta(raw)
if meta["status"] not in ("pending", "publishing"):
raise ValueError("only pending or publishing markers can be blocked")
meta = {
"status": "blocked",
"access": meta["access"],
"category": meta["category"],
"queued_at": meta["queued_at"],
"blocked_reason": clean(reason),
}
return render_meta(meta)
+321
View File
@@ -0,0 +1,321 @@
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
import fcntl
import math
from pathlib import Path
import re
from typing import Any, Iterable
from zoneinfo import ZoneInfo
from page_state import MarkerError, PageMarker, find_marker
from publication_meta import (
SLUG_RE,
mark_blocked,
mark_published,
mark_publishing,
same_meta,
)
from sanitize import assert_publishable, sanitize_text
RIYADH = ZoneInfo("Asia/Riyadh")
class AmbiguousWrite(RuntimeError):
"""The remote side effect may have succeeded; reconcile before retrying."""
class PublicVerificationError(RuntimeError):
pass
class DeterministicFailure(RuntimeError):
def __init__(self, reason: str, message: str):
super().__init__(message)
self.reason = reason
@dataclass(frozen=True)
class Candidate:
page: dict[str, Any]
marker: PageMarker
blocks: list[dict[str, Any]]
@contextmanager
def exclusive_run_lock(path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a+") as handle:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
yield False
return
try:
yield True
finally:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _queued_at(candidate: Candidate) -> datetime:
return datetime.fromisoformat(candidate.marker.meta["queued_at"].replace("Z", "+00:00"))
def _lexical(article: dict[str, Any]) -> dict[str, Any]:
children: list[dict[str, Any]] = []
for section in article["sections"]:
children.append({
"type": "heading",
"tag": "h2",
"version": 1,
"children": [{"type": "text", "version": 1, "text": section["heading"]}],
})
for paragraph in section["paragraphs"]:
children.append({
"type": "paragraph",
"version": 1,
"children": [{"type": "text", "version": 1, "text": paragraph}],
})
if section.get("code"):
children.append({
"type": "code",
"version": 1,
"language": "text",
"children": [{"type": "text", "version": 1, "text": section["code"]}],
})
return {
"root": {
"type": "root",
"format": "",
"indent": 0,
"version": 1,
"direction": "rtl",
"children": children,
}
}
def _validate_article(article: Any, access: str, expected_slug: str | None = None) -> dict[str, Any]:
if not isinstance(article, dict):
raise DeterministicFailure("article_schema", "article must be an object")
for field in ("title", "slug", "dek", "sections"):
if field not in article:
raise DeterministicFailure("article_schema", f"article field missing: {field}")
for field in ("title", "slug", "dek"):
if not isinstance(article[field], str) or not article[field].strip():
raise DeterministicFailure("article_schema", f"article field invalid: {field}")
if not SLUG_RE.fullmatch(article["slug"]):
raise DeterministicFailure("article_schema", "article slug must be lowercase ASCII kebab-case")
if expected_slug is not None and article["slug"] != expected_slug:
raise DeterministicFailure("article_schema", "republish slug cannot change")
if access == "vip" and (not isinstance(article.get("teaser"), str) or not article["teaser"].strip()):
raise DeterministicFailure("article_schema", "VIP teaser is required")
sections = article["sections"]
if not isinstance(sections, list) or not 3 <= len(sections) <= 6:
raise DeterministicFailure("article_schema", "article must contain 3 to 6 sections")
for section in sections:
if not isinstance(section, dict):
raise DeterministicFailure("article_schema", "section must be an object")
if not isinstance(section.get("heading"), str) or not section["heading"].strip():
raise DeterministicFailure("article_schema", "section heading is required")
paragraphs = section.get("paragraphs")
if not isinstance(paragraphs, list) or not paragraphs or not all(
isinstance(value, str) and value.strip() for value in paragraphs
):
raise DeterministicFailure("article_schema", "section paragraphs are invalid")
return article
def _riyadh_iso(value: str) -> str:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError("publishedAt must include a timezone")
return parsed.astimezone(RIYADH).isoformat()
class Publisher:
def __init__(self, *, appflowy, api, generator, media, lock_path: Path, allowed_hosts: Iterable[str] = ("anwarpy.ai",)):
self.appflowy = appflowy
self.api = api
self.generator = generator
self.media = media
self.lock_path = lock_path
self.allowed_hosts = set(allowed_hosts)
self.marker_errors: list[str] = []
def run(self) -> str:
with exclusive_run_lock(self.lock_path) as acquired:
if not acquired:
return "busy"
candidates = self._candidates()
publishing = sorted(
(candidate for candidate in candidates if candidate.marker.meta["status"] == "publishing"),
key=lambda candidate: (_queued_at(candidate), candidate.page["view_id"]),
)
if publishing:
return self._reconcile(publishing[0])
eligible = sorted(
(
candidate
for candidate in candidates
if candidate.marker.meta["status"] in ("pending", "republish")
),
key=lambda candidate: (_queued_at(candidate), candidate.page["view_id"]),
)
if not eligible:
return "idle"
return self._publish(eligible[0])
def _candidates(self) -> list[Candidate]:
found: list[Candidate] = []
self.marker_errors = []
for page in self.appflowy.list_pages():
page_id = page["view_id"]
blocks = self.appflowy.get_blocks(page_id)
try:
marker = find_marker(blocks)
except MarkerError as error:
self.marker_errors.append(f"{page_id}: {error}")
continue
if marker is not None:
found.append(Candidate(page=page, marker=marker, blocks=blocks))
return found
def _read_marker(self, candidate: Candidate) -> PageMarker:
marker = find_marker(self.appflowy.get_blocks(candidate.page["view_id"]))
if marker is None:
raise ValueError("publication marker disappeared")
return marker
def _edit_and_verify(self, candidate: Candidate, marker_text: str) -> PageMarker:
self.appflowy.edit_block(candidate.page["view_id"], candidate.marker.block_id, marker_text)
readback = self._read_marker(candidate)
if not same_meta(marker_text, readback.raw):
raise ValueError("AppFlowy marker read-back mismatch")
return readback
def _evidence(self, candidate: Candidate) -> str:
texts = [
str(block.get("text") or "")
for block in candidate.blocks
if block.get("id") != candidate.marker.block_id
]
sanitized = sanitize_text("\n\n".join(texts), allowed_hosts=self.allowed_hosts)
assert_publishable(sanitized, allowed_hosts=self.allowed_hosts)
if not sanitized.strip():
raise DeterministicFailure("sanitizer", "source is empty after sanitization")
return sanitized
def _block(self, candidate: Candidate, reason: str) -> str:
current = self._read_marker(candidate)
blocked = mark_blocked(current.raw, reason)
self._edit_and_verify(Candidate(candidate.page, current, candidate.blocks), blocked)
return f"blocked:{reason}"
def _publish(self, candidate: Candidate) -> str:
original = candidate.marker
meta = original.meta
try:
evidence = self._evidence(candidate)
article = _validate_article(
self.generator.generate(candidate.page["name"], evidence, meta),
meta["access"],
meta.get("slug") if meta["status"] == "republish" else None,
)
media = self.media.render(article, meta)
if not isinstance(media, dict) or "coverImage" not in media:
raise DeterministicFailure("cover", "cover output is incomplete")
except DeterministicFailure as error:
return self._block(candidate, error.reason)
except ValueError:
return self._block(candidate, "sanitizer")
current = self._read_marker(candidate)
if not same_meta(original.raw, current.raw):
raise ValueError("AppFlowy marker changed during generation")
claimed_text = mark_publishing(current.raw, article["slug"])
claimed = self._edit_and_verify(Candidate(candidate.page, current, candidate.blocks), claimed_text)
claimed_candidate = Candidate(candidate.page, claimed, candidate.blocks)
body = self._post_body(article, meta, media["coverImage"])
existing = self.api.lookup(article["slug"])
record = None
if meta["status"] == "republish":
if existing is None:
raise AmbiguousWrite("republish target is missing; POST is forbidden")
record = self.api.update(article["slug"], body)
elif existing is None:
try:
record = self.api.create(body)
except AmbiguousWrite:
record = self.api.lookup(article["slug"])
if record is None:
raise
elif existing.get("status") == "draft":
record = self.api.update(article["slug"], {"publish": True})
else:
record = existing
return self._finish(claimed_candidate, record)
def _reconcile(self, candidate: Candidate) -> str:
slug = candidate.marker.meta["slug"]
record = self.api.lookup(slug)
if record is None:
return f"awaiting-reconciliation:{slug}"
if record.get("status") == "draft":
record = self.api.update(slug, {"publish": True})
result = self._finish(candidate, record)
return result
def _finish(self, candidate: Candidate, record: Any) -> str:
if not isinstance(record, dict):
raise ValueError("agent API returned an invalid record")
expected = candidate.marker.meta
slug = expected["slug"]
for field in ("slug", "access", "category"):
if record.get(field) != expected[field]:
raise ValueError(f"agent API {field} mismatch")
if record.get("status") != "published":
raise ValueError("agent API post is not published")
published_at = record.get("publishedAt")
if not isinstance(published_at, str):
raise ValueError("agent API publishedAt is missing")
url = record.get("url") or f"https://anwarpy.ai/posts/{slug}"
if not self.api.verify_public(record):
raise PublicVerificationError(f"public verification failed for {slug}")
published = mark_published(candidate.marker.raw, _riyadh_iso(published_at), url)
self._edit_and_verify(candidate, published)
return f"published:{slug}"
@staticmethod
def _post_body(article: dict[str, Any], meta: dict[str, str], cover_image: dict[str, Any]) -> dict[str, Any]:
words = re.findall(r"\S+", " ".join(
paragraph
for section in article["sections"]
for paragraph in section["paragraphs"]
))
body: dict[str, Any] = {
"title": article["title"],
"slug": article["slug"],
"dek": article["dek"],
"content": _lexical(article),
"category": meta["category"],
"access": meta["access"],
"readingMinutes": max(1, math.ceil(len(words) / 200)),
"publish": True,
"coverImage": cover_image,
}
if meta["access"] == "vip":
body["teaser"] = article["teaser"]
if body["category"] != meta["category"]:
raise ValueError("category changed")
if body["access"] != meta["access"]:
raise ValueError("access changed")
return body
+336
View File
@@ -0,0 +1,336 @@
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import time
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
from cover import generate_cover
from publish_one import AmbiguousWrite, DeterministicFailure, Publisher, _validate_article
WORKSPACE_ID = "6082aea5-a29e-410f-97be-3cd55fbc2a3a"
ROOT_VIEW_ID = "3a743541-abbf-495a-a18a-74f405fa5d70"
def _json_from_text(text: str) -> Any:
value: Any = json.loads(text)
while isinstance(value, dict) and isinstance(value.get("result"), str):
value = json.loads(value["result"])
return value
class AppFlowyMcp:
"""Root-scoped AppFlowy access through Hermes's configured MCP endpoint."""
def __init__(self, config_path: Path | None = None):
self.config_path = config_path or Path.home() / ".hermes/config.yaml"
self.allowed_page_ids: set[str] = set()
async def _call_async(self, tool: str, arguments: dict[str, Any]) -> Any:
import httpx
import yaml
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
config = yaml.safe_load(self.config_path.read_text())["mcp_servers"]["appflowy"]
timeout = httpx.Timeout(30, read=120)
async with httpx.AsyncClient(
headers=config.get("headers", {}),
follow_redirects=True,
timeout=timeout,
) as http:
async with streamable_http_client(config["url"], http_client=http) as streams: # type: ignore[arg-type]
async with ClientSession(streams[0], streams[1]) as session:
await session.initialize()
result = await session.call_tool(tool, arguments)
if getattr(result, "is_error", False):
raise RuntimeError(f"AppFlowy MCP call failed: {tool}")
if not result.content or not hasattr(result.content[0], "text"):
raise RuntimeError(f"AppFlowy MCP returned no JSON: {tool}")
return _json_from_text(getattr(result.content[0], "text"))
def _call(self, tool: str, arguments: dict[str, Any]) -> Any:
# ponytail: one session per call; keep one open only if scheduled scans become measurably slow.
return asyncio.run(self._call_async(tool, arguments))
@staticmethod
def _flatten(children: list[dict[str, Any]]) -> list[dict[str, Any]]:
pages: list[dict[str, Any]] = []
for child in children:
pages.append(child)
pages.extend(AppFlowyMcp._flatten(child.get("children") or []))
return pages
def list_pages(self) -> list[dict[str, Any]]:
response = self._call(
"get_workspace_folder",
{"workspace_id": WORKSPACE_ID, "root_view_id": ROOT_VIEW_ID, "depth": 10},
)
root = response.get("data", response)
if root.get("view_id") != ROOT_VIEW_ID:
raise RuntimeError("AppFlowy returned the wrong source root")
pages = self._flatten(root.get("children") or [])
self.allowed_page_ids = {page["view_id"] for page in pages}
return pages
def _require_allowed(self, page_id: str) -> None:
if page_id not in self.allowed_page_ids:
raise PermissionError("page is outside the configured Ai research subtree")
def get_blocks(self, page_id: str) -> list[dict[str, Any]]:
self._require_allowed(page_id)
response = self._call(
"get_page_blocks",
{"workspace_id": WORKSPACE_ID, "page_id": page_id},
)
return response.get("blocks") or response.get("data", {}).get("blocks") or []
def edit_block(self, page_id: str, block_id: str, text: str) -> None:
self._require_allowed(page_id)
self._call(
"edit_block_text",
{
"workspace_id": WORKSPACE_ID,
"page_id": page_id,
"block_id": block_id,
"text": text,
},
)
class AgentApi:
def __init__(self, key: str, base_url: str = "https://anwarpy.ai"):
if not key:
raise ValueError("ANWARPY_AGENT_KEY is required")
self.key = key
self.base_url = base_url.rstrip("/")
self.body_probes: dict[str, str] = {}
def _request(self, method: str, path: str, body: dict[str, Any] | None = None, *, anonymous: bool = False):
data = None if body is None else json.dumps(body, ensure_ascii=False).encode()
headers = {"Accept": "application/json", "User-Agent": "anwarpy-publisher/1"}
if body is not None:
headers["Content-Type"] = "application/json"
if not anonymous:
headers["x-agent-key"] = self.key
request = Request(f"{self.base_url}{path}", data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=30) as response:
raw = response.read()
return response.status, raw, response.headers.get("content-type", "")
except HTTPError as error:
return error.code, error.read(), error.headers.get("content-type", "")
except (TimeoutError, URLError) as error:
if method in ("POST", "PATCH"):
raise AmbiguousWrite(f"agent API {method} outcome is unknown") from error
raise
@staticmethod
def _decode(status: int, raw: bytes) -> dict[str, Any]:
try:
value = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise RuntimeError(f"agent API returned invalid JSON ({status})") from error
if not isinstance(value, dict):
raise RuntimeError(f"agent API returned invalid object ({status})")
return value
@staticmethod
def _probe(body: dict[str, Any]) -> str | None:
children = body.get("content", {}).get("root", {}).get("children", [])
for child in children:
for text in child.get("children", []):
value = text.get("text")
if isinstance(value, str) and len(value.strip()) >= 20:
return value.strip()
return None
def lookup(self, slug: str) -> dict[str, Any] | None:
status, raw, _ = self._request("GET", f"/api/agent/posts/{quote(slug)}")
if status == 404:
return None
if status != 200:
raise RuntimeError(f"agent API lookup failed ({status})")
return self._decode(status, raw)
def create(self, body: dict[str, Any]) -> dict[str, Any]:
probe = self._probe(body)
if probe:
self.body_probes[body["slug"]] = probe
status, raw, _ = self._request("POST", "/api/agent/posts", body)
if status == 409:
existing = self.lookup(body["slug"])
if existing is None:
raise RuntimeError("agent API returned 409 without an existing slug")
return existing
if status != 201:
raise RuntimeError(f"agent API create failed ({status})")
return self._decode(status, raw)
def update(self, slug: str, body: dict[str, Any]) -> dict[str, Any]:
probe = self._probe(body)
if probe:
self.body_probes[slug] = probe
status, raw, _ = self._request("PATCH", f"/api/agent/posts/{quote(slug)}", body)
if status != 200:
raise RuntimeError(f"agent API update failed ({status})")
return self._decode(status, raw)
def _public_get(self, url: str, *, rsc: bool = False) -> tuple[int, bytes]:
headers = {"Accept": "text/x-component" if rsc else "text/html", "User-Agent": "anwarpy-publisher/1"}
if rsc:
headers["RSC"] = "1"
request = Request(url, headers=headers)
try:
with urlopen(request, timeout=20) as response:
return response.status, response.read()
except HTTPError as error:
return error.code, error.read()
def verify_public(self, record: dict[str, Any]) -> bool:
slug = record["slug"]
canonical = f"https://anwarpy.ai/posts/{slug}"
if record.get("url", canonical) != canonical:
return False
probe = self.body_probes.get(slug, "").encode()
for attempt in range(5):
html_status, html = self._public_get(canonical)
rsc_status, rsc = self._public_get(f"{canonical}?__rsc=publisher", rsc=True)
rest_status, rest, _ = self._request(
"GET",
f"/api/posts?where%5Bslug%5D%5Bequals%5D={quote(slug)}&depth=0",
anonymous=True,
)
status_ok = html_status == 200 and rsc_status in (200, 204) and rest_status in (200, 401, 403)
vip_leak = record.get("access") == "vip" and probe and any(probe in value for value in (html, rsc, rest))
if status_ok and not vip_leak:
return True
if attempt < 4:
time.sleep(2)
return False
class LocalArticleGenerator:
def __init__(self, endpoint: str = "http://100.64.0.5:8080/v1/chat/completions", model: str | None = None):
self.endpoint = endpoint
self.model = model
def _model_id(self) -> str:
if self.model:
return self.model
models_url = self.endpoint.rsplit("/chat/completions", 1)[0] + "/models"
try:
with urlopen(models_url, timeout=10) as response:
data = json.loads(response.read())
model = data["data"][0]["id"]
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as error:
raise DeterministicFailure("generation", "local model discovery failed") from error
if not isinstance(model, str) or not model:
raise DeterministicFailure("generation", "local model discovery returned no model")
self.model = model
return model
def _request(self, messages: list[dict[str, str]]) -> str:
payload = {
"model": self._model_id(),
"messages": messages,
"temperature": 0.35,
"max_tokens": 7000,
"response_format": {"type": "json_object"},
}
request = Request(
self.endpoint,
data=json.dumps(payload, ensure_ascii=False).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=240) as response:
data = json.loads(response.read())
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error:
raise DeterministicFailure("generation", "local article model failed") from error
return data["choices"][0]["message"]["content"]
@staticmethod
def _parse(raw: str, access: str, expected_slug: str | None) -> dict[str, Any]:
try:
article = json.loads(raw)
except json.JSONDecodeError as error:
raise DeterministicFailure("article_schema", "model returned invalid JSON") from error
article = _validate_article(article, access, expected_slug)
visible_words = sum(
len(paragraph.split())
for section in article["sections"]
for paragraph in section["paragraphs"]
)
if not 900 <= visible_words <= 2500:
raise DeterministicFailure("article_schema", "article must contain 900 to 2500 visible words")
return article
def generate(self, title: str, evidence: str, meta: dict[str, str]) -> dict[str, Any]:
expected_slug = meta.get("slug") if meta["status"] == "republish" else None
prompt = f"""اكتب دليلاً عربياً أصلياً لموقع anwarpy.ai اعتماداً حصراً على الأدلة أدناه.
العنوان المصدر: {title}
نوع الوصول الذي حدده المالك: {meta['access']}
التصنيف الذي حدده المالك: {meta['category']}
أعد JSON فقط بهذه الحقول: title, slug, dek, teaser, sections.
slug إنجليزي ASCII بصيغة kebab-case، ولا تغيّره عند إعادة النشر: {expected_slug or 'اختر وصفاً دقيقاً'}.
sections مصفوفة من 3 إلى 6 عناصر؛ كل عنصر يحتوي heading وparagraphs وcode.
النص المرئي بين 900 و2500 كلمة. رتّب الدليل: المشكلة، المتطلبات، التنفيذ، التحقق، القيود.
لا تختر أو تغيّر access أو category. لا تخترع إصدارات أو أرقاماً أو نتائج أو اقتباسات.
مقال VIP يحتاج teaser عربي مفيداً من فقرتين، والمقال المجاني يستخدم teaser فارغاً.
الأدلة المنقحة:
{evidence}
"""
messages = [
{"role": "system", "content": "أنت محرر تقني عربي دقيق. أعد JSON صالحاً فقط."},
{"role": "user", "content": prompt},
]
raw = self._request(messages)
try:
return self._parse(raw, meta["access"], expected_slug)
except DeterministicFailure as first_error:
corrected = self._request(messages + [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"صحح JSON فقط. خطأ التحقق: {first_error}"},
])
return self._parse(corrected, meta["access"], expected_slug)
class CoverMedia:
def __init__(self, output_dir: Path | None = None):
self.output_dir = output_dir or Path.home() / ".hermes/cache/scratch/anwarpy-covers"
def render(self, article: dict[str, Any], meta: dict[str, str]) -> dict[str, Any]:
try:
return generate_cover(article["slug"], meta["category"], self.output_dir)
except (OSError, ValueError) as error:
raise DeterministicFailure("cover", "cover generation failed") from error
def main() -> int:
key = os.environ.get("ANWARPY_AGENT_KEY", "")
publisher = Publisher(
appflowy=AppFlowyMcp(),
api=AgentApi(key),
generator=LocalArticleGenerator(),
media=CoverMedia(),
lock_path=Path.home() / ".hermes/locks/anwarpy-publish.lock",
)
result = publisher.run()
print(result)
for error in publisher.marker_errors:
print(f"marker-error: {error}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import ipaddress
import re
from typing import Iterable
from urllib.parse import urlsplit
REDACTION = "[REDACTED]"
_URL_RE = re.compile(r"https?://[^\s<>\]\[\)\(\"']+", re.IGNORECASE)
_EMAIL_RE = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
_IPV4_RE = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")
_IPV6_CANDIDATE_RE = re.compile(r"(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{0,4}:){2,}[0-9A-Fa-f]{0,4}(?![0-9A-Fa-f:])")
_PHONE_RE = re.compile(r"(?<!\w)\+?\d(?:[\s().-]*\d){7,14}(?!\w)")
_PATH_RE = re.compile(r"(?:/(?:Users|home|root|opt|etc|var)/[^\s,;]+|[A-Za-z]:\\Users\\[^\s,;]+)")
_CREDENTIAL_RE = re.compile(
r"(?i)\b(?:password|passwd|secret|token|api[_-]?key|authorization)\s*[:=]\s*[^\s,;]+"
)
_PRIVATE_HOST_RE = re.compile(r"\b(?:[A-Z0-9-]+\.)+(?:local|internal|lan|home)\b", re.IGNORECASE)
_FQDN_RE = re.compile(r"\b(?:[A-Z0-9-]+\.)+[A-Z]{2,63}\b", re.IGNORECASE)
def _normalized_hosts(hosts: Iterable[str]) -> set[str]:
return {host.strip().lower().rstrip(".") for host in hosts if host.strip()}
def _redact_url(match: re.Match[str], allowed_hosts: set[str]) -> str:
value = match.group(0)
host = (urlsplit(value).hostname or "").lower().rstrip(".")
return value if host in allowed_hosts else REDACTION
def _redact_ipv4(match: re.Match[str]) -> str:
value = match.group(0)
try:
ipaddress.IPv4Address(value)
except ipaddress.AddressValueError:
return value
return REDACTION
def _redact_ipv6(match: re.Match[str]) -> str:
value = match.group(0)
try:
ipaddress.IPv6Address(value)
except ipaddress.AddressValueError:
return value
return REDACTION
def _redact_fqdn(match: re.Match[str], allowed_hosts: set[str]) -> str:
host = match.group(0)
return host if host.lower().rstrip(".") in allowed_hosts else REDACTION
def sanitize_text(text: str, *, allowed_hosts: Iterable[str]) -> str:
hosts = _normalized_hosts(allowed_hosts)
cleaned = _URL_RE.sub(lambda match: _redact_url(match, hosts), text)
cleaned = _CREDENTIAL_RE.sub(REDACTION, cleaned)
cleaned = _EMAIL_RE.sub(REDACTION, cleaned)
cleaned = _PATH_RE.sub(REDACTION, cleaned)
cleaned = _IPV4_RE.sub(_redact_ipv4, cleaned)
cleaned = _IPV6_CANDIDATE_RE.sub(_redact_ipv6, cleaned)
cleaned = _PHONE_RE.sub(REDACTION, cleaned)
cleaned = _PRIVATE_HOST_RE.sub(REDACTION, cleaned)
cleaned = _FQDN_RE.sub(lambda match: _redact_fqdn(match, hosts), cleaned)
return cleaned
def assert_publishable(text: str, *, allowed_hosts: Iterable[str]) -> None:
sanitized = sanitize_text(text, allowed_hosts=allowed_hosts)
if sanitized != text:
raise ValueError("content still contains private or unapproved data")
+36
View File
@@ -0,0 +1,36 @@
import hashlib
import tempfile
import unittest
from pathlib import Path
from PIL import Image
from cover import generate_cover
class CoverTests(unittest.TestCase):
def test_generates_deterministic_1200x630_jpeg_under_5mb(self):
output_dir = Path(tempfile.mkdtemp())
first = generate_cover("arabic-ai-guide", "أدوات", output_dir)
first_hash = hashlib.sha256(first["path"].read_bytes()).hexdigest()
second = generate_cover("arabic-ai-guide", "أدوات", output_dir)
second_hash = hashlib.sha256(second["path"].read_bytes()).hexdigest()
self.assertEqual(first_hash, second_hash)
self.assertEqual(first["coverImage"]["filename"], "arabic-ai-guide.jpg")
self.assertLessEqual(first["path"].stat().st_size, 5 * 1024 * 1024)
with Image.open(first["path"]) as image:
self.assertEqual(image.size, (1200, 630))
self.assertEqual(image.format, "JPEG")
self.assertEqual(image.mode, "RGB")
def test_rejects_invalid_slug_or_category(self):
output_dir = Path(tempfile.mkdtemp())
with self.assertRaises(ValueError):
generate_cover("عنوان عربي", "أدوات", output_dir)
with self.assertRaises(ValueError):
generate_cover("valid-slug", "عام", output_dir)
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
import unittest
from page_state import MarkerError, find_marker
VALID = "anwarpy: status=pending | access=free | category=نماذج | queued_at=2026-10-09T15:12:00+03:00"
class PageStateTests(unittest.TestCase):
def test_finds_one_exact_marker_after_unrelated_blocks(self):
result = find_marker([
{"id": "a", "text": "ملاحظات"},
{"id": "b", "text": VALID},
{"id": "c", "text": "مصدر عام"},
])
self.assertEqual(result.block_id, "b")
self.assertEqual(result.meta["status"], "pending")
def test_returns_none_when_anwarpy_is_never_mentioned(self):
self.assertIsNone(find_marker([{"id": "a", "text": "لا توجد علامة"}]))
def test_reports_every_near_miss(self):
for text in (
VALID.replace("anwarpy:", "Anwarpy:"),
"prefix " + VALID,
"documentation mentions anwarpy but has no marker",
VALID.replace("anwarpy:", "anwarpy ="),
):
with self.subTest(text=text):
with self.assertRaisesRegex(MarkerError, "near-miss"):
find_marker([{"id": "bad", "text": text}])
def test_scans_all_blocks_and_rejects_ambiguity(self):
with self.assertRaisesRegex(MarkerError, "multiple"):
find_marker([
{"id": "first", "text": VALID},
{"id": "middle", "text": "unrelated"},
{"id": "last", "text": VALID.replace("category=نماذج", "category=أدوات")},
])
def test_exact_plus_near_miss_is_ambiguous(self):
with self.assertRaisesRegex(MarkerError, "ambiguous"):
find_marker([
{"id": "good", "text": VALID},
{"id": "bad", "text": "Anwarpy: pending"},
])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,83 @@
import unittest
from publication_meta import (
clean,
mark_blocked,
mark_published,
mark_publishing,
parse_meta,
same_meta,
)
PENDING = "anwarpy: status=pending | access=vip | category=أدوات | queued_at=2026-10-09T15:12:00+03:00"
class PublicationMetaTests(unittest.TestCase):
def test_parses_pending_and_normalizes_formatting_controls(self):
raw = "\ufeffanwarpy:\u00a0status=pending | access=vip | category=أدوات | queued_at=2026-10-09T15:12:00+03:00\u200f"
meta = parse_meta(raw)
self.assertEqual(meta["status"], "pending")
self.assertEqual(meta["category"], "أدوات")
def test_rejects_invalid_values_and_dates(self):
for raw in (
PENDING.replace("access=vip", "access=paid"),
PENDING.replace("category=أدوات", "category=عام"),
PENDING.replace("2026-10-09", "2026-13-45"),
PENDING.replace("+03:00", ""),
):
with self.subTest(raw=raw):
with self.assertRaises(ValueError):
parse_meta(raw)
def test_rejects_invalid_state_shapes(self):
with self.assertRaisesRegex(ValueError, "slug"):
parse_meta(PENDING.replace("status=pending", "status=publishing"))
with self.assertRaisesRegex(ValueError, "blocked_reason"):
parse_meta(PENDING.replace("status=pending", "status=blocked"))
with self.assertRaisesRegex(ValueError, "not allowed"):
parse_meta(PENDING + " | url=https://anwarpy.ai/posts/example")
def test_transitions_reparse_raw_input(self):
publishing = mark_publishing(PENDING, "arabic-ai-guide")
self.assertEqual(parse_meta(publishing)["status"], "publishing")
published = mark_published(
publishing,
"2026-10-09T12:20:11+00:00",
"https://anwarpy.ai/posts/arabic-ai-guide",
)
self.assertEqual(parse_meta(published)["status"], "published")
def test_blocked_is_terminal_and_carries_a_bounded_reason(self):
blocked = mark_blocked(PENDING, "cover")
self.assertEqual(parse_meta(blocked)["blocked_reason"], "cover")
publishing = mark_publishing(PENDING, "arabic-ai-guide")
blocked_after_claim = parse_meta(mark_blocked(publishing, "generation"))
self.assertEqual(blocked_after_claim["status"], "blocked")
self.assertNotIn("slug", blocked_after_claim)
with self.assertRaises(ValueError):
mark_blocked(PENDING, "network")
def test_canonical_url_and_slug_are_exact(self):
publishing = mark_publishing(PENDING, "arabic-ai-guide")
for url in (
"http://anwarpy.ai/posts/arabic-ai-guide",
"https://www.anwarpy.ai/posts/arabic-ai-guide",
"https://anwarpy.ai/posts/arabic-ai-guide/",
"https://anwarpy.ai/posts/other",
):
with self.subTest(url=url):
with self.assertRaises(ValueError):
mark_published(publishing, "2026-10-09T15:20:11+03:00", url)
with self.assertRaises(ValueError):
mark_publishing(PENDING, "عنوان-عربي")
def test_readback_comparison_is_semantic_after_cleaning(self):
formatted = "\u200fanwarpy: status=pending|access=vip|category=أدوات|queued_at=2026-10-09T15:12:00+03:00"
self.assertTrue(same_meta(PENDING, formatted))
self.assertEqual(clean("\ufeff أداة\u00a0"), "أداة")
if __name__ == "__main__":
unittest.main()
+269
View File
@@ -0,0 +1,269 @@
import fcntl
import tempfile
import unittest
from pathlib import Path
from page_state import find_marker
from publish_one import (
AmbiguousWrite,
DeterministicFailure,
PublicVerificationError,
Publisher,
)
WORKSPACE = "workspace"
PENDING_OLD = "anwarpy: status=pending | access=free | category=نماذج | queued_at=2026-10-08T09:00:00+03:00"
PENDING_NEW = "anwarpy: status=pending | access=vip | category=أدوات | queued_at=2026-10-09T09:00:00+03:00"
BLOCKED_OLDER = "anwarpy: status=blocked | access=free | category=نماذج | queued_at=2026-10-07T09:00:00+03:00 | blocked_reason=cover"
REPUBLISH = "anwarpy: status=republish | access=vip | category=أدوات | queued_at=2026-10-10T09:00:00+03:00 | slug=existing-guide | published_at=2026-10-09T12:00:00+03:00 | url=https://anwarpy.ai/posts/existing-guide"
def blocks(marker, evidence="Public source text"):
return [
{"id": "body", "type": "paragraph", "text": evidence, "depth": 0},
{"id": "marker", "type": "paragraph", "text": marker, "depth": 0},
]
class FakeAppFlowy:
def __init__(self, page_markers):
self.pages = [
{"view_id": page_id, "name": title}
for page_id, (title, _marker) in page_markers.items()
]
self.block_map = {
page_id: blocks(marker) for page_id, (_title, marker) in page_markers.items()
}
self.list_calls = 0
self.edits = []
def list_pages(self):
self.list_calls += 1
return self.pages
def get_blocks(self, page_id):
return [dict(block) for block in self.block_map[page_id]]
def edit_block(self, page_id, block_id, text):
self.edits.append((page_id, block_id, text))
for block in self.block_map[page_id]:
if block["id"] == block_id:
block["text"] = text
return
raise KeyError(block_id)
def marker(self, page_id):
return find_marker(self.get_blocks(page_id)).meta
class FakeGenerator:
def __init__(self, slugs=None):
self.slugs = slugs or {}
self.calls = []
def generate(self, title, evidence, meta):
self.calls.append((title, evidence, dict(meta)))
slug = self.slugs.get(title, "generated-guide")
return {
"title": f"دليل {title}",
"slug": slug,
"dek": "شرح عربي عملي ومباشر",
"teaser": "مقدمة مجانية مفيدة" if meta["access"] == "vip" else "",
"sections": [
{"heading": "المشكلة", "paragraphs": ["شرح واضح للمشكلة والحل."], "code": None},
{"heading": "التحقق", "paragraphs": ["تحقق من النتيجة بخطوة قابلة للقياس."], "code": None},
{"heading": "القيود", "paragraphs": ["راجع القيود قبل الاستخدام."], "code": None},
],
}
class FakeMedia:
def __init__(self):
self.calls = []
def render(self, article, meta):
self.calls.append((article["slug"], dict(meta)))
return {
"coverImage": {"filename": f"{article['slug']}.jpg", "dataBase64": "/9j/2Q=="},
"video_path": f"/tmp/{article['slug']}.mp4",
}
class FakeApi:
def __init__(self, records=None, timeout_after_create=False, public_ok=True):
self.records = records or {}
self.timeout_after_create = timeout_after_create
self.public_ok = public_ok
self.create_calls = []
self.update_calls = []
self.verify_calls = []
def lookup(self, slug):
record = self.records.get(slug)
return dict(record) if record else None
def create(self, body):
self.create_calls.append(dict(body))
record = {
"id": len(self.records) + 1,
"title": body["title"],
"slug": body["slug"],
"status": "published" if body["publish"] else "draft",
"access": body["access"],
"category": body["category"],
"publishedAt": "2026-10-09T12:20:11+00:00",
"url": f"https://anwarpy.ai/posts/{body['slug']}",
}
self.records[body["slug"]] = record
if self.timeout_after_create:
self.timeout_after_create = False
raise AmbiguousWrite("timeout after create")
return dict(record)
def update(self, slug, body):
self.update_calls.append((slug, dict(body)))
old = self.records[slug]
old.update({
"title": body.get("title", old.get("title")),
"status": "published" if body.get("publish") else old["status"],
"access": body.get("access", old["access"]),
"category": body.get("category", old["category"]),
"publishedAt": old.get("publishedAt") or "2026-10-09T12:20:11+00:00",
"url": f"https://anwarpy.ai/posts/{slug}",
})
return dict(old)
def verify_public(self, record):
self.verify_calls.append(dict(record))
return self.public_ok
class PublisherTests(unittest.TestCase):
def make_publisher(self, app, api, generator=None, media=None, lock_path=None):
return Publisher(
appflowy=app,
api=api,
generator=generator or FakeGenerator(),
media=media or FakeMedia(),
lock_path=lock_path or Path(tempfile.mkdtemp()) / "publisher.lock",
)
def test_lock_loser_makes_no_external_calls(self):
lock_path = Path(tempfile.mkdtemp()) / "publisher.lock"
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
api = FakeApi()
with lock_path.open("a+") as handle:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
result = self.make_publisher(app, api, lock_path=lock_path).run()
self.assertEqual(result, "busy")
self.assertEqual(app.list_calls, 0)
def test_fifo_uses_queued_at_and_skips_blocked(self):
app = FakeAppFlowy({
"blocked": ("Blocked", BLOCKED_OLDER),
"new": ("New", PENDING_NEW),
"old": ("Old", PENDING_OLD),
})
generator = FakeGenerator({"Old": "old-guide", "New": "new-guide"})
result = self.make_publisher(app, FakeApi(), generator=generator).run()
self.assertEqual(result, "published:old-guide")
self.assertEqual(generator.calls[0][0], "Old")
self.assertEqual(app.marker("blocked")["status"], "blocked")
self.assertEqual(app.marker("new")["status"], "pending")
def test_pending_absent_slug_posts_once_and_rerun_is_idle(self):
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
api = FakeApi()
publisher = self.make_publisher(app, api)
self.assertEqual(publisher.run(), "published:generated-guide")
self.assertEqual(len(api.create_calls), 1)
self.assertEqual(app.marker("page")["status"], "published")
self.assertEqual(publisher.run(), "idle")
self.assertEqual(len(api.create_calls), 1)
def test_existing_draft_is_promoted_without_post(self):
record = {
"id": 1, "title": "Draft", "slug": "generated-guide", "status": "draft",
"access": "free", "category": "نماذج", "publishedAt": None,
}
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
api = FakeApi({"generated-guide": record})
self.assertEqual(self.make_publisher(app, api).run(), "published:generated-guide")
self.assertEqual(api.create_calls, [])
self.assertEqual(len(api.update_calls), 1)
def test_republish_updates_existing_slug_and_never_posts(self):
record = {
"id": 7, "title": "Old", "slug": "existing-guide", "status": "published",
"access": "vip", "category": "أدوات", "publishedAt": "2026-10-09T09:00:00Z",
"url": "https://anwarpy.ai/posts/existing-guide",
}
app = FakeAppFlowy({"page": ("Topic", REPUBLISH)})
api = FakeApi({"existing-guide": record})
generator = FakeGenerator({"Topic": "existing-guide"})
self.assertEqual(self.make_publisher(app, api, generator=generator).run(), "published:existing-guide")
self.assertEqual(api.create_calls, [])
self.assertEqual(len(api.update_calls), 1)
def test_ambiguous_create_reconciles_without_second_post(self):
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
api = FakeApi(timeout_after_create=True)
self.assertEqual(self.make_publisher(app, api).run(), "published:generated-guide")
self.assertEqual(len(api.create_calls), 1)
def test_public_failure_leaves_publishing_and_does_not_repost(self):
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
api = FakeApi(public_ok=False)
publisher = self.make_publisher(app, api)
with self.assertRaises(PublicVerificationError):
publisher.run()
self.assertEqual(app.marker("page")["status"], "publishing")
self.assertEqual(len(api.create_calls), 1)
with self.assertRaises(PublicVerificationError):
publisher.run()
self.assertEqual(len(api.create_calls), 1)
def test_api_metadata_mismatch_leaves_publishing(self):
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
api = FakeApi()
original_create = api.create
def mismatched(body):
result = original_create(body)
result["access"] = "vip"
api.records[body["slug"]]["access"] = "vip"
return result
api.create = mismatched
with self.assertRaisesRegex(ValueError, "access mismatch"):
self.make_publisher(app, api).run()
self.assertEqual(app.marker("page")["status"], "publishing")
self.assertEqual(len(api.create_calls), 1)
def test_generation_failure_blocks_page_without_api_write(self):
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
api = FakeApi()
generator = FakeGenerator()
def fail(*_args):
raise DeterministicFailure("generation", "model failed")
generator.generate = fail
self.assertEqual(self.make_publisher(app, api, generator=generator).run(), "blocked:generation")
self.assertEqual(app.marker("page")["status"], "blocked")
self.assertEqual(api.create_calls, [])
def test_malformed_marker_is_reported_and_valid_page_can_run(self):
app = FakeAppFlowy({
"bad": ("Bad", "Anwarpy: pending"),
"good": ("Good", PENDING_OLD),
})
publisher = self.make_publisher(app, FakeApi(), generator=FakeGenerator({"Good": "good-guide"}))
self.assertEqual(publisher.run(), "published:good-guide")
self.assertEqual(len(publisher.marker_errors), 1)
self.assertIn("bad", publisher.marker_errors[0])
if __name__ == "__main__":
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from runtime import (
AgentApi,
AppFlowyMcp,
CoverMedia,
LocalArticleGenerator,
_json_from_text,
)
class FakeAppFlowy(AppFlowyMcp):
def __init__(self):
super().__init__(Path("unused"))
self.edits = []
def _call(self, tool, arguments):
if tool == "get_workspace_folder":
return {
"data": {
"view_id": "3a743541-abbf-495a-a18a-74f405fa5d70",
"children": [
{"view_id": "a", "name": "A", "children": [
{"view_id": "b", "name": "B", "children": []},
]},
],
}
}
if tool == "get_page_blocks":
return {"blocks": [{"id": "x", "text": "body"}]}
if tool == "edit_block_text":
self.edits.append(arguments)
return {"code": 0}
raise AssertionError(tool)
class FakeAgentApi(AgentApi):
def __init__(self):
super().__init__("test-key")
self.responses = []
self.requests = []
self.public = (200, b"page")
self.rsc = (200, b"rsc")
def _request(self, method, path, body=None, *, anonymous=False):
self.requests.append((method, path, body, anonymous))
if self.responses:
return self.responses.pop(0)
return 200, b"{}", "application/json"
def _public_get(self, url, *, rsc=False):
return self.rsc if rsc else self.public
class RuntimeTests(unittest.TestCase):
def test_unwraps_nested_mcp_result(self):
nested = json.dumps({"result": json.dumps({"data": {"ok": True}})})
self.assertEqual(_json_from_text(nested), {"data": {"ok": True}})
def test_appflowy_enforces_enumerated_subtree(self):
client = FakeAppFlowy()
self.assertEqual([page["view_id"] for page in client.list_pages()], ["a", "b"])
self.assertEqual(client.get_blocks("b")[0]["id"], "x")
client.edit_block("a", "x", "new")
self.assertEqual(client.edits[0]["page_id"], "a")
with self.assertRaises(PermissionError):
client.get_blocks("outside")
def test_agent_api_records_body_probe_and_decodes_create(self):
api = FakeAgentApi()
api.responses = [(201, json.dumps({"slug": "guide"}).encode(), "application/json")]
body = {
"slug": "guide",
"content": {"root": {"children": [
{"children": [{"text": "distinctive protected body sentence"}]},
]}},
}
self.assertEqual(api.create(body)["slug"], "guide")
self.assertEqual(api.body_probes["guide"], "distinctive protected body sentence")
def test_agent_api_updates_with_patch(self):
api = FakeAgentApi()
api.responses = [(200, json.dumps({"slug": "guide"}).encode(), "application/json")]
self.assertEqual(api.update("guide", {"publish": True})["slug"], "guide")
self.assertEqual(api.requests[0][0:2], ("PATCH", "/api/agent/posts/guide"))
@patch("runtime.time.sleep", return_value=None)
def test_vip_verification_rejects_body_leak(self, _sleep):
api = FakeAgentApi()
api.body_probes["guide"] = "VIP_BODY_MUST_NOT_LEAK"
api.public = (200, b"VIP_BODY_MUST_NOT_LEAK")
record = {"slug": "guide", "access": "vip", "url": "https://anwarpy.ai/posts/guide"}
self.assertFalse(api.verify_public(record))
def test_local_article_parser_enforces_word_count(self):
article = {
"title": "دليل عربي",
"slug": "arabic-guide",
"dek": "وصف عملي",
"teaser": "",
"sections": [
{"heading": "أ", "paragraphs": ["كلمة " * 300], "code": None},
{"heading": "ب", "paragraphs": ["كلمة " * 300], "code": None},
{"heading": "ج", "paragraphs": ["كلمة " * 300], "code": None},
],
}
parsed = LocalArticleGenerator._parse(json.dumps(article, ensure_ascii=False), "free", None)
self.assertEqual(parsed["slug"], "arabic-guide")
article["sections"][0]["paragraphs"] = ["قصير"]
with self.assertRaisesRegex(Exception, "900 to 2500"):
LocalArticleGenerator._parse(json.dumps(article, ensure_ascii=False), "free", None)
def test_cover_media_returns_cover_without_video(self):
media = CoverMedia(Path(tempfile.mkdtemp())).render(
{"slug": "arabic-guide"}, {"category": "أدوات"}
)
self.assertIn("coverImage", media)
self.assertNotIn("video_path", media)
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
import unittest
from sanitize import assert_publishable, sanitize_text
class SanitizeTests(unittest.TestCase):
def test_redacts_private_identifiers_and_credentials(self):
raw = """Connect to 10.1.2.3 or fd00::12 as admin@example.test.
SSH root@private.internal and use password=FakeSecret123.
Read /Users/example/private/config and call https://hidden.internal/api.
Phone +1 555 123 4567; token: abcdefghijklmnop.
"""
cleaned = sanitize_text(raw, allowed_hosts={"anwarpy.ai"})
for secret in (
"10.1.2.3",
"fd00::12",
"admin@example.test",
"root@private.internal",
"FakeSecret123",
"/Users/example/private/config",
"hidden.internal",
"+1 555 123 4567",
"abcdefghijklmnop",
):
self.assertNotIn(secret, cleaned)
assert_publishable(cleaned, allowed_hosts={"anwarpy.ai"})
def test_preserves_approved_public_urls(self):
text = "راجع https://anwarpy.ai/posts/example للمزيد."
self.assertEqual(sanitize_text(text, allowed_hosts={"anwarpy.ai"}), text)
assert_publishable(text, allowed_hosts={"anwarpy.ai"})
def test_redacts_unapproved_public_hosts(self):
cleaned = sanitize_text("Source: https://unapproved.example/article", allowed_hosts={"anwarpy.ai"})
self.assertNotIn("unapproved.example", cleaned)
assert_publishable(cleaned, allowed_hosts={"anwarpy.ai"})
def test_assert_publishable_raises_without_using_assert_statement(self):
with self.assertRaises(ValueError):
assert_publishable("internal address 192.168.1.10", allowed_hosts={"anwarpy.ai"})
if __name__ == "__main__":
unittest.main()
@@ -1,21 +0,0 @@
# ADR 0001 — Comments endpoint lives at /api/posts/[slug]/comments, not /api/comments
Date: 2026-10-03
## Status
Accepted
## Context
The comments API needs a route. The conventional-looking path `/api/comments` collides with Payload: it auto-mounts REST CRUD for every collection at `/api/<slug>`. A `comments` collection therefore already serves `/api/comments` for the admin panel, and moderation (approve/reject) runs through that panel. An app-router route at the same path would shadow and break the panel's CRUD.
## Decision
Comments are created at `POST /api/posts/[slug]/comments` (`src/app/api/posts/[slug]/comments/route.ts`), matching the existing `/api/posts/[slug]/like` pattern. Admin moderation keeps using Payload's own REST/admin panel.
## Tests for a real ADR
- **Hard to reverse**: the endpoint is a public contract consumed by the client form; moving it breaks every deployed client.
- **Surprising**: the collision with Payload's auto-mounted REST is non-obvious — a developer would naturally reach for `/api/comments`.
- **Real trade-off**: a shorter conventional path vs. a working admin moderation panel.
@@ -1,25 +0,0 @@
# ADR 0002 — Moderated comments with server-forced author
Date: 2026-10-03
## Status
Accepted
## Context
Comments are user-generated content on a public site. Risks: identity spoofing (a request forging `author`), spam (no gate), and orphaned identity data (nested user lookups through restrictive read access). QA-D04 required a real backend for comments.
## Decision
- Comments default to `status: 'pending'`; only `approved` are publicly visible. The read access rule returns `{ status: { equals: 'approved' } }` for non-admins.
- The author is forced server-side on create, twice: a `beforeChange` hook on the collection and the custom route — REST-created comments can never spoof identity.
- `authorName` is denormalized at submit time (snapshot of `fullName`, else «عضو») to avoid nested user-read access issues when rendering; the email is never exposed.
- Approval/rejection is admin-only, done in the Payload admin panel. Authors cannot edit or delete their comments (moderated model).
- Authors see their own pending comments rendered on the post page tagged «قيد المراجعة» (via `overrideAccess` query filtered to their own author id), so a submitted comment does not "vanish".
## Tests for a real ADR
- **Hard to reverse**: the display-name snapshot and status lifecycle are visible in every rendered comment and in the DB.
- **Surprising**: forcing author in two places (hook + route) and `overrideAccess` for the pending query are non-obvious.
- **Real trade-off**: moderation latency for users vs. spam safety; denormalization vs. live join through restricted user reads.
@@ -1,25 +0,0 @@
# ADR 0003 — Cloudflare Email Sending REST, not SMTP
Date: 2026-10-03
## Status
Accepted
## Context
The site needs exactly one transactional email: the verification mail sent by `POST /api/auth/resend-verification` via `sendVerificationEmail(to, token)` in `src/lib/mail.ts`. The first implementation used nodemailer over generic SMTP. Cloudflare Email Service (the mail provider chosen for anwarpy.ai) has **no SMTP interface** — it exposes a REST endpoint only — so the nodemailer path could never be activated on the target host.
## Decision
- `src/lib/mail.ts` sends via `POST https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/email/sending/send` using `fetch`, authenticated with `Authorization: Bearer ${CLOUDFLARE_API_TOKEN}`.
- The `from` field uses the REST shape `{ address, name }` (key is `address`, **not** `email`); default address `noreply@anwarpy.ai` (override: `MAIL_FROM`).
- Active only when `CLOUDFLARE_API_TOKEN` **and** `CLOUDFLARE_ACCOUNT_ID` are set; otherwise `{ sent: false }` (existing degrade-gracefully contract — the resend route answers 503 with a clear message).
- Non-OK response or fetch throw: `console.error` the error body, return `{ sent: false }`. No retry logic (single-shot; the resend route re-invokes).
- nodemailer is uninstalled; the exported signature `sendVerificationEmail(to: string, token: string): Promise<{ sent: boolean }>` is unchanged.
## Consequences
- The API token needs the Email Sending permission; the sender domain must be onboarded in Cloudflare (SPF/DKIM records created via `wrangler email sending dns get`).
- `smtpConfigured()` and all `SMTP_*` env vars are gone; anything expecting them must be updated when mail features grow.
- Adding more email types later means extending this one module — it is the single mail boundary.
@@ -1,28 +0,0 @@
# ADR 0004 — Coming-soon subscribe on public launch (no simulated VIP)
Date: 2026-10-03
## Status
Accepted
## Context
Until Stripe keys exist, the subscribe flow had two self-grant holes that are fatal on a public site:
1. `POST /api/subscribe` (no keys) returned `{ url: '/payment-success?simulated=1' }`.
2. `/payment-success` without a `session_id` set `activate = true` — i.e. any logged-in user visiting the URL became `role: 'vip'` for free.
On the public internet this lets anyone self-grant VIP.
## Decision
- With no Stripe keys, `POST /api/subscribe` returns `{ comingSoon: true }` and no `url`; `SubscribeButton` already renders a disabled «قريباً» button whenever the response has no `url`.
- `/payment-success` no longer activates anything without a session: the `else if (!sessionId) { activate = true }` branch is deleted and `simulated` is removed from the page props. After the activation block, free users (`role` not `vip`/`admin`) are redirected to `/subscription`. VIP/admin users pass through to the success copy.
- The real Stripe path is unchanged: `session_id` present + `STRIPE_SECRET_KEY` set → session verified with Stripe → VIP activated.
- When Stripe keys land, the same pages switch to real Checkout without further flow changes.
## Consequences
- No revenue until Stripe is configured; VIP can still be granted manually by an admin in `/admin`.
- Anyone with a saved `?simulated=1` link gets redirected — the query param is simply ignored now.
-47
View File
@@ -1,47 +0,0 @@
# ADR 0005 — Member trust model (server-only privilege/verification/billing fields)
Date: 2026-10-09
## Status
Accepted
## Context
The 2026-10-08 pentest (findings #1, #3, #4, #6) confirmed that anonymous or self-authenticated
client requests could write privileged fields on `users`:
1. `POST /api/users` accepted client-supplied `emailVerified`, `role`, `onboarded`,
`stripeCustomerId`, `verificationToken` — e.g. instant self-verified admin.
2. Self-`PATCH /api/users/{id}` accepted the same fields on update.
3. Per-account login lockout (no time-box) let an attacker lock the admin out of the site
indefinitely with wrong-password floods.
## Decision
- `role`, `emailVerified`, `verificationToken`, `stripeCustomerId`, `onboarded` are stripped from
every **untrusted write** in `Users.beforeChange` (`web/src/collections/Users.ts`). Untrusted =
no authenticated admin **and** no `context.systemActivation === true`. On create, the hook forces
`role: 'free'`, `emailVerified: false`, `onboarded: false`.
- Server-side writers opt back in with `context: { systemActivation: true }` on their local-API
`payload.update`/`create` calls. Enumerated writers: `verify-email`, `resend-verification`,
`subscribe`, `stripe/webhook` (×2), `payment-success`, `seed`. **Omitting the context silently
no-ops the write (the field is deleted) — that is the intended failure mode**, so a missing
context is caught in QA rather than leaking privilege.
- Login lockout is per-account, time-boxed: 5 failed attempts → 15-minute lock (`auth:
{ maxLoginAttempts: 5, lockTime: 15 * 60 * 1000 }`).
- `role === 'admin'` accounts are **exempt from lockout** (owner decision 2026-10-09): a
`beforeOperation` hook on `login` pre-clears `lockUntil`/`loginAttempts` via
`db.updateOne` (which, like Payload's own `incrementLoginAttempts`, bypasses hooks). Free/VIP
accounts keep the normal 5/15-min lockout.
- Commenting requires a verified email; admins are exempt (see the 403 gate in
`api/posts/[slug]/comments`).
## Consequences
- Every future server-side writer of these fields MUST pass `context.systemActivation`; a writer
that forgets it silently loses its write.
- The admin password has **no brute-force throttle** — compensate with a strong password. If that
posture changes, delete the `beforeOperation` hook and the account falls back to 5/15-min lockout.
- Client-sent profile fields (`fullName`, `termsVersion`, `interest`, `level`, `goal`, `prefs`) stay
writable; only privilege/verification/billing fields are protected.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

+1 -1
View File
@@ -25,7 +25,7 @@
<header class="topnav" data-od-id="topnav"><div class="container nav-inner"><a class="logo" href="arabic-ai-learning-catalog.html"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a><div class="nav-tools"><a class="nav-return" href="arabic-ai-learning-catalog.html">العودة إلى المكتبة</a><button class="icon-btn" id="menuTrigger" aria-label="فتح القائمة" aria-expanded="false" aria-controls="siteMenu" data-od-id="menu-button">☰</button></div></div></header> <header class="topnav" data-od-id="topnav"><div class="container nav-inner"><a class="logo" href="arabic-ai-learning-catalog.html"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a><div class="nav-tools"><a class="nav-return" href="arabic-ai-learning-catalog.html">العودة إلى المكتبة</a><button class="icon-btn" id="menuTrigger" aria-label="فتح القائمة" aria-expanded="false" aria-controls="siteMenu" data-od-id="menu-button">☰</button></div></div></header>
<main class="auth"> <main class="auth">
<section class="auth-story" data-od-id="login-story"><div><p class="eyebrow">مساحتك في أنور بي آي</p><h1 data-od-id="login-story-title">أكمل من حيث توقفت.</h1><p>احفظ أدلتك، تابع مسارات الفيديو، وارجع إلى ملاحظاتك من أي جهاز.</p><ul class="story-list"><li><span>المقالات المحفوظة</span><span class="story-mark">مرتبّة</span></li><li><span>تقدّم المسارات</span><span class="story-mark">متزامن</span></li><li><span>ملفات العضوية</span><span class="story-mark">جاهزة</span></li></ul></div><p class="story-foot">العربية أولاً · خصوصيتك محفوظة</p></section> <section class="auth-story" data-od-id="login-story"><div><p class="eyebrow">مساحتك في أنور بي آي</p><h1 data-od-id="login-story-title">أكمل من حيث توقفت.</h1><p>احفظ أدلتك، تابع مسارات الفيديو، وارجع إلى ملاحظاتك من أي جهاز.</p><ul class="story-list"><li><span>المقالات المحفوظة</span><span class="story-mark">مرتبّة</span></li><li><span>تقدّم المسارات</span><span class="story-mark">متزامن</span></li><li><span>ملفات العضوية</span><span class="story-mark">جاهزة</span></li></ul></div><p class="story-foot">العربية أولاً · خصوصيتك محفوظة</p></section>
<section class="auth-form-wrap" data-od-id="login-form-section"><form class="auth-form" id="loginForm" novalidate data-od-id="login-form"><p class="eyebrow">أهلاً بعودتك</p><h2 data-od-id="login-title">تسجيل الدخول</h2><p class="lead">استخدم بريدك وكلمة المرور للوصول إلى حسابك.</p><div class="field"><label for="email">البريد الإلكتروني</label><input class="input" id="email" name="email" type="email" inputmode="email" autocomplete="email" placeholder="name@example.com" dir="ltr" required /></div><div class="field"><label for="password">كلمة المرور</label><div class="input-wrap"><input class="input" id="password" name="password" type="password" autocomplete="current-password" minlength="8" required /><button class="password-toggle" id="passwordToggle" type="button" aria-controls="password">إظهار</button></div></div><div class="form-row"><label class="check"><input type="checkbox" name="remember" /> تذكّرني</label><a href="forgot-password.html">نسيت كلمة المرور؟</a></div><button class="btn btn-primary" type="submit">ادخل إلى حسابك</button><div class="status" id="loginStatus" role="status"></div><div class="divider">أو</div><button class="magic" id="magicLink" type="button">أرسل رابط دخول إلى بريدي</button><p class="signup">ليس لديك حساب؟ <a href="signup.html">أنشئ حساباً جديداً</a></p><p class="fine">بالمتابعة، أنت توافق على شروط الاستخدام و<a href="privacy.html">سياسة الخصوصية</a>.</p></form></section> <section class="auth-form-wrap" data-od-id="login-form-section"><form class="auth-form" id="loginForm" novalidate data-od-id="login-form"><p class="eyebrow">أهلاً بعودتك</p><h2 data-od-id="login-title">تسجيل الدخول</h2><p class="lead">استخدم بريدك وكلمة المرور للوصول إلى حسابك.</p><div class="field"><label for="email">البريد الإلكتروني</label><input class="input" id="email" name="email" type="email" inputmode="email" autocomplete="email" placeholder="name@example.com" dir="ltr" required /></div><div class="field"><label for="password">كلمة المرور</label><div class="input-wrap"><input class="input" id="password" name="password" type="password" autocomplete="current-password" minlength="8" required /><button class="password-toggle" id="passwordToggle" type="button" aria-controls="password">إظهار</button></div></div><div class="form-row"><label class="check"><input type="checkbox" name="remember" /> تذكّرني</label><a href="forgot-password.html">نسيت كلمة المرور؟</a></div><button class="btn btn-primary" type="submit">ادخل إلى حسابك</button><div class="status" id="loginStatus" role="status"></div><div class="divider">أو</div><button class="magic" id="magicLink" type="button">أرسل رابط دخول إلى بريدي</button><p class="signup">ليس لديك حساب؟ <a href="subscription.html">اكتشف العضوية</a></p><p class="fine">بالمتابعة، أنت توافق على شروط الاستخدام و<a href="privacy.html">سياسة الخصوصية</a>.</p></form></section>
</main> </main>
<aside class="menu-panel" id="siteMenu" aria-hidden="true" data-od-id="site-menu"><button class="menu-scrim" type="button" aria-label="إغلاق القائمة" data-menu-close></button><div class="menu-drawer" role="dialog" aria-modal="true" aria-label="قائمة أنور بي آي"><div class="menu-head"><a class="logo" href="arabic-ai-learning-catalog.html"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a><button class="icon-btn" type="button" aria-label="إغلاق القائمة" data-menu-close>×</button></div><nav class="menu-links"><a href="arabic-ai-learning-catalog.html#catalog">المكتبة <span>←</span></a><a href="article-agent-guide.html">مقال الأسبوع <span>←</span></a><a href="subscription.html">العضوية والأسعار <span>←</span></a></nav><div class="menu-foot"><p>محتوى عربي عملي، بلا ضجيج.</p></div></div></aside> <aside class="menu-panel" id="siteMenu" aria-hidden="true" data-od-id="site-menu"><button class="menu-scrim" type="button" aria-label="إغلاق القائمة" data-menu-close></button><div class="menu-drawer" role="dialog" aria-modal="true" aria-label="قائمة أنور بي آي"><div class="menu-head"><a class="logo" href="arabic-ai-learning-catalog.html"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a><button class="icon-btn" type="button" aria-label="إغلاق القائمة" data-menu-close>×</button></div><nav class="menu-links"><a href="arabic-ai-learning-catalog.html#catalog">المكتبة <span>←</span></a><a href="article-agent-guide.html">مقال الأسبوع <span>←</span></a><a href="subscription.html">العضوية والأسعار <span>←</span></a></nav><div class="menu-foot"><p>محتوى عربي عملي، بلا ضجيج.</p></div></div></aside>
<script> <script>
-87
View File
@@ -1,87 +0,0 @@
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src 'none'; worker-src 'none'" />
<meta name="description" content="إنشاء حساب جديد في أنور بي آي." />
<title>إنشاء حساب — أنور بي آي</title>
<style>
:root{--bg:oklch(16% .03 225);--surface:oklch(22% .04 220);--fg:oklch(96% .015 210);--muted:oklch(74% .03 215);--border:oklch(36% .05 215);--accent:oklch(78% .17 190);--accent-soft:color-mix(in oklch,var(--accent) 12%,transparent);--fg-soft:color-mix(in oklch,var(--fg) 6%,transparent);--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
*,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1,h2{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}
.auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.auth>*{min-width:0}.auth-story{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100% - var(--container))/2 + var(--gutter)));padding-inline-end:clamp(48px,8vw,100px);background:var(--fg);color:var(--surface);display:flex;flex-direction:column;justify-content:space-between}.eyebrow{font:11px/1.5 var(--font-mono);letter-spacing:0;color:var(--accent);margin:0 0 20px}.auth-story .eyebrow{color:var(--surface);opacity:.78}.auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;letter-spacing:-.025em;max-width:10ch;margin:0}.auth-story>div>p{color:color-mix(in oklch,var(--surface) 78%,transparent);font-size:19px;max-width:40ch}.story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 32%,transparent)}.story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 22%,transparent);display:flex;justify-content:space-between;gap:20px}.story-mark{font:12px/1.5 var(--font-mono);opacity:.74}.story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 72%,transparent)}
.auth-form-wrap{padding:clamp(46px,7vw,88px) max(var(--gutter),calc((100% - var(--container))/2 + var(--gutter)));padding-inline-start:clamp(48px,8vw,100px);display:flex;align-items:center}.auth-form{width:min(440px,100%)}.auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.lead{color:var(--muted);font-size:18px;margin:12px 0 28px}.field{display:grid;gap:7px;margin-top:16px}.field label{font-size:13px;color:var(--muted)}.input-wrap{position:relative}.input{width:100%;min-height:52px;border:1px solid color-mix(in oklch,var(--border) 72%,var(--fg));background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.check{display:flex;align-items:flex-start;gap:10px;margin:20px 0}.check input{margin-top:6px;flex:none}.check a{text-decoration:underline;text-underline-offset:3px}.check a:hover{color:var(--accent)}.btn{min-height:52px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.signin{text-align:center;color:var(--muted);font-size:14px;margin-top:22px}.signin a{color:var(--fg);text-decoration:underline;text-underline-offset:3px}.signin a:hover{color:var(--accent)}.fine{font-size:12px;color:var(--muted);margin-top:14px}.status{min-height:28px;margin-top:12px;color:var(--accent);font-size:13px}
@media(max-width:920px){:root{--gutter:22px}.auth{grid-template-columns:1fr}.auth-story{min-height:390px;padding:58px var(--gutter)}.auth-form-wrap{padding:58px var(--gutter);justify-content:center}.story-foot{margin-top:48px}}
@media(max-width:620px){:root{--gutter:18px}.nav-inner{gap:12px}.nav-return{font-size:13px}.auth-story{min-height:auto}.auth-story h1{font-size:46px}.auth-form-wrap{padding-block:46px}.story-list{margin-top:38px}}
@media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
</style>
<link rel="stylesheet" href="assets/anwarpi.css?v=5" />
<script src="assets/anwarpi.js?v=4" defer></script>
</head>
<body>
<header class="topnav" data-od-id="topnav">
<div class="container nav-inner">
<a class="logo" href="arabic-ai-learning-catalog.html" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352" />أنور بي آي</a>
<a class="nav-return" href="login.html" data-od-id="login-link">لديك حساب؟ سجّل الدخول</a>
</div>
</header>
<main class="auth">
<section class="auth-story" data-od-id="signup-story">
<div>
<p class="eyebrow">ابدأ مسارك</p>
<h1 data-od-id="signup-story-title">ابنِ معرفتك، خطوة بخطوة.</h1>
<p>أنشئ حساباً لحفظ المقالات ومتابعة تقدّمك والوصول إلى مزايا العضوية من أي جهاز.</p>
<ul class="story-list">
<li><span>مكتبتك الشخصية</span><span class="story-mark">محفوظة</span></li>
<li><span>تقدّم المسارات</span><span class="story-mark">متزامن</span></li>
<li><span>إعدادات واضحة</span><span class="story-mark">تحت سيطرتك</span></li>
</ul>
</div>
<p class="story-foot">العربية أولاً · خصوصيتك محفوظة</p>
</section>
<section class="auth-form-wrap" data-od-id="signup-form-section">
<form class="auth-form" id="signupForm" novalidate data-od-id="signup-form">
<p class="eyebrow">حساب جديد</p>
<h2 data-od-id="signup-title">إنشاء حساب</h2>
<p class="lead">أدخل بياناتك الأساسية للبدء.</p>
<div class="field"><label for="name">الاسم</label><input class="input" id="name" name="name" type="text" autocomplete="name" minlength="2" required /></div>
<div class="field"><label for="email">البريد الإلكتروني</label><input class="input" id="email" name="email" type="email" inputmode="email" autocomplete="email" placeholder="name@example.com" dir="ltr" required /></div>
<div class="field"><label for="password">كلمة المرور</label><div class="input-wrap"><input class="input" id="password" name="password" type="password" autocomplete="new-password" minlength="8" aria-describedby="passwordHint" required /><button class="password-toggle" type="button" data-toggle-password="password" aria-controls="password">إظهار</button></div><span class="fine" id="passwordHint">ثمانية أحرف على الأقل.</span></div>
<div class="field"><label for="confirmPassword">تأكيد كلمة المرور</label><div class="input-wrap"><input class="input" id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required /><button class="password-toggle" type="button" data-toggle-password="confirmPassword" aria-controls="confirmPassword">إظهار</button></div></div>
<label class="check"><input id="terms" name="terms" type="checkbox" required /><span>أوافق على <a href="privacy.html">سياسة الخصوصية</a> وشروط الاستخدام.</span></label>
<button class="btn btn-primary" type="submit" data-od-id="create-account-cta">أنشئ حسابي</button>
<div class="status" id="signupStatus" role="status" aria-live="polite"></div>
<p class="signin">لديك حساب بالفعل؟ <a href="login.html">سجّل الدخول</a></p>
<p class="fine">هذه معاينة للواجهة؛ إنشاء الحساب الفعلي يتطلب ربط خدمة مصادقة آمنة.</p>
</form>
</section>
</main>
<script>
(()=>{
const form=document.getElementById('signupForm');
const status=document.getElementById('signupStatus');
const password=document.getElementById('password');
const confirmPassword=document.getElementById('confirmPassword');
document.querySelectorAll('[data-toggle-password]').forEach(button=>button.addEventListener('click',()=>{
const input=document.getElementById(button.dataset.togglePassword);
const show=input.type==='password';
input.type=show?'text':'password';
button.textContent=show?'إخفاء':'إظهار';
}));
form.addEventListener('submit',event=>{
event.preventDefault();
confirmPassword.setCustomValidity(password.value===confirmPassword.value?'':'كلمتا المرور غير متطابقتين.');
if(!form.checkValidity()){
status.textContent=password.value!==confirmPassword.value?'كلمتا المرور غير متطابقتين.':'تحقق من جميع الحقول المطلوبة.';
form.reportValidity();
return;
}
status.textContent='تم التحقق من النموذج. ربط إنشاء الحساب بالخادم هو الخطوة التالية.';
});
confirmPassword.addEventListener('input',()=>confirmPassword.setCustomValidity(''));
})();
</script>
</body>
</html>
-1
View File
@@ -1,7 +1,6 @@
node_modules/ node_modules/
.next/ .next/
media/ media/
files-private/
.env .env
.env.* .env.*
!.env.example !.env.example
+1 -1
View File
@@ -35,7 +35,7 @@ curl -X POST http://localhost:3000/api/agent/posts \
-d '{"title":"...","slug":"my-post","dek":"...","category":"وكلاء","access":"vip","teaser":"...","readingMinutes":8,"content":{"root":{"type":"root","children":[],"format":"","indent":0,"version":1}}}' -d '{"title":"...","slug":"my-post","dek":"...","category":"وكلاء","access":"vip","teaser":"...","readingMinutes":8,"content":{"root":{"type":"root","children":[],"format":"","indent":0,"version":1}}}'
# update (cannot change status) # update (cannot change status)
curl -X PUT http://localhost:3000/api/agent/posts/my-post -H "x-agent-key: $AGENT_API_KEY" ... curl -X PATCH http://localhost:3000/api/agent/posts/my-post -H "x-agent-key: $AGENT_API_KEY" ...
``` ```
Omitting `cover` assigns the per-category default cover. Omitting `cover` assigns the per-category default cover.
-75
View File
@@ -1,75 +0,0 @@
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: anwarpi
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: anwarpi
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U anwarpi -d anwarpi"]
interval: 5s
timeout: 5s
retries: 10
web:
build:
context: .
args:
NEXT_PUBLIC_SITE_URL: https://anwarpy.ai
restart: unless-stopped
expose:
- "3000"
volumes:
- media-uploads:/app/media
- files-uploads:/app/files-private
environment:
DATABASE_URI: postgres://anwarpi:${DB_PASSWORD}@db:5432/anwarpi
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
AGENT_API_KEY: ${AGENT_API_KEY}
NEXT_PUBLIC_SITE_URL: https://anwarpy.ai
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID}
MAIL_FROM: noreply@anwarpy.ai
labels:
- traefik.enable=true
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`)
- traefik.http.routers.anwarpi.entrypoints=websecure
- traefik.http.routers.anwarpi.tls.certresolver=mytlschallenge
- traefik.http.routers.anwarpi.middlewares=pagelimit@docker,connlimit@docker
- traefik.http.services.anwarpi.loadbalancer.server.port=3000
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && (Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`))
- traefik.http.routers.anwarpi-api.entrypoints=websecure
- traefik.http.routers.anwarpi-api.tls=true
- traefik.http.routers.anwarpi-api.tls.certresolver=mytlschallenge
- traefik.http.routers.anwarpi-api.service=anwarpi
- traefik.http.routers.anwarpi-api.middlewares=apilimit@docker
- traefik.http.routers.anwarpi-api.priority=100
- traefik.http.middlewares.apilimit.ratelimit.average=90
- traefik.http.middlewares.apilimit.ratelimit.burst=180
- traefik.http.middlewares.apilimit.ratelimit.period=1m
# F1: the pentest flood froze pages while /api held. Cap per-IP page
# rate and in-flight requests; apilimit already guards /api itself.
- traefik.http.middlewares.pagelimit.ratelimit.average=60
- traefik.http.middlewares.pagelimit.ratelimit.period=1m
- traefik.http.middlewares.pagelimit.ratelimit.burst=120
- traefik.http.middlewares.connlimit.inflightreq.amount=50
- traefik.docker.network=proxy
depends_on:
db:
condition: service_healthy
networks:
- default
- proxy
volumes:
db-data:
media-uploads:
files-uploads:
networks:
default:
proxy:
external: true
-2
View File
@@ -27,7 +27,6 @@ services:
- "3000:3000" - "3000:3000"
volumes: volumes:
- media-uploads:/app/media - media-uploads:/app/media
- files-uploads:/app/files-private
environment: environment:
DATABASE_URI: postgres://anwarpi:${DB_PASSWORD}@db:5432/anwarpi DATABASE_URI: postgres://anwarpi:${DB_PASSWORD}@db:5432/anwarpi
PAYLOAD_SECRET: ${PAYLOAD_SECRET} PAYLOAD_SECRET: ${PAYLOAD_SECRET}
@@ -45,4 +44,3 @@ services:
volumes: volumes:
db-data: db-data:
media-uploads: media-uploads:
files-uploads:
+59 -84
View File
@@ -1,100 +1,75 @@
# Agent Publish API — /api/agent/posts # Agent publishing API
Publish and manage blog posts programmatically. Auth: send the agent key in the Base URL: `https://anwarpy.ai/api/agent/posts`
`x-agent-key` header. The key lives on the VPS in `/root/anwarpi/.env.production`
(`AGENT_API_KEY`).
- Base URL: `https://anwarpy.ai` Authentication: send the current secret in `x-agent-key`. The key is stored only in the deployment environment and the local secret store; it must not appear in source, logs, prompts, or this document.
- Content-Type: `application/json`
- Posts are created **draft** by default. Pass `"publish": true` to publish
(sets `status: published` + `publishedAt` now). Unpublishing stays admin-only
(`/admin`).
- `access` controls gating: `"free"` (public) or `"vip"` (members only).
- `content` (Lexical) may be omitted on create — a minimal valid doc is
inserted; update it later with PUT. Other writable fields: `title`, `slug`,
`dek`, `teaser`, `category` (`agents` | `models` | `automation` |
`foundations`), `readingMinutes`, `cover`.
- `attachments` attaches per-post work files: an array of
`{ "filename": "agent-state.md", "dataBase64": "…", "access": "vip" }`.
`access` is per file (`"free"` downloads for anyone, `"vip"` members only)
and defaults to `"vip"`. Max 5 files, ≤5MB each, extensions pdf/zip/md/csv/
txt/json. On PUT, sending `attachments` **replaces** the post's file set —
omit the field to keep existing files.
## Create + publish a post with an image ## Create
```bash `POST /api/agent/posts`
KEY=033d1eed44ca6abf483de10735c47155d38f7b5bc9d46449
# Inline base64 image (jpeg/png/webp, ≤5MB)
python3 - <<'EOF' > /tmp/post.json
import base64, json, pathlib
print(json.dumps({
"title": "دليلي الجديد",
"slug": "my-new-guide",
"dek": "وصف قصير يظهر في القوائم.",
"teaser": "نص المعاينة للزوار غير المشتركين.",
"category": "وكلاء",
"access": "vip", # or "free"
"readingMinutes": 9,
"publish": True, # omit → draft
"coverImage": {
"filename": "my-cover.jpg",
"mimeType": "image/jpeg",
"dataBase64": base64.b64encode(pathlib.Path("my-cover.jpg").read_bytes()).decode(),
},
}))
EOF
curl -s -X POST https://anwarpy.ai/api/agent/posts \
-H "x-agent-key: $KEY" -H "Content-Type: application/json" \
--data @/tmp/post.json
```
Response `201`:
```json ```json
{ {
"id": 113, "title": "دليلي الجديد", "slug": "my-new-guide", "title": "دليل عربي",
"status": "published", "access": "vip", "category": "agents", "slug": "arabic-ai-guide",
"cover": { "id": 113, "url": "/api/media/file/my-cover.jpg" }, "dek": "وصف عملي",
"attachments": [ "teaser": "مطلوب لمقال VIP فقط",
{ "id": 42, "filename": "agent-state.md", "access": "vip", "url": "/api/files/42" } "content": { "root": { "type": "root", "children": [] } },
] "category": "أدوات",
"access": "vip",
"readingMinutes": 7,
"publish": true,
"coverImage": {
"filename": "arabic-ai-guide.jpg",
"dataBase64": "<raw JPEG base64>"
}
} }
``` ```
## Update a post Rules:
```bash - `slug`: lowercase ASCII kebab-case.
curl -s -X PUT https://anwarpy.ai/api/agent/posts/my-new-guide \ - `category`: one of `وكلاء`, `نماذج`, `استرجاع`, `سلامة`, `أدوات`, `أعمال`.
-H "x-agent-key: $KEY" -H "Content-Type: application/json" \ - `access`: `free` or `vip`.
-d '{"access": "free", "publish": true, - VIP requires a non-empty teaser.
"coverImage": {"url": "https://example.com/cover.png"}}' - `coverImage` is optional. When provided it must be JPEG and no larger than 5MB. Otherwise the seeded category cover is used.
- `publish` omitted or false creates `status=draft`; true creates `status=published` with a server timestamp.
- Unknown fields, including direct `status`, `publishedAt`, and `level`, return 400.
- An existing or racing duplicate slug returns 409.
Success: 201.
## Lookup
`GET /api/agent/posts/<slug>`
Returns 200 for a draft or published post, 404 when absent. Authentication is required. The response never includes article or teaser content.
## Promote or republish
`PATCH /api/agent/posts/<slug>`
Draft promotion body:
```json
{ "publish": true }
``` ```
- `coverImage` accepts `{ "dataBase64": "…" }` or `{ "url": "https://…" }` A full republish uses the same body as create, requires `publish=true`, and requires the body slug to match the URL slug. It updates the existing post and never creates a second slug.
(server fetches and stores it; `filename`/`mimeType` optional).
- `cover` may also be an existing media ID (integer).
## Where the image appears ## Response
The cover is rendered on: ```json
{
"id": 42,
"title": "دليل عربي",
"slug": "arabic-ai-guide",
"status": "published",
"access": "vip",
"category": "أدوات",
"publishedAt": "2026-10-09T12:00:00.000Z",
"url": "https://anwarpy.ai/posts/arabic-ai-guide"
}
```
1. **Post page** (`/posts/<slug>`) — full-width cover under the header. Canonical URLs have no `www` and no trailing slash.
2. **Articles listing** (`/articles`) — thumbnail on each card.
3. **Homepage** (`/`) — "أحدث الأدلة" section (latest 6 published posts).
If a post has no cover, the catalog card falls back to the category's default
decorative cover; the post page and listing simply omit the image.
## Rules
- The agent **cannot** set `status` directly — only via `"publish": true`.
- The agent **cannot** unpublish or delete — do that in `/admin`.
- Images are validated: jpeg/png/webp, ≤5MB; stored as Payload `media`
documents and served from `/api/media/file/<filename>`.
- Attachments are stored as Payload `files` documents under `files-private/`
(volume-mount it like `media/` on the VPS). Downloads go through
`GET /api/files/<id>`: `free` files stream to anyone, `vip` files return
401 anonymous / 403 for non-members. The `vip` badge on the post page links
only for entitled viewers.
-1
View File
@@ -12,7 +12,6 @@ const nextConfig = {
{ key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
], ],
}] }]
}, },
+10
View File
@@ -12,6 +12,7 @@
"@payloadcms/next": "^3.60.0", "@payloadcms/next": "^3.60.0",
"@payloadcms/richtext-lexical": "^3.60.0", "@payloadcms/richtext-lexical": "^3.60.0",
"next": "15.4.11", "next": "15.4.11",
"nodemailer": "^10.0.13",
"payload": "^3.60.0", "payload": "^3.60.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -5225,6 +5226,15 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/nodemailer": {
"version": "10.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.13.tgz",
"integrity": "sha512-SzG86OlvcW/NNhUFC6uROMwRTL4n7MswfQqC/T8mhkmnY1YVa23zUEMYi4ijSeXSl9GLz9ZeTJDUatEDuY5FeQ==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+2
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"test": "node --experimental-strip-types --test tests/*.test.ts",
"build": "payload generate:types && next build", "build": "payload generate:types && next build",
"start": "next start", "start": "next start",
"generate:types": "payload generate:types", "generate:types": "payload generate:types",
@@ -16,6 +17,7 @@
"@payloadcms/next": "^3.60.0", "@payloadcms/next": "^3.60.0",
"@payloadcms/richtext-lexical": "^3.60.0", "@payloadcms/richtext-lexical": "^3.60.0",
"next": "15.4.11", "next": "15.4.11",
"nodemailer": "^10.0.13",
"payload": "^3.60.0", "payload": "^3.60.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

+99 -72
View File
@@ -1,72 +1,99 @@
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import SiteMenu from '@/components/site-menu'
import { getViewer } from '@/lib/viewer' import Footer from '@/components/footer'
import '../styles/about.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'عن الفريق' } export const dynamic = 'force-dynamic'
export const metadata = { title: 'عن الفريق' }
export default async function AboutPage() {
const viewer = await getViewer() export default function AboutPage() {
return ( return (
<div id="site" className="site-page v2 v2-about"> <>
<Topnav viewer={viewer} /> <Topnav
<main id="content"> viewer={null}
<section className="about-hero" aria-labelledby="about-title"> menu
<div className="wrap about-hero-grid"> links={[
<div> { href: '/articles', label: 'المكتبة' },
<h1 id="about-title">نكتب عن تجاربنا، ونشارك ما جرّبناه عمليًا</h1> { href: '/posts/article-agent-guide', label: 'المقالات' },
<p className="lede">أنور بآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى أدلة عربية يمكن تنفيذها في عملك.</p> { href: '/subscription', label: 'العضوية' },
</div> { href: '/about', label: 'عن الفريق' },
{/* eslint-disable-next-line @next/next/no-img-element */} ]}
<img className="about-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} /> />
</div> <SiteMenu
</section> links={[
{ href: '/articles', label: 'المكتبة' },
<section className="about-team" aria-labelledby="team-title"> { href: '/posts/article-agent-guide', label: 'المقالات' },
<div className="wrap"> { href: '/subscription', label: 'العضوية والأسعار' },
<h2 id="team-title">الفريق</h2> ]}
<div className="people"> foot={{ href: '/about', label: '', note: 'محتوى عربي عملي، بلا ضجيج.', variant: 'hidden' }}
<article className="person"> />
<p className="person-role">المؤسس، 20 عاماً في البنية التحتية</p> <main>
<h3>أنور عنبتاوي</h3> <section className="hero" data-od-id="about-hero">
<p>يقود رؤية المنصة بخبرة عقدين في البنية التحتية لتقنية المعلومات. يهتم بتشغيل النماذج الكبيرة محلياً، واستكشاف النماذج المفتوحة، وبناء بيئات تجعل التجربة التقنية قابلة للتشغيل الحقيقي.</p> <div className="container hero-grid">
<ul className="tags" aria-label="مجالات اهتمام أنور" role="list"> <div>
<li>البنية التحتية</li><li>النماذج المفتوحة</li><li>التشغيل المحلي</li><li>الأنظمة الموثوقة</li> <p className="eyebrow">عن أنور بي آي · خبرة تبني وتشرح</p>
</ul> <h1 data-od-id="about-title">خبرة تقنية تتحول إلى معرفة عملية.</h1>
<a className="text-link" href="https://www.linkedin.com/in/anwaranabtawi/" target="_blank" rel="noopener noreferrer">أنور على LinkedIn</a> <p className="lead">يقود أنور عنبتاوي المنصة، ويشاركه أحمد محمد شغف تحويل تقنيات الذكاء الاصطناعي والبنية التحتية إلى تطبيقات مفهومة ونافعة للأعمال.</p>
</article> </div>
<article className="person"> <div className="hero-note" data-od-id="team-mission">
<p className="person-role">الذكاء الاصطناعي التطبيقي وتكامل الأعمال</p> <strong>لا نكتفي بعرض الأدوات.</strong>
<h3>أحمد محمد</h3> <p>نشرح كيف تختارها، تربطها بسير العمل، وتشغّلها بوعي على بنية يمكن الاعتماد عليها.</p>
<p>يركز على ضبط النماذج، وأطر الوكلاء وتنسيقها، وأنظمة <bdi dir="ltr">RAG</bdi>. يهمه أن يصبح الذكاء الاصطناعي جزءاً فعّالاً من طريقة إنجاز العمل، لا تجربة منفصلة عنه.</p> </div>
<ul className="tags" aria-label="مجالات اهتمام أحمد" role="list"> </div>
<li>ضبط النماذج</li><li>أطر الوكلاء</li><li>تنسيق الوكلاء</li><li><bdi dir="ltr">RAG</bdi></li> </section>
</ul>
<a className="text-link" href="https://www.linkedin.com/in/ahmed-mohamed-423583151/" target="_blank" rel="noopener noreferrer">أحمد على LinkedIn</a> <section className="team" id="team-contact" data-od-id="team">
</article> <div className="container">
</div> <div className="section-head">
</div> <h2>العقول خلف أنور بي آي.</h2>
</section> <p>يجمع الفريق بين خبرة طويلة في البنية التحتية واهتمام تطبيقي بالنماذج المفتوحة والوكلاء وأنظمة الاسترجاع.</p>
</div>
<section className="about-principles" aria-labelledby="principles-title"> <div className="profile-grid">
<div className="wrap principles-grid"> <article className="profile profile-main" data-od-id="profile-anwar">
<h2 id="principles-title">كيف نكتب كل دليل</h2> <span className="profile-label">المؤسس الرئيسي · خبرة ٢٠ عاماً</span>
<dl className="principles"> <h3>أنور عنبتاوي</h3>
<div><dt>التطبيق قبل الاستعراض</dt><dd>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</dd></div> <p className="role">متخصص بنية تحتية وتقنيات معلومات</p>
<div><dt>نشرح ما تحت الغطاء</dt><dd>نفضّل الأدوات المفتوحة والتشغيل الذي يمنحك فهماً وتحكماً أكبر في بياناتك ومواردك.</dd></div> <p className="bio">يقود أنور الرؤية الأساسية للمنصة بخبرة تمتد لعقدين في البنية التحتية لتقنية المعلومات. يهتم بتشغيل النماذج الكبيرة محلياً، واستكشاف النماذج المفتوحة، وبناء بيئات قوية تجعل التجربة التقنية قابلة للتشغيل الحقيقي لا للعرض فقط.</p>
<div><dt>حدود قبل الإطلاق</dt><dd>الوكيل أو النموذج لا يكتمل دون مراقبة وحدود واضحة تمنع الفشل الصامت.</dd></div> <div className="topics" aria-label="مجالات اهتمام أنور">
</dl> <span className="topic">البنية التحتية</span>
</div> <span className="topic">النماذج المفتوحة</span>
</section> <span className="topic">تشغيل النماذج محلياً</span>
<span className="topic">الأنظمة الموثوقة</span>
<section className="about-contact"> </div>
<div className="wrap contact-strip"> <a className="profile-link" href="https://www.linkedin.com/in/anwaranabtawi/" target="_blank" rel="noopener noreferrer" data-od-id="anwar-linkedin"><span>ملف أنور على LinkedIn</span><span aria-hidden="true">↗</span></a>
<p>عندك سؤال عن دليل أو فكرة تريد تطبيقها؟</p> </article>
<a className="btn btn-quiet" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> <article className="profile" data-od-id="profile-ahmed">
</div> <span className="profile-label">ذكاء اصطناعي تطبيقي · تكامل الأعمال</span>
</section> <h3>أحمد محمد</h3>
</main> <p className="role">مهتم بهندسة حلول الذكاء الاصطناعي</p>
<Footer /> <p className="bio">يركز أحمد على ضبط النماذج، وأطر الذكاء الاصطناعي الوكيل، وتنسيق الوكلاء، وأنظمة <bdi dir="ltr">RAG</bdi>. اهتمامه الأساسي هو دمج الذكاء الاصطناعي داخل تدفقات الأعمال بحيث يتحول من تجربة منفصلة إلى جزء فعّال من طريقة إنجاز العمل.</p>
</div> <div className="topics" aria-label="مجالات اهتمام أحمد">
) <span className="topic">ضبط النماذج</span>
} <span className="topic">أطر الوكلاء</span>
<span className="topic">تنسيق الوكلاء</span>
<span className="topic"><bdi dir="ltr">RAG</bdi></span>
</div>
<a className="profile-link" href="https://www.linkedin.com/in/ahmed-mohamed-423583151/" target="_blank" rel="noopener noreferrer" data-od-id="ahmed-linkedin"><span>ملف أحمد على LinkedIn</span><span aria-hidden="true">↗</span></a>
</article>
</div>
</div>
</section>
<section className="principles" data-od-id="team-principles">
<div className="container principle-grid">
<div>
<p className="eyebrow">طريقة العمل</p>
<h2>من المختبر إلى سير العمل.</h2>
</div>
<div className="principle-list">
<article className="principle" data-od-id="principle-practical"><span className="principle-index">01</span><div><h3>التطبيق قبل الاستعراض</h3><p>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</p></div></article>
<article className="principle" data-od-id="principle-open"><span className="principle-index">02</span><div><h3>فهم ما يحدث تحت الغطاء</h3><p>نفضّل الأدوات المفتوحة والتشغيل الذي يمنح المستخدم فهماً وتحكماً أكبر في بياناته وموارده.</p></div></article>
<article className="principle" data-od-id="principle-reliable"><span className="principle-index">03</span><div><h3>أنظمة يمكن الاعتماد عليها</h3><p>الوكيل أو النموذج لا يكتمل من دون بنية ومراقبة وحدود واضحة تمنع الفشل الصامت.</p></div></article>
</div>
</div>
</section>
</main>
<Footer />
</>
)
}
+220 -220
View File
@@ -1,220 +1,220 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
/* 4-tab settings — markup from account-settings.html with the real Payload /* 4-tab settings — markup from account-settings.html with the real Payload
endpoints wired (PATCH /api/users/me for profile + password). Prefs stay endpoints wired (PATCH /api/users/me for profile + password). Prefs stay
client-side (localStorage) faithful to the original. Stripe is pending: client-side (localStorage) faithful to the original. Stripe is pending:
the cancel button renders disabled with a note. */ the cancel button renders disabled with a note. */
type Props = { type Props = {
email: string email: string
userId: number userId: number
fullName: string fullName: string
role: string role: string
} }
type Status = { ok: boolean; text: string } | null type Status = { ok: boolean; text: string } | null
const TABS = [ const TABS = [
{ id: 'profile', label: 'الملف الشخصي' }, { id: 'profile', label: 'الملف الشخصي' },
{ id: 'password', label: 'كلمة المرور' }, { id: 'password', label: 'كلمة المرور' },
{ id: 'prefs', label: 'التفضيلات' }, { id: 'prefs', label: 'التفضيلات' },
{ id: 'membership', label: 'العضوية والفوترة' }, { id: 'membership', label: 'العضوية والفوترة' },
] ]
export default function AccountSettingsClient({ email, userId, fullName, role }: Props) { export default function AccountSettingsClient({ email, userId, fullName, role }: Props) {
const router = useRouter() const router = useRouter()
const [tab, setTab] = useState('profile') const [tab, setTab] = useState('profile')
const [name, setName] = useState(fullName) const [name, setName] = useState(fullName)
const [profileStatus, setProfileStatus] = useState<Status>(null) const [profileStatus, setProfileStatus] = useState<Status>(null)
const [profileBusy, setProfileBusy] = useState(false) const [profileBusy, setProfileBusy] = useState(false)
const [currentPw, setCurrentPw] = useState('') const [currentPw, setCurrentPw] = useState('')
const [newPw, setNewPw] = useState('') const [newPw, setNewPw] = useState('')
const [confirmPw, setConfirmPw] = useState('') const [confirmPw, setConfirmPw] = useState('')
const [pwStatus, setPwStatus] = useState<Status>(null) const [pwStatus, setPwStatus] = useState<Status>(null)
const [pwBusy, setPwBusy] = useState(false) const [pwBusy, setPwBusy] = useState(false)
const [prefs, setPrefs] = useState({ weekly: true, replies: true, updates: false }) const [prefs, setPrefs] = useState({ weekly: true, replies: true, updates: false })
const [prefsStatus, setPrefsStatus] = useState<Status>(null) const [prefsStatus, setPrefsStatus] = useState<Status>(null)
const [logoutBusy, setLogoutBusy] = useState(false) const [logoutBusy, setLogoutBusy] = useState(false)
useEffect(() => { useEffect(() => {
try { try {
const raw = localStorage.getItem('anw-prefs') const raw = localStorage.getItem('anw-prefs')
if (raw) setPrefs(p => ({ ...p, ...JSON.parse(raw) })) if (raw) setPrefs(p => ({ ...p, ...JSON.parse(raw) }))
} catch { /* ignore */ } } catch { /* ignore */ }
}, []) }, [])
const rulesOk = { const rulesOk = {
len: newPw.length >= 8, len: newPw.length >= 8,
num: /\d/.test(newPw), num: /\d/.test(newPw),
diff: newPw.length > 0 && newPw !== currentPw, diff: newPw.length > 0 && newPw !== currentPw,
} }
async function saveProfile(e: React.FormEvent) { async function saveProfile(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (name.trim().length < 3) { if (name.trim().length < 3) {
setProfileStatus({ ok: false, text: 'أدخل اسماً من 3 أحرف على الأقل.' }) setProfileStatus({ ok: false, text: 'أدخل اسماً من ٣ أحرف على الأقل.' })
return return
} }
setProfileBusy(true) setProfileBusy(true)
const res = await fetch(`/api/users/${userId}`, { const res = await fetch(`/api/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fullName: name.trim() }), body: JSON.stringify({ fullName: name.trim() }),
}) })
setProfileStatus(res.ok setProfileStatus(res.ok
? { ok: true, text: 'تم حفظ الاسم ✓' } ? { ok: true, text: 'تم حفظ الاسم ✓' }
: { ok: false, text: 'تعذّر الحفظ — أعد المحاولة.' }) : { ok: false, text: 'تعذّر الحفظ — أعد المحاولة.' })
setProfileBusy(false) setProfileBusy(false)
router.refresh() router.refresh()
} }
async function changePassword(e: React.FormEvent) { async function changePassword(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!currentPw) { setPwStatus({ ok: false, text: 'أدخل كلمة المرور الحالية.' }); return } if (!currentPw) { setPwStatus({ ok: false, text: 'أدخل كلمة المرور الحالية.' }); return }
if (!(rulesOk.len && rulesOk.num)) { setPwStatus({ ok: false, text: 'كلمة المرور الجديدة لا تحقق الشروط أعلاه.' }); return } if (!(rulesOk.len && rulesOk.num)) { setPwStatus({ ok: false, text: 'كلمة المرور الجديدة لا تحقق الشروط أعلاه.' }); return }
if (newPw === currentPw) { setPwStatus({ ok: false, text: 'كلمة المرور الجديدة تطابق الحالية.' }); return } if (newPw === currentPw) { setPwStatus({ ok: false, text: 'كلمة المرور الجديدة تطابق الحالية.' }); return }
if (newPw !== confirmPw) { setPwStatus({ ok: false, text: 'التأكيد لا يطابق كلمة المرور الجديدة.' }); return } if (newPw !== confirmPw) { setPwStatus({ ok: false, text: 'التأكيد لا يطابق كلمة المرور الجديدة.' }); return }
setPwBusy(true) setPwBusy(true)
const res = await fetch(`/api/users/${userId}`, { const res = await fetch(`/api/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPw }), body: JSON.stringify({ password: newPw }),
}) })
setPwStatus(res.ok setPwStatus(res.ok
? { ok: true, text: 'تم تحديث كلمة المرور ✓' } ? { ok: true, text: 'تم تحديث كلمة المرور ✓' }
: { ok: false, text: 'تعذّر التحديث — تحقق من كلمة المرور الحالية أو أعد المحاولة.' }) : { ok: false, text: 'تعذّر التحديث — تحقق من كلمة المرور الحالية أو أعد المحاولة.' })
if (res.ok) { if (res.ok) {
setCurrentPw(''); setNewPw(''); setConfirmPw('') setCurrentPw(''); setNewPw(''); setConfirmPw('')
} }
setPwBusy(false) setPwBusy(false)
} }
function savePrefs() { function savePrefs() {
try { localStorage.setItem('anw-prefs', JSON.stringify(prefs)) } catch { /* ignore */ } try { localStorage.setItem('anw-prefs', JSON.stringify(prefs)) } catch { /* ignore */ }
setPrefsStatus({ ok: true, text: 'تم حفظ التفضيلات ✓' }) setPrefsStatus({ ok: true, text: 'تم حفظ التفضيلات ✓' })
} }
async function logout() { async function logout() {
setLogoutBusy(true) setLogoutBusy(true)
await fetch('/api/users/logout', { method: 'POST' }) await fetch('/api/users/logout', { method: 'POST' })
router.refresh() router.refresh()
router.push('/') router.push('/')
} }
const entitled = role === 'vip' || role === 'admin' const entitled = role === 'vip' || role === 'admin'
return ( return (
<> <>
<nav className="settings-nav" role="tablist" aria-label="أقسام الإعدادات" data-od-id="settings-nav"> <nav className="settings-nav" role="tablist" aria-label="أقسام الإعدادات" data-od-id="settings-nav">
{TABS.map(t => ( {TABS.map(t => (
<button role="tab" key={t.id} id={`tab-${t.id}`} aria-selected={tab === t.id} aria-controls={`panel-${t.id}`} data-tab={t.id} onClick={() => setTab(t.id)}> <button role="tab" key={t.id} id={`tab-${t.id}`} aria-selected={tab === t.id} aria-controls={`panel-${t.id}`} data-tab={t.id} onClick={() => setTab(t.id)}>
{t.label} {t.label}
</button> </button>
))} ))}
</nav> </nav>
<div className="panels"> <div className="panels">
<section className={`panel${tab === 'profile' ? ' active' : ''}`} id="panel-profile" role="tabpanel" aria-labelledby="tab-profile" data-od-id="panel-profile"> <section className={`panel${tab === 'profile' ? ' active' : ''}`} id="panel-profile" role="tabpanel" aria-labelledby="tab-profile" data-od-id="panel-profile">
<h2>الملف الشخصي</h2> <h2>الملف الشخصي</h2>
<p className="panel-sub">الاسم كما يظهر في تعليقاتك وقوائم الحفظ. البريد الإلكتروني هو معرّف الدخول ولا يتغير من هنا.</p> <p className="panel-sub">الاسم كما يظهر في تعليقاتك وقوائم الحفظ. البريد الإلكتروني هو معرّف الدخول ولا يتغير من هنا.</p>
<form className="form-grid" id="profileForm" noValidate onSubmit={saveProfile}> <form className="form-grid" id="profileForm" noValidate onSubmit={saveProfile}>
<div className="field"><label htmlFor="fullName">الاسم الكامل</label><input className="input" id="fullName" name="fullName" value={name} onChange={e => setName(e.target.value)} autoComplete="name" required /></div> <div className="field"><label htmlFor="fullName">الاسم الكامل</label><input className="input" id="fullName" name="fullName" value={name} onChange={e => setName(e.target.value)} autoComplete="name" required /></div>
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" value={email} disabled /><span className="hint">لتغيير البريد تواصل معنا على <bdi>hello@anwarpy.ai</bdi></span></div> <div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" value={email} disabled /><span className="hint">لتغيير البريد تواصل معنا على <bdi>hello@anwarpi.ai</bdi></span></div>
<button className="btn btn-primary" type="submit" data-od-id="save-profile" disabled={profileBusy}>{profileBusy ? 'جارٍ الحفظ…' : 'حفظ التغييرات'}</button> <button className="btn btn-primary" type="submit" data-od-id="save-profile" disabled={profileBusy}>{profileBusy ? 'جارٍ الحفظ…' : 'حفظ التغييرات'}</button>
<p className={`status ${profileStatus?.ok ? 'ok' : profileStatus ? 'err' : ''}`} id="profileStatus" role="status">{profileStatus?.text}</p> <p className={`status ${profileStatus?.ok ? 'ok' : profileStatus ? 'err' : ''}`} id="profileStatus" role="status">{profileStatus?.text}</p>
</form> </form>
</section> </section>
<section className={`panel${tab === 'password' ? ' active' : ''}`} id="panel-password" role="tabpanel" aria-labelledby="tab-password" data-od-id="panel-password"> <section className={`panel${tab === 'password' ? ' active' : ''}`} id="panel-password" role="tabpanel" aria-labelledby="tab-password" data-od-id="panel-password">
<h2>تغيير كلمة المرور</h2> <h2>تغيير كلمة المرور</h2>
<p className="panel-sub">بعد التغيير تبقى جلستك الحالية مفتوحة، وتُطلب كلمة المرور الجديدة عند الدخول من جهاز آخر.</p> <p className="panel-sub">بعد التغيير تبقى جلستك الحالية مفتوحة، وتُطلب كلمة المرور الجديدة عند الدخول من جهاز آخر.</p>
<form className="form-grid" id="passwordForm" noValidate onSubmit={changePassword}> <form className="form-grid" id="passwordForm" noValidate onSubmit={changePassword}>
<div className="field"><label htmlFor="currentPw">كلمة المرور الحالية</label><input className="input" id="currentPw" type="password" name="currentPw" autoComplete="current-password" required value={currentPw} onChange={e => setCurrentPw(e.target.value)} /></div> <div className="field"><label htmlFor="currentPw">كلمة المرور الحالية</label><input className="input" id="currentPw" type="password" name="currentPw" autoComplete="current-password" required value={currentPw} onChange={e => setCurrentPw(e.target.value)} /></div>
<div className="field"><label htmlFor="newPw">كلمة المرور الجديدة</label><input className="input" id="newPw" type="password" name="newPw" autoComplete="new-password" required minLength={8} aria-describedby="pwRules" value={newPw} onChange={e => setNewPw(e.target.value)} /></div> <div className="field"><label htmlFor="newPw">كلمة المرور الجديدة</label><input className="input" id="newPw" type="password" name="newPw" autoComplete="new-password" required minLength={8} aria-describedby="pwRules" value={newPw} onChange={e => setNewPw(e.target.value)} /></div>
<ul className="pw-rules" id="pwRules"> <ul className="pw-rules" id="pwRules">
<li data-rule="len" className={rulesOk.len ? 'ok' : ''}>8 أحرف على الأقل</li> <li data-rule="len" className={rulesOk.len ? 'ok' : ''}>٨ أحرف على الأقل</li>
<li data-rule="num" className={rulesOk.num ? 'ok' : ''}>تحتوي رقماً واحداً على الأقل</li> <li data-rule="num" className={rulesOk.num ? 'ok' : ''}>تحتوي رقماً واحداً على الأقل</li>
<li data-rule="diff" className={rulesOk.diff ? 'ok' : ''}>تختلف عن كلمة المرور الحالية</li> <li data-rule="diff" className={rulesOk.diff ? 'ok' : ''}>تختلف عن كلمة المرور الحالية</li>
</ul> </ul>
<div className="field"><label htmlFor="confirmPw">تأكيد كلمة المرور</label><input className="input" id="confirmPw" type="password" name="confirmPw" autoComplete="new-password" required value={confirmPw} onChange={e => setConfirmPw(e.target.value)} /></div> <div className="field"><label htmlFor="confirmPw">تأكيد كلمة المرور</label><input className="input" id="confirmPw" type="password" name="confirmPw" autoComplete="new-password" required value={confirmPw} onChange={e => setConfirmPw(e.target.value)} /></div>
<button className="btn btn-primary" type="submit" data-od-id="save-password" disabled={pwBusy}>{pwBusy ? 'جارٍ التحديث…' : 'تحديث كلمة المرور'}</button> <button className="btn btn-primary" type="submit" data-od-id="save-password" disabled={pwBusy}>{pwBusy ? 'جارٍ التحديث…' : 'تحديث كلمة المرور'}</button>
<p className={`status ${pwStatus?.ok ? 'ok' : pwStatus ? 'err' : ''}`} id="passwordStatus" role="status" aria-live="polite">{pwStatus?.text}</p> <p className={`status ${pwStatus?.ok ? 'ok' : pwStatus ? 'err' : ''}`} id="passwordStatus" role="status" aria-live="polite">{pwStatus?.text}</p>
</form> </form>
</section> </section>
<section className={`panel${tab === 'prefs' ? ' active' : ''}`} id="panel-prefs" role="tabpanel" aria-labelledby="tab-prefs" data-od-id="panel-prefs"> <section className={`panel${tab === 'prefs' ? ' active' : ''}`} id="panel-prefs" role="tabpanel" aria-labelledby="tab-prefs" data-od-id="panel-prefs">
<h2>التفضيلات</h2> <h2>التفضيلات</h2>
<p className="panel-sub">رسائل أنور بآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p> <p className="panel-sub">رسائل أنور بي آي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p>
<div className="prefs"> <div className="prefs">
<div className="pref-row"><div><strong>الرسالة الأسبوعية</strong><p>خلاصة ما نُشر من أدلة ومقالات، كل يوم أحد.</p></div> <div className="pref-row"><div><strong>الرسالة الأسبوعية</strong><p>خلاصة ما نُشر من أدلة ومقالات، كل يوم أحد.</p></div>
<label className="switch"><input type="checkbox" id="prefWeekly" aria-label="تفعيل الرسالة الأسبوعية" checked={prefs.weekly} onChange={e => setPrefs(p => ({ ...p, weekly: e.target.checked }))} /><span className="track"></span></label></div> <label className="switch"><input type="checkbox" id="prefWeekly" aria-label="تفعيل الرسالة الأسبوعية" checked={prefs.weekly} onChange={e => setPrefs(p => ({ ...p, weekly: e.target.checked }))} /><span className="track"></span></label></div>
<div className="pref-row"><div><strong>إشعارات التعليقات</strong><p>رسالة عند ردّ أحد الأعضاء على تعليقك.</p></div> <div className="pref-row"><div><strong>إشعارات التعليقات</strong><p>رسالة عند ردّ أحد الأعضاء على تعليقك.</p></div>
<label className="switch"><input type="checkbox" id="prefReplies" aria-label="تفعيل إشعارات التعليقات" checked={prefs.replies} onChange={e => setPrefs(p => ({ ...p, replies: e.target.checked }))} /><span className="track"></span></label></div> <label className="switch"><input type="checkbox" id="prefReplies" aria-label="تفعيل إشعارات التعليقات" checked={prefs.replies} onChange={e => setPrefs(p => ({ ...p, replies: e.target.checked }))} /><span className="track"></span></label></div>
<div className="pref-row"><div><strong>تحديثات الأدلة المحفوظة</strong><p>إشعار عندما يُحدَّث دليل حفظته للعودة إليه.</p></div> <div className="pref-row"><div><strong>تحديثات الأدلة المحفوظة</strong><p>إشعار عندما يُحدَّث دليل حفظته للعودة إليه.</p></div>
<label className="switch"><input type="checkbox" id="prefUpdates" aria-label="تفعيل تحديثات الأدلة المحفوظة" checked={prefs.updates} onChange={e => setPrefs(p => ({ ...p, updates: e.target.checked }))} /><span className="track"></span></label></div> <label className="switch"><input type="checkbox" id="prefUpdates" aria-label="تفعيل تحديثات الأدلة المحفوظة" checked={prefs.updates} onChange={e => setPrefs(p => ({ ...p, updates: e.target.checked }))} /><span className="track"></span></label></div>
</div> </div>
<button className="btn btn-primary" type="button" id="savePrefs" style={{ marginTop: 28 }} data-od-id="save-prefs" onClick={savePrefs}>حفظ التفضيلات</button> <button className="btn btn-primary" type="button" id="savePrefs" style={{ marginTop: 28 }} data-od-id="save-prefs" onClick={savePrefs}>حفظ التفضيلات</button>
<p className={`status ${prefsStatus?.ok ? 'ok' : prefsStatus ? 'err' : ''}`} id="prefsStatus" role="status">{prefsStatus?.text}</p> <p className={`status ${prefsStatus?.ok ? 'ok' : prefsStatus ? 'err' : ''}`} id="prefsStatus" role="status">{prefsStatus?.text}</p>
</section> </section>
<section className={`panel${tab === 'membership' ? ' active' : ''}`} id="panel-membership" role="tabpanel" aria-labelledby="tab-membership" data-od-id="panel-membership"> <section className={`panel${tab === 'membership' ? ' active' : ''}`} id="panel-membership" role="tabpanel" aria-labelledby="tab-membership" data-od-id="panel-membership">
<h2>العضوية والفوترة</h2> <h2>العضوية والفوترة</h2>
<p className="panel-sub">خطتك وحالة وصولك. الإلغاء يوقف التجديد التالي ويحفظ وصولك حتى نهاية الفترة المدفوعة.</p> <p className="panel-sub">خطتك وحالة وصولك. الإلغاء يوقف التجديد التالي ويحفظ وصولك حتى نهاية الفترة المدفوعة.</p>
<article className="membership-card" data-od-id="membership-card"> <article className="membership-card" data-od-id="membership-card">
<div className="membership-head"> <div className="membership-head">
<div><h3 id="planName">{entitled ? 'عضوية فردية' : 'حساب مجاني'}</h3><span className="hint">الحالة الحقيقية لحسابك</span></div> <div><h3 id="planName">{entitled ? 'عضوية فردية' : 'حساب مجاني'}</h3><span className="hint">الحالة الحقيقية لحسابك</span></div>
<span className={`state-chip${entitled ? '' : ' cancelled'}`} id="planState">{entitled ? 'نشطة' : role === 'admin' ? 'إداري' : 'مجاني'}</span> <span className={`state-chip${entitled ? '' : ' cancelled'}`} id="planState">{entitled ? 'نشطة' : role === 'admin' ? 'إداري' : 'مجاني'}</span>
</div> </div>
<div className="plan-details"> <div className="plan-details">
<div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div> <div><span className="price-tag"><bdi dir="ltr">10$</bdi><small>شهرياً</small></span></div>
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : 'الأدلة المجانية ومعاينة الأدلة'}</dd></dl> <dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : 'المقالات المجانية ومعاينة الأدلة'}</dd></dl>
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">ستُربط عبر Stripe</dd></dl> <dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">ستُربط عبر Stripe</dd></dl>
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl> <dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
</div> </div>
<div className="membership-actions" id="membershipActions"> <div className="membership-actions" id="membershipActions">
{!entitled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>} {!entitled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
</div> </div>
</article> </article>
<div className="cancel-zone" id="cancelZone" data-od-id="cancel-zone"> <div className="cancel-zone" id="cancelZone" data-od-id="cancel-zone">
<h3>إلغاء الاشتراك</h3> <h3>إلغاء الاشتراك</h3>
{entitled ? ( {entitled ? (
<> <>
<p>ربط الدفع (Stripe) غير مكتمل بعد — الإلغاء الذاتي سيتاح بعد ربط الدفع. حتى ذلك الحين تواصل معنا على <bdi>hello@anwarpy.ai</bdi> لأي تغيير.</p> <p>ربط الدفع (Stripe) غير مكتمل بعد — الإلغاء الذاتي سيتاح بعد ربط الدفع. حتى ذلك الحين تواصل معنا على <bdi>hello@anwarpi.ai</bdi> لأي تغيير.</p>
<button className="text-danger" type="button" id="cancelOpen" data-od-id="cancel-open" disabled>إلغاء الاشتراك</button> <button className="text-danger" type="button" id="cancelOpen" data-od-id="cancel-open" disabled>إلغاء الاشتراك</button>
<p className="hint">سيتاح بعد ربط الدفع</p> <p className="hint">سيتاح بعد ربط الدفع</p>
</> </>
) : ( ) : (
<p>لا يوجد اشتراك مدفوع لإلغائه. يمكنك الترقية في أي وقت من صفحة <Link className="text-link" href="/subscription">العضوية</Link>.</p> <p>لا يوجد اشتراك مدفوع لإلغائه. يمكنك الترقية في أي وقت من صفحة <Link className="text-link" href="/subscription">العضوية</Link>.</p>
)} )}
</div> </div>
<div className="billing" data-od-id="billing-history"> <div className="billing" data-od-id="billing-history">
<h3>سجل الفواتير</h3> <h3>سجل الفواتير</h3>
<p className="billing-sub">كل دفعة تُصدر فاتورة ضريبية فور اكتمالها وترسل إلى بريدك المسجّل. الفواتير متاحة للتنزيل في أي وقت.</p> <p className="billing-sub">كل دفعة تُصدر فاتورة ضريبية فور اكتمالها وترسل إلى بريدك المسجّل. الفواتير متاحة للتنزيل في أي وقت.</p>
<div className="billing-table" role="table" aria-label="سجل الفواتير"> <div className="billing-table" role="table" aria-label="سجل الفواتير">
<div className="billing-row head" role="row"> <div className="billing-row head" role="row">
<span role="columnheader">التاريخ</span><span role="columnheader">الوصف</span><span role="columnheader">المبلغ</span><span role="columnheader">الحالة</span><span role="columnheader">الفاتورة</span> <span role="columnheader">التاريخ</span><span role="columnheader">الوصف</span><span role="columnheader">المبلغ</span><span role="columnheader">الحالة</span><span role="columnheader">الفاتورة</span>
</div> </div>
<div className="billing-row" role="row"> <div className="billing-row" role="row">
<span className="bdate" role="cell">—</span> <span className="bdate" role="cell">—</span>
<span className="desc" role="cell">لا فواتير بعد — ربط الدفع قادم</span> <span className="desc" role="cell">لا فواتير بعد — ربط الدفع قادم</span>
<span className="amt" role="cell">—</span> <span className="amt" role="cell">—</span>
<span role="cell"><span className="hint">—</span></span> <span role="cell"><span className="hint">—</span></span>
<span role="cell"><span className="hint">—</span></span> <span role="cell"><span className="hint">—</span></span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<div className="form-actions" style={{ marginTop: 32 }}> <div className="form-actions" style={{ marginTop: 32 }}>
<button className="btn btn-secondary" type="button" onClick={logout} disabled={logoutBusy}>تسجيل الخروج</button> <button className="btn btn-secondary" type="button" onClick={logout} disabled={logoutBusy}>تسجيل الخروج</button>
</div> </div>
</> </>
) )
} }
@@ -1,51 +1,64 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import SiteMenu from '@/components/site-menu'
import AccountSettingsClient from './client' import Footer from '@/components/footer'
import { formatDate } from '@/lib/format' import AccountSettingsClient from './client'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import '../styles/account-settings.css' import '../styles/account-settings.css'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { title: 'إعدادات الحساب' } export const metadata = { title: 'إعدادات الحساب' }
export default async function AccountSettingsPage() { export default async function AccountSettingsPage() {
const payload = await getPayload({ config: configPromise }) const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() }) const { user } = await payload.auth({ headers: await headers() })
return ( return (
<div id="site" className="site-page page-account-settings"> <>
<Topnav viewer={user} /> <Topnav
{user ? ( viewer={user ? { email: String(user.email) } : null}
<main id="content"> accountLabel={user ? undefined : undefined}
<section className="page-head" data-od-id="settings-header"><div className="container"> menu
<p className="crumb">حسابك في أنور بآي</p> links={[
<h1 data-od-id="settings-title">إعدادات الحساب</h1> { href: '/articles', label: 'المكتبة' },
<p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? formatDate(String(user.createdAt)) : 'اليوم'}</p> { href: '/posts/article-agent-guide', label: 'المقالات' },
</div></section> { href: '/subscription', label: 'العضوية' },
<section className="container settings-layout" data-od-id="settings-layout"> ]}
<AccountSettingsClient />
email={String(user.email)} <SiteMenu
userId={Number(user.id)} links={[
fullName={(user.fullName as string) || ''} { href: '/articles', label: 'المكتبة' },
role={((user.role as string) || 'free')} { href: '/posts/agent-loop-stop-condition', label: 'دليل الأعضاء' },
/> { href: '/subscription', label: 'العضوية' },
</section> ]}
</main> />
) : ( {user ? (
<main id="content" className="gate"> <main>
<section className="gate-card"> <section className="page-head" data-od-id="settings-header"><div className="container">
<h1>سجّل الدخول لإدارة حسابك</h1> <p className="crumb">حسابك في أنور بي آي</p>
<p>من هنا تعدّل اسمك وكلمة المرور وتفضيلات الرسائل، وتدير عضويتك.</p> <h1 data-od-id="settings-title">إعدادات الحساب</h1>
<div className="actions"> <p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? new Date(String(user.createdAt)).toLocaleDateString('ar') : 'اليوم'}</p>
<a className="btn btn-primary" href="/login?next=%2Faccount-settings">تسجيل الدخول</a> </div></section>
<a className="btn btn-quiet" href="/signup">إنشاء حساب مجاني</a> <section className="container settings-layout" data-od-id="settings-layout">
</div> <AccountSettingsClient
</section> email={String(user.email)}
</main> userId={Number(user.id)}
)} fullName={(user.fullName as string) || ''}
<Footer /> role={((user.role as string) || 'free')}
</div> />
) </section>
} </main>
) : (
<main>
<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">يجب <a className="text-link" href="/login">تسجيل الدخول</a> لعرض إعدادات الحساب.</p>
</div></section>
</main>
)}
<Footer />
</>
)
}
@@ -1,14 +1,12 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { guidesLabel } from '@/lib/format'
/* Library controller — search/sort/filters/likes from articles.html, wired to /* Library controller — search/sort/filters/likes from articles.html, wired to
the real backend: likes toggle via /api/posts/[slug]/like (401 → login), the real backend: likes toggle via /api/posts/[slug]/like (401 → login),
views come from the post-page counter, sort reads data-* attributes. */ views come from the post-page counter, sort reads data-* attributes. */
export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) { export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) {
useEffect(() => { useEffect(() => {
const controller = new AbortController()
const params = new URLSearchParams(location.search) const params = new URLSearchParams(location.search)
const allowedTopics = ['all', 'agents', 'models', 'automation', 'foundations'] const allowedTopics = ['all', 'agents', 'models', 'automation', 'foundations']
const allowedAccess = ['all', 'free', 'vip'] const allowedAccess = ['all', 'free', 'vip']
@@ -25,7 +23,7 @@ export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) {
const empty = document.getElementById('emptyState') const empty = document.getElementById('emptyState')
const search = document.getElementById('postSearch') as HTMLInputElement | null const search = document.getElementById('postSearch') as HTMLInputElement | null
const sort = document.getElementById('postSort') as HTMLSelectElement | null const sort = document.getElementById('postSort') as HTMLSelectElement | null
const formatNumber = (value: number) => new Intl.NumberFormat('en-US').format(value) const formatNumber = (value: number) => new Intl.NumberFormat('ar').format(value)
function normalize(value: string) { function normalize(value: string) {
return value.toLocaleLowerCase('ar').normalize('NFKD').replace(/[\u064B-\u065F\u0670]/g, '').replace(/[إأآ]/g, 'ا').replace(/ى/g, 'ي') return value.toLocaleLowerCase('ar').normalize('NFKD').replace(/[\u064B-\u065F\u0670]/g, '').replace(/[إأآ]/g, 'ا').replace(/ى/g, 'ي')
@@ -35,7 +33,7 @@ export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) {
const views = Number(post.dataset.viewsCount || 0) const views = Number(post.dataset.viewsCount || 0)
const likes = Number(post.dataset.likesCount || 0) const likes = Number(post.dataset.likesCount || 0)
const viewsEl = post.querySelector('[data-views]') const viewsEl = post.querySelector('[data-views]')
if (viewsEl) viewsEl.textContent = `${formatNumber(views)} قراءة` if (viewsEl) viewsEl.textContent = `${formatNumber(views)} مشاهدة`
const like = post.querySelector('[data-like]') as HTMLElement | null const like = post.querySelector('[data-like]') as HTMLElement | null
if (like) { if (like) {
like.setAttribute('aria-pressed', post.dataset.liked === '1' ? 'true' : 'false') like.setAttribute('aria-pressed', post.dataset.liked === '1' ? 'true' : 'false')
@@ -53,13 +51,13 @@ export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) {
ordered.forEach(post => list.appendChild(post)) ordered.forEach(post => list.appendChild(post))
let visible = 0 let visible = 0
posts.forEach(post => { posts.forEach(post => {
const topicMatch = state.topic === 'all' || (post.dataset.topic || '').split(' ').includes(state.topic) const topicMatch = state.topic === 'all' || post.dataset.topic === state.topic
const accessMatch = state.access === 'all' || post.dataset.access === state.access const accessMatch = state.access === 'all' || post.dataset.access === state.access
const queryMatch = !state.query || normalize(post.textContent || '').includes(normalize(state.query)) const queryMatch = !state.query || normalize(post.textContent || '').includes(normalize(state.query))
post.hidden = !(topicMatch && accessMatch && queryMatch) post.hidden = !(topicMatch && accessMatch && queryMatch)
if (!post.hidden) visible += 1 if (!post.hidden) visible += 1
}) })
if (count) count.textContent = visible === 0 ? 'لا نتائج' : guidesLabel(visible) if (count) count.textContent = `${formatNumber(visible)} ${visible === 1 ? 'مادة' : 'مواد'}`
empty?.classList.toggle('show', visible === 0) empty?.classList.toggle('show', visible === 0)
} }
@@ -71,10 +69,10 @@ export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) {
}) })
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
applyFilters() applyFilters()
}, { signal: controller.signal })) }))
search?.addEventListener('input', () => { state.query = search.value.trim(); applyFilters() }, { signal: controller.signal }) search?.addEventListener('input', () => { state.query = search.value.trim(); applyFilters() })
sort?.addEventListener('change', () => { state.sort = sort.value; applyFilters() }, { signal: controller.signal }) sort?.addEventListener('change', () => { state.sort = sort.value; applyFilters() })
posts.forEach(post => { posts.forEach(post => {
paintMetrics(post) paintMetrics(post)
@@ -92,12 +90,11 @@ export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) {
post.dataset.liked = data.liked ? '1' : '0' post.dataset.liked = data.liked ? '1' : '0'
paintMetrics(post) paintMetrics(post)
applyFilters() applyFilters()
}, { signal: controller.signal }) })
}) })
buttons.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.filterValue === state[button.dataset.filterKind!]))) buttons.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.filterValue === state[button.dataset.filterKind!])))
applyFilters() applyFilters()
return () => controller.abort()
}, [loggedIn]) }, [loggedIn])
return null return null
+120 -71
View File
@@ -1,16 +1,30 @@
import Topnav from '@/components/topnav'
import Footer from '@/components/footer'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import Link from 'next/link' import Link from 'next/link'
import ArticlesClient from './articles-client' import ArticlesClient from './articles-client'
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics' import '../styles/articles.css'
import { formatNumber, guidesLabel, minutesLabel } from '@/lib/format'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { title: 'المكتبة' } export const metadata = { title: 'المكتبة' }
const TOPIC_MAP: Record<string, string> = {
'وكلاء': 'agents',
'نماذج': 'models',
'استرجاع': 'models',
'سلامة': 'agents',
'أدوات': 'agents',
'أعمال': 'automation',
}
/* Interest (member onboarding) → library topic, for personalization boost */
const INTEREST_MAP: Record<string, string> = {
'الوكلاء': 'agents',
'RAG': 'models',
'الأتمتة': 'automation',
'النماذج المفتوحة': 'models',
}
type PostDoc = { type PostDoc = {
id: number id: number
title: string title: string
@@ -23,23 +37,26 @@ type PostDoc = {
views: number views: number
likes: number likes: number
publishedAt: string publishedAt: string
cover?: { url?: string | null } | number | null
} }
type Viewer = { id: number; email: string; role: string; interest: string | null; level: string | null } | null type Viewer = { id: number; email: string; role: string; interest: string | null; level: string | null } | null
function toArabic(n: number): string {
return String(n).replace(/\d/g, d => '٠١٢٣٤٥٦٧٨٩'[Number(d)])
}
export default async function ArticlesPage() { export default async function ArticlesPage() {
const payload = await getPayload({ config: configPromise }) const payload = await getPayload({ config: configPromise })
const [res, auth] = await Promise.all([ const [res, auth] = await Promise.all([
payload.find({ payload.find({
collection: 'posts', collection: 'posts',
where: { status: { equals: 'published' } }, where: { status: { equals: 'published' } },
depth: 1, depth: 0,
limit: 100, limit: 100,
sort: '-publishedAt', sort: '-publishedAt',
select: { select: {
title: true, slug: true, dek: true, access: true, category: true, level: true, title: true, slug: true, dek: true, access: true, category: true, level: true,
readingMinutes: true, views: true, likes: true, publishedAt: true, cover: true, readingMinutes: true, views: true, likes: true, publishedAt: true,
}, },
}), }),
payload.auth({ headers: await headers() }), payload.auth({ headers: await headers() }),
@@ -73,101 +90,133 @@ export default async function ArticlesPage() {
posts.forEach((post, i) => { posts.forEach((post, i) => {
const el = post as unknown as { _order?: number; _match?: boolean } const el = post as unknown as { _order?: number; _match?: boolean }
el._order = posts.length - i el._order = posts.length - i
el._match = !!interestTopic && topicsFor(post).some(topic => topic === interestTopic) && post.level === viewer!.level el._match = !!interestTopic && TOPIC_MAP[post.category] === interestTopic && post.level === viewer!.level
if (el._match) el._order += 1000 if (el._match) el._order += 1000
}) })
const freeCount = posts.filter(p => p.access === 'free').length const weekly = posts.find(p => p.slug === 'article-agent-guide')
return ( return (
<div id="site" className="site-page v2 v2-library"> <>
<Topnav viewer={viewer} /> <TopnavStatic loggedIn={!!viewer} />
<ArticlesClient loggedIn={!!viewer} /> <ArticlesClient loggedIn={!!viewer} />
<main id="content"> <main id="content">
<section className="lib-head" aria-labelledby="library-title"> <section className="hero" data-od-id="library-hero">
<div className="wrap"> <div className="container hero-grid">
<h1 id="library-title">المكتبة</h1> <div>
<p className="lede">تضم المكتبة {guidesLabel(posts.length)}، منها {guidesLabel(freeCount)} متاحة مجاناً بالكامل. ابحث بالموضوع أو صفِّ النتائج حسب المسار.</p> <p className="eyebrow">مكتبة أنور بي آي</p>
<div className="lib-tools"> <h1 data-od-id="library-title">كل المقالات. بلا ضياع.</h1>
<label className="lib-search"> <p className="lead">ابحث، اختر مسارك، ورتّب النتائج حسب الأحدث أو التفاعل المسجّل.</p>
<span className="sr-only">ابحث في العناوين والوصف</span>
<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M13 13l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
<input id="postSearch" type="search" autoComplete="off" placeholder="ابحث: وكيل، RAG، اختيار النموذج…" />
</label>
<label className="lib-sort">
<span>الترتيب</span>
<select id="postSort"><option value="latest">الأحدث</option><option value="views">الأكثر قراءة</option><option value="likes">الأكثر إعجاباً</option></select>
</label>
</div>
<div className="lib-filters">
<div className="segmented" role="group" aria-label="المسار">
<button type="button" data-filter-kind="topic" data-filter-value="all" aria-pressed="true">كل المسارات</button>
{TOPICS.map(topic => (
<button type="button" data-filter-kind="topic" data-filter-value={topic.key} aria-pressed="false" key={topic.key}>{topic.label}</button>
))}
</div>
<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>
</div>
</div> </div>
{weekly && (
<a className="weekly" href={`/posts/${weekly.slug}`} data-od-id="weekly-post">
<div className="weekly-meta"><span>مقال هذا الأسبوع</span><span>مجاني · {toArabic(weekly.readingMinutes)} دقيقة</span></div>
<h2>{weekly.title}</h2>
<p>{weekly.dek}</p>
<span className="weekly-link">اقرأ المقال ←</span>
</a>
)}
</div> </div>
</section> </section>
<section className="lib-results" aria-label="الأدلة"> <section className="library" data-od-id="article-library">
<div className="wrap"> <div className="container">
<p className="results-count" id="resultCount" aria-live="polite">{guidesLabel(posts.length)}</p> <div className="library-tools" data-od-id="library-search-sort">
<div className="lib-grid" id="postList"> <label><span className="filter-label">ابحث في العناوين والوصف</span><input className="control" id="postSearch" type="search" autoComplete="off" placeholder="مثال: الوكلاء، RAG، اختيار النموذج" /></label>
<label><span className="filter-label">الترتيب</span><select className="control" id="postSort"><option value="latest">الأحدث أولاً</option><option value="views">الأكثر مشاهدة</option><option value="likes">الأكثر إعجاباً</option></select></label>
</div>
<div className="filter-panel" data-od-id="library-filters">
<div className="filter-group" role="group" aria-label="تصفية حسب المسار">
<span className="filter-label">المسار / الفئة</span>
<div className="filters">
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="all" aria-pressed="true">الكل</button>
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="agents" aria-pressed="false">الوكلاء</button>
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="models" aria-pressed="false">النماذج</button>
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="automation" aria-pressed="false">الأتمتة</button>
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="foundations" aria-pressed="false">الأساسيات</button>
</div>
</div>
<div className="filter-group" role="group" aria-label="تصفية حسب نوع الوصول">
<span className="filter-label">نوع الوصول</span>
<div className="filters">
<button className="filter" type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
<button className="filter" type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
<button className="filter" type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">VIP</button>
</div>
</div>
</div>
<div className="results-head">
<h2 data-od-id="posts-heading">جميع المواد</h2>
<span className="count" id="resultCount" aria-live="polite">{toArabic(posts.length)} مواد</span>
</div>
<div className="posts" id="postList">
{posts.map(post => { {posts.map(post => {
const meta = post as unknown as { _order?: number; _match?: boolean } const meta = post as unknown as { _order?: number; _match?: boolean }
const topics = topicsFor(post) const topic = TOPIC_MAP[post.category] ?? 'agents'
const coverUrl = typeof post.cover === 'object' ? post.cover?.url ?? null : null
return ( return (
<article <article
className="post guide-card" className="post"
key={post.id} key={post.id}
data-topic={topics.join(' ')} data-topic={topic}
data-access={post.access} data-access={post.access}
data-level={post.level} data-level={post.level}
data-views-count={post.views ?? 0} data-views-count={post.views ?? 0}
data-likes-count={post.likes ?? 0} data-likes-count={post.likes ?? 0}
data-liked={likedSlugs.has(post.slug) ? '1' : '0'} data-liked={likedSlugs.has(post.slug) ? '1' : '0'}
data-order={meta._order} data-order={meta._order}
data-od-id={`post-${post.slug}`}
> >
{coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />} <div className="post-meta">
<div className="guide-card-body"> <span>{post.category}</span>
<p className="meta-row"> <span className="num">{toArabic(post.readingMinutes)} دقيقة · {post.level}</span>
<span>{post.category}</span> {meta._match && <span className="foryou">يناسب مستواك</span>}
<span>{minutesLabel(post.readingMinutes)}</span> </div>
<span>{post.level}</span> <div><h3>{post.title}</h3><p>{post.dek}</p><Link className="post-link" href={`/posts/${post.slug}`}>{post.access === 'free' ? 'ابدأ القراءة ←' : 'شاهد المعاينة ←'}</Link></div>
{meta._match && <span className="for-you">يناسب مستواك</span>} <div className="post-side">
</p> <span className={`access${post.access === 'free' ? ' free' : ''}`}>{post.access === 'free' ? 'مجاني · كامل' : 'VIP · معاينة'}</span>
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2> <div className="engagement">
<p className="guide-card-dek">{post.dek}</p> <span data-views>{toArabic(post.views ?? 0)} مشاهدة</span>
<div className="guide-card-foot"> <button className="like" type="button" data-like={post.slug} aria-pressed={likedSlugs.has(post.slug)}>
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span> إعجاب · <span>{toArabic(post.likes ?? 0)}</span>
<span className="engagement"> </button>
<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="أعجبني">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 13.5 2.6 8.3a3.2 3.2 0 0 1 4.5-4.6L8 4.6l.9-.9a3.2 3.2 0 0 1 4.5 4.6Z" fill="none" stroke="currentColor" strokeWidth="1.4" /></svg>
<span>{formatNumber(post.likes ?? 0)}</span>
</button>
</span>
</div> </div>
</div> </div>
</article> </article>
) )
})} })}
</div> </div>
<div className="empty" id="emptyState"> <p className="empty" id="emptyState">لا توجد مواد تطابق البحث والفلاتر الحالية.</p>
<p>لا يوجد دليل يطابق هذا البحث.</p>
<p>جرّب كلمة أقصر أو اختر «كل المسارات».</p>
</div>
</div> </div>
</section> </section>
</main> </main>
<Footer /> <footer className="pagefoot" data-od-id="footer">
</div> <div className="container footer-row"><span>© أنور بي آي ٢٠٢٦ · تعلّم بوضوح</span><span><Link href="/terms">الشروط</Link> · <Link href="/privacy">الخصوصية</Link></span></div>
</footer>
</>
)
}
function TopnavStatic({ loggedIn }: { loggedIn: boolean }) {
return (
<header className="topnav" data-od-id="topnav">
<div className="container topnav-inner">
<Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
أنور بي آي
</Link>
<nav aria-label="التنقل الرئيسي">
<Link href="/articles" aria-current="page" data-od-id="nav-library">المكتبة</Link>
<Link href="/#topics" data-od-id="nav-topics">المسارات</Link>
<Link href="/subscription" data-od-id="nav-subscription">العضوية</Link>
<Link href="/about" data-od-id="nav-about">عن الفريق</Link>
</nav>
{!loggedIn
? <Link className="nav-action" href="/login" data-od-id="login-link">تسجيل الدخول</Link>
: <Link className="nav-action" href="/account-settings" data-od-id="account-link">حسابي</Link>}
</div>
</header>
) )
} }
+111 -124
View File
@@ -1,124 +1,111 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
export type DeckSlide = { export type DeckSlide = {
kicker: string kicker: string
index: string index: string
title: React.ReactNode title: React.ReactNode
body: string body: string
format: React.ReactNode format: React.ReactNode
link?: { href: string; label: string } link?: { href: string; label: string }
meta?: React.ReactNode meta?: React.ReactNode
} }
const V1_SLIDES: DeckSlide[] = [ const V1_SLIDES: DeckSlide[] = [
{ {
kicker: 'دليل حصري · المستوى المتوسط', kicker: 'دليل حصري · المستوى المتوسط',
index: '01', index: '01',
title: 'ابنِ وكيلك الأول دون فوضى', title: 'ابنِ وكيلك الأول دون فوضى',
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.', body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>, format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
link: { href: '/posts/agent-loop-stop-condition', label: 'شاهد المعاينة ←' }, link: { href: '/posts/agent-loop-stop-condition', label: 'شاهد المعاينة ←' },
}, },
{ {
kicker: 'مقال تطبيقي · للمبتدئين', kicker: 'مقال تطبيقي · للمبتدئين',
index: '02', index: '02',
title: 'كيف تختار نموذجاً لمهمتك؟', title: 'كيف تختار نموذجاً لمهمتك؟',
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.', body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
format: <>قراءة · 12 دقيقة</>, format: <>قراءة · 12 دقيقة</>,
link: { href: '/posts/article-agent-guide', label: 'ابدأ القراءة ←' }, link: { href: '/posts/article-agent-guide', label: 'ابدأ القراءة ←' },
}, },
{ {
kicker: 'مسار متقدم · للأعضاء', kicker: 'مسار متقدم · للأعضاء',
index: '03', index: '03',
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>, title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.', body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>, format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
link: { href: '/subscription', label: 'شاهد للأعضاء ←' }, link: { href: '/subscription', label: 'شاهد للأعضاء ←' },
}, },
] ]
const DOT_LABELS = ['الشريحة الأولى', 'الشريحة الثانية', 'الشريحة الثالثة'] const DOT_LABELS = ['الشريحة الأولى', 'الشريحة الثانية', 'الشريحة الثالثة']
/* Featured content carousel. Manual selection pauses rotation until resumed. */ /* Featured deck — markup and slide logic from arabic-ai-learning-catalog(.v2).html
export default function CatalogDeck({ slides = V1_SLIDES }: { slides?: DeckSlide[] }) { (autoplay 6.5s, dots, arrows, localStorage persistence). Slides render a
const [activeSlide, setActiveSlide] = useState(0) link when given; otherwise the v2 home variant shows a meta tag. */
const [ready, setReady] = useState(false) export default function CatalogDeck({ slides = V1_SLIDES }: { slides?: DeckSlide[] }) {
const [playing, setPlaying] = useState(true) useEffect(() => {
const [hovered, setHovered] = useState(false) const slideEls = [...document.querySelectorAll('.deck-slide')] as HTMLElement[]
const [focused, setFocused] = useState(false) const dots = [...document.querySelectorAll('.deck-dot')] as HTMLElement[]
const current = slides.length ? activeSlide % slides.length : 0 let activeSlide = Number(localStorage.getItem('midad-slide') || 0) % slideEls.length
const advancing = ready && playing && !hovered && !focused && slides.length > 1 let timer: ReturnType<typeof setInterval> | undefined
useEffect(() => { function showSlide(index: number) {
try { activeSlide = (index + slideEls.length) % slideEls.length
const stored = Number(localStorage.getItem('midad-slide')) slideEls.forEach((slide, i) => slide.classList.toggle('active', i === activeSlide))
if (Number.isInteger(stored) && stored >= 0) setActiveSlide(stored) dots.forEach((dot, i) => {
} catch { /* Persistence is optional. */ } dot.classList.toggle('active', i === activeSlide)
const motion = window.matchMedia('(prefers-reduced-motion: reduce)') dot.setAttribute('aria-current', i === activeSlide ? 'true' : 'false')
if (motion.matches) setPlaying(false) })
const onMotion = () => { if (motion.matches) setPlaying(false) } localStorage.setItem('midad-slide', String(activeSlide))
motion.addEventListener('change', onMotion) }
setReady(true) function restartDeck() {
return () => motion.removeEventListener('change', onMotion) clearInterval(timer)
}, []) if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) timer = setInterval(() => showSlide(activeSlide + 1), 6500)
}
useEffect(() => { document.getElementById('prevSlide')?.addEventListener('click', () => { showSlide(activeSlide - 1); restartDeck() })
if (!ready) return document.getElementById('nextSlide')?.addEventListener('click', () => { showSlide(activeSlide + 1); restartDeck() })
try { localStorage.setItem('midad-slide', String(current)) } catch { /* optional persistence */ } dots.forEach(dot => dot.addEventListener('click', () => { showSlide(Number((dot as HTMLElement).dataset.dot)); restartDeck() }))
}, [current, ready]) /* QA-05: keep controls stationary — pause autoplay while hovered/focused. */
const deck = document.querySelector('.deck')
useEffect(() => { deck?.addEventListener('mouseenter', () => clearInterval(timer))
if (!advancing) return deck?.addEventListener('mouseleave', () => restartDeck())
const timer = setTimeout(() => setActiveSlide(index => (index + 1) % slides.length), 6500) deck?.addEventListener('focusin', () => clearInterval(timer))
return () => clearTimeout(timer) deck?.addEventListener('focusout', () => restartDeck())
}, [advancing, current, slides.length]) showSlide(activeSlide)
restartDeck()
function selectSlide(index: number) { return () => clearInterval(timer)
setActiveSlide((index + slides.length) % slides.length) }, [])
setPlaying(false)
} return (
<div className="deck" data-od-id="featured-deck" aria-label="مختارات مميزة">
if (!slides.length) return null <div className="deck-frame" aria-live="polite">
{slides.map((s, i) => (
return ( <article className={`deck-slide${i === 0 ? ' active' : ''}`} data-slide={i} key={i}>
<div className="deck" data-od-id="featured-deck" role="region" aria-roledescription="عرض شرائح" aria-label="مختارات مميزة" <div className="deck-slide-top"><span className="deck-slide-kicker">{s.kicker}</span><span className="deck-slide-index num">{s.index}</span></div>
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} <div className="deck-slide-body"><h2>{s.title}</h2><p>{s.body}</p></div>
onFocusCapture={() => setFocused(true)} <div className="deck-slide-foot">
onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false) }} <span className="deck-slide-format">{s.format}</span>
onKeyDown={event => { {s.link
if (event.key === 'ArrowLeft') { event.preventDefault(); selectSlide(current + 1) } ? <Link className="deck-slide-link" href={s.link.href}>{s.link.label}</Link>
if (event.key === 'ArrowRight') { event.preventDefault(); selectSlide(current - 1) } : <span className="meta">{s.meta}</span>}
}}> </div>
</article>
<div className="deck-frame" aria-live={advancing ? 'off' : 'polite'}> ))}
{slides.map((s, i) => ( </div>
<article className={`deck-slide${i === current ? ' active' : ''}`} hidden={i !== current} aria-label={`${i + 1} من ${slides.length}`} data-slide={i} key={i}> <div className="deck-controls">
<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-dots" aria-label="اختيار الشريحة">
<div className="deck-slide-body"><h2>{s.title}</h2><p>{s.body}</p></div> {slides.map((_, i) => (
<div className="deck-slide-foot"> <button className={`deck-dot${i === 0 ? ' active' : ''}`} data-dot={i} aria-label={DOT_LABELS[i]} key={i}></button>
<span className="deck-slide-format">{s.format}</span> ))}
{s.link </div>
? <Link className="deck-slide-link" href={s.link.href}>{s.link.label}</Link> <div className="deck-arrows">
: <span className="meta">{s.meta}</span>} <button className="icon-btn" id="prevSlide" aria-label="الشريحة السابقة">←</button>
</div> <button className="icon-btn" id="nextSlide" aria-label="الشريحة التالية">→</button>
</article> </div>
))} </div>
</div> </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>
)
}
@@ -2,10 +2,10 @@
import { useEffect } from 'react' import { useEffect } from 'react'
/* Filter bar for the editors' picks. Cards carry space-separated topics. */ /* Filter bar — port of the catalog page's filter logic; also wired to the
topic path cards via [data-topic-link]. Hides non-matching catalog cards. */
export default function CatalogFilters() { export default function CatalogFilters() {
useEffect(() => { useEffect(() => {
const controller = new AbortController()
const filters = [...document.querySelectorAll('.filter')] as HTMLElement[] const filters = [...document.querySelectorAll('.filter')] as HTMLElement[]
const cards = [...document.querySelectorAll('.catalog-card')] as HTMLElement[] const cards = [...document.querySelectorAll('.catalog-card')] as HTMLElement[]
const empty = document.getElementById('emptyState') const empty = document.getElementById('emptyState')
@@ -18,7 +18,7 @@ export default function CatalogFilters() {
button.setAttribute('aria-pressed', String(selected)) button.setAttribute('aria-pressed', String(selected))
}) })
cards.forEach(card => { cards.forEach(card => {
const show = topic === 'all' || (card.dataset.topic || '').split(' ').includes(topic) const show = topic === 'all' || card.dataset.topic === topic
card.hidden = !show card.hidden = !show
if (show) visible += 1 if (show) visible += 1
}) })
@@ -28,17 +28,23 @@ export default function CatalogFilters() {
filters.forEach(button => button.addEventListener('click', () => { filters.forEach(button => button.addEventListener('click', () => {
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
filterCatalog((button as HTMLElement).dataset.filter!) filterCatalog((button as HTMLElement).dataset.filter!)
}, { signal: controller.signal })) }))
return () => controller.abort() document.querySelectorAll('[data-topic-link]').forEach(link =>
link.addEventListener('click', () => {
const topic = (link as HTMLElement).dataset.topicLink!
filterCatalog(topic)
filters.find(button => button.dataset.filter === topic)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
})
)
}, []) }, [])
return ( return (
<div className="filterbar" role="group" aria-label="تصفية المحتوى" data-od-id="catalog-filters"> <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 className="filter active" data-filter="all" aria-pressed="true">الكل</button>
<button type="button" className="filter" data-filter="agents" aria-pressed="false">الوكلاء</button> <button className="filter" data-filter="agents" aria-pressed="false">الوكلاء</button>
<button type="button" className="filter" data-filter="models" aria-pressed="false">النماذج</button> <button className="filter" data-filter="models" aria-pressed="false">النماذج</button>
<button type="button" className="filter" data-filter="automation" aria-pressed="false">الأتمتة</button> <button className="filter" data-filter="automation" aria-pressed="false">الأتمتة</button>
<button type="button" className="filter" data-filter="foundations" aria-pressed="false">الأساسيات</button> <button className="filter" data-filter="foundations" aria-pressed="false">الأساسيات</button>
</div> </div>
) )
} }
+59 -62
View File
@@ -1,62 +1,59 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import Footer from '@/components/footer'
import '../styles/forgot-password.css' import '../styles/forgot-password.css'
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
async function onSubmit(e: React.FormEvent<HTMLFormElement>) { async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget const form = e.currentTarget
if (!form.checkValidity()) { if (!form.checkValidity()) {
setStatus('أدخل بريداً إلكترونياً صحيحاً.') setStatus('أدخل بريداً إلكترونياً صحيحاً.')
form.reportValidity() form.reportValidity()
return return
} }
setBusy(true) setBusy(true)
setStatus('') await fetch('/api/users/forgot-password', {
try { method: 'POST',
const response = await fetch('/api/users/forgot-password', { headers: { 'Content-Type': 'application/json' },
method: 'POST', body: JSON.stringify({ email }),
headers: { 'Content-Type': 'application/json' }, })
body: JSON.stringify({ email }), setStatus('إذا كان البريد مسجلاً، فسيصلك رابط الاستعادة.')
}) form.reset()
if (!response.ok) throw new Error('Request failed') setBusy(false)
setStatus('إذا كان البريد مسجلاً، فسيصلك رابط الاستعادة.') }
setEmail('')
} catch { return (
setStatus('تعذّر إرسال الطلب. حاول مرة أخرى بعد قليل.') <>
} finally { <Topnav
setBusy(false) viewer={null}
} logoHref="/articles"
} links={[]}
/>
return ( <main className="reset" data-od-id="password-reset">
<div id="site" className="site-page page-forgot-password"> <section className="reset-card" data-od-id="reset-card">
<Topnav /> <p className="eyebrow">استعادة الحساب</p>
<main id="content" className="reset" data-od-id="password-reset"> <h1 data-od-id="reset-title">أرسل رابطاً جديداً.</h1>
<section className="reset-card" data-od-id="reset-card"> <p className="lead">أدخل بريد حسابك وسنجهّز طلب إعادة تعيين كلمة المرور.</p>
<p className="eyebrow">استعادة الحساب</p> <form id="resetForm" method="post" action="/api/users/forgot-password" noValidate onSubmit={onSubmit} data-od-id="reset-form">
<h1 data-od-id="reset-title">استعادة كلمة المرور</h1> <div className="field">
<p className="lead">أدخل البريد الإلكتروني المرتبط بحسابك لتصلك رسالة إعادة تعيين كلمة المرور.</p> <label htmlFor="resetEmail">البريد الإلكتروني</label>
<form id="resetForm" method="post" action="/api/users/forgot-password" noValidate onSubmit={onSubmit} data-od-id="reset-form"> <input className="input" id="resetEmail" name="email" type="email" inputMode="email" autoComplete="email" dir="ltr" required value={email} onChange={e => setEmail(e.target.value)} />
<div className="field"> </div>
<label htmlFor="resetEmail">البريد الإلكتروني</label> <button className="btn" type="submit" data-od-id="reset-submit" disabled={busy}>{busy ? 'جارٍ الإرسال…' : 'إرسال رابط الاستعادة'}</button>
<input className="input" id="resetEmail" name="email" type="email" inputMode="email" autoComplete="email" dir="ltr" required value={email} onChange={e => setEmail(e.target.value)} /> <p className="status" id="resetStatus" role="status" aria-live="polite">{status}</p>
</div> </form>
<button className="btn btn-primary" type="submit" data-od-id="reset-submit" disabled={busy}>{busy ? 'جارٍ الإرسال…' : 'إرسال رابط الاستعادة'}</button> <Link className="back" href="/login">تذكرت كلمة المرور؟ سجّل الدخول</Link>
<p className="status" id="resetStatus" role="status" aria-live="polite">{status}</p> </section>
</form> </main>
<Link className="back" href="/login">تذكرت كلمة المرور؟ سجّل الدخول</Link> <Footer />
</section> </>
</main> )
<Footer /> }
</div>
)
}
+59 -25
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import SiteMenu from '@/components/site-menu'
import { safeNext } from '@/lib/next' import { safeNext } from '@/lib/next'
import '../styles/login.css' import '../styles/login.css'
@@ -30,34 +30,69 @@ export default function LoginPage() {
setBusy(true) setBusy(true)
setStatus('') setStatus('')
const data = new FormData(form) const data = new FormData(form)
try { const res = await fetch('/api/users/login', {
const res = await fetch('/api/users/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }), body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
}) })
if (res.ok) { if (res.ok) {
router.refresh() router.refresh()
router.push(safeNext(new URLSearchParams(window.location.search).get('next')) ?? '/account-settings') router.push(safeNext(new URLSearchParams(window.location.search).get('next')) ?? '/account-settings')
return return
}
setStatus('تعذّر تسجيل الدخول — تحقّق من البريد وكلمة المرور.')
} catch {
setStatus('تعذّر الاتصال. حاول تسجيل الدخول مرة أخرى.')
} finally {
setBusy(false)
} }
setStatus('تعذّر تسجيل الدخول — تحقّق من البريد وكلمة المرور.')
setBusy(false)
}
/* رابط الدخول بالبريد — يستخدم مسار استعادة كلمة المرور الحقيقي في Payload. */
async function sendMagicLink() {
const form = document.getElementById('loginForm') as HTMLFormElement
const email = form.elements.namedItem('email') as HTMLInputElement
if (!email.value.trim() || !email.checkValidity()) {
setStatus('أدخل بريداً إلكترونياً صحيحاً أولاً.')
email.focus()
return
}
setStatus('')
const res = await fetch('/api/users/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim() }),
})
setStatus(res.ok ? 'أرسلنا رابط الدخول إلى بريدك إن كان مسجلاً.' : 'تعذّر الإرسال — خدمة البريد غير مربوطة بعد.')
} }
return ( return (
<div id="site" className="site-page page-login"> <>
<Topnav /> <Topnav
<main id="content" className="auth"> viewer={null}
<section className="auth-story"> menu
{/* eslint-disable-next-line @next/next/no-img-element */} hideAction
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} /> logoHref="/"
<h1>أكمل من حيث توقفت.</h1> links={[]}
<p>سجّل الدخول لمتابعة قراءة الأدلة والمشاركة في النقاشات.</p> returnLink={{ href: '/', label: 'العودة إلى المكتبة' }}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'مقال الأسبوع' },
{ href: '/subscription', label: 'العضوية والأسعار' },
]}
foot={{ href: '/login', label: '', note: 'محتوى عربي عملي، بلا ضجيج.', variant: 'hidden' }}
/>
<main className="auth">
<section className="auth-story" data-od-id="login-story">
<div>
<p className="eyebrow">مساحتك في أنور بي آي</p>
<h1 data-od-id="login-story-title">أكمل من حيث توقفت.</h1>
<p>احفظ أدلتك، تابع مسارات الفيديو، وارجع إلى ملاحظاتك من أي جهاز.</p>
<ul className="story-list">
<li><span>المقالات المحفوظة</span><span className="story-mark">مرتبّة</span></li>
<li><span>تقدّم المسارات</span><span className="story-mark">متزامن</span></li>
<li><span>ملفات العضوية</span><span className="story-mark">جاهزة</span></li>
</ul>
</div>
<p className="story-foot">العربية أولاً · خصوصيتك محفوظة</p>
</section> </section>
<section className="auth-form-wrap" data-od-id="login-form-section"> <section className="auth-form-wrap" data-od-id="login-form-section">
<form className="auth-form" id="loginForm" method="post" action="/api/users/login" noValidate onSubmit={onSubmit} data-od-id="login-form"> <form className="auth-form" id="loginForm" method="post" action="/api/users/login" noValidate onSubmit={onSubmit} data-od-id="login-form">
@@ -72,23 +107,22 @@ export default function LoginPage() {
<label htmlFor="password">كلمة المرور</label> <label htmlFor="password">كلمة المرور</label>
<div className="input-wrap"> <div className="input-wrap">
<input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="current-password" minLength={8} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة المرور.')} onInput={e => e.currentTarget.setCustomValidity('')} /> <input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="current-password" minLength={8} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة المرور.')} onInput={e => e.currentTarget.setCustomValidity('')} />
<button className="password-toggle" id="passwordToggle" type="button" aria-controls="password" aria-label={showPassword ? 'إخفاء كلمة المرور' : 'إظهار كلمة المرور'} aria-pressed={showPassword} onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button> <button className="password-toggle" id="passwordToggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button>
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="check"><input type="checkbox" name="remember" /> تذكّرني</label> <label className="check"><input type="checkbox" name="remember" /> تذكّرني</label>
<Link href="/forgot-password">نسيت كلمة المرور؟</Link> <Link href="/forgot-password">نسيت كلمة المرور؟</Link>
</div> </div>
<button className="btn btn-primary" type="submit" disabled={busy}>{busy ? 'جارٍ تسجيل الدخول…' : 'ادخل إلى حسابك'}</button> <button className="btn btn-primary" type="submit" disabled={busy}>{busy ? 'جارٍ الدخول…' : 'ادخل إلى حسابك'}</button>
<div className="status" id="loginStatus" role="status">{status}</div> <div className="status" id="loginStatus" role="status">{status}</div>
<div className="divider">أو</div> <div className="divider">أو</div>
<Link className="magic" href="/forgot-password">استعد الوصول إلى حسابك</Link> <button className="magic" id="magicLink" type="button" onClick={sendMagicLink}>أرسل رابط دخول إلى بريدي</button>
<p className="signup">ليس لديك حساب؟ <Link href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : '/signup'}>أنشئ حساباً جديداً</Link></p> <p className="signup">ليس لديك حساب؟ <Link href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : '/signup'}>أنشئ حساباً جديداً</Link></p>
<p className="fine">بالمتابعة، أنت توافق على <Link href="/terms">شروط الاستخدام</Link> و<Link href="/privacy">سياسة الخصوصية</Link>.</p> <p className="fine">بالمتابعة، أنت توافق على <Link href="/terms">شروط الاستخدام</Link> و<Link href="/privacy">سياسة الخصوصية</Link>.</p>
</form> </form>
</section> </section>
</main> </main>
<Footer /> </>
</div>
) )
} }
+12 -12
View File
@@ -6,8 +6,8 @@ import { safeNext } from '@/lib/next'
type Viewer = { id: number; email: string; interest: string | null; level: string | null; onboarded: boolean } | null type Viewer = { id: number; email: string; interest: string | null; level: string | null; onboarded: boolean } | null
const COPY = ['الخطوة 1 من 3', 'الخطوة 2 من 3', 'الخطوة 3 من 3'] const COPY = ['الخطوة ١ من ٣', 'الخطوة ٢ من ٣', 'الخطوة ٣ من ٣']
const PERCENTS = ['33٪', '67٪', '100٪'] const PERCENTS = ['٣٣٪', '٦٧٪', '١٠٠٪']
/* Wizard markup/steps from member-onboarding.html. Interest + level PATCH the /* Wizard markup/steps from member-onboarding.html. Interest + level PATCH the
user record when leaving each step (falls back to localStorage if no session). */ user record when leaving each step (falls back to localStorage if no session). */
@@ -71,7 +71,7 @@ export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
<nav className="step-nav" aria-label="خطوات إعداد الحساب" data-od-id="onboarding-steps"> <nav className="step-nav" aria-label="خطوات إعداد الحساب" data-od-id="onboarding-steps">
{[0, 1, 2].map(i => ( {[0, 1, 2].map(i => (
<div key={i} className={i === step ? 'active' : i < step ? 'done' : ''} data-step-label={i}> <div key={i} className={i === step ? 'active' : i < step ? 'done' : ''} data-step-label={i}>
<span>{['01', '02', '03'][i]}</span><span>{['اهتمامك', 'مستواك', 'خيارات العضوية'][i]}</span> <span>{['٠١', '٠٢', '٠٣'][i]}</span><span>{['اهتمامك', 'مستواك', 'اختر الوصول'][i]}</span>
</div> </div>
))} ))}
</nav> </nav>
@@ -82,7 +82,7 @@ export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
<div className="choices"> <div className="choices">
{[ {[
['الوكلاء', 'الوكلاء وتنسيقهم', 'التخطيط والتنفيذ والتحقق وتنسيق عدة وكلاء.'], ['الوكلاء', 'الوكلاء وتنسيقهم', 'التخطيط والتنفيذ والتحقق وتنسيق عدة وكلاء.'],
['RAG', 'التوليد المعزّز بالاسترجاع (RAG)', 'اختيار المصادر وقياس جودة الاسترجاع والإجابة.'], ['RAG', 'الاسترجاع وRAG', 'اختيار المصادر وقياس جودة الاسترجاع والإجابة.'],
['الأتمتة', 'الأتمتة للأعمال', 'دمج الذكاء الاصطناعي في تدفقات عمل قابلة للمراقبة.'], ['الأتمتة', 'الأتمتة للأعمال', 'دمج الذكاء الاصطناعي في تدفقات عمل قابلة للمراقبة.'],
['النماذج المفتوحة', 'النماذج المفتوحة', 'التشغيل المحلي والضبط الدقيق واختيار النموذج المناسب.'], ['النماذج المفتوحة', 'النماذج المفتوحة', 'التشغيل المحلي والضبط الدقيق واختيار النموذج المناسب.'],
].map(([value, label, desc]) => ( ].map(([value, label, desc]) => (
@@ -98,12 +98,12 @@ export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
</div> </div>
</section> </section>
<section className={`panel${step === 1 ? ' active' : ''}`} data-step="1" data-od-id="step-experience"> <section className={`panel${step === 1 ? ' active' : ''}`} data-step="1" data-od-id="step-experience">
<h2>ما مستوى خبرتك العملية؟</h2> <h2>ما مستوى خبرتك العملي؟</h2>
<p className="intro">سنستخدمه فقط لترتيب نقطة البداية، لا لإخفاء أي محتوى مجاني.</p> <p className="intro">سنستخدمه فقط لترتيب نقطة البداية، لا لإخفاء أي محتوى مجاني.</p>
<div className="choices"> <div className="choices">
{[ {[
['مبتدئ', 'أبدأ من الأساسيات', 'أستخدم أدوات الذكاء الاصطناعي ولم أبنِ مشروعاً بعد.'], ['مبتدئ', 'أبدأ من الأساسيات', 'أستخدم أدوات الذكاء الاصطناعي ولم أبنِ مشروعاً بعد.'],
['متوسط', 'بنيت تجارب بسيطة', 'جرّبت واجهات برمجة التطبيقات (API) أو أتمتة أو تطبيقاً صغيراً.'], ['متوسط', 'بنيت تجارب بسيطة', 'جرّبت واجهات API أو أتمتة أو تطبيقاً صغيراً.'],
['متقدم', 'أعمل على أنظمة فعلية', 'أبحث عن أنماط أعمق للقياس والموثوقية والتشغيل.'], ['متقدم', 'أعمل على أنظمة فعلية', 'أبحث عن أنماط أعمق للقياس والموثوقية والتشغيل.'],
].map(([value, label, desc]) => ( ].map(([value, label, desc]) => (
<label className="choice" key={value}> <label className="choice" key={value}>
@@ -120,23 +120,23 @@ export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
</section> </section>
<section className={`panel${step === 2 ? ' active' : ''}`} data-step="2" data-od-id="step-access"> <section className={`panel${step === 2 ? ' active' : ''}`} data-step="2" data-od-id="step-access">
<p className="eyebrow">اختيارك، بلا ضغط</p> <p className="eyebrow">اختيارك، بلا ضغط</p>
<h2>ابدأ مجاناً أو افتح محتوى العضوية الكامل.</h2> <h2>ابدأ مجاناً أو افتح تجربة VIP الكاملة.</h2>
<p className="intro" id="summaryCopy">{summary}</p> <p className="intro" id="summaryCopy">{summary}</p>
<div className="benefits" data-od-id="vip-benefits"> <div className="benefits" data-od-id="vip-benefits">
<article className="benefit"><span className="benefit-num">01</span><div><h3>الأدلة المتقدمة كاملة</h3><p>اقرأ ما بعد المعاينة في أدلة الوكلاء وRAG والأتمتة والنماذج.</p></div></article> <article className="benefit"><span className="benefit-num">٠١</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">٠٢</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">٠٣</span><div><h3>نقطة بداية مخصصة</h3><p>تهيئة VIP ترتّب دليلك الأول ورسائلك بناءً على هدفك.</p></div></article>
</div> </div>
<div className="upgrade-note"> <div className="upgrade-note">
<strong>الحساب المجاني لا يختفي.</strong> <strong>الحساب المجاني لا يختفي.</strong>
<p>يمكنك قراءة الأدلة المجانية وحفظها، والترقية عندما تحتاج الأدلة الحصرية.</p> <p>يمكنك قراءة المقالات المجانية وحفظها، والترقية عندما تحتاج الأدلة الحصرية.</p>
</div> </div>
<div className="actions" data-od-id="final-actions"> <div className="actions" data-od-id="final-actions">
{nextPath ? ( {nextPath ? (
<Link className="btn btn-primary" href={nextPath} data-od-id="continue-next-cta">تابع إلى وجهتك</Link> <Link className="btn btn-primary" href={nextPath} data-od-id="continue-next-cta">تابع إلى وجهتك</Link>
) : ( ) : (
<> <>
<Link className="btn btn-secondary" href="/articles?access=free" data-od-id="continue-free-cta">ابدأ بالأدلة المجانية</Link> <Link className="btn btn-secondary" href="/articles?access=free" data-od-id="continue-free-cta">ابدأ بالمقالات المجانية</Link>
<Link className="btn btn-primary" href="/subscription" data-od-id="view-membership-cta">شاهد العضوية والأسعار</Link> <Link className="btn btn-primary" href="/subscription" data-od-id="view-membership-cta">شاهد العضوية والأسعار</Link>
</> </>
)} )}
+18 -12
View File
@@ -1,10 +1,7 @@
import Topnav from '@/components/topnav'
import Footer from '@/components/footer'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import Link from 'next/link' import Link from 'next/link'
import { redirect } from 'next/navigation'
import OnboardingWizard from './client' import OnboardingWizard from './client'
import '../styles/member-onboarding.css' import '../styles/member-onboarding.css'
@@ -31,26 +28,35 @@ export default async function OnboardingPage() {
/* render wizard without persistence */ /* render wizard without persistence */
} }
if (!viewer) redirect('/login?next=%2Fonboarding')
return ( return (
<div id="site" className="site-page page-onboarding"> <>
<Topnav viewer={viewer} /> <header className="topnav" data-od-id="topnav">
<main id="content"> <div className="container nav-inner">
<Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
أنور بي آي
</Link>
<Link className="nav-return" href="/articles" data-od-id="skip-link">تخطي إلى المكتبة</Link>
</div>
</header>
<main>
<section className="head" data-od-id="onboarding-hero"> <section className="head" data-od-id="onboarding-hero">
<div className="container"> <div className="container">
<p className="eyebrow">حسابك جاهز · إعداد سريع</p> <p className="eyebrow">حسابك جاهز · إعداد سريع</p>
<h1 data-od-id="onboarding-title">اختر ما تريد تعلّمه أولاً.</h1> <h1 data-od-id="onboarding-title">اختر ما تريد تعلّمه أولاً.</h1>
<p className="lead">سؤالان فقط لترتيب المكتبة حول هدفك، ثم نوضح الفرق بين الحساب المجاني والعضوية المدفوعة.</p> <p className="lead">سؤالان فقط لترتيب المكتبة حول هدفك، ثم نوضح الفرق بين الحساب المجاني وعضوية VIP.</p>
<div className="progress" aria-label="تقدم الإعداد"> <div className="progress" aria-label="تقدم الإعداد">
<div className="progress-copy"><span id="progressLabel">الخطوة 1 من 3</span><span id="progressPercent">33٪</span></div> <div className="progress-copy"><span id="progressLabel">الخطوة ١ من ٣</span><span id="progressPercent">٣٣٪</span></div>
<div className="progress-track"><div className="progress-bar" id="progressBar"></div></div> <div className="progress-track"><div className="progress-bar" id="progressBar"></div></div>
</div> </div>
</div> </div>
</section> </section>
<OnboardingWizard viewer={viewer} /> <OnboardingWizard viewer={viewer} />
</main> </main>
<Footer /> <footer className="pagefoot" data-od-id="footer">
</div> <div className="container footer-row"><span>© أنور بي آي ٢٠٢٦</span><span>يمكنك تغيير المسار لاحقاً من إعدادات الحساب.</span></div>
</footer>
</>
) )
} }
+137 -122
View File
@@ -1,153 +1,168 @@
import { getViewer } from '@/lib/viewer' import { getViewer } from '@/lib/viewer'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import CatalogDeck from './catalog/catalog-deck'
import CatalogFilters from './catalog/catalog-filters'
import Link from 'next/link' import Link from 'next/link'
import { getPayload } from 'payload' import './styles/arabic-ai-learning-catalog.css'
import configPromise from '@payload-config'
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/* Editorial picks for «ابدأ من هنا». */ const HOME_SLIDES = [
const START_HERE = ['first-arabic-agent', 'choosing-your-model', 'prototype-to-production', 'simple-arabic-rag', 'safe-agent-guardrails', 'agent-loop-stop-condition'] {
kicker: 'دليل حصري · المستوى المتوسط',
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 } index: '01',
title: <>ابنِ وكيلك الأول دون فوضى</>,
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
meta: <bdi dir="ltr">AGENTIC AI</bdi>,
},
{
kicker: 'مقال تطبيقي · للمبتدئين',
index: '02',
title: <>كيف تختار نموذجاً لمهمتك؟</>,
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
format: <>قراءة · 12 دقيقة</>,
meta: <>النماذج</>,
},
{
kicker: 'مسار متقدم · للأعضاء',
index: '03',
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
meta: <bdi dir="ltr">RAG</bdi>,
},
]
export default async function HomePage() { export default async function HomePage() {
const viewer = await getViewer() const viewer = await getViewer()
const payload = await getPayload({ config: configPromise })
const { docs: raw } = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
depth: 1,
limit: 200,
sort: '-publishedAt',
select: { title: true, slug: true, dek: true, access: true, category: true, level: true, readingMinutes: true, cover: true },
})
const docs = raw as unknown as Doc[]
const freeCount = docs.filter(d => d.access === 'free').length
// Hero personalization: matching the viewer's interest floats to the top,
// level match ranks higher, ties keep the -publishedAt order. Anonymous
// viewers get the plain newest-first list.
const interestTopic = viewer?.interest ? INTEREST_MAP[viewer.interest] : undefined
const score = (d: Doc) => {
if (!interestTopic || !topicsFor(d).includes(interestTopic)) return 0
return d.level === viewer?.level ? 2 : 1
}
const hero = docs
.map((d, i) => ({ d, i, s: score(d) }))
.sort((a, b) => b.s - a.s || a.i - b.i)
.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))
return ( return (
<div id="site" className="site-page v2 v2-home"> <>
<Topnav viewer={viewer} /> <Topnav
viewer={viewer}
logoHref="#top"
loginClass="nav-action"
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '#topics', label: 'المسارات' },
{ href: '#membership', label: 'العضوية' },
]}
/>
<main id="content"> <main id="content">
<section className="home-hero"> <section className="section hero" id="top" data-od-id="hero">
<div className="wrap home-hero-grid"> <div className="container hero-split">
<div className="home-hero-copy"> <div className="hero-copy">
<p className="kicker">مكتبة عربية للذكاء الاصطناعي العملي</p> <p className="eyebrow">مكتبة عربية عملية · <bdi dir="ltr">AI + AGENTS</bdi></p>
<h1>افهم الذكاء الاصطناعي، ثم ابنِ به.</h1> <h1 data-od-id="hero-title">افهم الذكاء الاصطناعي. ثم ابنِ به.</h1>
<p className="lede">أدلة عربية عملية عن الوكلاء والنماذج والأتمتة، يكتبها مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p> <p className="lead">أدلة واضحة، دروس فيديو، ومسارات متدرجة تساعدك على الانتقال من الفكرة إلى وكيل يعمل.</p>
<div className="actions"> <div className="hero-cta">
<a className="btn btn-primary" href="/articles?access=free">ابدأ بدليل مجاني</a> <a className="btn btn-primary" href="/articles" data-od-id="hero-primary-cta">استكشف المكتبة</a>
<Link className="btn btn-quiet" href="/articles">تصفح المكتبة</Link> <a className="btn btn-ghost" href="#membership">شاهد محتوى العضوية ←</a>
</div> </div>
<p className="trust-line">بالعربية أولاً · أمثلة قابلة للتطبيق · تحديثات مرتبطة بما يتغير فعلاً</p>
</div> </div>
{hero.length > 0 && ( <CatalogDeck slides={HOME_SLIDES} />
<aside className="hero-panel" aria-labelledby="new-title">
<h2 id="new-title">أحدث الأدلة</h2>
<ul role="list">
{hero.map(d => {
const coverUrl = typeof d.cover === 'object' ? d.cover?.url ?? null : null
return (
<li key={d.id}>
<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>
</Link>
</li>
)
})}
</ul>
<Link className="text-link" href="/articles">كل الأدلة</Link>
</aside>
)}
</div> </div>
</section> </section>
<section className="home-paths" id="topics" aria-labelledby="paths-title"> <section className="section" id="topics" data-od-id="learning-paths">
<div className="wrap"> <div className="container">
<header className="section-head"> <div className="row-between section-head">
<h2 id="paths-title">اختر مساراً حسب ما تريد بناءه</h2> <div><p className="eyebrow">ابدأ من اهتمامك</p><h2 data-od-id="topics-title">أربعة مسارات. هدف واحد: فهم قابل للاستخدام.</h2></div>
<p>كل دليل ينتمي إلى مسار، وأدلة المبتدئين تظهر أيضاً في الأساسيات.</p> <p className="lead">تصفح المحتوى حسب ما تريد بناءه، لا حسب المصطلحات التي تحفظها.</p>
</header> </div>
<ol className="paths" role="list"> <div className="topic-grid">
{TOPICS.map(topic => ( <a className="topic" href="/articles" data-topic-link="agents" data-od-id="topic-agents"><strong>الوكلاء</strong><span>تخطيط · أدوات · ذاكرة</span></a>
<li key={topic.key}> <a className="topic" href="/articles" data-topic-link="models" data-od-id="topic-models"><strong>النماذج</strong><span>اختيار · تقييم · تشغيل</span></a>
<Link href={`/articles?topic=${topic.key}`} className="path"> <a className="topic" href="/articles" data-topic-link="automation" data-od-id="topic-automation"><strong>الأتمتة</strong><span>سير عمل · ربط · مراقبة</span></a>
<span className="path-name">{topic.label}</span> <a className="topic" href="/articles" data-topic-link="foundations" data-od-id="topic-foundations"><strong>الأساسيات</strong><span>مفاهيم · تطبيق · أمان</span></a>
<span className="path-hint">{topic.hint}</span> </div>
<span className="path-count">{guidesLabel(counts[topic.key])}</span>
</Link>
</li>
))}
</ol>
</div> </div>
</section> </section>
{startHere.length > 0 && ( <section className="section" id="catalog" data-od-id="catalog">
<section className="home-start" aria-labelledby="start-title"> <div className="container">
<div className="wrap"> <div className="row-between section-head">
<header className="section-head"> <div><p className="eyebrow">مختارات المحررين</p><h2 data-od-id="catalog-title">دليل تقرؤه اليوم، وتستخدمه غداً.</h2></div>
<h2 id="start-title">ابدأ من هنا</h2> <span className="meta">محتوى عربي · نص وفيديو</span>
<Link className="text-link" href="/articles">كل الأدلة ({formatNumber(docs.length)})</Link>
</header>
<ul className="card-grid" role="list">
{startHere.map(post => {
const coverUrl = typeof post.cover === 'object' ? post.cover?.url ?? null : null
return (
<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>
<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>
</article>
</li>
)
})}
</ul>
</div> </div>
</section> <CatalogFilters />
)}
<section className="home-membership" id="membership" aria-labelledby="membership-title"> <div className="catalog" id="catalogGrid">
<div className="wrap membership-panel"> <article className="catalog-card" data-topic="agents" data-od-id="guide-agent-architecture">
<div className="cover"><span className="cover-label">دليل عملي · حصري</span><span className="cover-title">تشريح وكيل ناجح</span></div>
<div className="card-meta"><span>الوكلاء</span><span className="num">18 دقيقة</span></div>
<h3>البنية التي تمنع وكيلك من الدوران في حلقة</h3>
<p>حدود واضحة بين التخطيط، التنفيذ، والتحقق مع نموذج بسيط لسجلّ الحالة.</p>
<Link className="card-link" href="/posts/agent-loop-stop-condition"><span>شاهد المعاينة</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="models" data-od-id="guide-model-selection">
<div className="cover cover-light"><span className="cover-label">مقال · مجاني</span><span className="cover-title">خريطة اختيار النموذج</span></div>
<div className="card-meta"><span>النماذج</span><span className="num">12 دقيقة</span></div>
<h3>لا تبدأ باسم النموذج؛ ابدأ بطبيعة المهمة</h3>
<p>مصفوفة قرار عملية توازن بين الدقة، السياق، السرعة، والكلفة.</p>
<Link className="card-link" href="/posts/systematic-model-eval"><span>ابدأ القراءة</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="automation" data-od-id="guide-automation-observability">
<div className="cover cover-lined"><span className="cover-label">فيديو · حصري</span><span className="cover-title">راقب قبل أن تؤتمت</span></div>
<div className="card-meta"><span>الأتمتة</span><span className="num">24 دقيقة</span></div>
<h3>كيف تكتشف فشل سير العمل قبل المستخدم؟</h3>
<p>إشارات قليلة تكفي لمراقبة التكلفة، التأخير، ونقاط الانقطاع.</p>
<Link className="card-link" href="/posts/prototype-to-production"><span>شاهد المقدمة</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="foundations" data-od-id="guide-prompt-context">
<div className="cover cover-lined"><span className="cover-label">شرح · مجاني</span><span className="cover-title">السياق قبل البرومبت</span></div>
<div className="card-meta"><span>الأساسيات</span><span className="num">9 دقائق</span></div>
<h3>لماذا لا يصلح تحسين الصياغة بياناتك الرديئة؟</h3>
<p>افصل بين التعليمات، المعرفة، وذاكرة المحادثة لتحصل على نتيجة مستقرة.</p>
<Link className="card-link" href="/posts/first-arabic-agent"><span>ابدأ القراءة</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="agents" data-od-id="guide-tool-safety">
<div className="cover cover-light"><span className="cover-label">قائمة فحص · حصري</span><span className="cover-title">أدوات بلا مفاجآت</span></div>
<div className="card-meta"><span>الوكلاء</span><span className="num">15 دقيقة</span></div>
<h3>قبل أن تمنح الوكيل صلاحية التنفيذ</h3>
<p>حدود الصلاحيات، التأكيد البشري، وسجل تدقيق يمكنك الرجوع إليه.</p>
<Link className="card-link" href="/posts/safe-agent-guardrails"><span>عرض التفاصيل</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="models" data-od-id="guide-rag-evaluation">
<div className="cover"><span className="cover-label">سلسلة فيديو · حصري</span><span className="cover-title">قياس RAG بصدق</span></div>
<div className="card-meta"><span>النماذج</span><span className="num">4 أجزاء</span></div>
<h3>اختبر الاسترجاع والإجابة كلّاً على حدة</h3>
<p>منهج تقييم يفصل فشل المصدر عن فشل النموذج ويقودك إلى إصلاح صحيح.</p>
<Link className="card-link" href="/posts/simple-arabic-rag"><span>شاهد المقدمة</span><span>←</span></Link>
</article>
</div>
<p className="empty" id="emptyState">لا توجد مواد في هذا المسار حالياً.</p>
</div>
</section>
<section className="section membership" id="membership" data-od-id="membership">
<div className="container membership-grid">
<div> <div>
<h2 id="membership-title">العضوية تفتح الأدلة المتقدمة وملفات العمل.</h2> <p className="eyebrow">عضوية أنور بي آي</p>
<p>الأدلة المجانية كاملة دائماً. العضوية تضيف الأدلة المعمّقة، القوالب الجاهزة للتنزيل، والنقاش مع الفريق.</p> <h2 data-od-id="membership-title">المحتوى الذي يحترم وقتك يبقى معك.</h2>
<p className="lead" style={{ marginTop: 20 }}>وصول إلى الأدلة المتقدمة، مسارات الفيديو، والملفات العملية عند إضافتها.</p>
</div> </div>
<div className="membership-offer"> <div>
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p> <ul className="membership-list" aria-label="مزايا العضوية">
<ul role="list"> <li><span>الأدلة الحصرية</span><span>كاملة</span></li>
<li>{guidesLabel(docs.length - freeCount)} للأعضاء، وتُضاف أدلة جديدة باستمرار</li> <li><span>مسارات الفيديو</span><span>متدرجة</span></li>
<li>قوالب وقوائم فحص قابلة للتنزيل</li> <li><span>ملفات العمل</span><span>قابلة للتنزيل</span></li>
<li>التعليق والنقاش على كل دليل</li>
</ul> </ul>
<Link className="btn btn-primary" href="/subscription">اطّلع على العضوية</Link> <Link className="btn btn-primary" href="/subscription" data-od-id="membership-cta">شاهد الأسعار والمزايا</Link>
</div> </div>
</div> </div>
</section> </section>
</main> </main>
<Footer /> <footer className="pagefoot" data-od-id="footer">
</div> <div className="container row-between"><span>© أنور بي آي ٢٠٢٦ · تعلّم بوضوح</span><span className="meta"><Link href="/terms">الشروط</Link> · <Link href="/privacy">الخصوصية</Link></span></div>
</footer>
</>
) )
} }
@@ -1,37 +1,46 @@
import Topnav from '@/components/topnav' import Link from 'next/link'
import Footer from '@/components/footer' import '../styles/payment-cancelled.css'
import Link from 'next/link'
import '../styles/payment-cancelled.css' export const metadata = { title: 'لم يكتمل الدفع' }
export const metadata = { title: 'لم يكتمل الدفع' } /* Payment cancelled — design from payment-cancelled.html. */
export default function PaymentCancelledPage() {
/* Payment cancelled — design from payment-cancelled.html. */ return (
export default function PaymentCancelledPage() { <>
return ( <header className="topnav" data-od-id="topnav">
<div id="site" className="site-page page-payment-cancelled"> <div className="container topnav-inner">
<Topnav /> <Link className="logo" href="/" data-od-id="brand">
<main id="content"> {/* eslint-disable-next-line @next/next/no-img-element */}
<section className="section" data-od-id="payment-cancelled"> <img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
<div className="container"> أنور بي آي
<article className="state-card"> </Link>
<div className="status-mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={2}><path d="M9 9l14 14M23 9 9 23" /></svg></div> <Link className="nav-link" href="/account-settings" data-od-id="account-link">حسابي</Link>
<p className="eyebrow">لم يكتمل الدفع</p> </div>
<h1 data-od-id="cancelled-title">لم تُخصم أي دفعة.</h1> </header>
<p className="lead">ألغيت عملية الدفع أو عدت قبل إكمالها. حسابك المجاني وأدلتك المحفوظة لم تتغير.</p> <main>
<div className="notice" data-od-id="account-safety-note"> <section className="section" data-od-id="payment-cancelled">
<h2 data-od-id="account-safety-title">يمكنك المتابعة وقتما يناسبك</h2> <div className="container">
<p>ارجع إلى صفحة العضوية لمراجعة المزايا أو أكمل استخدام المكتبة المجانية الآن.</p> <article className="state-card">
</div> <div className="status-mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={2}><path d="M9 9l14 14M23 9 9 23" /></svg></div>
<div className="actions"> <p className="eyebrow">لم يكتمل الدفع</p>
<Link className="btn btn-primary" href="/subscription" data-od-id="retry-payment-cta">العودة إلى العضوية</Link> <h1 data-od-id="cancelled-title">لم تُخصم أي دفعة.</h1>
<Link className="btn btn-secondary" href="/articles" data-od-id="browse-free-articles-cta">تصفح الأدلة المجانية</Link> <p className="lead">ألغيت عملية الدفع أو عدت قبل إكمالها. حسابك المجاني ومقالاتك المحفوظة لم تتغير.</p>
</div> <div className="notice" data-od-id="account-safety-note">
<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> <h2 data-od-id="account-safety-title">يمكنك المتابعة وقتما يناسبك</h2>
</article> <p>ارجع إلى صفحة العضوية لمراجعة المزايا أو أكمل استخدام المكتبة المجانية الآن.</p>
</div> </div>
</section> <div className="actions">
</main> <Link className="btn btn-primary" href="/subscription" data-od-id="retry-payment-cta">العودة إلى العضوية</Link>
<Footer /> <Link className="btn btn-secondary" href="/articles" data-od-id="browse-free-articles-cta">تصفح المقالات المجانية</Link>
</div> </div>
) <p className="help">واجهت مشكلة أثناء الدفع؟ <a href="mailto:hello@anwarpi.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>
</div>
</section>
</main>
<footer className="pagefoot" data-od-id="footer">
<div className="container footer-row"><span>© أنور بي آي ٢٠٢٦</span><span>لن تتغير حالة الحساب حتى يكتمل الدفع.</span></div>
</footer>
</>
)
}
+88 -99
View File
@@ -1,99 +1,88 @@
import Topnav from '@/components/topnav' import { getPayload } from 'payload'
import Footer from '@/components/footer' import { headers } from 'next/headers'
import Stripe from 'stripe' import { redirect } from 'next/navigation'
import { getPayload } from 'payload' import configPromise from '@payload-config'
import { headers } from 'next/headers' import Link from 'next/link'
import { redirect } from 'next/navigation' import '../styles/payment-success.css'
import configPromise from '@payload-config'
import Link from 'next/link' export const dynamic = 'force-dynamic'
import '../styles/payment-success.css' export const metadata = { title: 'تم تفعيل العضوية' }
export const dynamic = 'force-dynamic' type Props = { searchParams: Promise<{ session_id?: string; simulated?: string }> }
export const metadata = { title: 'تم تفعيل العضوية' }
/* Payment success — design from payment-success.html.
type Props = { searchParams: Promise<{ session_id?: string }> } - simulated gateway (no Stripe keys): activates VIP on the logged-in account
- real Stripe: verifies the checkout session_id with Stripe before activating
/* Payment success — design from payment-success.html. - no session: VIP users pass through; logged-out users go to login */
- real Stripe: verifies the checkout session_id with Stripe before export default async function PaymentSuccessPage({ searchParams }: Props) {
activating (paid + the checkout email matches the signed-in account) const { session_id: sessionId, simulated } = await searchParams
- no session: nobody is activated here — /api/stripe/webhook is the primary const stripeKey = process.env.STRIPE_SECRET_KEY
activator; this page only re-verifies a session it can prove */
export default async function PaymentSuccessPage({ searchParams }: Props) { const payload = await getPayload({ config: configPromise })
const { session_id: sessionId } = await searchParams const { user } = await payload.auth({ headers: await headers() })
const stripeKey = process.env.STRIPE_SECRET_KEY if (!user) redirect('/login?next=/payment-success')
const payload = await getPayload({ config: configPromise }) let activate = false
const { user } = await payload.auth({ headers: await headers() }) if (sessionId && stripeKey) {
if (!user) redirect('/login?next=/payment-success') const { default: Stripe } = await import('stripe')
const stripe = new Stripe(stripeKey)
let activate = false const session = await stripe.checkout.sessions.retrieve(sessionId)
if (sessionId && stripeKey) { activate = session.payment_status === 'paid' || session.status === 'complete'
try { } else if (!sessionId) {
const stripe = new Stripe(stripeKey) // Simulated gateway (pending mode) — activate the logged-in account
const session = await stripe.checkout.sessions.retrieve(sessionId) activate = true
activate = }
session.payment_status === 'paid' &&
(session.customer_details?.email ?? '').toLowerCase() === user.email.toLowerCase() if (activate && user.role !== 'vip' && user.role !== 'admin') {
} catch { await payload.update({
// Any Stripe failure (bad key, unknown session, network) → no activation. collection: 'users',
activate = false id: user.id,
} data: { role: 'vip' },
} overrideAccess: true,
context: { systemActivation: true },
if (activate && user.role !== 'vip' && user.role !== 'admin') { })
await payload.update({ }
collection: 'users',
id: user.id, return (
data: { role: 'vip' }, <>
overrideAccess: true, <header className="topnav" data-od-id="topnav">
context: { systemActivation: true }, <div className="container topnav-inner">
}) <Link className="logo" href="/" data-od-id="brand">
} {/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
const vipNow = user.role === 'vip' || user.role === 'admin' || activate أنور بي آي
</Link>
return ( <Link className="nav-link" href="/account-settings" data-od-id="account-link">حسابي</Link>
<div id="site" className="site-page page-payment-success"> </div>
<Topnav viewer={user} /> </header>
<main id="content"> <main>
<section className="section" data-od-id="payment-success"> <section className="section" data-od-id="payment-success">
<div className="container"> <div className="container">
<article className="state-card"> <article className="state-card">
<div className="status-mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={2}><path d="m8 17 5 5 11-13" /></svg></div> <div className="status-mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={2}><path d="m8 17 5 5 11-13" /></svg></div>
{vipNow ? ( <p className="eyebrow">تم الدفع · العضوية فعّالة</p>
<> <h1 data-od-id="success-title">مرحباً بك في عضوية أنور بي آي.</h1>
<p className="eyebrow">تم الدفع · العضوية فعّالة</p> <p className="lead">تم تفعيل وصولك إلى الأدلة الحصرية ومسارات الفيديو وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p>
<h1 data-od-id="success-title">مرحباً بك في عضوية أنور بآي.</h1> <dl className="summary" data-od-id="payment-summary">
<p className="lead">تم تفعيل وصولك إلى الأدلة الحصرية وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p> <div className="summary-row"><dt>الحالة</dt><dd>عضوية فعّالة</dd></div>
<dl className="summary" data-od-id="payment-summary"> <div className="summary-row"><dt>الخطة</dt><dd>عضوية فردية · شهرياً · <bdi dir="ltr">10$</bdi></dd></div>
<div className="summary-row"><dt>الحالة</dt><dd>عضوية فعّالة</dd></div> <div className="summary-row"><dt>الوصول</dt><dd>كل محتوى VIP وملفات الأعضاء</dd></div>
<div className="summary-row"><dt>الخطة</dt><dd>عضوية فردية · شهرياً · <bdi dir="ltr">$10</bdi></dd></div> </dl>
<div className="summary-row"><dt>الوصول</dt><dd>كل محتوى العضوية وملفات الأعضاء</dd></div> <div className="next-step" data-od-id="next-step">
</dl> <h2 data-od-id="next-step-title">جهّز مسارك الأول</h2>
<div className="next-step" data-od-id="next-step"> <p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بدليل واحد يناسبك.</p>
<h2 data-od-id="next-step-title">جهّز مسارك الأول</h2> <div className="actions">
<p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بدليل واحد يناسبك.</p> <Link className="btn btn-primary" href="/vip-onboarding" data-od-id="start-onboarding-cta">ابدأ إعداد العضوية</Link>
<div className="actions"> <Link className="btn btn-secondary" href="/posts/agent-loop-stop-condition" data-od-id="open-member-guide-cta">افتح دليل الأعضاء</Link>
<Link className="btn btn-primary" href="/vip-onboarding" data-od-id="start-onboarding-cta">ابدأ إعداد العضوية</Link> </div>
<Link className="btn btn-secondary" href="/posts/agent-loop-stop-condition" data-od-id="open-member-guide-cta">افتح دليل الأعضاء</Link> </div>
</div> </article>
</div> </div>
</> </section>
) : ( </main>
<> <footer className="pagefoot" data-od-id="footer">
<p className="eyebrow">لم تكتمل عملية الدفع</p> <div className="container footer-row"><span>© أنور بي آي ٢٠٢٦</span><span>يمكنك إدارة العضوية من صفحة حسابك.</span></div>
<h1 data-od-id="payment-incomplete-title">لم يتم تأكيد الدفع بعد</h1> </footer>
<p className="lead">لم نتمكن من تأكيد عملية دفع مرتبطة بحسابك. إن أتممت الدفع للتو فقد يحتاج التأكيد بضع دقائق، ويمكنك إعادة المحاولة من صفحة الاشتراك.</p> </>
<div className="actions"> )
<Link className="btn btn-primary" href="/subscription" data-od-id="retry-subscription-cta">العودة إلى صفحة الاشتراك</Link> }
</div>
</>
)}
</article>
</div>
</section>
</main>
<Footer />
</div>
)
}
@@ -44,10 +44,9 @@ export function LikeButton({ slug, loggedIn, initialLiked, initialCount }: { slu
onClick={toggle} onClick={toggle}
disabled={busy} disabled={busy}
aria-pressed={liked} aria-pressed={liked}
aria-label={liked ? 'ألغِ الإعجاب' : 'أعجبني'} data-od-id="post-like"
> >
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 13.5 2.6 8.3a3.2 3.2 0 0 1 4.5-4.6L8 4.6l.9-.9a3.2 3.2 0 0 1 4.5 4.6Z" fill="none" stroke="currentColor" strokeWidth="1.4" /></svg> إعجاب · <span>{count}</span>
<span>{count}</span>
</button> </button>
) )
} }
+375 -400
View File
@@ -1,400 +1,375 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { notFound } from 'next/navigation' import { headers } from 'next/headers'
import Link from 'next/link' import { notFound } from 'next/navigation'
import configPromise from '@payload-config' import Link from 'next/link'
import Topnav from '@/components/topnav' import configPromise from '@payload-config'
import Footer from '@/components/footer' import Topnav from '@/components/topnav'
import PostExtras from './post-extras' import SiteMenu from '@/components/site-menu'
import { LikeButton, ViewPing } from './engagement' import Footer from '@/components/footer'
import { getViewer } from '@/lib/viewer' import SetBodyAttr from '@/components/set-body-attr'
import { TOPICS, topicsFor } from '@/lib/topics' import PostExtras from './post-extras'
import { formatDate, formatNumber, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format' import { LikeButton, ViewPing } from './engagement'
import '../../styles/post-locked.css'
export const dynamic = 'force-dynamic' import '../../styles/post-unlocked.css'
/* QA-06: article pages get their own title/description. */ export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: Args) {
const { slug } = await params /* QA-06: article pages get their own title/description. */
const payload = await getPayload({ config: configPromise }) export async function generateMetadata({ params }: Args) {
const res = await payload.find({ const { slug } = await params
collection: 'posts', const payload = await getPayload({ config: configPromise })
where: { slug: { equals: slug } }, const res = await payload.find({
depth: 0, collection: 'posts',
limit: 1, where: { slug: { equals: slug } },
select: { title: true, dek: true, publishedAt: true }, depth: 0,
}) limit: 1,
const post = res.docs[0] as unknown as { title: string; dek: string; publishedAt?: string | null } | undefined select: { title: true, dek: true, publishedAt: true },
return post })
? { const post = res.docs[0] as unknown as { title: string; dek: string; publishedAt?: string | null } | undefined
title: post.title, return post
description: post.dek, ? {
openGraph: { type: 'article', title: post.title, description: post.dek, publishedTime: post.publishedAt ?? undefined }, title: post.title,
} description: post.dek,
: { title: 'أنور بآي' } openGraph: { type: 'article', title: post.title, description: post.dek, publishedTime: post.publishedAt ?? undefined },
} }
: { title: 'أنور بي آي' }
type Args = { params: Promise<{ slug: string }> } }
type PostDoc = { type Args = { params: Promise<{ slug: string }> }
id: number
title: string type PostDoc = {
slug: string id: number
dek: string title: string
access: 'free' | 'vip' slug: string
category: string dek: string
level?: string | null access: 'free' | 'vip'
views?: number | null category: string
likes?: number | null level?: string | null
readingMinutes: number views?: number | null
publishedAt?: string | null likes?: number | null
teaser?: string | null readingMinutes: number
content?: unknown publishedAt?: string | null
cover?: { url?: string | null } teaser?: string | null
attachments?: (number | { id: number })[] content?: unknown
} cover?: { url?: string | null }
}
async function isLikedByViewer(postId: number, viewerId?: number): Promise<boolean> {
try { async function getViewerRole(): Promise<string | null> {
if (!viewerId) return false const payload = await getPayload({ config: configPromise })
const payload = await getPayload({ config: configPromise }) const { user } = await payload.auth({ headers: await headers() })
const { docs } = await payload.find({ return (user?.role as string) ?? null
collection: 'post-likes', }
where: { and: [{ user: { equals: viewerId } }, { post: { equals: postId } }] },
depth: 0, async function getViewerField(field: 'level' | 'id'): Promise<string | number | null> {
limit: 1, try {
overrideAccess: true, const payload = await getPayload({ config: configPromise })
}) const { user } = await payload.auth({ headers: await headers() })
return docs.length > 0 if (!user) return null
} catch { return (user as unknown as Record<string, string | number>)[field] ?? null
return false } catch {
} return null
} }
}
export default async function PostPage({ params }: Args) {
const { slug } = await params async function isLikedByViewer(postId: number): Promise<boolean> {
const payload = await getPayload({ config: configPromise }) try {
const viewerId = await getViewerField('id')
const res = await payload.find({ if (!viewerId) return false
collection: 'posts', const payload = await getPayload({ config: configPromise })
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] }, const { docs } = await payload.find({
depth: 1, collection: 'post-likes',
limit: 1, where: { and: [{ user: { equals: viewerId } }, { post: { equals: postId } }] },
// content selected only for entitled viewers — never reaches non-VIP clients depth: 0,
select: { limit: 1,
title: true, slug: true, dek: true, access: true, category: true, level: true, overrideAccess: true,
views: true, likes: true, })
readingMinutes: true, publishedAt: true, teaser: true, cover: true, return docs.length > 0
content: true, attachments: true, } catch {
}, return false
}) }
const post = res.docs[0] as unknown as PostDoc | undefined }
if (!post) notFound()
export default async function PostPage({ params }: Args) {
// One auth lookup per request; everything below reads from it. const { slug } = await params
const viewer = await getViewer() const payload = await getPayload({ config: configPromise })
const role = viewer?.role ?? null
const viewerId = viewer?.id const res = await payload.find({
const viewerLevel = viewer?.level ?? null collection: 'posts',
const unlocked = post.access === 'free' || role === 'vip' || role === 'admin' where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
const postTopics = topicsFor(post) depth: 1,
// The downloadable templates are agent-workflow files; only offer them on agent guides. limit: 1,
const hasAgentFiles = postTopics.includes('agents') // VIP content is deliberately excluded here. It is fetched only after
const liked = await isLikedByViewer(post.id, viewerId) // the viewer's entitlement has been established below.
select: {
// Attachment metadata (label/access) is safe to expose — the bytes are title: true, slug: true, dek: true, access: true, category: true, level: true,
// gated by GET /api/files/[id]. Fetched with overrideAccess because the views: true, likes: true,
// files collection read rule is vip/admin and depth population would readingMinutes: true, publishedAt: true, teaser: true, cover: true,
// 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' } const post = res.docs[0] as unknown as PostDoc | undefined
let attachments: Attachment[] = [] if (!post) notFound()
if (attachmentIds.length > 0) {
const filesRes = await payload.find({ const role = await getViewerRole()
collection: 'files', const unlocked = post.access === 'free' || role === 'vip' || role === 'admin'
depth: 0, if (unlocked) {
limit: 20, const entitled = await payload.findByID({
overrideAccess: true, collection: 'posts',
where: { id: { in: attachmentIds } }, id: post.id,
select: { label: true, filename: true, access: true }, depth: 0,
}) overrideAccess: true,
const fileDocs = filesRes.docs as unknown as Attachment[] select: { content: true },
attachments = attachmentIds })
.map(id => fileDocs.find(d => d.id === id)) post.content = entitled.content
.filter((d): d is Attachment => Boolean(d)) }
} const accessChip = post.access === 'free' ? 'مقال مجاني · وصول كامل' : 'VIP · وصول كامل'
const viewerLevel = await getViewerField('level')
// «اقرأ بعد ذلك»: real published guides, same learning path first. const viewerId = await getViewerField('id')
const { docs: others } = await payload.find({
collection: 'posts', // Approved comments (public read access allows the approved-only filter).
where: { and: [{ status: { equals: 'published' } }, { slug: { not_equals: post.slug } }] }, const comments = await payload.find({
depth: 0, collection: 'comments',
limit: 100, depth: 0,
sort: '-publishedAt', limit: 50,
select: { title: true, slug: true, category: true, access: true, readingMinutes: true, level: true }, sort: '-createdAt',
}) where: { and: [{ post: { equals: post.id } }, { status: { equals: 'approved' } }] },
const related = [...others] })
.sort((a, b) => Number(topicsFor(b).includes(postTopics[0])) - Number(topicsFor(a).includes(postTopics[0]))) // Authors see their own pending comments tagged «قيد المراجعة» — needs
.slice(0, 3) // overrideAccess because non-admin read access filters to approved only.
const myPending = viewerId
// Approved comments (public read access allows the approved-only filter). ? await payload.find({
const comments = await payload.find({ collection: 'comments',
collection: 'comments', depth: 0,
depth: 0, limit: 10,
limit: 50, overrideAccess: true,
sort: '-createdAt', where: {
where: { and: [{ post: { equals: post.id } }, { status: { equals: 'approved' } }] }, and: [
}) { post: { equals: post.id } },
// Authors see their own pending comments tagged «قيد المراجعة» — needs { author: { equals: viewerId } },
// overrideAccess because non-admin read access filters to approved only. { status: { equals: 'pending' } },
const myPending = viewerId ],
? await payload.find({ },
collection: 'comments', })
depth: 0, : null
limit: 10,
overrideAccess: true, // TOC from content h2s (server-extracted from the Lexical tree)
where: { const toc = unlocked ? extractH2s(post.content) : []
and: [ const accountEmail = await getViewerEmail().catch(() => null)
{ post: { equals: post.id } },
{ author: { equals: viewerId } }, return (
{ status: { equals: 'pending' } }, <>
], <SetBodyAttr attr="data-anw-dim" />
}, <ViewPing slug={post.slug} />
}) <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: post.title, datePublished: post.publishedAt ?? undefined, author: { '@type': 'Organization', name: 'أنور بي آي' }, inLanguage: 'ar' }) }} />
: null <Topnav
viewer={accountEmail ? { email: accountEmail } : null}
// TOC from content h2s (server-extracted from the Lexical tree) menu
const toc = unlocked ? extractH2s(post.content) : [] links={[
{ href: '/articles', label: 'المكتبة' },
const topic = postTopics[0] { href: '/posts/article-agent-guide', label: 'المقالات' },
const topicLabel = TOPICS.find(t => t.key === topic)?.label ?? post.category { href: '/subscription', label: 'العضوية' },
]}
return ( />
<div id="site" className="site-page v2 v2-article page-posts"> <SiteMenu />
<ViewPing slug={post.slug} /> <main>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: post.title, datePublished: post.publishedAt ?? undefined, author: { '@type': 'Organization', name: 'أنور بآي' }, inLanguage: 'ar' }) }} /> <section className="article-head" data-od-id="article-header"><div className="container">
<Topnav viewer={viewer} /> {!unlocked && post.access === 'vip' ? (
<main id="content"> <span className="lock-chip" data-od-id="access-status">
<header className="art-head"> <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>
<div className="wrap art-head-grid"> VIP · معاينة مجانية
<div className="art-head-copy"> </span>
<nav className="crumbs" aria-label="مسار التصفح"> ) : (
<Link href="/articles">المكتبة</Link> <span className="open-chip" data-od-id="access-status">{accessChip}</span>
<span aria-hidden="true">/</span> )}
<Link href={`/articles?topic=${topic}`}>{topicLabel}</Link> <p className="crumb">{post.category} · دليل عملي</p>
</nav> <h1 data-od-id="article-title">{post.title}</h1>
<h1>{post.title}</h1> <p className="dek">{post.dek}</p>
<p className="lede">{post.dek}</p> <div className="article-meta">
<div className="art-meta"> <span>فريق تحرير أنور بي آي</span>
<span className={`badge${post.access === 'vip' ? ' badge-vip' : ''}`}> <span className="num">قراءة {post.readingMinutes} دقيقة · {post.level ?? 'مبتدئ'}</span>
{post.access === 'vip' && <LockIcon />} {(post.level ?? 'مبتدئ') === viewerLevel && <span className="foryou num">يناسب مستواك</span>}
{post.access === 'free' ? 'مجاني بالكامل' : unlocked ? 'للأعضاء' : 'معاينة مجانية'} <span className="num" data-views>{post.views ?? 0} مشاهدة</span>
</span> <LikeButton slug={post.slug} loggedIn={role !== null} initialLiked={await isLikedByViewer(post.id)} initialCount={post.likes ?? 0} />
<span>{post.category}</span> {post.publishedAt && <span>نُشر: {new Date(post.publishedAt).toLocaleDateString('ar')}</span>}
<span>{minutesLabel(post.readingMinutes)} للقراءة</span> {!unlocked && <span>المتاح مجاناً: المقدمة والقسم الأول</span>}
<span>{post.level ?? 'مبتدئ'}</span> {unlocked && post.access === 'vip' && <SaveButton />}
{post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>} </div>
{(post.level ?? 'مبتدئ') === viewerLevel && <span className="for-you">يناسب مستواك</span>} </div></section>
</div> <section className="container article-layout" data-od-id="article-content">
<div className="art-actions"> <article className="article-body">
<LikeButton slug={post.slug} loggedIn={role !== null} initialLiked={liked} initialCount={post.likes ?? 0} /> {unlocked ? (
<span className="views" data-views>{formatNumber(post.views)} قراءة</span> <RichText data={post.content} />
{unlocked && post.access === 'vip' && <SaveButton />} ) : (
</div> <>
</div> <p className="free-tag">معاينة مجانية</p>
</div> {(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => (
</header> i === 0 ? <p key={i} className="lede">{para}</p>
: i === arr.length - 1 ? (
{typeof post.cover === 'object' && post.cover?.url && ( <div className="lock-cut" data-od-id="lock-cut" key={i}>
<div className="wrap"><figure className="art-cover"><img src={post.cover.url} alt={post.title} width={1200} height={630} loading="eager" /></figure></div> <p className="cut-p">{para}</p>
)} </div>
)
<div className="wrap art-layout"> : <p key={i}>{para}</p>
<article className="art-body"> ))}
{unlocked ? ( <div className="paywall" data-od-id="paywall" aria-labelledby="paywall-title">
<RichText data={post.content} /> <p className="crumb">الجزء المتبقي · للأعضاء فقط</p>
) : ( <h2 id="paywall-title">أكملت الجزء المجاني من هذا الدليل</h2>
<> <p>بقية الدليل غير موجودة في صفحة المعاينة، وتُفتح فقط بعد تسجيل الدخول بعضوية فعّالة. اشتراك واحد يفتح الأدلة الحصرية ومسارات الفيديو وملفات العمل.</p>
{(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => ( <ul className="locked-in">
i === arr.length - 1 && arr.length > 1 <li><LockIcon />بقية الأقسام العملية للدليل<span className="num">—</span></li>
? <p key={i} className="fade-out">{para}</p> <li><LockIcon />كل الأدلة الحصرية الأخرى<span className="num">—</span></li>
: <p key={i} className={i === 0 ? 'intro' : undefined}>{para}</p> <li><LockIcon />ملفات العمل قابلة للتنزيل<span className="num">ملفات</span></li>
))} </ul>
<section className="paywall" aria-labelledby="paywall-title"> <div className="paywall-actions">
<h2 id="paywall-title">بقية هذا الدليل للأعضاء</h2> <a className="btn btn-primary" href="/subscription" data-od-id="subscribe-cta">اشترك لفتح الدليل</a>
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل الأدلة الحصرية الأخرى.</p> <a className="text-link" href={`/login?next=/posts/${post.slug}`} data-od-id="login-cta">لدي اشتراك؟ سجّل الدخول</a>
<div className="paywall-actions"> </div>
<a className="btn btn-primary" href="/subscription">اشترك بـ <bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> شهرياً</a> <p className="price-line"><bdi dir="ltr">10$</bdi> شهرياً · إلغاء فوري في أي وقت</p>
<a className="btn btn-quiet" href={`/login?next=/posts/${post.slug}`}>لدي عضوية، تسجيل الدخول</a> </div>
</div> </>
<p className="fine">تلغي في أي وقت من صفحة حسابك.</p> )}
</section> </article>
</> <aside className="toc" aria-label="فهرس الدليل" data-od-id="article-toc">
)} <strong>داخل هذا الدليل</strong>
</article> {unlocked ? (
<aside className="art-aside" aria-label="داخل هذا الدليل"> <>
{unlocked && toc.length > 0 && ( {toc.map((t, i) => <a key={i} href={`#sec-${i}`}>{t}</a>)}
<nav className="toc" aria-label="فهرس الدليل"> <div className="progress-card" data-od-id="progress-card">
<p className="aside-title">في هذا الدليل</p> <strong>تقدّمك في المسار</strong>
<ol>{toc.map((t, i) => <li key={i}><a href={`#sec-${i}`}>{t}</a></li>)}</ol> <div className="progress-track" role="progressbar" aria-valuenow={40} aria-valuemin={0} aria-valuemax={100} aria-label="تقدم المسار"><div className="progress-bar"></div></div>
</nav> <span className="p-label">قرأت ٢ من ٥ أدلة مسار الوكلاء</span>
)} </div>
{unlocked && hasAgentFiles && ( <div className="files-card" data-od-id="files-card"><strong>ملفات العمل</strong>
<div className="files"> <div className="file-row"><span>قالب حالة الوكيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="agent-state.md">تنزيل</button></div>
<p className="aside-title">ملفات العمل</p> <div className="file-row"><span>قائمة الفحص قبل التشغيل</span><button className="dl-btn" type="button" data-dl="pre-run-checklist.md">تنزيل</button></div>
<ul> <div className="file-row"><span>سجل الأخطاء <bdi dir="ltr">.csv</bdi></span><button className="dl-btn" type="button" data-dl="error-log.csv">تنزيل</button></div>
<li><span>قالب حالة الوكيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="agent-state.md">تنزيل</button></li> </div>
<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> <span className="toc-locked"><LockIcon />الأقسام العملية</span>
)} <span className="toc-locked"><LockIcon />ملفات العمل</span>
{!unlocked && ( <div className="member-note"><strong>ملفات الأعضاء</strong><p>قالب حالة الوكيل وقائمة الفحص قبل التشغيل متاحان للتنزيل داخل العضوية.</p><a className="btn btn-primary" href="/subscription">شاهد مزايا العضوية</a></div>
<div className="files is-locked"> </>
<p className="aside-title">مع العضوية</p> )}
<ul> </aside>
<li><LockIcon />الأقسام العملية كاملة</li> </section>
{hasAgentFiles && <li><LockIcon />ملفات العمل القابلة للتنزيل</li>} {unlocked && (
<li><LockIcon />النقاش مع الفريق</li> <section className="comments" data-od-id="comments"><div className="container comments-wrap">
</ul> <p className="crumb">نقاش الأعضاء · هادئ ومفيد</p>
</div> <h2 data-od-id="comments-title">التعليقات</h2>
)} <div className="comment-list" id="commentList">
{attachments.length > 0 && ( {comments.docs.map((c) => (
<div className="files"> <article key={c.id} className="comment">
<p className="aside-title">ملفات هذا الدليل</p> <div className="comment-head">
<ul> <strong>{c.authorName || 'عضو'}</strong>
{attachments.map(item => { <span className="crumb">{new Date(String(c.createdAt)).toLocaleDateString('ar')}</span>
// Per-file gate mirrors GET /api/files/[id]: free streams to </div>
// anyone; vip needs a vip/admin viewer (post access is <p>{c.body}</p>
// irrelevant — a vip file on a free post stays locked). </article>
const canDownload = item.access === 'free' || role === 'vip' || role === 'admin' ))}
const accessLabel = item.access === 'free' ? 'مجاني' : 'للأعضاء' {myPending?.docs.map((c) => (
return ( <article key={c.id} className="comment">
<li key={item.id}> <div className="comment-head">
<span>{item.label || item.filename} <span className="file-access">· {accessLabel}</span></span> <strong>{c.authorName || 'عضو'}</strong>
{canDownload <span className="crumb">قيد المراجعة</span>
? <a className="dl-btn" href={`/api/files/${item.id}`} download>تنزيل</a> </div>
: <span className="dl-locked"><LockIcon />للأعضاء</span>} <p>{c.body}</p>
</li> </article>
) ))}
})} {comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && (
</ul> <p className="crumb">لا تعليقات بعد — كن أول من يشارك.</p>
</div> )}
)} </div>
</aside> <PostExtras slug={post.slug} loggedIn={role !== null} />
</div> </div></section>
)}
{unlocked && ( <section className="related" data-od-id="related-articles"><div className="container">
<section className="art-comments" aria-labelledby="comments-title"> <p className="crumb">اقرأ بعد ذلك · ضمن عضويتك</p>
<div className="wrap narrow"> <div className="related-grid">
<h2 id="comments-title">النقاش</h2> <article className="related-card" data-od-id="related-monitor"><span>الأتمتة · ٢٤ دقيقة</span><h3>اكتشف فشل سير العمل قبل المستخدم</h3><Link href="/articles?topic=automation" aria-label="افتح الدليل التالي">تصفح المكتبة ←</Link></article>
<div className="comment-list" id="commentList"> <article className="related-card" data-od-id="related-rag"><span>النماذج · ٤ أجزاء</span><h3>افصل فشل الاسترجاع عن فشل الإجابة</h3><Link href="/articles?topic=models" aria-label="افتح الدليل التالي">تصفح المكتبة ←</Link></article>
{comments.docs.map((c) => ( <article className="related-card" data-od-id="related-catalog"><span>المكتبة</span><h3>كل الأدلة والمسارات</h3><Link href="/articles">افتح المكتبة ←</Link></article>
<article key={c.id} className="comment"> </div>
<header><strong>{c.authorName || 'عضو'}</strong><time dateTime={String(c.createdAt)}>{formatDate(String(c.createdAt))}</time></header> </div></section>
<p>{c.body}</p> </main>
</article> <Footer />
))} </>
{myPending?.docs.map((c) => ( )
<article key={c.id} className="comment is-pending"> }
<header><strong>{c.authorName || 'عضو'}</strong><span>بانتظار المراجعة</span></header>
<p>{c.body}</p> function LockIcon() {
</article> 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>
{comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && ( )
<p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا الدليل.</p> }
)}
</div> function SaveButton() {
<PostExtras slug={post.slug} loggedIn={role !== null} /> return <button className="save-btn" id="saveArticle" type="button" aria-pressed="false" data-od-id="save-article">احفظ للعودة لاحقاً</button>
</div> }
</section>
)} async function getViewerEmail(): Promise<string | null> {
try {
{related.length > 0 && ( const payload = await getPayload({ config: configPromise })
<section className="art-related" aria-labelledby="related-title"> const { user } = await payload.auth({ headers: await headers() })
<div className="wrap"> return (user?.email as string) ?? null
<h2 id="related-title">اقرأ بعد ذلك</h2> } catch {
<ul className="related-grid" role="list"> return null
{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> // Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules.
<span className="related-title">{item.title}</span> type Node = { type?: string; text?: string; children?: Node[]; tag?: string; url?: string; listType?: string; code?: string }
</Link>
</li> function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] {
))} return (nodes || []).map((n, i) => {
</ul> const key = `${n.type}-${i}`
</div> if (n.type === 'text') {
</section> const text = n.text || ''
)} return <span key={key}>{text}</span>
</main> }
<Footer /> if (n.type === 'linebreak') return <br key={key} />
</div> 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': {
function LockIcon() { const Tag = (n.tag || 'h2') as 'h1' | 'h2' | 'h3' | 'h4'
return ( const id = Tag === 'h2' ? `sec-${counter.h2++}` : undefined
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><rect x="3" y="7" width="10" height="7" /><path d="M5 7V5a3 3 0 0 1 6 0v2" /></svg> return <Tag key={key} id={id}>{inner}</Tag>
) }
} case 'quote': return <blockquote key={key}>{inner}</blockquote>
case 'list': {
function SaveButton() { if (n.listType === 'number') return <ol key={key}>{inner}</ol>
return <button className="save-btn" id="saveArticle" type="button" aria-pressed="false">احفظ للعودة لاحقاً</button> return <ul key={key}>{inner}</ul>
} }
case 'listitem': return <li key={key}>{inner}</li>
// Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules. case 'paragraph': return <p key={key}>{inner}</p>
type Node = { type?: string; text?: string; children?: Node[]; tag?: string; url?: string; listType?: string; code?: string } case 'code': return <pre key={key} aria-label="مثال برمجي"><code>{n.code ?? innerText(n.children)}</code></pre>
case 'horizontalrule': return <hr key={key} />
function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] { default: return <p key={key}>{inner}</p>
return (nodes || []).map((n, i) => { }
const key = `${n.type}-${i}` })
if (n.type === 'text') { }
const text = n.text || ''
return <span key={key}>{text}</span> function innerText(nodes?: Node[]): string {
} return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
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) function extractH2s(data: unknown): string[] {
switch (n.type) { try {
case 'heading': { const root = (data as { root?: { children?: Node[] } })?.root
const Tag = (n.tag || 'h2') as 'h1' | 'h2' | 'h3' | 'h4' if (!root?.children) return []
const id = Tag === 'h2' ? `sec-${counter.h2++}` : undefined return root.children
return <Tag key={key} id={id}>{inner}</Tag> .filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
} .map(n => innerText(n.children))
case 'quote': return <blockquote key={key}>{inner}</blockquote> } catch {
case 'list': { return []
if (n.listType === 'number') return <ol key={key}>{inner}</ol> }
return <ul key={key}>{inner}</ul> }
}
case 'listitem': return <li key={key}>{inner}</li> function RichText({ data }: { data: unknown }) {
case 'paragraph': return <p key={key}>{inner}</p> if (!data || typeof data !== 'object' || !('root' in data)) return null
case 'code': return <pre key={key} aria-label="مثال برمجي"><code>{n.code ?? innerText(n.children)}</code></pre> const rootUnknown = data.root
case 'horizontalrule': return <hr key={key} /> if (!rootUnknown || typeof rootUnknown !== 'object' || !('children' in rootUnknown)) return null
default: return <p key={key}>{inner}</p> const root = rootUnknown as { children?: Node[] } // named const: same JSON tree the editor produced
} return <>{renderChildren(root.children, { h2: 0 })}</>
}) }
}
function innerText(nodes?: Node[]): string {
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
}
function extractH2s(data: unknown): string[] {
try {
const root = (data as { root?: { children?: Node[] } })?.root
if (!root?.children) return []
return root.children
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
.map(n => innerText(n.children))
} catch {
return []
}
}
function RichText({ data }: { data: unknown }) {
if (!data || typeof data !== 'object' || !('root' in data)) return null
const rootUnknown = data.root
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 })}</>
}
@@ -56,11 +56,11 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
'', '',
'- [ ] الهدف مكتوب في جملة واحدة قابلة للقياس', '- [ ] الهدف مكتوب في جملة واحدة قابلة للقياس',
'- [ ] شرط توقف واضح يمنع الحلقة اللانهائية', '- [ ] شرط توقف واضح يمنع الحلقة اللانهائية',
'- [ ] كل أداة لها صلاحية محددة وشخص مسؤول عن الموافقة', '- [ ] كل أداة لها صلاحية محددة ومسؤول تأكيد بشري',
'- [ ] بيانات الاختبار جاهزة وليست بيانات إنتاج', '- [ ] بيانات الاختبار جاهزة وليست بيانات إنتاج',
'- [ ] سجل تدقيق يكتب كل فعل يقوم به الوكيل', '- [ ] سجل تدقيق يكتب كل فعل يقوم به الوكيل',
'- [ ] سقف تكلفة وزمن محدد لكل تشغيل', '- [ ] سقف تكلفة وزمن محدد للتشغيلة الواحدة',
'- [ ] خطة مراجعة بشرية للمخرجات قبل نشرها', '- [ ] خطة تراجع بشري للمخرجات قبل نشرها',
].join('\n'), ].join('\n'),
}, },
'error-log.csv': { 'error-log.csv': {
@@ -106,7 +106,7 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
e.preventDefault() e.preventDefault()
const form = e.currentTarget const form = e.currentTarget
if (!form.checkValidity()) { if (!form.checkValidity()) {
setStatus('اكتب تعليقاً من 8 أحرف على الأقل.') setStatus('اكتب تعليقاً من ٨ أحرف على الأقل.')
form.reportValidity() form.reportValidity()
return return
} }
@@ -135,24 +135,15 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
} }
} }
if (!loggedIn) {
return (
<div className="comment-gate">
<p>سجّل الدخول لتشارك سؤالك أو تجربتك مع هذا الدليل.</p>
<a className="btn btn-quiet" href={`/login?next=/posts/${slug}`}>سجّل الدخول للتعليق</a>
<p>لا تملك حساباً؟ <a href={`/signup?next=/posts/${slug}`}>أنشئ حساباً مجاناً</a></p>
</div>
)
}
return ( return (
<form className="comment-form" id="commentForm" method="post" noValidate onSubmit={onSubmit}> <form className="comment-form" id="commentForm" method="post" noValidate onSubmit={onSubmit} data-od-id="comment-form">
<div className="field"> <div className="field">
<label htmlFor="commentBody">تعليقك</label> <label htmlFor="commentBody">تعليقك</label>
<textarea className="textarea" id="commentBody" name="comment" required minLength={8} maxLength={2000}></textarea> <textarea className="textarea" id="commentBody" name="comment" required minLength={8} maxLength={2000}></textarea>
</div> </div>
<button className="btn btn-primary" type="submit" disabled={busy}>{busy ? 'جارٍ الإرسال…' : 'أرسل التعليق'}</button> <button className="btn btn-primary" type="submit" disabled={busy}>{busy ? '…' : 'أضف التعليق'}</button>
<p className="status" id="commentStatus" role="status">{status}</p> <p className="status" id="commentStatus" role="status">{status}</p>
{!loggedIn && <p className="crumb">التعليق متاح للأعضاء بعد <a className="text-link" href="/login">تسجيل الدخول</a>.</p>}
</form> </form>
) )
} }
+48 -32
View File
@@ -1,32 +1,48 @@
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import SiteMenu from '@/components/site-menu'
import '../styles/privacy.css' import '../styles/privacy.css'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { title: 'الخصوصية' } export const metadata = { title: 'الخصوصية' }
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<div id="site" className="site-page page-privacy"> <>
<Topnav /> <Topnav
<main id="content"> viewer={null}
<section className="hero" data-od-id="privacy-hero"> menu
<div className="container"> links={[
<p className="eyebrow">الخصوصية · مسودة ما قبل الإطلاق</p> { href: '/articles', label: 'المكتبة' },
<h1 data-od-id="privacy-title">بياناتك ليست مادة للمحتوى.</h1> { href: '/subscription', label: 'العضوية' },
<p className="lead">توضح هذه المسودة الحد الأدنى المقترح لجمع البيانات واستخدامها داخل أنور بآي.</p> { href: '/privacy', label: 'الخصوصية' },
<p className="notice" role="note">هذه سياسة تجريبية للواجهة وليست نصاً قانونياً نهائياً. يجب مراجعتها واعتمادها قبل نشر الموقع.</p> ]}
</div> />
</section> <SiteMenu
<div className="container policy" data-od-id="privacy-content"> links={[
<section data-od-id="privacy-data"><h2>ما الذي نجمعه؟</h2><p>عند تشغيل الحسابات فعلياً قد نحتاج إلى الاسم والبريد الإلكتروني، وبيانات العضوية والفوترة التي يوفرها مزوّد الدفع. لا ينبغي تخزين بيانات البطاقة كاملة داخل المنصة.</p></section> { href: '/articles', label: 'المكتبة' },
<section data-od-id="privacy-use"><h2>كيف تُستخدم البيانات؟</h2><ul><li>تسجيل الدخول وحفظ التقدّم والتفضيلات.</li><li>إدارة العضوية وإرسال الرسائل التي اختارها المستخدم.</li><li>حماية المنصة وتشخيص الأعطال وتحسين تجربة القراءة.</li></ul></section> { href: '/posts/article-agent-guide', label: 'مقال الأسبوع' },
<section data-od-id="privacy-sharing"><h2>المشاركة والاحتفاظ</h2><p>لا تُباع البيانات الشخصية. قبل الإطلاق يجب تسمية مزوّدي الاستضافة والبريد والدفع والتحليلات، وتحديد مدة الاحتفاظ بكل نوع من البيانات بوضوح.</p></section> { href: '/subscription', label: 'العضوية والأسعار' },
<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> foot={{ href: '/login', label: '', note: 'محتوى عربي عملي، بلا ضجيج.', variant: 'hidden' }}
</div> />
</main> <main>
<Footer /> <section className="hero" data-od-id="privacy-hero">
</div> <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@anwarpi.ai">hello@anwarpi.ai</a>.</p></section>
<section data-od-id="privacy-updates"><h2>التحديثات</h2><p>يجب إضافة تاريخ السريان وملخص أي تغيير جوهري عند اعتماد النسخة القانونية النهائية.</p></section>
</div>
</main>
<footer className="pagefoot" data-od-id="footer"><div className="container footer-bottom"><span>© أنور بي آي ٢٠٢٦</span><a href="mailto:hello@anwarpi.ai">تواصل معنا</a></div></footer>
</>
)
}
+27 -21
View File
@@ -1,7 +1,4 @@
'use client' 'use client'
import Topnav from '@/components/topnav'
import Footer from '@/components/footer'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
@@ -28,15 +25,14 @@ export default function SignupPage() {
const form = e.currentTarget const form = e.currentTarget
const data = new FormData(form) const data = new FormData(form)
const confirm = form.elements.namedItem('confirmPassword') as HTMLInputElement const confirm = form.elements.namedItem('confirmPassword') as HTMLInputElement
confirm.setCustomValidity(data.get('password') === data.get('confirmPassword') ? '' : 'كلمتا المرور غير متطابقتان.') confirm.setCustomValidity(data.get('password') === data.get('confirmPassword') ? '' : 'كلمتا المرور غير متطابقتين.')
if (!form.checkValidity()) { if (!form.checkValidity()) {
setStatus(data.get('password') !== data.get('confirmPassword') ? 'كلمتا المرور غير متطابقتان.' : 'تحقق من جميع الحقول المطلوبة.') setStatus(data.get('password') !== data.get('confirmPassword') ? 'كلمتا المرور غير متطابقتين.' : 'تحقق من جميع الحقول المطلوبة.')
form.reportValidity() form.reportValidity()
return return
} }
setBusy(true) setBusy(true)
setStatus('') setStatus('')
try {
const res = await fetch('/api/users', { const res = await fetch('/api/users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -62,22 +58,33 @@ export default function SignupPage() {
} }
setStatus('تم إنشاء الحساب — سجّل الدخول للمتابعة.') setStatus('تم إنشاء الحساب — سجّل الدخول للمتابعة.')
router.push(nextPath ? '/login?next=' + encodeURIComponent(nextPath) : '/login') router.push(nextPath ? '/login?next=' + encodeURIComponent(nextPath) : '/login')
} catch {
setStatus('تعذّر الاتصال. حاول مرة أخرى بعد التحقق من اتصالك.')
} finally {
setBusy(false)
}
} }
return ( return (
<div id="site" className="site-page page-signup"> <>
<Topnav /> <header className="topnav" data-od-id="topnav">
<main id="content" className="auth"> <div className="container nav-inner">
<section className="auth-story"> <Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} /> <img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
<h1>ابدأ بحساب مجاني.</h1> أنور بي آي
<p>الحساب المجاني يفتح كل الأدلة المجانية كاملة والتعليق عليها. يمكنك الاشتراك في العضوية لاحقاً إن احتجتها.</p> </Link>
<Link className="nav-return" href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : '/login'} data-od-id="login-link">لديك حساب؟ سجّل الدخول</Link>
</div>
</header>
<main className="auth">
<section className="auth-story" data-od-id="signup-story">
<div>
<p className="eyebrow">ابدأ مسارك</p>
<h1 data-od-id="signup-story-title">ابنِ معرفتك، خطوة بخطوة.</h1>
<p>أنشئ حساباً لحفظ المقالات ومتابعة تقدّمك والوصول إلى مزايا العضوية من أي جهاز.</p>
<ul className="story-list">
<li><span>مكتبتك الشخصية</span><span className="story-mark">محفوظة</span></li>
<li><span>تقدّم المسارات</span><span className="story-mark">متزامن</span></li>
<li><span>إعدادات واضحة</span><span className="story-mark">تحت سيطرتك</span></li>
</ul>
</div>
<p className="story-foot">العربية أولاً · خصوصيتك محفوظة</p>
</section> </section>
<section className="auth-form-wrap" data-od-id="signup-form-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"> <form className="auth-form" id="signupForm" method="post" action="/api/users" noValidate onSubmit={onSubmit} data-od-id="signup-form">
@@ -95,7 +102,6 @@ export default function SignupPage() {
</form> </form>
</section> </section>
</main> </main>
<Footer /> </>
</div>
) )
} }
+77 -89
View File
@@ -1,89 +1,77 @@
/* Page-scoped styles: .page-about. Shared tokens live in globals.css. */ :root {
.page-about *,.page-about *::before,.page-about *::after{box-sizing:border-box} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-about{-webkit-text-size-adjust:100%} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-about{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
.page-about img,.page-about svg{display:block;max-width:100%;height:auto} --container: 1120px;
.page-about a{color:inherit;text-decoration:none} --gutter: 32px;
.page-about h1,.page-about h2,.page-about h3{font-family:var(--font-display);text-wrap:balance} }
.page-about p{text-wrap:pretty} *,*::before,*::after{box-sizing:border-box}
.page-about :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px} html{-webkit-text-size-adjust:100%}
.page-about .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
.page-about .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)} img,svg{display:block;max-width:100%;height:auto}
.page-about .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px} a{color:inherit;text-decoration:none}
.page-about .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700} h1,h2,h3{font-family:var(--font-display);text-wrap:balance}
.page-about .logo-mark{width:30px;height:34px;object-fit:contain;flex:none;border-radius:4px} p{text-wrap:pretty}
.page-about .nav-links{display:flex;align-items:center;gap:28px} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-about .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)} .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-about .nav-links a:hover,.page-about .nav-links a[aria-current="page"]{color:var(--fg)} .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-about .hero{padding-block:clamp(70px,10vw,132px);border-bottom:1px solid var(--border)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.page-about .hero-grid{display:grid;grid-template-columns:1.25fr .75fr;gap:clamp(48px,9vw,112px);align-items:flex-end} .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}
.page-about .eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 22px} .logo-mark{width:30px;height:34px;object-fit:contain;flex:none;border-radius:4px}
.page-about .hero h1{font-size:clamp(48px,7.5vw,92px);line-height:1.06;max-width:10ch;margin:0} .nav-links{display:flex;align-items:center;gap:28px}
.page-about .lead{font-size:clamp(19px,2vw,23px);line-height:1.75;color:var(--muted);max-width:54ch;margin:28px 0 0} .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
.page-about .hero-note{border-top:1px solid var(--fg);padding-top:24px} .nav-links a:hover,.nav-links a[aria-current="page"]{color:var(--fg)}
.page-about .hero-note strong{display:block;font-size:clamp(24px,3vw,34px);line-height:1.35} .hero{padding-block:clamp(70px,10vw,132px);border-bottom:1px solid var(--border)}
.page-about .hero-note p{color:var(--muted);margin:10px 0 0} .hero-grid{display:grid;grid-template-columns:1.25fr .75fr;gap:clamp(48px,9vw,112px);align-items:end}
.page-about .team{padding-block:clamp(64px,9vw,104px);border-bottom:1px solid var(--border)} .eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 22px}
.page-about .section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:40px;margin-bottom:46px} .hero h1{font-size:clamp(48px,7.5vw,92px);line-height:1.06;max-width:10ch;margin:0}
.page-about .section-head h2{font-size:clamp(38px,5vw,62px);line-height:1.12;max-width:12ch;margin:0} .lead{font-size:clamp(19px,2vw,23px);line-height:1.75;color:var(--muted);max-width:54ch;margin:28px 0 0}
.page-about .section-head p{color:var(--muted);max-width:48ch;margin:0} .hero-note{border-top:1px solid var(--fg);padding-top:24px}
.page-about .profile-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:42px;border-top:1px solid var(--fg)} .hero-note strong{display:block;font-size:clamp(24px,3vw,34px);line-height:1.35}
.page-about .profile{padding:34px 0 0;display:flex;flex-direction:column;min-width:0} .hero-note p{color:var(--muted);margin:10px 0 0}
.page-about .profile+.profile{padding-inline-start:42px;border-inline-start:1px solid var(--border)} .team{padding-block:clamp(64px,9vw,104px);border-bottom:1px solid var(--border)}
.page-about .profile-label{font:12px/1.5 var(--font-mono);color:var(--muted)} .section-head{display:flex;align-items:end;justify-content:space-between;gap:40px;margin-bottom:46px}
.page-about .profile-main .profile-label{color:var(--accent)} .section-head h2{font-size:clamp(38px,5vw,62px);line-height:1.12;max-width:12ch;margin:0}
.page-about .profile h3{font-size:clamp(32px,4vw,48px);line-height:1.15;margin:20px 0 8px} .section-head p{color:var(--muted);max-width:48ch;margin:0}
.page-about .role{color:var(--fg);font-weight:700;margin:0 0 22px} .profile-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:42px;border-top:1px solid var(--fg)}
.page-about .bio{color:var(--muted);font-size:17px;margin:0;max-width:58ch} .profile{padding:34px 0 0;display:flex;flex-direction:column;min-width:0}
.page-about .topics{display:flex;flex-wrap:wrap;gap:8px;margin:28px 0 32px} .profile+.profile{padding-inline-start:42px;border-inline-start:1px solid var(--border)}
.page-about .topic{display:inline-flex;align-items:center;min-height:34px;padding:6px 12px;border:1px solid var(--border);color:var(--muted);font-size:13px} .profile-label{font:12px/1.5 var(--font-mono);color:var(--muted)}
.page-about .profile-link{min-height:50px;display:inline-flex;align-items:center;justify-content:space-between;gap:20px;margin-top:auto;padding:12px 16px;border:1px solid var(--fg);font-weight:700} .profile-main .profile-label{color:var(--accent)}
.page-about .profile-link:hover{background:var(--fg);color:var(--surface)} .profile h3{font-size:clamp(32px,4vw,48px);line-height:1.15;margin:20px 0 8px}
.page-about .principles{padding-block:clamp(64px,9vw,104px)} .role{color:var(--fg);font-weight:700;margin:0 0 22px}
.page-about .principle-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:clamp(48px,9vw,112px);align-items:flex-start} .bio{color:var(--muted);font-size:17px;margin:0;max-width:58ch}
.page-about .principle-grid h2{font-size:clamp(38px,5vw,60px);line-height:1.14;margin:0;max-width:11ch} .topics{display:flex;flex-wrap:wrap;gap:8px;margin:28px 0 32px}
.page-about .principle-list{border-top:1px solid var(--fg)} .topic{display:inline-flex;align-items:center;min-height:34px;padding:6px 12px;border:1px solid var(--border);color:var(--muted);font-size:13px}
.page-about .principle{display:grid;grid-template-columns:56px 1fr;gap:20px;padding:26px 0;border-bottom:1px solid var(--border)} .profile-link{min-height:50px;display:inline-flex;align-items:center;justify-content:space-between;gap:20px;margin-top:auto;padding:12px 16px;border:1px solid var(--fg);font-weight:700}
.page-about .principle-index{font:12px/1.5 var(--font-mono);color:var(--muted);padding-top:7px} .profile-link:hover{background:var(--fg);color:var(--surface)}
.page-about .principle h3{font-size:24px;margin:0 0 8px} .principles{padding-block:clamp(64px,9vw,104px)}
.page-about .principle p{color:var(--muted);margin:0} .principle-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:clamp(48px,9vw,112px);align-items:start}
.page-about .pagefoot{padding-block:50px;border-top:1px solid var(--border);color:var(--muted)} .principle-grid h2{font-size:clamp(38px,5vw,60px);line-height:1.14;margin:0;max-width:11ch}
.page-about .footer-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;flex-wrap:wrap} .principle-list{border-top:1px solid var(--fg)}
.page-about .footer-links{display:flex;gap:20px;flex-wrap:wrap} .principle{display:grid;grid-template-columns:56px 1fr;gap:20px;padding:26px 0;border-bottom:1px solid var(--border)}
.page-about .footer-links a{min-height:44px;display:inline-flex;align-items:center} .principle-index{font:12px/1.5 var(--font-mono);color:var(--muted);padding-top:7px}
.page-about .footer-links a:hover{color:var(--fg);text-decoration:underline;text-underline-offset:4px} .principle h3{font-size:24px;margin:0 0 8px}
@media(max-width:920px){ .principle p{color:var(--muted);margin:0}
.page-about .hero-grid,.page-about .profile-grid,.page-about .principle-grid{grid-template-columns:1fr} .pagefoot{padding-block:50px;border-top:1px solid var(--border);color:var(--muted)}
.page-about .profile-grid{gap:0} .footer-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;flex-wrap:wrap}
.page-about .profile+.profile{padding-inline-start:0;border-inline-start:0;border-top:1px solid var(--border);margin-top:42px;padding-top:42px} .footer-links{display:flex;gap:20px;flex-wrap:wrap}
.page-about .section-head{align-items:flex-start;flex-direction:column} .footer-links a{min-height:44px;display:inline-flex;align-items:center}
.page-about .hero-note{max-width:560px} .footer-links a:hover{color:var(--fg);text-decoration:underline;text-underline-offset:4px}
} @media(max-width:920px){
@media(max-width:620px){ :root{--gutter:22px}
.page-about .hero{padding-block:54px} .hero-grid,.profile-grid,.principle-grid{grid-template-columns:1fr}
.page-about .team,.page-about .principles{padding-block:58px} .profile-grid{gap:0}
.page-about .profile h3{font-size:36px} .profile+.profile{padding-inline-start:0;border-inline-start:0;border-top:1px solid var(--border);margin-top:42px;padding-top:42px}
.page-about .principle{grid-template-columns:42px 1fr} .section-head{align-items:start;flex-direction:column}
.page-about .footer-bottom,.page-about .footer-links{align-items:stretch;flex-direction:column;gap:8px} .hero-note{max-width:560px}
} }
@media(prefers-reduced-motion:reduce){.page-about *{scroll-behavior:auto!important;transition-duration:.01ms!important}} @media(max-width:620px){
:root{--gutter:18px}
/* Matched team cards, independent of generic card and heading overrides. */ .hero{padding-block:54px}
#site.page-about .section-head { display: grid; grid-template-columns: 1fr 1fr; align-items: start; gap: 40px; margin-bottom: 32px; } .team,.principles{padding-block:58px}
#site.page-about .section-head h2 { max-width: none; margin: 0; font-size: clamp(28px, 3vw, 40px); } .profile h3{font-size:36px}
#site.page-about .section-head p { max-width: none; margin: 0; line-height: 1.9; } .principle{grid-template-columns:42px 1fr}
#site.page-about .profile-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; border: 0; align-items: stretch; } .footer-bottom,.footer-links{align-items:stretch;flex-direction:column;gap:8px}
#site.page-about .profile, #site.page-about .profile + .profile { margin: 0; padding: 32px; border: 1px solid var(--border); background: var(--surface); border-radius: 16px; } }
#site.page-about .profile-label { font: 13px/1.8 var(--font-body); color: var(--accent); min-height: 24px; } @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
#site.page-about .profile h3 { font-size: 32px; line-height: 1.6; margin: 16px 0 4px; }
#site.page-about .profile .role { font-size: 16px; line-height: 1.8; margin: 0 0 20px; }
#site.page-about .profile .bio { font-size: 16px; line-height: 2; max-width: none; }
#site.page-about .topics { margin: 24px 0; gap: 8px; }
#site.page-about .topic { border-radius: 24px; background: var(--accent-soft); border-color: transparent; color: var(--muted); }
#site.page-about .profile-link { margin-top: auto; border: 1px solid var(--border); border-radius: 8px; color: var(--accent); font-size: 14px; }
#site.page-about .profile-link:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
@media (max-width: 760px) {
#site.page-about .section-head, #site.page-about .profile-grid { grid-template-columns: minmax(0, 1fr); gap: 24px; }
#site.page-about .profile, #site.page-about .profile + .profile { padding: 24px; }
#site.page-about .profile h3 { font-size: 28px; }
}
+112 -108
View File
@@ -1,108 +1,112 @@
/* Page-scoped styles: .page-account-settings. Shared tokens live in globals.css. */ :root {
.page-account-settings *,.page-account-settings *::before,.page-account-settings *::after{box-sizing:border-box} .page-account-settings{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-account-settings{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-account-settings.locked{overflow:hidden} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-account-settings a{color:inherit;text-decoration:none} .page-account-settings button,.page-account-settings input{font:inherit} .page-account-settings button{cursor:pointer} .page-account-settings h1,.page-account-settings h2,.page-account-settings h3{font-family:var(--font-display);text-wrap:balance} .page-account-settings p{text-wrap:pretty} .page-account-settings :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-account-settings .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} --font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
.page-account-settings .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-account-settings .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px} }
.page-account-settings .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-account-settings .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-account-settings .nav-links{display:flex;align-items:center;gap:28px}.page-account-settings .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-account-settings .nav-links a:hover{color:var(--fg)} *,*::before,*::after{box-sizing:border-box} html{scroll-behavior:smooth;-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} body.locked{overflow:hidden}
.page-account-settings .nav-tools{display:flex;align-items:center;gap:8px} a{color:inherit;text-decoration:none} button,input{font:inherit} button{cursor:pointer} h1,h2,h3{font-family:var(--font-display);text-wrap:balance} p{text-wrap:pretty} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-account-settings .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-account-settings .nav-account:hover{background:var(--accent);border-color:var(--accent)} .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-account-settings .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} .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)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.page-account-settings .icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-account-settings .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)} .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.page-account-settings .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center} .nav-tools{display:flex;align-items:center;gap:8px}
.page-account-settings .demo-strip b{color:var(--surface);font-weight:700} .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}.nav-account:hover{background:var(--accent);border-color:var(--accent)}
.page-account-settings .page-head{padding-block:clamp(48px,7vw,84px) 0} .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-account-settings .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px} .icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
.page-account-settings .page-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0} .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
.page-account-settings .page-head .sub{color:var(--muted);margin:16px 0 0;max-width:52ch} .demo-strip b{color:var(--surface);font-weight:700}
.page-account-settings .settings-layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:clamp(40px,7vw,90px);align-items:start;padding-block:56px 90px} .page-head{padding-block:clamp(48px,7vw,84px) 0}
.page-account-settings .settings-nav{position:sticky;top:100px;border-top:1px solid var(--fg)} .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}
.page-account-settings .settings-nav button{width:100%;min-height:52px;display:flex;justify-content:space-between;align-items:center;gap:12px;background:transparent;border:0;border-bottom:1px solid var(--border);color:var(--muted);font-size:16px;text-align:right;padding:0 4px} .page-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0}
.page-account-settings .settings-nav button:hover{color:var(--fg)} .page-head .sub{color:var(--muted);margin:16px 0 0;max-width:52ch}
.page-account-settings .settings-nav button[aria-selected="true"]{color:var(--fg);font-weight:700} .settings-layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:clamp(40px,7vw,90px);align-items:start;padding-block:56px 90px}
.page-account-settings .settings-nav button[aria-selected="true"]::after{content:"";width:8px;height:8px;background:var(--accent);transform:rotate(45deg)} .settings-nav{position:sticky;top:100px;border-top:1px solid var(--fg)}
.page-account-settings .panel{display:none} .settings-nav button{width:100%;min-height:52px;display:flex;justify-content:space-between;align-items:center;gap:12px;background:transparent;border:0;border-bottom:1px solid var(--border);color:var(--muted);font-size:16px;text-align:right;padding:0 4px}
.page-account-settings .panel.active{display:block} .settings-nav button:hover{color:var(--fg)}
.page-account-settings .panel h2{font-size:clamp(30px,3.4vw,42px);line-height:1.2;margin:0 0 10px} .settings-nav button[aria-selected="true"]{color:var(--fg);font-weight:700}
.page-account-settings .panel .panel-sub{color:var(--muted);max-width:56ch;margin:0 0 34px} .settings-nav button[aria-selected="true"]::after{content:"";width:8px;height:8px;background:var(--accent);transform:rotate(45deg)}
.page-account-settings .form-grid{display:grid;gap:24px;max-width:560px} .panel{display:none}
.page-account-settings .field{display:grid;gap:7px} .panel.active{display:block}
.page-account-settings .field label{font-size:13px;color:var(--muted)} .panel h2{font-size:clamp(30px,3.4vw,42px);line-height:1.2;margin:0 0 10px}
.page-account-settings .field .hint{font-size:12px;color:var(--muted)} .panel .panel-sub{color:var(--muted);max-width:56ch;margin:0 0 34px}
.page-account-settings .input{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;min-height:48px} .form-grid{display:grid;gap:24px;max-width:560px}
.page-account-settings .input:hover{border-color:var(--muted)} .field{display:grid;gap:7px}
.page-account-settings .input:focus{outline:2px solid var(--accent);outline-offset:1px} .field label{font-size:13px;color:var(--muted)}
.page-account-settings .input[disabled]{background:var(--fg-soft);color:var(--muted);cursor:not-allowed} .field .hint{font-size:12px;color:var(--muted)}
.page-account-settings .input.invalid{border-color:var(--accent)} .input{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;min-height:48px}
.page-account-settings .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700} .input:hover{border-color:var(--muted)}
.page-account-settings .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-account-settings .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)} .input:focus{outline:2px solid var(--accent);outline-offset:1px}
.page-account-settings .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}.page-account-settings .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)} .input[disabled]{background:var(--fg-soft);color:var(--muted);cursor:not-allowed}
.page-account-settings .status{font-size:14px;min-height:24px;display:flex;align-items:center;gap:8px} .input.invalid{border-color:var(--accent)}
.page-account-settings .status.ok{color:var(--accent);font-weight:700} .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
.page-account-settings .status.err{color:var(--accent)} .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
.page-account-settings .pw-rules{list-style:none;padding:0;margin:4px 0 0;display:grid;gap:4px} .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}.btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
.page-account-settings .pw-rules li{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px} .status{font-size:14px;min-height:24px;display:flex;align-items:center;gap:8px}
.page-account-settings .pw-rules li::before{content:"";width:7px;height:7px;border:1px solid var(--muted);transform:rotate(45deg);flex:none} .status.ok{color:var(--accent);font-weight:700}
.page-account-settings .pw-rules li.ok{color:var(--fg)} .status.err{color:var(--accent)}
.page-account-settings .pw-rules li.ok::before{background:var(--accent);border-color:var(--accent)} .pw-rules{list-style:none;padding:0;margin:4px 0 0;display:grid;gap:4px}
.page-account-settings .prefs{border-top:1px solid var(--fg);max-width:640px} .pw-rules li{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px}
.page-account-settings .pref-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px 0;border-bottom:1px solid var(--border)} .pw-rules li::before{content:"";width:7px;height:7px;border:1px solid var(--muted);transform:rotate(45deg);flex:none}
.page-account-settings .pref-row strong{display:block;font-size:17px} .pw-rules li.ok{color:var(--fg)}
.page-account-settings .pref-row p{margin:4px 0 0;color:var(--muted);font-size:14px;max-width:44ch} .pw-rules li.ok::before{background:var(--accent);border-color:var(--accent)}
.page-account-settings .switch{position:relative;width:52px;min-height:32px;flex:none} .prefs{border-top:1px solid var(--fg);max-width:640px}
.page-account-settings .switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer} .pref-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px 0;border-bottom:1px solid var(--border)}
.page-account-settings .switch .track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none;transition:background .15s,border-color .15s} .pref-row strong{display:block;font-size:17px}
.page-account-settings .switch input:checked + .track{background:var(--accent);border-color:var(--accent)} .pref-row p{margin:4px 0 0;color:var(--muted);font-size:14px;max-width:44ch}
.page-account-settings .switch .track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s} .switch{position:relative;width:52px;min-height:32px;flex:none}
.page-account-settings .switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)} .switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
.page-account-settings .switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px} .switch .track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none;transition:background .15s,border-color .15s}
.page-account-settings .membership-card{border:1px solid var(--fg);padding:clamp(24px,4vw,38px);max-width:640px} .switch input:checked + .track{background:var(--accent);border-color:var(--accent)}
.page-account-settings .membership-head{display:flex;justify-content:space-between;align-items:start;gap:18px;padding-bottom:22px;border-bottom:1px solid var(--border)} .switch .track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s}
.page-account-settings .membership-head h3{font-size:28px;margin:0} .switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)}
.page-account-settings .state-chip{font:11px/1.5 var(--font-mono);padding:6px 12px;border:1px solid var(--accent);color:var(--accent);white-space:nowrap} .switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-account-settings .state-chip.cancelled{border-color:var(--muted);color:var(--muted)} .membership-card{border:1px solid var(--fg);padding:clamp(24px,4vw,38px);max-width:640px}
.page-account-settings .plan-details{display:grid;grid-template-columns:repeat(2,1fr);gap:20px 32px;padding-block:24px} .membership-head{display:flex;justify-content:space-between;align-items:start;gap:18px;padding-bottom:22px;border-bottom:1px solid var(--border)}
.page-account-settings .plan-details dt{font:12px/1.6 var(--font-mono);color:var(--muted)} .membership-head h3{font-size:28px;margin:0}
.page-account-settings .plan-details dd{margin:4px 0 0;font-size:16px} .state-chip{font:11px/1.5 var(--font-mono);padding:6px 12px;border:1px solid var(--accent);color:var(--accent);white-space:nowrap}
.page-account-settings .price-tag{font-family:var(--font-display);font-size:44px;line-height:1} .state-chip.cancelled{border-color:var(--muted);color:var(--muted)}
.page-account-settings .price-tag small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:6px} .plan-details{display:grid;grid-template-columns:repeat(2,1fr);gap:20px 32px;padding-block:24px}
.page-account-settings .membership-actions{display:flex;gap:14px;flex-wrap:wrap;padding-top:22px;border-top:1px solid var(--border)} .plan-details dt{font:12px/1.6 var(--font-mono);color:var(--muted)}
.page-account-settings .cancel-zone{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px} .plan-details dd{margin:4px 0 0;font-size:16px}
.page-account-settings .cancel-zone h3{font-size:22px;margin:0 0 6px} .price-tag{font-family:var(--font-display);font-size:44px;line-height:1}
.page-account-settings .cancel-zone p{color:var(--muted);font-size:14px;margin:0 0 16px;max-width:52ch} .price-tag small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:6px}
.page-account-settings .billing{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px} .membership-actions{display:flex;gap:14px;flex-wrap:wrap;padding-top:22px;border-top:1px solid var(--border)}
.page-account-settings .billing h3{font-size:22px;margin:0 0 6px} .cancel-zone{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
.page-account-settings .billing-sub{color:var(--muted);font-size:14px;margin:0 0 18px;max-width:52ch} .cancel-zone h3{font-size:22px;margin:0 0 6px}
.page-account-settings .billing-table{border:1px solid var(--border)} .cancel-zone p{color:var(--muted);font-size:14px;margin:0 0 16px;max-width:52ch}
.page-account-settings .billing-row{display:grid;grid-template-columns:1.1fr 1.5fr .7fr .8fr auto;gap:12px;align-items:center;padding:13px 18px;border-bottom:1px solid var(--border);font-size:14px} .billing{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
.page-account-settings .billing-row:last-child{border-bottom:0} .billing h3{font-size:22px;margin:0 0 6px}
.page-account-settings .billing-row.head{font:11px/1.6 var(--font-mono);color:var(--muted);padding:10px 18px} .billing-sub{color:var(--muted);font-size:14px;margin:0 0 18px;max-width:52ch}
.page-account-settings .billing-row .amt,.page-account-settings .billing-row .bdate{font-family:var(--font-mono);white-space:nowrap} .billing-table{border:1px solid var(--border)}
.page-account-settings .billing-row .desc{color:var(--fg)} .billing-row{display:grid;grid-template-columns:1.1fr 1.5fr .7fr .8fr auto;gap:12px;align-items:center;padding:13px 18px;border-bottom:1px solid var(--border);font-size:14px}
.page-account-settings .chip{display:inline-block;padding:2px 10px;border:1px solid var(--border);font:11px/1.9 var(--font-mono);color:var(--muted);white-space:nowrap} .billing-row:last-child{border-bottom:0}
.page-account-settings .chip.paid{color:var(--fg);border-color:var(--fg)} .billing-row.head{font:11px/1.6 var(--font-mono);color:var(--muted);padding:10px 18px}
.page-account-settings .chip.upcoming{color:var(--accent);border-color:var(--accent)} .billing-row .amt,.billing-row .bdate{font-family:var(--font-mono);white-space:nowrap}
.page-account-settings .chip.voided{color:var(--muted);border-color:var(--border);text-decoration:line-through} .billing-row .desc{color:var(--fg)}
.page-account-settings .inv-link{font:12px/1.6 var(--font-mono);color:var(--fg);text-decoration:underline;text-underline-offset:3px} .chip{display:inline-block;padding:2px 10px;border:1px solid var(--border);font:11px/1.9 var(--font-mono);color:var(--muted);white-space:nowrap}
.page-account-settings .inv-link:hover{color:var(--accent)} .chip.paid{color:var(--fg);border-color:var(--fg)}
.page-account-settings .inv-link:disabled{color:var(--muted);text-decoration:none;cursor:default} .chip.upcoming{color:var(--accent);border-color:var(--accent)}
.page-account-settings .inv-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .chip.voided{color:var(--muted);border-color:var(--border);text-decoration:line-through}
.page-account-settings .text-danger{background:transparent;border:0;color:var(--accent);font-weight:700;text-decoration:underline;text-underline-offset:4px;min-height:44px;padding:0 4px} .inv-link{font:12px/1.6 var(--font-mono);color:var(--fg);text-decoration:underline;text-underline-offset:3px}
.page-account-settings .text-danger:hover{color:color-mix(in oklch,var(--accent) 80%,black)} .inv-link:hover{color:var(--accent)}
.page-account-settings .dialog{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-account-settings .dialog.open{visibility:visible;pointer-events:auto} .inv-link:disabled{color:var(--muted);text-decoration:none;cursor:default}
.page-account-settings .dialog-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 58%,transparent);opacity:0;transition:opacity .2s} .inv-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.page-account-settings .dialog.open .dialog-scrim{opacity:1} .text-danger{background:transparent;border:0;color:var(--accent);font-weight:700;text-decoration:underline;text-underline-offset:4px;min-height:44px;padding:0 4px}
.page-account-settings .dialog-panel{position:absolute;inset-inline:0;top:50%;transform:translateY(calc(-50% + 12px));margin-inline:auto;width:min(560px,92vw);background:var(--bg);border:1px solid var(--fg);padding:clamp(26px,4vw,40px);opacity:0;transition:opacity .2s,transform .2s} .text-danger:hover{color:color-mix(in oklch,var(--accent) 80%,black)}
.page-account-settings .dialog.open .dialog-panel{opacity:1;transform:translateY(-50%)} .dialog{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.dialog.open{visibility:visible;pointer-events:auto}
.page-account-settings .dialog-panel h2{font-size:30px;margin:0 0 12px} .dialog-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 58%,transparent);opacity:0;transition:opacity .2s}
.page-account-settings .dialog-panel p{color:var(--muted);margin:0 0 14px} .dialog.open .dialog-scrim{opacity:1}
.page-account-settings .dialog-panel .keep-note{border-inline-start:2px solid var(--border);padding-inline-start:14px} .dialog-panel{position:absolute;inset-inline:0;top:50%;transform:translateY(calc(-50% + 12px));margin-inline:auto;width:min(560px,92vw);background:var(--bg);border:1px solid var(--fg);padding:clamp(26px,4vw,40px);opacity:0;transition:opacity .2s,transform .2s}
.page-account-settings .dialog-actions{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px} .dialog.open .dialog-panel{opacity:1;transform:translateY(-50%)}
.page-account-settings .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)} .dialog-panel h2{font-size:30px;margin:0 0 12px}
.page-account-settings .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px} .dialog-panel p{color:var(--muted);margin:0 0 14px}
.page-account-settings .footer-col strong{display:block;color:var(--fg);margin-bottom:12px} .dialog-panel .keep-note{border-inline-start:2px solid var(--border);padding-inline-start:14px}
.page-account-settings .footer-col a{display:block;min-height:36px}.page-account-settings .footer-col a:hover{color:var(--fg);text-decoration:underline} .dialog-actions{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px}
.page-account-settings .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} .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
.page-account-settings .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-account-settings .menu-panel.open{visibility:visible;pointer-events:auto}.page-account-settings .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-account-settings .menu-panel.open .menu-scrim{opacity:1}.page-account-settings .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-account-settings .menu-panel.open .menu-drawer{transform:translateX(0)}.page-account-settings .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-account-settings .menu-links{display:flex;flex-direction:column}.page-account-settings .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px} .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
@media(max-width:920px){.page-account-settings .nav-links{display:none}.page-account-settings .settings-layout{grid-template-columns:1fr}.page-account-settings .settings-nav{position:static;display:grid;grid-template-columns:1fr 1fr;border-top:0;margin-bottom:10px}.page-account-settings .settings-nav button{border:1px solid var(--border)}.page-account-settings .footer-grid{grid-template-columns:1fr 1fr}} .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
@media(max-width:620px){.page-account-settings .nav-account .name-label{display:none}.page-account-settings .page-head h1{font-size:44px}.page-account-settings .settings-layout{padding-block:40px 60px}.page-account-settings .footer-grid{grid-template-columns:1fr;gap:28px}.page-account-settings .plan-details{grid-template-columns:1fr}.page-account-settings .dialog-actions{flex-direction:column}.page-account-settings .dialog-actions .btn{width:100%}.page-account-settings .billing-row{grid-template-columns:1fr 1fr;row-gap:2px}.page-account-settings .billing-row.head{display:none}.page-account-settings .billing-row .bdate{grid-column:1}.page-account-settings .billing-row .desc{grid-column:1 / -1;color:var(--muted)}} .footer-col a{display:block;min-height:36px}.footer-col a:hover{color:var(--fg);text-decoration:underline}
@media(prefers-reduced-motion:reduce){.page-account-settings *{scroll-behavior:auto!important;transition-duration:.01ms!important}} .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}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.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}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:flex;flex-direction:column}.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){:root{--gutter:22px}.nav-links{display:none}.settings-layout{grid-template-columns:1fr}.settings-nav{position:static;display:grid;grid-template-columns:1fr 1fr;border-top:0;margin-bottom:10px}.settings-nav button{border:1px solid var(--border)}.footer-grid{grid-template-columns:1fr 1fr}}
@media(max-width:620px){:root{--gutter:18px}.nav-account .name-label{display:none}.page-head h1{font-size:44px}.settings-layout{padding-block:40px 60px}.footer-grid{grid-template-columns:1fr;gap:28px}.plan-details{grid-template-columns:1fr}.dialog-actions{flex-direction:column}.dialog-actions .btn{width:100%}.billing-row{grid-template-columns:1fr 1fr;row-gap:2px}.billing-row.head{display:none}.billing-row .bdate{grid-column:1}.billing-row .desc{grid-column:1 / -1;color:var(--muted)}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
@@ -1,7 +1,28 @@
/* Page-scoped styles: .page-home. Shared tokens live in globals.css. */ :root {
.page-home *, .page-home *::before, .page-home *::after { box-sizing: border-box; } --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-home { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-home { --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--fs-h1: clamp(46px, 6.3vw, 82px);
--fs-h2: clamp(32px, 4vw, 52px);
--fs-h3: 22px;
--fs-lead: 19px;
--fs-body: 16px;
--fs-meta: 12px;
--gap-xs: 8px;
--gap-sm: 12px;
--gap-md: 20px;
--gap-lg: 32px;
--gap-xl: 56px;
--gap-2xl: 96px;
--container: 1180px;
--gutter: 32px;
--radius: 2px;
--radius-lg: 2px;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
@@ -11,179 +32,181 @@
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.page-home.modal-open { overflow: hidden; } body.modal-open { overflow: hidden; }
.page-home img, .page-home svg { display: block; max-width: 100%; } img, svg { display: block; max-width: 100%; }
.page-home a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
.page-home button, .page-home input { font: inherit; } button, input { font: inherit; }
.page-home button { cursor: pointer; } button { cursor: pointer; }
.page-home p { text-wrap: pretty; } p { text-wrap: pretty; }
.page-home h1, .page-home h2, .page-home h3 { text-wrap: balance; } h1, h2, h3 { text-wrap: balance; }
.page-home :focus-visible { outline: 3px solid color-mix(in oklch, var(--accent) 55%, transparent); outline-offset: 3px; } :focus-visible { outline: 3px solid color-mix(in oklch, var(--accent) 55%, transparent); outline-offset: 3px; }
.page-home .container { max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); } .container { max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
.page-home .section { padding-block: clamp(64px, 9vw, var(--gap-2xl)); } .section { padding-block: clamp(64px, 9vw, var(--gap-2xl)); }
.page-home .section + .section { border-top: 1px solid var(--border); } .section + .section { border-top: 1px solid var(--border); }
.page-home .stack { display: flex; flex-direction: column; } .stack { display: flex; flex-direction: column; }
.page-home .stack > * + * { margin-top: var(--gap-md); } .stack > * + * { margin-top: var(--gap-md); }
.page-home .row { display: flex; align-items: center; gap: var(--gap-md); } .row { display: flex; align-items: center; gap: var(--gap-md); }
.page-home .row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--gap-md); } .row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--gap-md); }
.page-home .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap-lg); } .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap-lg); }
.page-home .grid-2-1 { display: grid; grid-template-columns: 2fr 1fr; gap: var(--gap-xl); align-items: start; } .grid-2-1 { display: grid; grid-template-columns: 2fr 1fr; gap: var(--gap-xl); align-items: start; }
.page-home .h1, .page-home h1 { font-family: var(--font-display); font-size: var(--fs-h1); line-height: 1.08; margin: 0; } .h1, h1 { font-family: var(--font-display); font-size: var(--fs-h1); line-height: 1.08; margin: 0; }
.page-home .h2, .page-home h2 { font-family: var(--font-display); font-size: var(--fs-h2); line-height: 1.15; margin: 0; } .h2, h2 { font-family: var(--font-display); font-size: var(--fs-h2); line-height: 1.15; margin: 0; }
.page-home .h3, .page-home h3 { font-family: var(--font-display); font-size: var(--fs-h3); line-height: 1.35; margin: 0; } .h3, h3 { font-family: var(--font-display); font-size: var(--fs-h3); line-height: 1.35; margin: 0; }
.page-home .lead { font-size: var(--fs-lead); line-height: 1.7; color: var(--muted); max-width: 54ch; margin: 0; } .lead { font-size: var(--fs-lead); line-height: 1.7; color: var(--muted); max-width: 54ch; margin: 0; }
.page-home .eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--accent); margin: 0 0 var(--gap-md); } .eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--accent); margin: 0 0 var(--gap-md); }
.page-home .meta { font-family: var(--font-mono); font-size: var(--fs-meta); color: var(--muted); } .meta { font-family: var(--font-mono); font-size: var(--fs-meta); color: var(--muted); }
.page-home .num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } .num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.page-home .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); } .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-home .topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: var(--gap-lg); } .topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: var(--gap-lg); }
.page-home .logo { display: inline-flex; align-items: center; gap: 10px; font-family: var(--font-display); font-size: 23px; font-weight: 700; } .logo { display: inline-flex; align-items: center; gap: 10px; font-family: var(--font-display); font-size: 23px; font-weight: 700; }
.page-home .logo-mark { width: 12px; height: 12px; background: var(--fg); transform: rotate(45deg); } .logo-mark { width: 12px; height: 12px; background: var(--fg); transform: rotate(45deg); }
.page-home .logo:hover { opacity: .72; } .logo:hover { opacity: .72; }
.page-home .topnav nav { display: flex; align-items: center; gap: var(--gap-lg); } .topnav nav { display: flex; align-items: center; gap: var(--gap-lg); }
.page-home .topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); font-size: 14px; border-bottom: 1px solid transparent; } .topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); font-size: 14px; border-bottom: 1px solid transparent; }
.page-home .topnav nav a:hover { color: var(--fg); border-color: var(--fg); } .topnav nav a:hover { color: var(--fg); border-color: var(--fg); }
.page-home .nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); background: transparent; color: var(--fg); } .nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); background: transparent; color: var(--fg); }
.page-home .nav-action:hover { border-color: var(--fg); background: var(--fg-soft); } .nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
.page-home .nav-tools { display: flex; align-items: center; gap: 8px; } .nav-tools { display: flex; align-items: center; gap: 8px; }
.page-home .menu-trigger { width: 46px; height: 46px; display: grid; place-items: center; border: 1px solid var(--fg); background: var(--fg); color: var(--surface); } .menu-trigger { width: 46px; height: 46px; display: grid; place-items: center; border: 1px solid var(--fg); background: var(--fg); color: var(--surface); }
.page-home .menu-trigger:hover { background: var(--accent); border-color: var(--accent); color: var(--surface); } .menu-trigger:hover { background: var(--accent); border-color: var(--accent); color: var(--surface); }
.page-home .menu-trigger svg { width: 20px; height: 20px; } .menu-trigger svg { width: 20px; height: 20px; }
.page-home .menu-panel { position: fixed; inset: 0; z-index: 60; visibility: hidden; pointer-events: none; } .menu-panel { position: fixed; inset: 0; z-index: 60; visibility: hidden; pointer-events: none; }
.page-home .menu-panel.open { visibility: visible; pointer-events: auto; } .menu-panel.open { visibility: visible; pointer-events: auto; }
.page-home .menu-scrim { position: absolute; inset: 0; border: 0; background: color-mix(in oklch, var(--fg) 55%, transparent); opacity: 0; transition: opacity .2s ease; } .menu-scrim { position: absolute; inset: 0; border: 0; background: color-mix(in oklch, var(--fg) 55%, transparent); opacity: 0; transition: opacity .2s ease; }
.page-home .menu-panel.open .menu-scrim { opacity: 1; } .menu-panel.open .menu-scrim { opacity: 1; }
.page-home .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; } .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-home .menu-panel.open .menu-drawer { transform: translateX(0); } .menu-panel.open .menu-drawer { transform: translateX(0); }
.page-home .menu-head { display: flex; align-items: center; justify-content: space-between; padding-bottom: 24px; border-bottom: 1px solid var(--border); } .menu-head { display: flex; align-items: center; justify-content: space-between; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
.page-home .menu-links { display: grid; margin-top: 34px; } .menu-links { display: grid; margin-top: 34px; }
.page-home .menu-links a { min-height: 70px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); font-family: var(--font-display); font-size: clamp(27px, 4vw, 38px); } .menu-links a { min-height: 70px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); font-family: var(--font-display); font-size: clamp(27px, 4vw, 38px); }
.page-home .menu-links a:hover { color: var(--accent); } .menu-links a:hover { color: var(--accent); }
.page-home .menu-foot { margin-top: auto; padding-top: 24px; color: var(--muted); } .menu-foot { margin-top: auto; padding-top: 24px; color: var(--muted); }
.page-home .btn { min-height: 46px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 11px 20px; border-radius: var(--radius); border: 1px solid transparent; font-weight: 700; transition: transform .12s ease, background .16s ease, border-color .16s ease; } .btn { min-height: 46px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 11px 20px; border-radius: var(--radius); border: 1px solid transparent; font-weight: 700; transition: transform .12s ease, background .16s ease, border-color .16s ease; }
.page-home .btn:active { transform: translateY(1px); } .btn:active { transform: translateY(1px); }
.page-home .btn-primary { background: var(--accent); color: var(--surface); border-color: var(--accent); } .btn-primary { background: var(--accent); color: var(--surface); border-color: var(--accent); }
.page-home .btn-primary:hover { background: color-mix(in oklch, var(--accent) 86%, black); color: var(--surface); } .btn-primary:hover { background: color-mix(in oklch, var(--accent) 86%, black); color: var(--surface); }
.page-home .btn-secondary { background: transparent; color: var(--fg); border-color: var(--border); } .btn-secondary { background: transparent; color: var(--fg); border-color: var(--border); }
.page-home .btn-secondary:hover { background: var(--fg); color: var(--surface); border-color: var(--fg); } .btn-secondary:hover { background: var(--fg); color: var(--surface); border-color: var(--fg); }
.page-home .btn-ghost { background: transparent; color: var(--fg); border-color: transparent; padding-inline: 8px; } .btn-ghost { background: transparent; color: var(--fg); border-color: transparent; padding-inline: 8px; }
.page-home .btn-ghost:hover { border-color: var(--fg); } .btn-ghost:hover { border-color: var(--fg); }
.page-home .hero { padding-block: clamp(56px, 8vw, 112px); overflow: hidden; } .hero { padding-block: clamp(56px, 8vw, 112px); overflow: hidden; }
.page-home .hero-split { display: grid; grid-template-columns: .85fr 1.15fr; gap: clamp(42px, 7vw, 96px); align-items: center; } .hero-split { display: grid; grid-template-columns: .85fr 1.15fr; gap: clamp(42px, 7vw, 96px); align-items: center; }
.page-home .hero-copy h1 { max-width: 9ch; } .hero-copy h1 { max-width: 9ch; }
.page-home .hero-copy .lead { margin-top: 24px; } .hero-copy .lead { margin-top: 24px; }
.page-home .hero-cta { display: flex; gap: var(--gap-sm); flex-wrap: wrap; margin-top: 32px; } .hero-cta { display: flex; gap: var(--gap-sm); flex-wrap: wrap; margin-top: 32px; }
.page-home .trust-line { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; } .trust-line { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.page-home .deck { position: relative; min-height: 510px; } .deck { position: relative; min-height: 510px; }
.page-home .deck-frame { position: relative; min-height: 470px; border: 1px solid var(--fg); background: var(--surface); overflow: hidden; } .deck-frame { position: relative; min-height: 470px; border: 1px solid var(--fg); background: var(--surface); overflow: hidden; }
.page-home .deck-slide { position: absolute; inset: 0; padding: clamp(28px, 4vw, 52px); display: none; grid-template-rows: auto 1fr auto; } .deck-slide { position: absolute; inset: 0; padding: clamp(28px, 4vw, 52px); display: none; grid-template-rows: auto 1fr auto; }
.page-home .deck-slide.active { display: grid; } .deck-slide.active { display: grid; }
.page-home .deck-slide::after { content: ""; position: absolute; inset-inline-start: 0; bottom: 0; width: 100%; height: 4px; background: var(--fg); } .deck-slide::after { content: ""; position: absolute; inset-inline-start: 0; bottom: 0; width: 100%; height: 4px; background: var(--fg); }
.page-home .deck-slide-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .deck-slide-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.page-home .deck-slide-kicker { font-family: var(--font-mono); font-size: 11px; color: var(--muted); } .deck-slide-kicker { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.page-home .deck-slide-index { font-family: var(--font-display); font-size: 30px; } .deck-slide-index { font-family: var(--font-display); font-size: 30px; }
.page-home .deck-slide-body { align-self: center; } .deck-slide-body { align-self: center; }
.page-home .deck-slide-body h2 { font-size: clamp(38px, 5vw, 66px); max-width: 9ch; } .deck-slide-body h2 { font-size: clamp(38px, 5vw, 66px); max-width: 9ch; }
.page-home .deck-slide-body p { color: var(--muted); max-width: 38ch; margin: 18px 0 0; } .deck-slide-body p { color: var(--muted); max-width: 38ch; margin: 18px 0 0; }
.page-home .deck-slide-foot { display: flex; align-items: end; justify-content: space-between; gap: 20px; border-top: 1px solid var(--border); padding-top: 18px; } .deck-slide-foot { display: flex; align-items: end; justify-content: space-between; gap: 20px; border-top: 1px solid var(--border); padding-top: 18px; }
.page-home .deck-slide-format { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; } .deck-slide-format { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; }
.page-home .deck-slide-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); } .deck-slide-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); }
.page-home .deck-slide-link:hover { color: var(--accent); border-color: var(--accent); } .deck-slide-link:hover { color: var(--accent); border-color: var(--accent); }
.page-home .play { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--fg); border-radius: 50%; } .play { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--fg); border-radius: 50%; }
.page-home .deck-controls { min-height: 44px; display: flex; justify-content: space-between; align-items: center; margin-top: 14px; } .deck-controls { min-height: 44px; display: flex; justify-content: space-between; align-items: center; margin-top: 14px; }
.page-home .deck-dots { display: flex; gap: 8px; } .deck-dots { display: flex; gap: 8px; }
.page-home .deck-dot { width: 34px; height: 4px; border: 0; padding: 0; background: var(--border); } .deck-dot { width: 34px; height: 4px; border: 0; padding: 0; background: var(--border); }
.page-home .deck-dot.active { background: var(--fg); } .deck-dot.active { background: var(--fg); }
.page-home .deck-arrows { display: flex; gap: 8px; direction: ltr; } .deck-arrows { display: flex; gap: 8px; direction: ltr; }
.page-home .icon-btn { width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid var(--border); background: transparent; color: var(--fg); } .icon-btn { width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid var(--border); background: transparent; color: var(--fg); }
.page-home .icon-btn:hover { border-color: var(--fg); background: var(--fg-soft); } .icon-btn:hover { border-color: var(--fg); background: var(--fg-soft); }
.page-home .topic-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-block: 1px solid var(--border); } .topic-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-block: 1px solid var(--border); }
.page-home .topic { min-height: 132px; padding: 24px; border-inline-start: 1px solid var(--border); display: flex; flex-direction: column; justify-content: space-between; } .topic { min-height: 132px; padding: 24px; border-inline-start: 1px solid var(--border); display: flex; flex-direction: column; justify-content: space-between; }
.page-home .topic:first-child { border-inline-start: 0; } .topic:first-child { border-inline-start: 0; }
.page-home .topic strong { font-family: var(--font-display); font-size: 22px; } .topic strong { font-family: var(--font-display); font-size: 22px; }
.page-home .topic span { color: var(--muted); font-size: 13px; } .topic span { color: var(--muted); font-size: 13px; }
.page-home .topic:hover { background: var(--fg-soft); } .topic:hover { background: var(--fg-soft); }
.page-home .section-head { margin-bottom: 42px; align-items: end; } .section-head { margin-bottom: 42px; align-items: end; }
.page-home .section-head > div { max-width: 54ch; } .section-head > div { max-width: 54ch; }
.page-home .filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 34px; } .filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 34px; }
.page-home .filter { min-height: 44px; padding: 9px 16px; border: 1px solid var(--border); background: transparent; color: var(--muted); } .filter { min-height: 44px; padding: 9px 16px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
.page-home .filter:hover { color: var(--fg); border-color: var(--fg); } .filter:hover { color: var(--fg); border-color: var(--fg); }
.page-home .filter.active { background: var(--fg); color: var(--surface); border-color: var(--fg); } .filter.active { background: var(--fg); color: var(--surface); border-color: var(--fg); }
.page-home .catalog { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 28px; border-top: 1px solid var(--fg); } .catalog { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 28px; border-top: 1px solid var(--fg); }
.page-home .catalog-card { padding-block: 28px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; min-height: 330px; } .catalog-card { padding-block: 28px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; min-height: 330px; }
.page-home .catalog-card[hidden] { display: none; } .catalog-card[hidden] { display: none; }
.page-home .cover { min-height: 142px; padding: 20px; margin-bottom: 22px; background: var(--fg); color: var(--surface); display: flex; flex-direction: column; justify-content: space-between; position: relative; overflow: hidden; } .cover { min-height: 142px; padding: 20px; margin-bottom: 22px; background: var(--fg); color: var(--surface); display: flex; flex-direction: column; justify-content: space-between; position: relative; overflow: hidden; }
.page-home .cover::before { content: ""; position: absolute; width: 120px; height: 120px; border: 1px solid color-mix(in oklch, var(--surface) 28%, transparent); border-radius: 50%; inset-inline-end: -28px; top: -35px; } .cover::before { content: ""; position: absolute; width: 120px; height: 120px; border: 1px solid color-mix(in oklch, var(--surface) 28%, transparent); border-radius: 50%; inset-inline-end: -28px; top: -35px; }
.page-home .cover-light { background: var(--border); color: var(--fg); } .cover-light { background: var(--border); color: var(--fg); }
.page-home .cover-lined { background: repeating-linear-gradient(0deg, var(--surface), var(--surface) 23px, var(--border) 24px); color: var(--fg); border: 1px solid var(--border); } .cover-lined { background: repeating-linear-gradient(0deg, var(--surface), var(--surface) 23px, var(--border) 24px); color: var(--fg); border: 1px solid var(--border); }
.page-home .cover-label { position: relative; font-family: var(--font-mono); font-size: 10px; } .cover-label { position: relative; font-family: var(--font-mono); font-size: 10px; }
.page-home .cover-title { position: relative; font-family: var(--font-display); font-size: 27px; line-height: 1.2; max-width: 11ch; } .cover-title { position: relative; font-family: var(--font-display); font-size: 27px; line-height: 1.2; max-width: 11ch; }
.page-home .card-meta { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; } .card-meta { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; }
.page-home .catalog-card h3 { font-size: 24px; } .catalog-card h3 { font-size: 24px; }
.page-home .catalog-card p { color: var(--muted); margin: 9px 0 20px; font-size: 14px; } .catalog-card p { color: var(--muted); margin: 9px 0 20px; font-size: 14px; }
.page-home .card-link { margin-top: auto; min-height: 44px; display: inline-flex; align-items: center; justify-content: space-between; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--fg); padding: 14px 0 0; font-weight: 700; text-align: start; } .card-link { margin-top: auto; min-height: 44px; display: inline-flex; align-items: center; justify-content: space-between; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--fg); padding: 14px 0 0; font-weight: 700; text-align: start; }
.page-home .card-link:hover { border-color: var(--fg); } .card-link:hover { border-color: var(--fg); }
.page-home .card-link span:last-child { direction: ltr; } .card-link span:last-child { direction: ltr; }
.page-home .empty { display: none; padding: 40px 0; color: var(--muted); } .empty { display: none; padding: 40px 0; color: var(--muted); }
.page-home .empty.show { display: block; } .empty.show { display: block; }
.page-home .membership { background: var(--fg); color: var(--surface); } .membership { background: var(--fg); color: var(--surface); }
.page-home .membership .eyebrow { color: var(--surface); opacity: .7; } .membership .eyebrow { color: var(--surface); opacity: .7; }
.page-home .membership .lead { color: color-mix(in oklch, var(--surface) 70%, transparent); } .membership .lead { color: color-mix(in oklch, var(--surface) 70%, transparent); }
.page-home .membership-grid { display: grid; grid-template-columns: 1.35fr .65fr; gap: var(--gap-xl); align-items: end; } .membership-grid { display: grid; grid-template-columns: 1.35fr .65fr; gap: var(--gap-xl); align-items: end; }
.page-home .membership-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid color-mix(in oklch, var(--surface) 25%, transparent); } .membership-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid color-mix(in oklch, var(--surface) 25%, transparent); }
.page-home .membership-list li { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid color-mix(in oklch, var(--surface) 15%, transparent); } .membership-list li { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid color-mix(in oklch, var(--surface) 15%, transparent); }
.page-home .membership .btn-primary { background: var(--surface); color: var(--fg); border-color: var(--surface); margin-top: 24px; width: 100%; } .membership .btn-primary { background: var(--surface); color: var(--fg); border-color: var(--surface); margin-top: 24px; width: 100%; }
.page-home .membership .btn-primary:hover { background: var(--border); color: var(--fg); } .membership .btn-primary:hover { background: var(--border); color: var(--fg); }
.page-home .pagefoot { padding-block: var(--gap-xl); color: var(--muted); font-size: 13px; border-top: 1px solid var(--border); } .pagefoot { padding-block: var(--gap-xl); color: var(--muted); font-size: 13px; border-top: 1px solid var(--border); }
.page-home .footer-grid { display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: var(--gap-xl); padding-bottom: var(--gap-xl); } .footer-grid { display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: var(--gap-xl); padding-bottom: var(--gap-xl); }
.page-home .footer-brand p { max-width: 34ch; margin: 14px 0 0; } .footer-brand p { max-width: 34ch; margin: 14px 0 0; }
.page-home .footer-col strong { display: block; color: var(--fg); margin-bottom: 12px; } .footer-col strong { display: block; color: var(--fg); margin-bottom: 12px; }
.page-home .footer-col a { display: block; min-height: 36px; } .footer-col a { display: block; min-height: 36px; }
.page-home .footer-col a:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 4px; } .footer-col a:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 4px; }
.page-home .footer-bottom { padding-top: 22px; border-top: 1px solid var(--border); flex-wrap: wrap; } .footer-bottom { padding-top: 22px; border-top: 1px solid var(--border); flex-wrap: wrap; }
.page-home .modal { position: fixed; inset: 0; z-index: 50; display: none; place-items: center; padding: 24px; background: color-mix(in oklch, var(--fg) 62%, transparent); } .modal { position: fixed; inset: 0; z-index: 50; display: none; place-items: center; padding: 24px; background: color-mix(in oklch, var(--fg) 62%, transparent); }
.page-home .modal.open { display: grid; } .modal.open { display: grid; }
.page-home .modal-panel { width: min(620px, 100%); max-height: min(680px, 90vh); overflow: auto; background: var(--surface); color: var(--fg); border: 1px solid var(--fg); padding: clamp(26px, 5vw, 52px); } .modal-panel { width: min(620px, 100%); max-height: min(680px, 90vh); overflow: auto; background: var(--surface); color: var(--fg); border: 1px solid var(--fg); padding: clamp(26px, 5vw, 52px); }
.page-home .modal-close { float: left; margin: -14px -14px 20px 0; } .modal-close { float: left; margin: -14px -14px 20px 0; }
.page-home .modal-panel h2 { clear: both; font-size: clamp(32px, 5vw, 50px); } .modal-panel h2 { clear: both; font-size: clamp(32px, 5vw, 50px); }
.page-home .modal-panel p { color: var(--muted); } .modal-panel p { color: var(--muted); }
.page-home .modal-outline { margin: 28px 0; padding: 0; list-style: none; border-top: 1px solid var(--border); } .modal-outline { margin: 28px 0; padding: 0; list-style: none; border-top: 1px solid var(--border); }
.page-home .modal-outline li { padding: 13px 0; border-bottom: 1px solid var(--border); } .modal-outline li { padding: 13px 0; border-bottom: 1px solid var(--border); }
@media (max-width: 920px) { @media (max-width: 920px) {
.page-home .hero-split, .page-home .grid-3, .page-home .grid-2-1, .page-home .membership-grid { grid-template-columns: 1fr; } :root { --gutter: 22px; }
.page-home .topnav nav { display: none; } .hero-split, .grid-3, .grid-2-1, .membership-grid { grid-template-columns: 1fr; }
.page-home .footer-grid { grid-template-columns: 1fr 1fr; } .topnav nav { display: none; }
.page-home .hero-copy h1 { max-width: 11ch; } .footer-grid { grid-template-columns: 1fr 1fr; }
.page-home .deck { min-height: 440px; } .hero-copy h1 { max-width: 11ch; }
.page-home .deck-frame { min-height: 400px; } .deck { min-height: 440px; }
.page-home .topic-grid { grid-template-columns: repeat(2, 1fr); } .deck-frame { min-height: 400px; }
.page-home .topic:nth-child(3) { border-inline-start: 0; border-top: 1px solid var(--border); } .topic-grid { grid-template-columns: repeat(2, 1fr); }
.page-home .topic:nth-child(4) { border-top: 1px solid var(--border); } .topic:nth-child(3) { border-inline-start: 0; border-top: 1px solid var(--border); }
.page-home .catalog { grid-template-columns: 1fr 1fr; } .topic:nth-child(4) { border-top: 1px solid var(--border); }
.catalog { grid-template-columns: 1fr 1fr; }
} }
@media (max-width: 620px) { @media (max-width: 620px) {
.page-home .nav-action { padding-inline: 12px; } :root { --gutter: 18px; }
.page-home .hero { padding-top: 42px; } .nav-action { padding-inline: 12px; }
.page-home .deck-slide { padding: 26px; } .hero { padding-top: 42px; }
.page-home .deck-slide-body h2 { font-size: 38px; } .deck-slide { padding: 26px; }
.page-home .deck-slide-foot { align-items: start; flex-direction: column; } .deck-slide-body h2 { font-size: 38px; }
.page-home .topic-grid, .page-home .catalog { grid-template-columns: 1fr; } .deck-slide-foot { align-items: start; flex-direction: column; }
.page-home .topic { min-height: 104px; border-inline-start: 0; border-top: 1px solid var(--border); } .topic-grid, .catalog { grid-template-columns: 1fr; }
.page-home .topic:first-child { border-top: 0; } .topic { min-height: 104px; border-inline-start: 0; border-top: 1px solid var(--border); }
.page-home .section-head { align-items: start; flex-direction: column; } .topic:first-child { border-top: 0; }
.page-home .catalog-card { min-height: auto; } .section-head { align-items: start; flex-direction: column; }
.page-home .nav-action { display: none; } .catalog-card { min-height: auto; }
.page-home .footer-grid { grid-template-columns: 1fr; gap: 28px; } .nav-action { display: none; }
.footer-grid { grid-template-columns: 1fr; gap: 28px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.page-home *, .page-home *::before, .page-home *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; } *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
} }
@@ -1,16 +1,20 @@
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */ :root {
.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} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.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} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.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} --font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
.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 .menu-trigger{background:var(--fg);color:var(--surface);border-color:var(--fg)}.page-posts .menu-trigger:hover{background:var(--accent);color:var(--surface);border-color:var(--accent)} *,*::before,*::after{box-sizing:border-box} html{scroll-behavior:smooth;-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} body.locked{overflow:hidden}
.page-posts .article-head{padding-block:clamp(58px,9vw,110px);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 .access-chip{display:inline-flex;align-items:center;min-height:34px;padding:6px 14px;border:1px solid var(--accent);color:var(--accent);font:12px/1.5 var(--font-mono);margin-bottom:22px}.page-posts .article-head h1{font-size:clamp(48px,7.4vw,94px);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)} a{color:inherit;text-decoration:none} button,input,textarea{font:inherit} button{cursor:pointer} h1,h2,h3{font-family:var(--font-display);text-wrap:balance} p{text-wrap:pretty} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.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:72px 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:24px;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:52px 1fr;gap:18px;padding:22px 0;border-top:1px solid var(--border)}.page-posts .steps li::before{content:counter(step,decimal-leading-zero);font:14px/1.7 var(--font-mono);color:var(--accent)} .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} .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)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.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:block;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.page-posts .toc a:hover{color:var(--fg)}.page-posts .member-note{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}.page-posts .member-note p{color:color-mix(in oklch,var(--surface) 72%,transparent);font-size:14px}.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 20px;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);color:var(--surface)}.page-posts .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-posts .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)} .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.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-empty{color:var(--muted);padding:28px 0;border-bottom:1px solid var(--border)}.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;border-radius:0}.page-posts .textarea{min-height:130px;resize:vertical}.page-posts .input:focus,.page-posts .textarea:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.page-posts .form-status{min-height:24px;color:var(--accent);font-weight:700} .nav-tools{display:flex;align-items:center;gap:8px}.nav-login,.icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.nav-login{padding-inline:16px}.nav-login:hover,.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.icon-btn{width:46px}.menu-trigger{background:var(--fg);color:var(--surface);border-color:var(--fg)}.menu-trigger:hover{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.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)} .article-head{padding-block:clamp(58px,9vw,110px);border-bottom:1px solid var(--border)}.crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.access-chip{display:inline-flex;align-items:center;min-height:34px;padding:6px 14px;border:1px solid var(--accent);color:var(--accent);font:12px/1.5 var(--font-mono);margin-bottom:22px}.article-head h1{font-size:clamp(48px,7.4vw,94px);line-height:1.06;max-width:12ch;margin:0}.dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}.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 .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} .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}.article-body{font-size:19px;line-height:2}.article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:72px 0 20px}.article-body h3{font-size:27px;margin:42px 0 14px}.article-body p{margin:0 0 26px}.article-body ul{padding-inline-start:24px}.article-body li{margin-block:10px}.lede{font-family:var(--font-display);font-size:28px;line-height:1.6}.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}.note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}.note strong{display:block;font-family:var(--font-display);font-size:24px;margin-bottom:8px}.steps{counter-reset:step;list-style:none;padding:0}.steps li{counter-increment:step;display:grid;grid-template-columns:52px 1fr;gap:18px;padding:22px 0;border-top:1px solid var(--border)}.steps li::before{content:counter(step,decimal-leading-zero);font:14px/1.7 var(--font-mono);color:var(--accent)}
.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:grid;margin-top:34px}.page-posts .menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.page-posts .menu-links a:hover{color:var(--accent)}.page-posts .menu-foot{margin-top:auto;color:var(--muted)} .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}.toc strong{font-family:var(--font-display);font-size:20px}.toc a{display:block;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.toc a:hover{color:var(--fg)}.member-note{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}.member-note p{color:color-mix(in oklch,var(--surface) 72%,transparent);font-size:14px}.btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 20px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
@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}} .comments{padding-block:80px;border-top:1px solid var(--border)}.comments-wrap{max-width:740px}.comments h2{font-size:clamp(36px,5vw,54px);margin:0}.comment-empty{color:var(--muted);padding:28px 0;border-bottom:1px solid var(--border)}.comment-list{display:grid}.comment{padding:24px 0;border-bottom:1px solid var(--border)}.comment-head{display:flex;justify-content:space-between;gap:16px}.comment p{margin:8px 0 0}.comment-form{display:grid;gap:18px;margin-top:36px}.field{display:grid;gap:7px}.field label{font-size:13px;color:var(--muted)}.input,.textarea{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.textarea{min-height:130px;resize:vertical}.input:focus,.textarea:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.form-status{min-height:24px;color:var(--accent);font-weight:700}
@media(max-width:620px){.page-posts .nav-login{display:none}.page-posts .article-head h1{font-size:48px}.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}} .related{padding-block:80px;border-top:1px solid var(--border)}.related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.related-card{padding:24px 0;border-bottom:1px solid var(--border)}.related-card+.related-card{margin-inline-start:28px}.related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.related-card h3{font-size:24px;margin:12px 0}.related-card a{font-weight:700}.related-card a:hover{color:var(--accent)}
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}} .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}.footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}.footer-col strong{display:block;color:var(--fg);margin-bottom:12px}.footer-col a{display:block;min-height:36px}.footer-col a:hover{color:var(--fg);text-decoration:underline}.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}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.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}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:grid;margin-top:34px}.menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.menu-links a:hover{color:var(--accent)}.menu-foot{margin-top:auto;color:var(--muted)}
@media(max-width:920px){:root{--gutter:22px}.nav-links{display:none}.article-layout{grid-template-columns:1fr}.toc{position:static;order:0;margin-top:24px}.footer-grid{grid-template-columns:1fr 1fr}.related-grid{grid-template-columns:1fr}.related-card+.related-card{margin-inline-start:0}.article-body{font-size:18px}}
@media(max-width:620px){:root{--gutter:18px}.nav-login{display:none}.article-head h1{font-size:48px}.article-layout,.comments,.related{padding-block:56px}.footer-grid{grid-template-columns:1fr;gap:28px}.article-meta{align-items:flex-start;flex-direction:column}.steps li{grid-template-columns:38px 1fr}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
+95 -87
View File
@@ -1,95 +1,103 @@
/* Page-scoped styles: .page-articles. Shared tokens live in globals.css. */
.page-articles *, .page-articles *::before, .page-articles *::after { box-sizing: border-box; } :root {
.page-articles { -webkit-text-size-adjust: 100%; } --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-articles { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.7 var(--font-body); text-rendering: optimizeLegibility; } --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-articles a { color: inherit; text-decoration: none; } --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
.page-articles button { color: inherit; font: inherit; cursor: pointer; } --container: 1180px;
.page-articles h1, .page-articles h2, .page-articles h3 { margin: 0; font-family: var(--font-display); text-wrap: balance; } --gutter: 32px;
.page-articles p { text-wrap: pretty; } }
.page-articles :focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } *, *::before, *::after { box-sizing: border-box; }
.page-articles .container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); } html { -webkit-text-size-adjust: 100%; }
.page-articles .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); } body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.7 var(--font-body); text-rendering: optimizeLegibility; }
.page-articles .topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 28px; } a { color: inherit; text-decoration: none; }
.page-articles .logo { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 23px; font-weight: 700; } button { color: inherit; font: inherit; cursor: pointer; }
.page-articles .logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; } h1, h2, h3 { margin: 0; font-family: var(--font-display); text-wrap: balance; }
.page-articles .topnav nav { display: flex; align-items: center; gap: 28px; } p { text-wrap: pretty; }
.page-articles .topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); border-bottom: 1px solid transparent; } :focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.page-articles .topnav nav a:hover, .page-articles .topnav nav a[aria-current="page"] { color: var(--fg); border-color: var(--accent); } .container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
.page-articles .nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); } .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-articles .nav-action:hover { border-color: var(--fg); background: var(--fg-soft); } .topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
.page-articles .hero { padding-block: clamp(56px, 8vw, 96px); border-bottom: 1px solid var(--border); } .logo { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 23px; font-weight: 700; }
.page-articles .hero-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(360px, 1.1fr); gap: clamp(36px, 7vw, 88px); align-items: end; } .logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; }
.page-articles .eyebrow { margin: 0 0 18px; color: var(--accent); font: 11px/1.5 var(--font-mono); } .topnav nav { display: flex; align-items: center; gap: 28px; }
.page-articles h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; } .topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); border-bottom: 1px solid transparent; }
.page-articles .lead { max-width: 56ch; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2vw, 22px); } .topnav nav a:hover, .topnav nav a[aria-current="page"] { color: var(--fg); border-color: var(--accent); }
.page-articles .weekly { display: block; padding: clamp(24px, 4vw, 40px); background: var(--surface); border: 1px solid var(--border); border-top-color: var(--accent); } .nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); }
.page-articles .weekly:hover { border-color: var(--fg); background: color-mix(in oklch, var(--surface) 88%, var(--fg)); } .nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
.page-articles .weekly-meta { display: flex; justify-content: space-between; gap: 16px; color: var(--muted); font: 11px/1.5 var(--font-mono); } .hero { padding-block: clamp(56px, 8vw, 96px); border-bottom: 1px solid var(--border); }
.page-articles .weekly h2 { margin-top: 42px; font-size: clamp(28px, 4vw, 44px); line-height: 1.18; } .hero-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(360px, 1.1fr); gap: clamp(36px, 7vw, 88px); align-items: end; }
.page-articles .weekly p { margin: 14px 0 30px; color: var(--muted); } .eyebrow { margin: 0 0 18px; color: var(--accent); font: 11px/1.5 var(--font-mono); }
.page-articles .weekly-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); } h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; }
.page-articles .library { padding-block: clamp(52px, 8vw, 88px); } .lead { max-width: 56ch; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2vw, 22px); }
.page-articles .library-tools { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 16px; margin-bottom: 22px; } .weekly { display: block; padding: clamp(24px, 4vw, 40px); background: var(--surface); border: 1px solid var(--border); border-top-color: var(--accent); }
.page-articles .control { width: 100%; min-height: 48px; padding: 11px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--fg); font: inherit; } .weekly:hover { border-color: var(--fg); background: color-mix(in oklch, var(--surface) 88%, var(--fg)); }
.page-articles .control::placeholder { color: var(--muted); } .weekly-meta { display: flex; justify-content: space-between; gap: 16px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
.page-articles .control:hover { border-color: var(--fg); } .weekly h2 { margin-top: 42px; font-size: clamp(28px, 4vw, 44px); line-height: 1.18; }
.page-articles .control:focus { outline: 3px solid var(--accent-soft); border-color: var(--accent); } .weekly p { margin: 14px 0 30px; color: var(--muted); }
.page-articles .filter-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-bottom: 44px; padding-block: 22px; border-block: 1px solid var(--border); } .weekly-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); }
.page-articles .filter-group { min-width: 0; } .library { padding-block: clamp(52px, 8vw, 88px); }
.page-articles .filter-label { display: block; margin-bottom: 10px; color: var(--muted); font: 11px/1.5 var(--font-mono); } .library-tools { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 16px; margin-bottom: 22px; }
.page-articles .filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: none; } .control { width: 100%; min-height: 48px; padding: 11px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--fg); font: inherit; }
.page-articles .filters::-webkit-scrollbar { display: none; } .control::placeholder { color: var(--muted); }
.page-articles .filter { flex: none; min-height: 44px; padding: 9px 15px; border: 1px solid var(--border); background: transparent; color: var(--muted); } .control:hover { border-color: var(--fg); }
.page-articles .filter:hover { color: var(--fg); border-color: var(--fg); } .control:focus { outline: 3px solid var(--accent-soft); border-color: var(--accent); }
.page-articles .filter[aria-pressed="true"] { background: var(--fg); color: var(--surface); border-color: var(--fg); } .filter-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-bottom: 44px; padding-block: 22px; border-block: 1px solid var(--border); }
.page-articles .results-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; } .filter-group { min-width: 0; }
.page-articles h2 { font-size: clamp(30px, 4vw, 46px); line-height: 1.15; } .filter-label { display: block; margin-bottom: 10px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
.page-articles .foryou { color: var(--accent); font: 11px/1.5 var(--font-mono); } .filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: none; }
.page-articles .count { color: var(--muted); font: 12px/1.5 var(--font-mono); } .filters::-webkit-scrollbar { display: none; }
.page-articles .posts { border-top: 1px solid var(--fg); } .filter { flex: none; min-height: 44px; padding: 9px 15px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
.page-articles .post { display: grid; grid-template-columns: 138px minmax(0, 1fr) 168px; gap: 28px; align-items: start; padding-block: 28px; border-bottom: 1px solid var(--border); } .filter:hover { color: var(--fg); border-color: var(--fg); }
.page-articles .post[hidden] { display: none; } .filter[aria-pressed="true"] { background: var(--fg); color: var(--surface); border-color: var(--fg); }
.page-articles .post-meta { color: var(--muted); font: 12px/1.8 var(--font-mono); } .results-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
.page-articles .post-meta span { display: block; } h2 { font-size: clamp(30px, 4vw, 46px); line-height: 1.15; }
.page-articles .post h3 { font-size: clamp(22px, 2.5vw, 30px); line-height: 1.3; } .foryou { color: var(--accent); font: 11px/1.5 var(--font-mono); }
.page-articles .post p { max-width: 62ch; margin: 9px 0 0; color: var(--muted); } .count { color: var(--muted); font: 12px/1.5 var(--font-mono); }
.page-articles .post-side { justify-self: stretch; display: grid; gap: 12px; } .posts { border-top: 1px solid var(--fg); }
.page-articles .access { justify-self: end; display: inline-flex; align-items: center; min-height: 34px; padding: 5px 11px; border: 1px solid var(--border); color: var(--muted); font: 11px/1.5 var(--font-mono); } .post { display: grid; grid-template-columns: 138px minmax(0, 1fr) 168px; gap: 28px; align-items: start; padding-block: 28px; border-bottom: 1px solid var(--border); }
.page-articles .access.free { border-color: var(--fg); color: var(--fg); } .post[hidden] { display: none; }
.page-articles .engagement { display: flex; justify-content: flex-end; gap: 12px; color: var(--muted); font: 11px/1.5 var(--font-mono); } .post-meta { color: var(--muted); font: 12px/1.8 var(--font-mono); }
.page-articles .like { min-height: 44px; padding: 0 10px; border: 1px solid transparent; background: transparent; color: var(--muted); } .post-meta span { display: block; }
.page-articles .like:hover, .page-articles .like[aria-pressed="true"] { color: var(--fg); border-color: var(--border); } .post h3 { font-size: clamp(22px, 2.5vw, 30px); line-height: 1.3; }
.page-articles .post-link { display: inline-flex; align-items: center; min-height: 44px; margin-top: 14px; font-weight: 700; border-bottom: 1px solid transparent; } .post p { max-width: 62ch; margin: 9px 0 0; color: var(--muted); }
.page-articles .post-link:hover { color: var(--accent); border-color: var(--accent); } .post-side { justify-self: stretch; display: grid; gap: 12px; }
.page-articles .soon { color: var(--muted); cursor: default; } .access { justify-self: end; display: inline-flex; align-items: center; min-height: 34px; padding: 5px 11px; border: 1px solid var(--border); color: var(--muted); font: 11px/1.5 var(--font-mono); }
.page-articles .empty { display: none; padding: 42px 0; color: var(--muted); } .access.free { border-color: var(--fg); color: var(--fg); }
.page-articles .empty.show { display: block; } .engagement { display: flex; justify-content: flex-end; gap: 12px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
.page-articles .pagefoot { padding-block: 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; } .like { min-height: 44px; padding: 0 10px; border: 1px solid transparent; background: transparent; color: var(--muted); }
.page-articles .footer-row { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; } .like:hover, .like[aria-pressed="true"] { color: var(--fg); border-color: var(--border); }
.post-link { display: inline-flex; align-items: center; min-height: 44px; margin-top: 14px; font-weight: 700; border-bottom: 1px solid transparent; }
.post-link:hover { color: var(--accent); border-color: var(--accent); }
.soon { color: var(--muted); cursor: default; }
.empty { display: none; padding: 42px 0; color: var(--muted); }
.empty.show { display: block; }
.pagefoot { padding-block: 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.footer-row { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
@media (max-width: 920px) { @media (max-width: 920px) {
.page-articles .topnav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; } :root { --gutter: 22px; }
.page-articles .logo, .page-articles .nav-action { margin-bottom: 10px; } .topnav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
.page-articles .topnav nav { order: 3; flex: 0 0 calc(100% + (var(--gutter) * 2)); gap: 6px; margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter); overflow-x: auto; border-top: 1px solid var(--border); scrollbar-width: none; } .logo, .nav-action { margin-bottom: 10px; }
.page-articles .topnav nav::-webkit-scrollbar { display: none; } .topnav nav { order: 3; flex: 0 0 calc(100% + (var(--gutter) * 2)); gap: 6px; margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter); overflow-x: auto; border-top: 1px solid var(--border); scrollbar-width: none; }
.page-articles .topnav nav a { flex: none; min-height: 52px; padding-inline: 12px; white-space: nowrap; } .topnav nav::-webkit-scrollbar { display: none; }
.page-articles .hero-grid, .page-articles .filter-panel { grid-template-columns: 1fr; gap: 20px; } .topnav nav a { flex: none; min-height: 52px; padding-inline: 12px; white-space: nowrap; }
.page-articles .weekly { margin-top: 8px; } .hero-grid, .filter-panel { grid-template-columns: 1fr; gap: 20px; }
.page-articles .post { grid-template-columns: 112px minmax(0, 1fr); } .weekly { margin-top: 8px; }
.page-articles .post-side { justify-self: start; grid-column: 2; } .post { grid-template-columns: 112px minmax(0, 1fr); }
.page-articles .access { justify-self: start; } .post-side { justify-self: start; grid-column: 2; }
.page-articles .engagement { justify-content: flex-start; } .access { justify-self: start; }
.engagement { justify-content: flex-start; }
} }
@media (max-width: 620px) { @media (max-width: 620px) {
.page-articles .nav-action { padding-inline: 12px; } :root { --gutter: 18px; }
.page-articles h1 { font-size: clamp(42px, 13vw, 58px); } .nav-action { padding-inline: 12px; }
.page-articles .library-tools { grid-template-columns: 1fr; } h1 { font-size: clamp(42px, 13vw, 58px); }
.page-articles .results-head { align-items: start; flex-direction: column; } .library-tools { grid-template-columns: 1fr; }
.page-articles .post { grid-template-columns: 1fr; gap: 12px; } .results-head { align-items: start; flex-direction: column; }
.page-articles .post-side { grid-column: 1; } .post { grid-template-columns: 1fr; gap: 12px; }
.page-articles .post-link { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; } .post-side { grid-column: 1; }
.post-link { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.page-articles * { scroll-behavior: auto !important; transition-duration: .01ms !important; } * { scroll-behavior: auto !important; transition-duration: .01ms !important; }
} }
@@ -1,8 +1,8 @@
/* Page-scoped styles: .page-verify-email. Shared tokens live in globals.css. */
.page-verify-email *,.page-verify-email *::before,.page-verify-email *::after{box-sizing:border-box}.page-verify-email{-webkit-text-size-adjust:100%}.page-verify-email{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);min-height:100vh}.page-verify-email a{color:inherit;text-decoration:none}.page-verify-email button{font:inherit;cursor:pointer}.page-verify-email h1,.page-verify-email h2{font-family:var(--font-display);text-wrap:balance}.page-verify-email p{text-wrap:pretty}.page-verify-email :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-verify-email .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} :root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
.page-verify-email .topnav{border-bottom:1px solid var(--border)}.page-verify-email .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:20px}.page-verify-email .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.page-verify-email .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.page-verify-email .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.page-verify-email .nav-return:hover{color:var(--fg);border-color:var(--fg)} *,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);min-height:100vh}a{color:inherit;text-decoration:none}button{font:inherit;cursor:pointer}h1,h2{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-verify-email .verification{min-height:calc(100vh - 70px);display:grid;place-items:center;padding-block:clamp(54px,9vw,110px)}.page-verify-email .card{width:min(700px,100%);background:color-mix(in oklch,var(--surface) 92%,transparent);border:1px solid var(--border);padding:clamp(28px,6vw,64px)}.page-verify-email .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-verify-email .card h1{font-size:clamp(42px,7vw,72px);line-height:1.08;margin:0;max-width:11ch}.page-verify-email .lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:54ch;margin:22px 0 0}.page-verify-email .email{display:block;color:var(--fg);font:700 16px/1.6 var(--font-mono);direction:ltr;text-align:right;overflow-wrap:anywhere;margin-top:8px}.page-verify-email .steps{list-style:none;margin:36px 0;padding:0;border-top:1px solid var(--fg)}.page-verify-email .steps li{display:grid;grid-template-columns:34px 1fr;gap:14px;padding:17px 0;border-bottom:1px solid var(--border)}.page-verify-email .step-num{font:12px/1.8 var(--font-mono);color:var(--accent)}.page-verify-email .actions{display:flex;gap:12px;flex-wrap:wrap}.page-verify-email .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.page-verify-email .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-verify-email .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-verify-email .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-verify-email .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.page-verify-email .status{min-height:27px;color:var(--muted);font-size:14px;margin:16px 0 0}.page-verify-email .fine{color:var(--muted);font-size:12px;margin:24px 0 0;padding-top:20px;border-top:1px solid var(--border)} .topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:20px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}
@media(max-width:620px){.page-verify-email .nav-inner{gap:10px}.page-verify-email .logo{font-size:20px}.page-verify-email .nav-return{font-size:13px}.page-verify-email .verification{align-items:start;padding-block:34px}.page-verify-email .card{padding:28px 20px}.page-verify-email .actions{align-items:stretch}.page-verify-email .actions .btn{width:100%}} .verification{min-height:calc(100vh - 70px);display:grid;place-items:center;padding-block:clamp(54px,9vw,110px)}.card{width:min(700px,100%);background:color-mix(in oklch,var(--surface) 92%,transparent);border:1px solid var(--border);padding:clamp(28px,6vw,64px)}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.card h1{font-size:clamp(42px,7vw,72px);line-height:1.08;margin:0;max-width:11ch}.lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:54ch;margin:22px 0 0}.email{display:block;color:var(--fg);font:700 16px/1.6 var(--font-mono);direction:ltr;text-align:right;overflow-wrap:anywhere;margin-top:8px}.steps{list-style:none;margin:36px 0;padding:0;border-top:1px solid var(--fg)}.steps li{display:grid;grid-template-columns:34px 1fr;gap:14px;padding:17px 0;border-bottom:1px solid var(--border)}.step-num{font:12px/1.8 var(--font-mono);color:var(--accent)}.actions{display:flex;gap:12px;flex-wrap:wrap}.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.status{min-height:27px;color:var(--muted);font-size:14px;margin:16px 0 0}.fine{color:var(--muted);font-size:12px;margin:24px 0 0;padding-top:20px;border-top:1px solid var(--border)}
@media(prefers-reduced-motion:reduce){.page-verify-email *{transition-duration:.01ms!important}} @media(max-width:620px){:root{--gutter:18px}.nav-inner{gap:10px}.logo{font-size:20px}.nav-return{font-size:13px}.verification{align-items:start;padding-block:34px}.card{padding:28px 20px}.actions{align-items:stretch}.actions .btn{width:100%}}
@media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
@@ -1,5 +1,9 @@
/* Page-scoped styles: .page-forgot-password. Shared tokens live in globals.css. */ :root {
.page-forgot-password *,.page-forgot-password *::before,.page-forgot-password *::after{box-sizing:border-box}.page-forgot-password{-webkit-text-size-adjust:100%}.page-forgot-password{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body)}.page-forgot-password a{color:inherit;text-decoration:none}.page-forgot-password button,.page-forgot-password input{font:inherit}.page-forgot-password button{cursor:pointer}.page-forgot-password h1{font-family:var(--font-display);text-wrap:balance}.page-forgot-password p{text-wrap:pretty}.page-forgot-password .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-forgot-password .topnav{border-bottom:1px solid var(--border)}.page-forgot-password .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.page-forgot-password .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.page-forgot-password .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.page-forgot-password .nav-return:hover{color:var(--fg);border-color:var(--fg)} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-forgot-password .reset{min-height:calc(100dvh - 142px);display:grid;place-items:center;padding:64px var(--gutter)}.page-forgot-password .reset-card{width:min(520px,100%);border:1px solid var(--border);background:var(--surface);padding:clamp(28px,6vw,54px)}.page-forgot-password .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.page-forgot-password .reset-card h1{font-size:clamp(38px,6vw,58px);line-height:1.12;margin:0}.page-forgot-password .lead{color:var(--muted);font-size:18px;margin:16px 0 30px}.page-forgot-password .field{display:grid;gap:8px}.page-forgot-password .field label{font-size:13px;color:var(--muted)}.page-forgot-password .input{width:100%;min-height:52px;border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:13px 14px}.page-forgot-password .input:focus{outline:2px solid var(--accent);outline-offset:1px}.page-forgot-password .btn{width:100%;min-height:50px;margin-top:18px;border:1px solid var(--accent);background:var(--accent);color:var(--surface);font-weight:700;padding:12px 20px}.page-forgot-password .btn:hover{background:color-mix(in oklch,var(--accent) 86%,black)}.page-forgot-password .status{min-height:26px;margin:16px 0 0;color:var(--fg)}.page-forgot-password .back{min-height:44px;display:inline-flex;align-items:center;margin-top:18px;color:var(--muted);text-decoration:underline;text-underline-offset:4px}.page-forgot-password .back:hover{color:var(--fg)}.page-forgot-password .pagefoot{padding-block:26px;border-top:1px solid var(--border);color:var(--muted);text-align:center;font-size:13px} --font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
@media(max-width:620px){.page-forgot-password .nav-return{display:none}.page-forgot-password .reset{padding-block:40px;align-items:start}.page-forgot-password .reset-card{padding:26px 20px}} }
*,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body)}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}
.reset{min-height:calc(100dvh - 142px);display:grid;place-items:center;padding:64px var(--gutter)}.reset-card{width:min(520px,100%);border:1px solid var(--border);background:var(--surface);padding:clamp(28px,6vw,54px)}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.reset-card h1{font-size:clamp(38px,6vw,58px);line-height:1.12;margin:0}.lead{color:var(--muted);font-size:18px;margin:16px 0 30px}.field{display:grid;gap:8px}.field label{font-size:13px;color:var(--muted)}.input{width:100%;min-height:52px;border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:13px 14px}.input:focus{outline:2px solid var(--accent);outline-offset:1px}.btn{width:100%;min-height:50px;margin-top:18px;border:1px solid var(--accent);background:var(--accent);color:var(--surface);font-weight:700;padding:12px 20px}.btn:hover{background:color-mix(in oklch,var(--accent) 86%,black)}.status{min-height:26px;margin:16px 0 0;color:var(--fg)}.back{min-height:44px;display:inline-flex;align-items:center;margin-top:18px;color:var(--muted);text-decoration:underline;text-underline-offset:4px}.back:hover{color:var(--fg)}.pagefoot{padding-block:26px;border-top:1px solid var(--border);color:var(--muted);text-align:center;font-size:13px}
@media(max-width:620px){:root{--gutter:18px}.nav-return{display:none}.reset{padding-block:40px;align-items:start}.reset-card{padding:26px 20px}}
@@ -1,124 +0,0 @@
/* Homepage presentation, scoped to avoid changing article and account layouts. */
.home-page {
--border: #2a2d32;
--surface: #15171a;
--muted: #a5a39d;
--radius: 10px;
--radius-lg: 20px;
background: linear-gradient(180deg, #0b0c0eee, #0c0d0f 70%);
}
.home-page .topnav { background: #0c0d0fed; }
.home-page .nav-inner { min-height: 84px; }
.home-page .logo { text-decoration: none; }
.home-page .nav-action { border-radius: 8px; font-size: 14px; }
.home-page .section { border-bottom: 0; padding-block: 76px; }
.home-page section[id] { scroll-margin-top: 135px; }
.home-page .hero { padding-block: 86px 72px; background: radial-gradient(ellipse at 20% 30%, #3a332640, transparent 60%); }
.home-page .hero-split { grid-template-columns: 1.1fr 1fr; gap: 64px; }
.home-page .eyebrow { font-family: var(--font-body); font-size: 13px; font-weight: 600; }
.home-page .hero-copy h1 { max-width: 15ch; font-size: clamp(42px, 4.7vw, 64px); line-height: 1.4; letter-spacing: -.025em; }
.home-page .hero-highlight { color: var(--accent); }
.home-page .hero .lead { font-size: 18px; max-width: 44ch; margin-top: 20px; }
.home-page .btn { min-height: 52px; padding-inline: 24px; }
.home-page .btn-ghost { border-color: var(--border); }
.home-page .trust-line { font-size: 13px; line-height: 1.9; }
.home-page .deck { min-height: 460px; }
.home-page .deck-frame { min-height: 400px; border: 1px solid #33363b; border-radius: 20px; background: linear-gradient(135deg, #1c1e22, #121316); box-shadow: 0 24px 60px #0003; }
.home-page .deck-frame::before { content: ''; position: absolute; width: 280px; height: 280px; border: 1px solid #d4b47c26; border-radius: 50%; left: -100px; top: -120px; box-shadow: 0 0 0 35px #d4b47c0a, 0 0 0 70px #d4b47c08; pointer-events: none; }
.home-page .deck-slide { padding: 30px; }
.home-page .deck-slide::after { background: var(--accent); height: 3px; opacity: .65; }
.home-page .deck-slide-index { color: var(--accent); font: 24px var(--font-mono); }
.home-page .deck-slide-body h2 { font-size: clamp(30px, 3.2vw, 42px); line-height: 1.5; max-width: 16ch; margin: 0; }
.home-page .deck-slide-body p { font-size: 15px; line-height: 1.9; }
.home-page .deck-slide-kicker { font: 12px var(--font-body); }
.home-page .deck-slide-foot { font-size: 13px; }
.home-page .deck-slide-link { color: var(--accent); border: 0; }
.home-page .icon-btn { border-radius: 50%; }
.home-page .deck-dot { height: 44px; position: relative; background: transparent; }
.home-page .deck-dot::before { content: ''; position: absolute; inset: 20px 0; background: var(--border); border-radius: 8px; }
.home-page .deck-dot.active::before { background: var(--accent); }
.home-page .section-head { margin-bottom: 30px; gap: 32px; }
.home-page .section h2 { font-size: clamp(28px, 3vw, 40px); line-height: 1.5; margin: 0; }
.home-page .section-head .lead { font-size: 16px; max-width: 35ch; }
.home-page .topic-grid { border: 0; gap: 16px; }
.home-page .topic { position: relative; min-height: 158px; border: 1px solid var(--border); border-radius: 12px; padding: 24px; background: #141619; }
.home-page .topic::before { content: '01'; color: var(--accent); font: 12px var(--font-mono); margin-bottom: 12px; }
.home-page .topic:nth-child(2)::before { content: '02'; color: #c9c6bf; }
.home-page .topic:nth-child(3)::before { content: '03'; color: #d4b47c; }
.home-page .topic:nth-child(4)::before { content: '04'; color: #c9c6bf; }
.home-page .topic::after { content: '↖'; position: absolute; top: 20px; left: 22px; color: var(--muted); }
.home-page .topic strong { font-size: 21px; margin-bottom: 4px; }
.home-page .topic:hover { border-color: var(--accent); background: #1a1c20; }
.home-page #catalog { background: #101114; }
.home-page .filterbar { gap: 10px; margin-bottom: 28px; }
.home-page .filter { border-radius: 30px; font-size: 14px; padding-inline: 22px; }
.home-page .filter.active { background: var(--fg); border-color: var(--fg); color: var(--bg); }
.home-page .catalog { border: 0; gap: 24px; }
.home-page .catalog-card { border: 1px solid var(--border); padding: 14px; border-radius: 16px; background: #141619; }
.home-page .cover { min-height: 172px; border-radius: 9px; margin-bottom: 20px; background: linear-gradient(135deg, #3a3d43, #1a1c20); color: #f4f2ee; }
.home-page .cover-light { background: linear-gradient(135deg, #43392d, #1f1b16); }
.home-page .cover-lined { background: repeating-linear-gradient(135deg, transparent, transparent 22px, #ffffff06 23px, #ffffff06 24px), #2a2826; border: 0; }
.home-page .cover-label { font: 12px var(--font-body); color: #e4e1db; }
.home-page .cover-title { font-size: 26px; line-height: 1.5; max-width: 14ch; }
.home-page .card-meta, .home-page .catalog-card h3, .home-page .catalog-card p, .home-page .card-link { margin-inline: 8px; }
.home-page .catalog-card h3 { font-size: 20px; line-height: 1.65; }
.home-page .catalog-card p { line-height: 1.9; }
.home-page .card-link { color: var(--accent); font-size: 14px; padding-bottom: 8px; }
.home-page .membership { margin: 64px auto; max-width: 1116px; border: 1px solid #3a3d43; border-radius: 20px; background: linear-gradient(115deg, #1e1d1b, #141517); color: var(--fg); }
.home-page .membership .eyebrow { color: var(--accent); opacity: 1; }
.home-page .membership .lead { color: var(--muted); font-size: 17px; }
.home-page .membership-list, .home-page .membership-list li { border-color: #ffffff20; }
.home-page .membership-list li span:last-child { color: var(--accent); font-size: 14px; }
.home-page .membership .btn-primary { background: var(--fg); border-color: var(--fg); color: var(--bg); }
.home-page .pagefoot { padding-block: 36px; }
.home-page .pagefoot .row-between { flex-wrap: wrap; font-size: 13px; }
.home-page .footer-home-brand { font-size: 20px; font-weight: 700; color: var(--fg); }
.home-page .footer-home-brand span { display: block; font-size: 12px; font-weight: 400; color: var(--muted); }
@media (max-width: 920px) {
.home-page .hero-split { grid-template-columns: 1fr; gap: 36px; }
.home-page .hero-copy h1 { max-width: 20ch; }
.home-page .hero { padding-top: 48px; }
.home-page .deck { min-height: 0; }
.home-page .membership { margin-inline: 22px; }
.home-page .nav-inner { min-height: 64px; }
}
@media (max-width: 620px) {
.home-page .section { padding-block: 44px; }
.home-page .hero-copy h1 { font-size: 36px !important; line-height: 1.5; }
.home-page .hero .lead { font-size: 16px; }
.home-page .hero-cta { margin-top: 24px; }
.home-page .nav-action { display: inline-flex; font-size: 12px; padding-inline: 10px; }
.home-page .topic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.home-page .topic { padding: 16px; min-height: 150px; }
.home-page .topic span { font-size: 12px; }
.home-page .topic::after { left: 14px; top: 14px; }
.home-page .deck-frame { min-height: 390px; }
.home-page .deck-slide { padding: 24px; }
.home-page .deck-slide-body h2 { font-size: 30px; }
.home-page .deck-slide-foot { flex-direction: row; align-items: center; gap: 12px; }
.home-page .deck-slide-format { font-size: 12px; }
.home-page .play { width: 28px; height: 28px; }
.home-page .membership { margin: 32px 18px; }
.home-page .membership-grid { gap: 28px; }
.home-page .filterbar { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 6px; }
.home-page .filter { flex: none; }
.home-page .pagefoot .row-between { flex-direction: column; align-items: start; gap: 18px; }
}
/* Carousel controls and content must not inherit legacy active backgrounds. */
#site.home-page .deck-dot, #site.home-page .deck-dot.active { width: 34px; height: 44px; border: 0; padding: 0; background: transparent; border-radius: 8px; }
#site.home-page .deck-dot::before { inset: 20px 0; background: var(--border); }
#site.home-page .deck-dot.active::before { background: var(--accent); }
#site.home-page .deck-frame { display: grid; min-height: 400px; }
#site.home-page .deck-slide { position: relative; inset: auto; min-height: 400px; gap: 24px; }
#site.home-page .deck-slide-body h2 { max-width: none; }
#site.home-page .deck-slide-foot { flex-wrap: wrap; }
#site.home-page .deck-controls { gap: 16px; align-items: center; }
#site.home-page .deck-arrows { display: flex; gap: 8px; }
#site.home-page .deck-controls button:disabled { cursor: default; }
@media (max-width: 620px) {
#site.home-page .deck-slide { min-height: 390px; padding: 24px; }
#site.home-page .deck-controls { gap: 8px; }
#site.home-page .deck-arrows { gap: 6px; }
#site.home-page .deck-dot, #site.home-page .deck-dot.active { width: 28px; }
}
+9 -9
View File
@@ -1,9 +1,9 @@
/* Page-scoped styles: .page-login. Shared tokens live in globals.css. */ :root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
.page-login *,.page-login *::before,.page-login *::after{box-sizing:border-box}.page-login{-webkit-text-size-adjust:100%}.page-login{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}.page-login.locked{overflow:hidden}.page-login a{color:inherit;text-decoration:none}.page-login button,.page-login input{font:inherit}.page-login button{cursor:pointer}.page-login h1,.page-login h2,.page-login h3{font-family:var(--font-display);text-wrap:balance}.page-login p{text-wrap:pretty}.page-login :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-login .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} *,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}body.locked{overflow:hidden}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1,h2,h3{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-login .topnav{border-bottom:1px solid var(--border)}.page-login .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.page-login .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-login .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)}.page-login .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.page-login .nav-return:hover{color:var(--fg);border-color:var(--fg)}.page-login .icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--fg);background:var(--fg);color:var(--surface)}.page-login .icon-btn:hover{background:var(--accent);border-color:var(--accent);color:var(--surface)}.page-login .nav-tools{display:flex;align-items:center;gap:14px} .topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}.icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--fg);background:var(--fg);color:var(--surface)}.icon-btn:hover{background:var(--accent);border-color:var(--accent);color:var(--surface)}.nav-tools{display:flex;align-items:center;gap:14px}
.page-login .auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:1fr 1fr}.page-login .auth-story{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100vw - var(--container))/2 + var(--gutter)));padding-inline-end:clamp(48px,8vw,100px);background:var(--fg);color:var(--surface);display:flex;flex-direction:column;justify-content:space-between}.page-login .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-login .auth-story .eyebrow{color:var(--surface);opacity:.68}.page-login .auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;max-width:9ch;margin:0}.page-login .auth-story>div>p{color:color-mix(in oklch,var(--surface) 68%,transparent);font-size:19px;max-width:40ch}.page-login .story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 24%,transparent)}.page-login .story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 15%,transparent);display:flex;justify-content:space-between;gap:20px}.page-login .story-mark{font:12px/1.5 var(--font-mono);opacity:.58}.page-login .story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 58%,transparent)} .auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:1fr 1fr}.auth-story{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100vw - var(--container))/2 + var(--gutter)));padding-inline-end:clamp(48px,8vw,100px);background:var(--fg);color:var(--surface);display:flex;flex-direction:column;justify-content:space-between}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.auth-story .eyebrow{color:var(--surface);opacity:.68}.auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;max-width:9ch;margin:0}.auth-story>div>p{color:color-mix(in oklch,var(--surface) 68%,transparent);font-size:19px;max-width:40ch}.story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 24%,transparent)}.story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 15%,transparent);display:flex;justify-content:space-between;gap:20px}.story-mark{font:12px/1.5 var(--font-mono);opacity:.58}.story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 58%,transparent)}
.page-login .auth-form-wrap{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100vw - var(--container))/2 + var(--gutter)));padding-inline-start:clamp(48px,8vw,100px);display:flex;align-items:center}.page-login .auth-form{width:min(440px,100%)}.page-login .auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.page-login .lead{color:var(--muted);font-size:18px;margin:12px 0 34px}.page-login .field{display:grid;gap:7px;margin-top:18px}.page-login .field label{font-size:13px;color:var(--muted)}.page-login .input-wrap{position:relative}.page-login .input{width:100%;min-height:52px;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.page-login .input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.page-login .password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.page-login .password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.page-login .form-row{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:18px 0 24px;font-size:13px}.page-login .check{display:flex;align-items:center;gap:8px}.page-login .form-row a{text-decoration:underline;text-underline-offset:3px}.page-login .form-row a:hover{color:var(--accent)}.page-login .btn{min-height:50px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.page-login .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-login .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.page-login .divider{display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px;margin:26px 0}.page-login .divider::before,.page-login .divider::after{content:"";height:1px;background:var(--border);flex:1}.page-login .magic{min-height:50px;width:100%;border:1px solid var(--border);background:transparent;color:var(--fg);font-weight:700}.page-login .magic:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}.page-login .signup{text-align:center;color:var(--muted);font-size:14px;margin-top:24px}.page-login .signup a{color:var(--fg);font-weight:700;text-decoration:underline;text-underline-offset:3px}.page-login .status{min-height:25px;color:var(--accent);font-weight:700;margin-top:14px}.page-login .fine{font-size:12px;color:var(--muted);margin-top:28px} .auth-form-wrap{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100vw - var(--container))/2 + var(--gutter)));padding-inline-start:clamp(48px,8vw,100px);display:flex;align-items:center}.auth-form{width:min(440px,100%)}.auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.lead{color:var(--muted);font-size:18px;margin:12px 0 34px}.field{display:grid;gap:7px;margin-top:18px}.field label{font-size:13px;color:var(--muted)}.input-wrap{position:relative}.input{width:100%;min-height:52px;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.form-row{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:18px 0 24px;font-size:13px}.check{display:flex;align-items:center;gap:8px}.form-row a{text-decoration:underline;text-underline-offset:3px}.form-row a:hover{color:var(--accent)}.btn{min-height:50px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.divider{display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px;margin:26px 0}.divider::before,.divider::after{content:"";height:1px;background:var(--border);flex:1}.magic{min-height:50px;width:100%;border:1px solid var(--border);background:transparent;color:var(--fg);font-weight:700}.magic:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}.signup{text-align:center;color:var(--muted);font-size:14px;margin-top:24px}.signup a{color:var(--fg);font-weight:700;text-decoration:underline;text-underline-offset:3px}.status{min-height:25px;color:var(--accent);font-weight:700;margin-top:14px}.fine{font-size:12px;color:var(--muted);margin-top:28px}
.page-login .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-login .menu-panel.open{visibility:visible;pointer-events:auto}.page-login .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-login .menu-panel.open .menu-scrim{opacity:1}.page-login .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-login .menu-panel.open .menu-drawer{transform:translateX(0)}.page-login .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-login .menu-links{display:grid;margin-top:34px}.page-login .menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.page-login .menu-links a:hover{color:var(--accent)}.page-login .menu-foot{margin-top:auto;color:var(--muted)} .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.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}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:grid;margin-top:34px}.menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.menu-links a:hover{color:var(--accent)}.menu-foot{margin-top:auto;color:var(--muted)}
@media(max-width:920px){.page-login .auth{grid-template-columns:1fr}.page-login .auth-story{min-height:440px;padding:64px var(--gutter)}.page-login .auth-form-wrap{padding:64px var(--gutter);justify-content:center}.page-login .story-foot{margin-top:54px}} @media(max-width:920px){:root{--gutter:22px}.auth{grid-template-columns:1fr}.auth-story{min-height:440px;padding:64px var(--gutter)}.auth-form-wrap{padding:64px var(--gutter);justify-content:center}.story-foot{margin-top:54px}}
@media(max-width:620px){.page-login .nav-return{display:none}.page-login .auth-story{min-height:auto}.page-login .auth-story h1{font-size:46px}.page-login .auth-form-wrap{padding-block:50px}.page-login .form-row{align-items:flex-start;flex-direction:column}} @media(max-width:620px){:root{--gutter:18px}.nav-return{display:none}.auth-story{min-height:auto}.auth-story h1{font-size:46px}.auth-form-wrap{padding-block:50px}.form-row{align-items:flex-start;flex-direction:column}}
@media(prefers-reduced-motion:reduce){.page-login *{transition-duration:.01ms!important}} @media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
@@ -1,11 +1,11 @@
/* Page-scoped styles: .page-onboarding. Shared tokens live in globals.css. */
.page-onboarding *,.page-onboarding *::before,.page-onboarding *::after{box-sizing:border-box}.page-onboarding{-webkit-text-size-adjust:100%}.page-onboarding{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body)}.page-onboarding a{color:inherit;text-decoration:none}.page-onboarding button,.page-onboarding input{font:inherit}.page-onboarding button{cursor:pointer}.page-onboarding h1,.page-onboarding h2,.page-onboarding h3{font-family:var(--font-display);text-wrap:balance}.page-onboarding p{text-wrap:pretty}.page-onboarding :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-onboarding .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} :root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
.page-onboarding .topnav{border-bottom:1px solid var(--border)}.page-onboarding .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:20px}.page-onboarding .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.page-onboarding .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.page-onboarding .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-onboarding .nav-return:hover{color:var(--fg)} *,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body)}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1,h2,h3{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-onboarding .head{padding-block:clamp(40px,6vw,64px) 34px;border-bottom:1px solid var(--border)}.page-onboarding .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.page-onboarding .head h1{font-size:clamp(40px,5vw,58px);line-height:1.16;margin:0;max-width:15ch}.page-onboarding .lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:58ch;margin:16px 0 0}.page-onboarding .progress{margin-top:26px;max-width:720px}.page-onboarding .progress-copy{display:flex;justify-content:space-between;color:var(--fg);font:12px/1.5 var(--font-mono);margin-bottom:10px}.page-onboarding .progress-track{height:6px;background:var(--border)}.page-onboarding .progress-bar{height:100%;width:33.333%;background:var(--accent);transition:width .2s ease} .topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:20px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-return:hover{color:var(--fg)}
.page-onboarding .flow{display:grid;grid-template-columns:220px minmax(0,1fr);gap:clamp(38px,7vw,84px);align-items:start;padding-block:46px 72px}.page-onboarding .step-nav{border-top:1px solid var(--fg)}.page-onboarding .step-nav div{min-height:58px;display:grid;grid-template-columns:34px 1fr;align-items:center;gap:12px;border-bottom:1px solid var(--border);color:var(--muted)}.page-onboarding .step-nav div.active{color:var(--fg);font-weight:700;border-bottom-color:var(--fg)}.page-onboarding .step-nav div.done{color:var(--fg)}.page-onboarding .step-nav span:first-child{font:12px/1 var(--font-mono)}.page-onboarding .step-nav div.active span:first-child{color:var(--accent)}.page-onboarding .panel{display:none;max-width:760px}.page-onboarding .panel.active{display:block}.page-onboarding .panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px}.page-onboarding .intro{color:var(--muted);max-width:58ch;margin:0 0 30px}.page-onboarding .choices{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.page-onboarding .choice{position:relative;display:block}.page-onboarding .choice input{position:absolute;opacity:0}.page-onboarding .choice-body{min-height:145px;display:flex;flex-direction:column;padding:22px;border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 90%,transparent)}.page-onboarding .choice-body:hover{border-color:var(--fg)}.page-onboarding .choice input:checked+.choice-body{border-color:var(--fg);background:var(--fg-soft)}.page-onboarding .choice input:focus-visible+.choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-onboarding .choice strong{font-size:19px;padding-inline-end:34px}.page-onboarding .choice small{color:var(--muted);font-size:14px;margin-top:8px}.page-onboarding .choice-mark{position:absolute;inset-block-start:24px;inset-inline-end:22px;width:18px;height:18px;border:1px solid var(--fg)}.page-onboarding .choice input:checked+.choice-body .choice-mark{background:var(--fg);box-shadow:inset 0 0 0 4px var(--surface)} .head{padding-block:clamp(40px,6vw,64px) 34px;border-bottom:1px solid var(--border)}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.head h1{font-size:clamp(40px,5vw,58px);line-height:1.16;margin:0;max-width:15ch}.lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:58ch;margin:16px 0 0}.progress{margin-top:26px;max-width:720px}.progress-copy{display:flex;justify-content:space-between;color:var(--fg);font:12px/1.5 var(--font-mono);margin-bottom:10px}.progress-track{height:6px;background:var(--border)}.progress-bar{height:100%;width:33.333%;background:var(--accent);transition:width .2s ease}
.page-onboarding .benefits{border-top:1px solid var(--fg)}.page-onboarding .benefit{display:grid;grid-template-columns:42px 1fr;gap:16px;padding:20px 0;border-bottom:1px solid var(--border)}.page-onboarding .benefit-num{font:12px/1.8 var(--font-mono);color:var(--muted)}.page-onboarding .benefit h3{font-size:22px;margin:0 0 5px}.page-onboarding .benefit p{color:var(--muted);margin:0}.page-onboarding .upgrade-note{margin-top:26px;padding:22px;border:1px solid var(--border);background:var(--surface)}.page-onboarding .upgrade-note strong{display:block;font-size:18px}.page-onboarding .upgrade-note p{color:var(--muted);margin:6px 0 0}.page-onboarding .actions{display:flex;gap:12px;align-items:center;margin-top:32px}.page-onboarding .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.page-onboarding .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-onboarding .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-onboarding .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-onboarding .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.page-onboarding .status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0}.page-onboarding .pagefoot{padding-block:42px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}.page-onboarding .footer-row{display:flex;justify-content:space-between;gap:18px;flex-wrap:wrap} .flow{display:grid;grid-template-columns:220px minmax(0,1fr);gap:clamp(38px,7vw,84px);align-items:start;padding-block:46px 72px}.step-nav{border-top:1px solid var(--fg)}.step-nav div{min-height:58px;display:grid;grid-template-columns:34px 1fr;align-items:center;gap:12px;border-bottom:1px solid var(--border);color:var(--muted)}.step-nav div.active{color:var(--fg);font-weight:700;border-bottom-color:var(--fg)}.step-nav div.done{color:var(--fg)}.step-nav span:first-child{font:12px/1 var(--font-mono)}.step-nav div.active span:first-child{color:var(--accent)}.panel{display:none;max-width:760px}.panel.active{display:block}.panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px}.intro{color:var(--muted);max-width:58ch;margin:0 0 30px}.choices{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.choice{position:relative;display:block}.choice input{position:absolute;opacity:0}.choice-body{min-height:145px;display:flex;flex-direction:column;padding:22px;border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 90%,transparent)}.choice-body:hover{border-color:var(--fg)}.choice input:checked+.choice-body{border-color:var(--fg);background:var(--fg-soft)}.choice input:focus-visible+.choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.choice strong{font-size:19px;padding-inline-end:34px}.choice small{color:var(--muted);font-size:14px;margin-top:8px}.choice-mark{position:absolute;inset-block-start:24px;inset-inline-end:22px;width:18px;height:18px;border:1px solid var(--fg)}.choice input:checked+.choice-body .choice-mark{background:var(--fg);box-shadow:inset 0 0 0 4px var(--surface)}
@media(max-width:920px){.page-onboarding .flow{grid-template-columns:1fr}.page-onboarding .step-nav{display:grid;grid-template-columns:repeat(3,1fr);border-top:0}.page-onboarding .step-nav div{padding-inline:12px;border:1px solid var(--border)}.page-onboarding .choices{grid-template-columns:1fr 1fr}} .benefits{border-top:1px solid var(--fg)}.benefit{display:grid;grid-template-columns:42px 1fr;gap:16px;padding:20px 0;border-bottom:1px solid var(--border)}.benefit-num{font:12px/1.8 var(--font-mono);color:var(--muted)}.benefit h3{font-size:22px;margin:0 0 5px}.benefit p{color:var(--muted);margin:0}.upgrade-note{margin-top:26px;padding:22px;border:1px solid var(--border);background:var(--surface)}.upgrade-note strong{display:block;font-size:18px}.upgrade-note p{color:var(--muted);margin:6px 0 0}.actions{display:flex;gap:12px;align-items:center;margin-top:32px}.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0}.pagefoot{padding-block:42px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}.footer-row{display:flex;justify-content:space-between;gap:18px;flex-wrap:wrap}
@media(max-width:620px){.page-onboarding .logo{font-size:20px}.page-onboarding .nav-return{font-size:13px}.page-onboarding .head{padding-block:40px 30px}.page-onboarding .flow{padding-block:30px 60px;gap:30px}.page-onboarding .step-nav{overflow-x:auto;grid-template-columns:repeat(3,minmax(140px,1fr))}.page-onboarding .choices{grid-template-columns:1fr}.page-onboarding .choice-body{min-height:130px}.page-onboarding .actions{align-items:stretch;flex-direction:column-reverse}.page-onboarding .actions .btn{width:100%}.page-onboarding .footer-row{flex-direction:column}} @media(max-width:920px){:root{--gutter:22px}.flow{grid-template-columns:1fr}.step-nav{display:grid;grid-template-columns:repeat(3,1fr);border-top:0}.step-nav div{padding-inline:12px;border:1px solid var(--border)}.choices{grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.page-onboarding *{transition-duration:.01ms!important}} @media(max-width:620px){:root{--gutter:18px}.logo{font-size:20px}.nav-return{font-size:13px}.head{padding-block:40px 30px}.flow{padding-block:30px 60px;gap:30px}.step-nav{overflow-x:auto;grid-template-columns:repeat(3,minmax(140px,1fr))}.choices{grid-template-columns:1fr}.choice-body{min-height:130px}.actions{align-items:stretch;flex-direction:column-reverse}.actions .btn{width:100%}.footer-row{flex-direction:column}}
@media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
@@ -1,39 +1,45 @@
/* Page-scoped styles: .page-payment-cancelled. Shared tokens live in globals.css. */
.page-payment-cancelled *,.page-payment-cancelled *::before,.page-payment-cancelled *::after{box-sizing:border-box} :root {
.page-payment-cancelled{-webkit-text-size-adjust:100%} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-payment-cancelled{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-payment-cancelled a{color:inherit;text-decoration:none} --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
.page-payment-cancelled h1,.page-payment-cancelled h2{font-family:var(--font-display);text-wrap:balance} --container: 920px;
.page-payment-cancelled p{text-wrap:pretty} --gutter: 32px;
.page-payment-cancelled :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px} }
.page-payment-cancelled .container{width:min(100%,var(--container));margin-inline:auto;padding-inline:var(--gutter)} *,*::before,*::after{box-sizing:border-box}
.page-payment-cancelled .topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)} html{-webkit-text-size-adjust:100%}
.page-payment-cancelled .topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px} body{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
.page-payment-cancelled .logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700} a{color:inherit;text-decoration:none}
.page-payment-cancelled .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px} h1,h2{font-family:var(--font-display);text-wrap:balance}
.page-payment-cancelled .nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)} p{text-wrap:pretty}
.page-payment-cancelled .nav-link:hover{color:var(--fg)} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-payment-cancelled .section{padding-block:clamp(56px,9vw,104px)} .container{width:min(100%,var(--container));margin-inline:auto;padding-inline:var(--gutter)}
.page-payment-cancelled .state-card{max-width:760px;margin-inline:auto;padding:clamp(28px,6vw,64px);border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 92%,transparent)} .topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)}
.page-payment-cancelled .status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px} .topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.page-payment-cancelled .status-mark svg{width:28px;height:28px} .logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700}
.page-payment-cancelled .eyebrow{margin:0 0 18px;color:var(--muted);font:12px/1.5 var(--font-mono);} .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}
.page-payment-cancelled h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;} .nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
.page-payment-cancelled .lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)} .nav-link:hover{color:var(--fg)}
.page-payment-cancelled .notice{margin-top:34px;padding-block:22px;border-block:1px solid var(--border)} .section{padding-block:clamp(56px,9vw,104px)}
.page-payment-cancelled .notice h2{margin:0 0 8px;font-size:24px} .state-card{max-width:760px;margin-inline:auto;padding:clamp(28px,6vw,64px);border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 92%,transparent)}
.page-payment-cancelled .notice p{margin:0;color:var(--muted)} .status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px}
.page-payment-cancelled .actions{display:flex;align-items:center;gap:12px;margin-top:30px} .status-mark svg{width:28px;height:28px}
.page-payment-cancelled .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700} .eyebrow{margin:0 0 18px;color:var(--muted);font:12px/1.5 var(--font-mono);}
.page-payment-cancelled .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)} h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;}
.page-payment-cancelled .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)} .lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)}
.page-payment-cancelled .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)} .notice{margin-top:34px;padding-block:22px;border-block:1px solid var(--border)}
.page-payment-cancelled .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)} .notice h2{margin:0 0 8px;font-size:24px}
.page-payment-cancelled .help{margin:24px 0 0;color:var(--muted);font-size:14px} .notice p{margin:0;color:var(--muted)}
.page-payment-cancelled .help a{text-decoration:underline;text-underline-offset:4px;color:var(--fg)} .actions{display:flex;align-items:center;gap:12px;margin-top:30px}
.page-payment-cancelled .help a:hover{color:var(--accent)} .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
.page-payment-cancelled .pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px} .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.page-payment-cancelled .footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap} .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
@media(max-width:620px){.page-payment-cancelled .topnav-inner{min-height:64px}.page-payment-cancelled .state-card{padding:26px 20px}.page-payment-cancelled .actions{align-items:stretch;flex-direction:column}.page-payment-cancelled .actions .btn{width:100%}.page-payment-cancelled .footer-row{flex-direction:column;gap:8px}} .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
.btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
.help{margin:24px 0 0;color:var(--muted);font-size:14px}
.help a{text-decoration:underline;text-underline-offset:4px;color:var(--fg)}
.help a:hover{color:var(--accent)}
.pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}
.footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media(max-width:620px){:root{--gutter:18px}.topnav-inner{min-height:64px}.state-card{padding:26px 20px}.actions{align-items:stretch;flex-direction:column}.actions .btn{width:100%}.footer-row{flex-direction:column;gap:8px}}
@@ -1,40 +1,46 @@
/* Page-scoped styles: .page-payment-success. Shared tokens live in globals.css. */
.page-payment-success *,.page-payment-success *::before,.page-payment-success *::after{box-sizing:border-box} :root {
.page-payment-success{-webkit-text-size-adjust:100%} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-payment-success{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-payment-success a{color:inherit;text-decoration:none} --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
.page-payment-success h1,.page-payment-success h2{font-family:var(--font-display);text-wrap:balance} --container: 920px;
.page-payment-success p{text-wrap:pretty} --gutter: 32px;
.page-payment-success :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px} }
.page-payment-success .container{width:min(100%,var(--container));margin-inline:auto;padding-inline:var(--gutter)} *,*::before,*::after{box-sizing:border-box}
.page-payment-success .topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)} html{-webkit-text-size-adjust:100%}
.page-payment-success .topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px} body{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
.page-payment-success .logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700} a{color:inherit;text-decoration:none}
.page-payment-success .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px} h1,h2{font-family:var(--font-display);text-wrap:balance}
.page-payment-success .nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)} p{text-wrap:pretty}
.page-payment-success .nav-link:hover{color:var(--fg)} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-payment-success .section{padding-block:clamp(56px,9vw,104px)} .container{width:min(100%,var(--container));margin-inline:auto;padding-inline:var(--gutter)}
.page-payment-success .state-card{max-width:760px;margin-inline:auto;padding:clamp(28px,6vw,64px);border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 92%,transparent)} .topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)}
.page-payment-success .status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px} .topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.page-payment-success .status-mark svg{width:30px;height:30px} .logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700}
.page-payment-success .eyebrow{margin:0 0 18px;color:var(--accent);font:12px/1.5 var(--font-mono);} .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}
.page-payment-success h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;} .nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
.page-payment-success .lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)} .nav-link:hover{color:var(--fg)}
.page-payment-success .summary{margin:34px 0 0;border-top:1px solid var(--fg)} .section{padding-block:clamp(56px,9vw,104px)}
.page-payment-success .summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:17px 0;border-bottom:1px solid var(--border)} .state-card{max-width:760px;margin-inline:auto;padding:clamp(28px,6vw,64px);border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 92%,transparent)}
.page-payment-success .summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)} .status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px}
.page-payment-success .summary-row dd{margin:0;font-weight:700} .status-mark svg{width:30px;height:30px}
.page-payment-success .next-step{margin-top:34px;padding-top:30px;border-top:1px solid var(--border)} .eyebrow{margin:0 0 18px;color:var(--accent);font:12px/1.5 var(--font-mono);}
.page-payment-success .next-step h2{margin:0 0 8px;font-size:26px} h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;}
.page-payment-success .next-step p{margin:0;color:var(--muted)} .lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)}
.page-payment-success .actions{display:flex;align-items:center;gap:12px;margin-top:26px} .summary{margin:34px 0 0;border-top:1px solid var(--fg)}
.page-payment-success .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700} .summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:17px 0;border-bottom:1px solid var(--border)}
.page-payment-success .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)} .summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)}
.page-payment-success .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)} .summary-row dd{margin:0;font-weight:700}
.page-payment-success .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)} .next-step{margin-top:34px;padding-top:30px;border-top:1px solid var(--border)}
.page-payment-success .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)} .next-step h2{margin:0 0 8px;font-size:26px}
.page-payment-success .pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px} .next-step p{margin:0;color:var(--muted)}
.page-payment-success .footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap} .actions{display:flex;align-items:center;gap:12px;margin-top:26px}
@media(max-width:620px){.page-payment-success .topnav-inner{min-height:64px}.page-payment-success .state-card{padding:26px 20px}.page-payment-success .summary-row{grid-template-columns:1fr;gap:4px}.page-payment-success .actions{align-items:stretch;flex-direction:column}.page-payment-success .actions .btn{width:100%}.page-payment-success .footer-row{flex-direction:column;gap:8px}} .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
.btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
.pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}
.footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media(max-width:620px){:root{--gutter:18px}.topnav-inner{min-height:64px}.state-card{padding:26px 20px}.summary-row{grid-template-columns:1fr;gap:4px}.actions{align-items:stretch;flex-direction:column}.actions .btn{width:100%}.footer-row{flex-direction:column;gap:8px}}
+67 -63
View File
@@ -1,63 +1,67 @@
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */ :root {
.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} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.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} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} --font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
.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)} *,*::before,*::after{box-sizing:border-box} html{scroll-behavior:smooth;-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} body.locked{overflow:hidden}
.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} a{color:inherit;text-decoration:none} button,input,textarea{font:inherit} button{cursor:pointer} h1,h2,h3{font-family:var(--font-display);text-wrap:balance} p{text-wrap:pretty} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center} .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-posts .demo-strip b{color:var(--surface);font-weight:700} .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)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)} .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px} .nav-tools{display:flex;align-items:center;gap:8px}.nav-login,.icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.nav-login{padding-inline:16px}.nav-login:hover,.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.icon-btn{width:46px}
.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} .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
.page-posts .lock-chip svg{width:14px;height:14px} .demo-strip b{color:var(--surface);font-weight:700}
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0} .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0} .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
.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)} .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 .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px} .lock-chip svg{width:14px;height:14px}
.page-posts .article-body{font-size:19px;line-height:2} .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px} .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
.page-posts .article-body p{margin:0 0 26px} .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 .lede{font-family:var(--font-display);font-size:28px;line-height:1.6} .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0} .article-body{font-size:19px;line-height:2}
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px} .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
.page-posts .free-tag{font:11px/1.5 var(--font-mono);color:var(--muted);margin-bottom:6px;display:block} .article-body p{margin:0 0 26px}
.page-posts .lock-cut{position:relative} .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
.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} .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
.page-posts .lock-cut .cut-p{margin-bottom:0} .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
.page-posts .paywall{margin-top:-30px;position:relative;border:1px solid var(--fg);background:var(--surface);padding:clamp(28px,4vw,44px);z-index:2} .free-tag{font:11px/1.5 var(--font-mono);color:var(--muted);margin-bottom:6px;display:block}
.page-posts .paywall .crumb{margin-bottom:12px} .lock-cut{position:relative}
.page-posts .paywall h2{font-size:clamp(30px,3.6vw,44px);line-height:1.18;margin:0 0 14px;max-width:16ch} .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 .paywall p{color:var(--muted);max-width:56ch;margin:0 0 24px} .lock-cut .cut-p{margin-bottom:0}
.page-posts .locked-in{border-top:1px solid var(--fg);margin:0 0 26px} .paywall{margin-top:-30px;position:relative;border:1px solid var(--fg);background:var(--surface);padding:clamp(28px,4vw,44px);z-index:2}
.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} .paywall .crumb{margin-bottom:12px}
.page-posts .locked-in svg{width:14px;height:14px;flex:none} .paywall h2{font-size:clamp(30px,3.6vw,44px);line-height:1.18;margin:0 0 14px;max-width:16ch}
.page-posts .locked-in .num{margin-inline-start:auto;font:12px/1.5 var(--font-mono)} .paywall p{color:var(--muted);max-width:56ch;margin:0 0 24px}
.page-posts .paywall-actions{display:flex;align-items:center;gap:18px;flex-wrap:wrap} .locked-in{border-top:1px solid var(--fg);margin:0 0 26px}
.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} .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 .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)} .locked-in svg{width:14px;height:14px;flex:none}
.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)} .locked-in .num{margin-inline-start:auto;font:12px/1.5 var(--font-mono)}
.page-posts .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)} .paywall-actions{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.page-posts .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)} .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 .text-link{font-weight:700;color:var(--fg);text-decoration:underline;text-underline-offset:4px;min-height:44px;display:inline-flex;align-items:center} .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.page-posts .text-link:hover{color:var(--accent)} .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
.page-posts .price-line{font:12px/1.6 var(--font-mono);color:var(--muted);margin-top:18px} .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px} .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
.page-posts .toc strong{font-family:var(--font-display);font-size:20px} .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 .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)} .text-link:hover{color:var(--accent)}
.page-posts .toc a:hover{color:var(--fg)} .price-line{font:12px/1.6 var(--font-mono);color:var(--muted);margin-top:18px}
.page-posts .toc .toc-locked{color:color-mix(in oklch,var(--muted) 60%,transparent)} .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
.page-posts .toc .toc-locked svg{width:12px;height:12px;flex:none} .toc strong{font-family:var(--font-display);font-size:20px}
.page-posts .member-note{margin-top:28px;border:1px solid var(--fg);padding:22px} .toc a,.toc .toc-locked{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}
.page-posts .member-note strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:8px} .toc a:hover{color:var(--fg)}
.page-posts .member-note p{color:var(--muted);font-size:14px;margin:0 0 16px} .toc .toc-locked{color:color-mix(in oklch,var(--muted) 60%,transparent)}
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)} .toc .toc-locked svg{width:12px;height:12px;flex:none}
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px} .member-note{margin-top:28px;border:1px solid var(--fg);padding:22px}
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px} .member-note strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:8px}
.page-posts .footer-col a{display:block;min-height:36px} .member-note p{color:var(--muted);font-size:14px;margin:0 0 16px}
.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline} .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
.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} .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
.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} .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
@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}} .footer-col a{display:block;min-height:36px}
@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%}} .footer-col a:hover{color:var(--fg);text-decoration:underline}
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}} .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}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.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}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:flex;flex-direction:column}.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){:root{--gutter:22px}.nav-links{display:none}.article-layout{grid-template-columns:1fr}.toc{position:static;order:0;margin-top:24px}.footer-grid{grid-template-columns:1fr 1fr}.article-body{font-size:18px}}
@media(max-width:620px){:root{--gutter:18px}.nav-login{display:none}.article-head h1{font-size:46px}.article-layout{padding-block:56px}.footer-grid{grid-template-columns:1fr;gap:28px}.paywall-actions{flex-direction:column;align-items:stretch}.paywall-actions .btn{width:100%}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
+66 -62
View File
@@ -1,62 +1,66 @@
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */ :root {
.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} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.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} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} --font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
.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)} *,*::before,*::after{box-sizing:border-box} html{scroll-behavior:smooth;-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} body.locked{overflow:hidden}
.page-posts .nav-tools{display:flex;align-items:center;gap:8px} a{color:inherit;text-decoration:none} button,input,textarea{font:inherit} button{cursor:pointer} h1,h2,h3{font-family:var(--font-display);text-wrap:balance} p{text-wrap:pretty} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.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)} .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.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} .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)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.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)} .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center} .nav-tools{display:flex;align-items:center;gap:8px}
.page-posts .demo-strip b{color:var(--surface);font-weight:700} .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}.nav-account:hover{background:var(--accent);border-color:var(--accent)}
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)} .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 .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px} .icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
.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} .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0} .demo-strip b{color:var(--surface);font-weight:700}
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0} .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
.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)} .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
.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)} .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-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px} .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
.page-posts .article-body{font-size:19px;line-height:2} .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px} .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-body h3{font-size:27px;margin:42px 0 14px} .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)}.save-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.save-btn[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
.page-posts .article-body p{margin:0 0 26px} .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 ul{padding-inline-start:24px}.page-posts .article-body li{margin-block:10px} .article-body{font-size:19px;line-height:2}
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6} .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
.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} .article-body h3{font-size:27px;margin:42px 0 14px}
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0} .article-body p{margin:0 0 26px}
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px} .article-body ul{padding-inline-start:24px}.article-body li{margin-block:10px}
.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} .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
.page-posts .steps strong{display:block;font-size:20px;margin-bottom:6px}.page-posts .steps p{margin:0;color:var(--muted)} .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 .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px} .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
.page-posts .toc strong{font-family:var(--font-display);font-size:20px} .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
.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)} .steps{counter-reset:step;list-style:none;padding:0}.steps li{counter-increment:step;display:grid;grid-template-columns:56px 1fr;gap:18px;padding:22px 0;border-bottom:1px solid var(--border)}.steps li::before{content:"٠" counter(step,arabic-indic);font-family:var(--font-display);font-size:34px;color:var(--accent);line-height:1}
.page-posts .progress-card{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)} .steps strong{display:block;font-size:20px;margin-bottom:6px}.steps p{margin:0;color:var(--muted)}
.page-posts .progress-card strong{font-size:18px;display:block;margin-bottom:12px} .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
.page-posts .progress-track{height:6px;background:color-mix(in oklch,var(--surface) 25%,transparent);margin-bottom:8px} .toc strong{font-family:var(--font-display);font-size:20px}
.page-posts .progress-bar{height:100%;background:var(--accent);width:40%} .toc a{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.toc a:hover{color:var(--fg)}
.page-posts .progress-card .p-label{font:11px/1.6 var(--font-mono);color:color-mix(in oklch,var(--surface) 72%,transparent)} .progress-card{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}
.page-posts .files-card{margin-top:18px;border:1px solid var(--fg);padding:22px} .progress-card strong{font-size:18px;display:block;margin-bottom:12px}
.page-posts .files-card strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:12px} .progress-track{height:6px;background:color-mix(in oklch,var(--surface) 25%,transparent);margin-bottom:8px}
.page-posts .file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)} .progress-bar{height:100%;background:var(--accent);width:40%}
.page-posts .file-row:last-of-type{border-bottom:0} .progress-card .p-label{font:11px/1.6 var(--font-mono);color:color-mix(in oklch,var(--surface) 72%,transparent)}
.page-posts .file-row span{font-size:14px} .files-card{margin-top:18px;border:1px solid var(--fg);padding:22px}
.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)} .files-card strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:12px}
.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} .file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)}
.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} .file-row:last-of-type{border-bottom: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)} .file-row span{font-size:14px}
.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} .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}.dl-btn:hover{border-color:var(--accent);background:var(--accent-soft)}
.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} .comments{padding-block:80px;border-top:1px solid var(--border)}.comments-wrap{max-width:740px}.comments h2{font-size:clamp(36px,5vw,54px);margin:0}
.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)} .comment-list{display:grid}.comment{padding:24px 0;border-bottom:1px solid var(--border)}.comment-head{display:flex;justify-content:space-between;gap:16px}.comment p{margin:8px 0 0}
.page-posts .status{font-size:14px;color:var(--muted);min-height:22px} .comment-form{display:grid;gap:18px;margin-top:36px}.field{display:grid;gap:7px}.field label{font-size:13px;color:var(--muted)}
.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)} .input,.textarea{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px}.textarea{min-height:130px;resize:vertical}
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)} .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 .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px} .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px} .status{font-size:14px;color:var(--muted);min-height:22px}
.page-posts .footer-col a{display:block;min-height:36px}.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline} .related{padding-block:80px;border-top:1px solid var(--border)}.related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.related-card{padding:24px 0;border-bottom:1px solid var(--border)}.related-card+.related-card{margin-inline-start:28px}.related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.related-card h3{font-size:24px;margin:12px 0}.related-card a{font-weight:700}.related-card a:hover{color:var(--accent)}
.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} .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
.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} .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
@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}} .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
@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}} .footer-col a{display:block;min-height:36px}.footer-col a:hover{color:var(--fg);text-decoration:underline}
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}} .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}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.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}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:flex;flex-direction:column}.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){:root{--gutter:22px}.nav-links{display:none}.article-layout{grid-template-columns:1fr}.toc{position:static;order:0;margin-top:24px}.footer-grid{grid-template-columns:1fr 1fr}.related-grid{grid-template-columns:1fr}.related-card+.related-card{margin-inline-start:0}.article-body{font-size:18px}}
@media(max-width:620px){:root{--gutter:18px}.nav-account .name-label{display:none}.article-head h1{font-size:46px}.article-layout,.comments,.related{padding-block:56px}.footer-grid{grid-template-columns:1fr;gap:28px}.article-meta{align-items:flex-start;flex-direction:column}.steps li{grid-template-columns:38px 1fr}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
+11 -7
View File
@@ -1,7 +1,11 @@
/* Page-scoped styles: :is(.page-privacy, .page-terms). Shared tokens live in globals.css. */ :root {
:is(.page-privacy, .page-terms) *,:is(.page-privacy, .page-terms) *::before,:is(.page-privacy, .page-terms) *::after{box-sizing:border-box}:is(.page-privacy, .page-terms){-webkit-text-size-adjust:100%}:is(.page-privacy, .page-terms){margin:0;background:var(--bg);color:var(--fg);font:16px/1.85 var(--font-body)}:is(.page-privacy, .page-terms) a{color:inherit;text-decoration:none}:is(.page-privacy, .page-terms) h1,:is(.page-privacy, .page-terms) h2{font-family:var(--font-display);text-wrap:balance}:is(.page-privacy, .page-terms) p{text-wrap:pretty}:is(.page-privacy, .page-terms) .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
:is(.page-privacy, .page-terms) .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)}:is(.page-privacy, .page-terms) .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}:is(.page-privacy, .page-terms) .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}:is(.page-privacy, .page-terms) .nav-links{display:flex;align-items:center;gap:28px}:is(.page-privacy, .page-terms) .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}:is(.page-privacy, .page-terms) .nav-links a:hover{color:var(--fg)} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
:is(.page-privacy, .page-terms) .hero{padding-block:clamp(58px,9vw,110px);border-bottom:1px solid var(--border)}:is(.page-privacy, .page-terms) .eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}:is(.page-privacy, .page-terms) .hero h1{font-size:clamp(44px,7vw,78px);line-height:1.08;margin:0;max-width:12ch}:is(.page-privacy, .page-terms) .lead{font-size:19px;color:var(--muted);max-width:58ch;margin:24px 0 0}:is(.page-privacy, .page-terms) .notice{margin-top:28px;border:1px solid var(--accent);padding:18px 20px;color:var(--fg);max-width:64ch} --font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 980px; --gutter: 32px;
:is(.page-privacy, .page-terms) .policy{padding-block:72px 90px}:is(.page-privacy, .page-terms) .policy section{padding-block:30px;border-top:1px solid var(--border)}:is(.page-privacy, .page-terms) .policy h2{font-size:clamp(28px,4vw,40px);margin:0 0 12px}:is(.page-privacy, .page-terms) .policy p,:is(.page-privacy, .page-terms) .policy li{color:var(--muted)}:is(.page-privacy, .page-terms) .policy ul{margin:0;padding-inline-start:24px}:is(.page-privacy, .page-terms) .policy a{color:var(--fg);text-decoration:underline;text-underline-offset:4px}:is(.page-privacy, .page-terms) .pagefoot{padding-block:46px;border-top:1px solid var(--border);color:var(--muted)}:is(.page-privacy, .page-terms) .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap} }
@media(max-width:920px){:is(.page-privacy, .page-terms) .nav-links{display:flex}} *,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.85 var(--font-body)}a{color:inherit;text-decoration:none}h1,h2{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
@media(max-width:620px){:is(.page-privacy, .page-terms) .hero{padding-block:48px}:is(.page-privacy, .page-terms) .policy{padding-block:48px 64px}:is(.page-privacy, .page-terms) .notice{padding:16px}} .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)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.hero{padding-block:clamp(58px,9vw,110px);border-bottom:1px solid var(--border)}.eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.hero h1{font-size:clamp(44px,7vw,78px);line-height:1.08;margin:0;max-width:12ch}.lead{font-size:19px;color:var(--muted);max-width:58ch;margin:24px 0 0}.notice{margin-top:28px;border:1px solid var(--accent);padding:18px 20px;color:var(--fg);max-width:64ch}
.policy{padding-block:72px 90px}.policy section{padding-block:30px;border-top:1px solid var(--border)}.policy h2{font-size:clamp(28px,4vw,40px);margin:0 0 12px}.policy p,.policy li{color:var(--muted)}.policy ul{margin:0;padding-inline-start:24px}.policy a{color:var(--fg);text-decoration:underline;text-underline-offset:4px}.pagefoot{padding-block:46px;border-top:1px solid var(--border);color:var(--muted)}.footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media(max-width:920px){:root{--gutter:22px}.nav-links{display:flex}}
@media(max-width:620px){:root{--gutter:18px}.hero{padding-block:48px}.policy{padding-block:48px 64px}.notice{padding:16px}}
+8 -8
View File
@@ -1,8 +1,8 @@
/* Page-scoped styles: .page-signup. Shared tokens live in globals.css. */ :root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
.page-signup *,.page-signup *::before,.page-signup *::after{box-sizing:border-box}.page-signup{-webkit-text-size-adjust:100%}.page-signup{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}.page-signup a{color:inherit;text-decoration:none}.page-signup button,.page-signup input{font:inherit}.page-signup button{cursor:pointer}.page-signup h1,.page-signup h2{font-family:var(--font-display);text-wrap:balance}.page-signup p{text-wrap:pretty}.page-signup :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-signup .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} *,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1,h2{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-signup .topnav{border-bottom:1px solid var(--border)}.page-signup .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.page-signup .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.page-signup .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.page-signup .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.page-signup .nav-return:hover{color:var(--fg);border-color:var(--fg)} .topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}
.page-signup .auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.page-signup .auth>*{min-width:0}.page-signup .auth-story{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100% - var(--container))/2 + var(--gutter)));padding-inline-end:clamp(48px,8vw,100px);background:var(--fg);color:var(--surface);display:flex;flex-direction:column;justify-content:space-between}.page-signup .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-signup .auth-story .eyebrow{color:var(--surface);opacity:.78}.page-signup .auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;max-width:10ch;margin:0}.page-signup .auth-story>div>p{color:color-mix(in oklch,var(--surface) 78%,transparent);font-size:19px;max-width:40ch}.page-signup .story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 32%,transparent)}.page-signup .story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 22%,transparent);display:flex;justify-content:space-between;gap:20px}.page-signup .story-mark{font:12px/1.5 var(--font-mono);opacity:.74}.page-signup .story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 72%,transparent)} .auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.auth>*{min-width:0}.auth-story{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100% - var(--container))/2 + var(--gutter)));padding-inline-end:clamp(48px,8vw,100px);background:var(--fg);color:var(--surface);display:flex;flex-direction:column;justify-content:space-between}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.auth-story .eyebrow{color:var(--surface);opacity:.78}.auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;max-width:10ch;margin:0}.auth-story>div>p{color:color-mix(in oklch,var(--surface) 78%,transparent);font-size:19px;max-width:40ch}.story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 32%,transparent)}.story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 22%,transparent);display:flex;justify-content:space-between;gap:20px}.story-mark{font:12px/1.5 var(--font-mono);opacity:.74}.story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 72%,transparent)}
.page-signup .auth-form-wrap{padding:clamp(46px,7vw,88px) max(var(--gutter),calc((100% - var(--container))/2 + var(--gutter)));padding-inline-start:clamp(48px,8vw,100px);display:flex;align-items:center}.page-signup .auth-form{width:min(440px,100%)}.page-signup .auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.page-signup .lead{color:var(--muted);font-size:18px;margin:12px 0 28px}.page-signup .field{display:grid;gap:7px;margin-top:16px}.page-signup .field label{font-size:13px;color:var(--muted)}.page-signup .input-wrap{position:relative}.page-signup .input{width:100%;min-height:52px;border:1px solid color-mix(in oklch,var(--border) 72%,var(--fg));background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.page-signup .input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.page-signup .password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.page-signup .password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.page-signup .check{display:flex;align-items:flex-start;gap:10px;margin:20px 0}.page-signup .check input{margin-top:6px;flex:none}.page-signup .check a{text-decoration:underline;text-underline-offset:3px}.page-signup .check a:hover{color:var(--accent)}.page-signup .btn{min-height:52px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.page-signup .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-signup .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-signup .signin{text-align:center;color:var(--muted);font-size:14px;margin-top:22px}.page-signup .signin a{color:var(--fg);text-decoration:underline;text-underline-offset:3px}.page-signup .signin a:hover{color:var(--accent)}.page-signup .fine{font-size:12px;color:var(--muted);margin-top:14px}.page-signup .status{min-height:28px;margin-top:12px;color:var(--accent);font-size:13px} .auth-form-wrap{padding:clamp(46px,7vw,88px) max(var(--gutter),calc((100% - var(--container))/2 + var(--gutter)));padding-inline-start:clamp(48px,8vw,100px);display:flex;align-items:center}.auth-form{width:min(440px,100%)}.auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.lead{color:var(--muted);font-size:18px;margin:12px 0 28px}.field{display:grid;gap:7px;margin-top:16px}.field label{font-size:13px;color:var(--muted)}.input-wrap{position:relative}.input{width:100%;min-height:52px;border:1px solid color-mix(in oklch,var(--border) 72%,var(--fg));background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.check{display:flex;align-items:flex-start;gap:10px;margin:20px 0}.check input{margin-top:6px;flex:none}.check a{text-decoration:underline;text-underline-offset:3px}.check a:hover{color:var(--accent)}.btn{min-height:52px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.signin{text-align:center;color:var(--muted);font-size:14px;margin-top:22px}.signin a{color:var(--fg);text-decoration:underline;text-underline-offset:3px}.signin a:hover{color:var(--accent)}.fine{font-size:12px;color:var(--muted);margin-top:14px}.status{min-height:28px;margin-top:12px;color:var(--accent);font-size:13px}
@media(max-width:920px){.page-signup .auth{grid-template-columns:1fr}.page-signup .auth-story{min-height:390px;padding:58px var(--gutter)}.page-signup .auth-form-wrap{padding:58px var(--gutter);justify-content:center}.page-signup .story-foot{margin-top:48px}} @media(max-width:920px){:root{--gutter:22px}.auth{grid-template-columns:1fr}.auth-story{min-height:390px;padding:58px var(--gutter)}.auth-form-wrap{padding:58px var(--gutter);justify-content:center}.story-foot{margin-top:48px}}
@media(max-width:620px){.page-signup .nav-inner{gap:12px}.page-signup .nav-return{font-size:13px}.page-signup .auth-story{min-height:auto}.page-signup .auth-story h1{font-size:46px}.page-signup .auth-form-wrap{padding-block:46px}.page-signup .story-list{margin-top:38px}} @media(max-width:620px){:root{--gutter:18px}.nav-inner{gap:12px}.nav-return{font-size:13px}.auth-story{min-height:auto}.auth-story h1{font-size:46px}.auth-form-wrap{padding-block:46px}.story-list{margin-top:38px}}
@media(prefers-reduced-motion:reduce){.page-signup *{transition-duration:.01ms!important}} @media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
+20 -20
View File
@@ -1,21 +1,21 @@
/* Page-scoped styles: .page-subscription. Shared tokens live in globals.css. */ :root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
.page-subscription *,.page-subscription *::before,.page-subscription *::after{box-sizing:border-box}.page-subscription{scroll-behavior:smooth;-webkit-text-size-adjust:100%}.page-subscription{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}.page-subscription.locked{overflow:hidden}.page-subscription a{color:inherit;text-decoration:none}.page-subscription button{font:inherit;cursor:pointer}.page-subscription h1,.page-subscription h2,.page-subscription h3{font-family:var(--font-display);text-wrap:balance}.page-subscription p{text-wrap:pretty}.page-subscription :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-subscription .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} *,*::before,*::after{box-sizing:border-box}html{scroll-behavior:smooth;-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}body.locked{overflow:hidden}a{color:inherit;text-decoration:none}button{font:inherit;cursor:pointer}h1,h2,h3{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-subscription .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-subscription .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.page-subscription .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-subscription .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)}.page-subscription .nav-links{display:flex;align-items:center;gap:28px}.page-subscription .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-subscription .nav-links a:hover{color:var(--fg)}.page-subscription .nav-tools{display:flex;align-items:center;gap:8px}.page-subscription .nav-login{min-height:44px;display:inline-flex;align-items:center;padding-inline:16px;border:1px solid var(--border)}.page-subscription .nav-login:hover{border-color:var(--fg);background:var(--fg-soft)}.page-subscription .icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--fg);background:var(--fg);color:var(--surface)}.page-subscription .icon-btn:hover{background:var(--accent);border-color:var(--accent);color:var(--surface)} .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)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)}.nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}.nav-tools{display:flex;align-items:center;gap:8px}.nav-login{min-height:44px;display:inline-flex;align-items:center;padding-inline:16px;border:1px solid var(--border)}.nav-login:hover{border-color:var(--fg);background:var(--fg-soft)}.icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--fg);background:var(--fg);color:var(--surface)}.icon-btn:hover{background:var(--accent);border-color:var(--accent);color:var(--surface)}
.page-subscription .hero{padding-block:clamp(72px,11vw,140px);border-bottom:1px solid var(--border)}.page-subscription .hero-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:clamp(48px,9vw,110px);align-items:end}.page-subscription .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-subscription .hero .eyebrow{display:block;margin-bottom:34px}.page-subscription .hero h1{font-size:clamp(52px,8vw,102px);line-height:1.08;max-width:10ch;margin:0}.page-subscription .lead{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:52ch;margin:26px 0 0}.page-subscription .hero-note{border-top:1px solid var(--fg);padding-top:24px}.page-subscription .hero-note strong{font-family:var(--font-display);font-size:28px;display:block}.page-subscription .hero-note p{color:var(--muted);margin:8px 0 0} .hero{padding-block:clamp(72px,11vw,140px);border-bottom:1px solid var(--border)}.hero-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:clamp(48px,9vw,110px);align-items:end}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.hero .eyebrow{display:block;margin-bottom:34px}.hero h1{font-size:clamp(52px,8vw,102px);line-height:1.08;max-width:10ch;margin:0}.lead{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:52ch;margin:26px 0 0}.hero-note{border-top:1px solid var(--fg);padding-top:24px}.hero-note strong{font-family:var(--font-display);font-size:28px;display:block}.hero-note p{color:var(--muted);margin:8px 0 0}
.page-subscription .benefits{padding-block:90px;border-bottom:1px solid var(--border)}.page-subscription .section-head{display:flex;justify-content:space-between;align-items:end;gap:40px;margin-bottom:50px}.page-subscription .section-head h2{font-size:clamp(38px,5vw,60px);line-height:1.15;margin:0;max-width:13ch}.page-subscription .section-head p{color:var(--muted);max-width:46ch}.page-subscription .benefit-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.page-subscription .benefit{padding:30px 0;border-bottom:1px solid var(--border)}.page-subscription .benefit+.benefit{margin-inline-start:32px}.page-subscription .benefit-index{font:12px/1.5 var(--font-mono);color:var(--accent)}.page-subscription .benefit h3{font-size:28px;margin:26px 0 10px}.page-subscription .benefit p{color:var(--muted);margin:0} .benefits{padding-block:90px;border-bottom:1px solid var(--border)}.section-head{display:flex;justify-content:space-between;align-items:end;gap:40px;margin-bottom:50px}.section-head h2{font-size:clamp(38px,5vw,60px);line-height:1.15;margin:0;max-width:13ch}.section-head p{color:var(--muted);max-width:46ch}.benefit-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.benefit{padding:30px 0;border-bottom:1px solid var(--border)}.benefit+.benefit{margin-inline-start:32px}.benefit-index{font:12px/1.5 var(--font-mono);color:var(--accent)}.benefit h3{font-size:28px;margin:26px 0 10px}.benefit p{color:var(--muted);margin:0}
.page-subscription .guide-samples{padding-block:90px;background:var(--surface);border-bottom:1px solid var(--border)}.page-subscription .guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.page-subscription .guide-card{display:flex;flex-direction:column;min-height:440px;border:1px solid var(--border);background:var(--bg)}.page-subscription .guide-cover{min-height:174px;padding:26px;display:flex;flex-direction:column;justify-content:space-between;background:var(--fg);color:var(--surface)}.page-subscription .guide-cover-light{background:var(--surface);color:var(--fg);border-bottom:1px solid var(--fg)}.page-subscription .guide-cover span:first-child{font:11px/1.5 var(--font-mono);}.page-subscription .guide-cover strong{font-family:var(--font-display);font-size:clamp(28px,3vw,40px);line-height:1.1;max-width:9ch}.page-subscription .guide-body{padding:26px;display:flex;flex:1;flex-direction:column}.page-subscription .guide-meta{display:flex;justify-content:space-between;gap:16px;color:var(--muted);font:12px/1.5 var(--font-mono)}.page-subscription .guide-body h3{font-size:25px;margin:24px 0 10px}.page-subscription .guide-body p{color:var(--muted);margin:0}.page-subscription .guide-output{margin:22px 0 0;padding:18px 0;border-block:1px solid var(--border)}.page-subscription .guide-output strong{display:block;font-size:13px;margin-bottom:6px}.page-subscription .guide-output span{color:var(--muted);font-size:14px}.page-subscription .guide-link{min-height:44px;display:flex;align-items:end;justify-content:space-between;margin-top:auto;padding-top:22px;font-weight:700}.page-subscription .guide-link:hover{color:var(--accent)} .guide-samples{padding-block:90px;background:var(--surface);border-bottom:1px solid var(--border)}.guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.guide-card{display:flex;flex-direction:column;min-height:440px;border:1px solid var(--border);background:var(--bg)}.guide-cover{min-height:174px;padding:26px;display:flex;flex-direction:column;justify-content:space-between;background:var(--fg);color:var(--surface)}.guide-cover-light{background:var(--surface);color:var(--fg);border-bottom:1px solid var(--fg)}.guide-cover span:first-child{font:11px/1.5 var(--font-mono);}.guide-cover strong{font-family:var(--font-display);font-size:clamp(28px,3vw,40px);line-height:1.1;max-width:9ch}.guide-body{padding:26px;display:flex;flex:1;flex-direction:column}.guide-meta{display:flex;justify-content:space-between;gap:16px;color:var(--muted);font:12px/1.5 var(--font-mono)}.guide-body h3{font-size:25px;margin:24px 0 10px}.guide-body p{color:var(--muted);margin:0}.guide-output{margin:22px 0 0;padding:18px 0;border-block:1px solid var(--border)}.guide-output strong{display:block;font-size:13px;margin-bottom:6px}.guide-output span{color:var(--muted);font-size:14px}.guide-link{min-height:44px;display:flex;align-items:end;justify-content:space-between;margin-top:auto;padding-top:22px;font-weight:700}.guide-link:hover{color:var(--accent)}
.page-subscription .outcomes{padding-block:90px;border-bottom:1px solid var(--border)}.page-subscription .outcome-grid{display:grid;grid-template-columns:.9fr 1.1fr;gap:clamp(48px,9vw,110px);align-items:start}.page-subscription .outcome-grid h2{font-size:clamp(40px,5vw,64px);line-height:1.12;margin:0}.page-subscription .outcome-list{border-top:1px solid var(--fg)}.page-subscription .outcome-card{display:grid;grid-template-columns:72px 1fr;gap:22px;padding:28px 0;border-bottom:1px solid var(--border)}.page-subscription .outcome-card .benefit-index{padding-top:7px}.page-subscription .outcome-card h3{font-size:27px;margin:0 0 8px}.page-subscription .outcome-card p{color:var(--muted);margin:0}.page-subscription .team-contact{margin-top:34px;padding:30px;border:1px solid var(--fg);display:flex;align-items:center;justify-content:space-between;gap:28px}.page-subscription .team-contact h3{font-size:26px;margin:0 0 6px}.page-subscription .team-contact p{color:var(--muted);margin:0;max-width:48ch}.page-subscription .team-contact .btn{flex:0 0 auto} .outcomes{padding-block:90px;border-bottom:1px solid var(--border)}.outcome-grid{display:grid;grid-template-columns:.9fr 1.1fr;gap:clamp(48px,9vw,110px);align-items:start}.outcome-grid h2{font-size:clamp(40px,5vw,64px);line-height:1.12;margin:0}.outcome-list{border-top:1px solid var(--fg)}.outcome-card{display:grid;grid-template-columns:72px 1fr;gap:22px;padding:28px 0;border-bottom:1px solid var(--border)}.outcome-card .benefit-index{padding-top:7px}.outcome-card h3{font-size:27px;margin:0 0 8px}.outcome-card p{color:var(--muted);margin:0}.team-contact{margin-top:34px;padding:30px;border:1px solid var(--fg);display:flex;align-items:center;justify-content:space-between;gap:28px}.team-contact h3{font-size:26px;margin:0 0 6px}.team-contact p{color:var(--muted);margin:0;max-width:48ch}.team-contact .btn{flex:0 0 auto}
.page-subscription .pricing{padding-block:90px;background:var(--fg);color:var(--surface)}.page-subscription .pricing-grid{display:grid;grid-template-columns:1fr 1fr;gap:clamp(48px,9vw,110px);align-items:start}.page-subscription .pricing h2{font-size:clamp(40px,6vw,70px);line-height:1.08;margin:0}.page-subscription .pricing .lead{color:color-mix(in oklch,var(--surface) 70%,transparent)}.page-subscription .plan{background:var(--surface);color:var(--fg);padding:clamp(30px,5vw,54px)}.page-subscription .plan-head{display:flex;justify-content:space-between;align-items:start;gap:20px;padding-bottom:26px;border-bottom:1px solid var(--border)}.page-subscription .plan h3{font-size:32px;margin:0}.page-subscription .price{font-family:var(--font-display);font-size:58px;line-height:1;text-align:left}.page-subscription .price small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:8px}.page-subscription .trial-label{display:inline-block;font:11px/1.5 var(--font-mono);color:var(--accent);margin-top:10px}.page-subscription .plan-list{list-style:none;margin:24px 0;padding:0}.page-subscription .plan-list li{display:flex;justify-content:space-between;gap:16px;padding:13px 0;border-bottom:1px solid var(--border)}.page-subscription .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.page-subscription .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-subscription .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.page-subscription .plan .btn{width:100%}.page-subscription .plan-fine{font-size:12px;color:var(--muted);text-align:center;margin:12px 0 0} .pricing{padding-block:90px;background:var(--fg);color:var(--surface)}.pricing-grid{display:grid;grid-template-columns:1fr 1fr;gap:clamp(48px,9vw,110px);align-items:start}.pricing h2{font-size:clamp(40px,6vw,70px);line-height:1.08;margin:0}.pricing .lead{color:color-mix(in oklch,var(--surface) 70%,transparent)}.plan{background:var(--surface);color:var(--fg);padding:clamp(30px,5vw,54px)}.plan-head{display:flex;justify-content:space-between;align-items:start;gap:20px;padding-bottom:26px;border-bottom:1px solid var(--border)}.plan h3{font-size:32px;margin:0}.price{font-family:var(--font-display);font-size:58px;line-height:1;text-align:left}.price small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:8px}.trial-label{display:inline-block;font:11px/1.5 var(--font-mono);color:var(--accent);margin-top:10px}.plan-list{list-style:none;margin:24px 0;padding:0}.plan-list li{display:flex;justify-content:space-between;gap:16px;padding:13px 0;border-bottom:1px solid var(--border)}.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.plan .btn{width:100%}.plan-fine{font-size:12px;color:var(--muted);text-align:center;margin:12px 0 0}
.page-subscription .compare{padding-block:90px;border-bottom:1px solid var(--border)}.page-subscription .compare h2,.page-subscription .faq h2{font-size:clamp(38px,5vw,58px);margin:0 0 40px}.page-subscription .table-wrap{overflow-x:auto}.page-subscription .table{width:100%;border-collapse:collapse;min-width:620px}.page-subscription .table th,.page-subscription .table td{padding:16px 14px;border-bottom:1px solid var(--border);text-align:right}.page-subscription .table th{font:12px/1.5 var(--font-mono);color:var(--muted)}.page-subscription .table td:not(:first-child),.page-subscription .table th:not(:first-child){text-align:center}.page-subscription .member-col{border-inline:1px solid var(--accent)} .compare{padding-block:90px;border-bottom:1px solid var(--border)}.compare h2,.faq h2{font-size:clamp(38px,5vw,58px);margin:0 0 40px}.table-wrap{overflow-x:auto}.table{width:100%;border-collapse:collapse;min-width:620px}.table th,.table td{padding:16px 14px;border-bottom:1px solid var(--border);text-align:right}.table th{font:12px/1.5 var(--font-mono);color:var(--muted)}.table td:not(:first-child),.table th:not(:first-child){text-align:center}.member-col{border-inline:1px solid var(--accent)}
.page-subscription .faq{padding-block:90px}.page-subscription .faq-wrap{display:grid;grid-template-columns:.7fr 1.3fr;gap:clamp(46px,8vw,100px)}.page-subscription .faq-list{border-top:1px solid var(--fg)}.page-subscription .faq-item{border-bottom:1px solid var(--border)}.page-subscription .faq-button{width:100%;min-height:68px;border:0;background:transparent;color:var(--fg);display:flex;justify-content:space-between;align-items:center;gap:20px;text-align:right;font-weight:700}.page-subscription .faq-button:hover{color:var(--accent)}.page-subscription .faq-answer{display:none;color:var(--muted);padding:0 0 22px;max-width:64ch}.page-subscription .faq-item.open .faq-answer{display:block}.page-subscription .faq-item.open .faq-button span:last-child{transform:rotate(45deg)} .faq{padding-block:90px}.faq-wrap{display:grid;grid-template-columns:.7fr 1.3fr;gap:clamp(46px,8vw,100px)}.faq-list{border-top:1px solid var(--fg)}.faq-item{border-bottom:1px solid var(--border)}.faq-button{width:100%;min-height:68px;border:0;background:transparent;color:var(--fg);display:flex;justify-content:space-between;align-items:center;gap:20px;text-align:right;font-weight:700}.faq-button:hover{color:var(--accent)}.faq-answer{display:none;color:var(--muted);padding:0 0 22px;max-width:64ch}.faq-item.open .faq-answer{display:block}.faq-item.open .faq-button span:last-child{transform:rotate(45deg)}
.page-subscription .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}.page-subscription .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}.page-subscription .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}.page-subscription .footer-col a{display:block;min-height:36px}.page-subscription .footer-col a:hover{color:var(--fg);text-decoration:underline}.page-subscription .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} .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}.footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}.footer-col strong{display:block;color:var(--fg);margin-bottom:12px}.footer-col a{display:block;min-height:36px}.footer-col a:hover{color:var(--fg);text-decoration:underline}.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-subscription .menu-panel,.page-subscription .dialog{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-subscription .menu-panel.open,.page-subscription .dialog.open{visibility:visible;pointer-events:auto}.page-subscription .menu-scrim,.page-subscription .dialog-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 58%,transparent);opacity:0}.page-subscription .menu-panel.open .menu-scrim,.page-subscription .dialog.open .dialog-scrim{opacity:1}.page-subscription .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-subscription .menu-panel.open .menu-drawer{transform:translateX(0)}.page-subscription .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-subscription .menu-links{display:grid;margin-top:34px}.page-subscription .menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.page-subscription .menu-links a:hover{color:var(--accent)}.page-subscription .menu-foot{margin-top:auto;color:var(--muted)}.page-subscription .dialog-panel{position:relative;width:min(560px,calc(100% - 36px));margin:10vh auto 0;background:var(--surface);padding:clamp(30px,5vw,54px);border:1px solid var(--fg)}.page-subscription .dialog-panel h2{font-size:42px;margin:12px 0}.page-subscription .dialog-panel p{color:var(--muted)}.page-subscription .dialog-close{position:absolute;inset-inline-end:18px;top:18px;background:transparent;color:var(--fg);border-color:var(--border)}.page-subscription .dialog-actions{display:flex;gap:10px;margin-top:28px}.page-subscription .dialog-actions .btn{flex:1}.page-subscription .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-subscription .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)} .menu-panel,.dialog{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open,.dialog.open{visibility:visible;pointer-events:auto}.menu-scrim,.dialog-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 58%,transparent);opacity:0}.menu-panel.open .menu-scrim,.dialog.open .dialog-scrim{opacity:1}.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}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:grid;margin-top:34px}.menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.menu-links a:hover{color:var(--accent)}.menu-foot{margin-top:auto;color:var(--muted)}.dialog-panel{position:relative;width:min(560px,calc(100% - 36px));margin:10vh auto 0;background:var(--surface);padding:clamp(30px,5vw,54px);border:1px solid var(--fg)}.dialog-panel h2{font-size:42px;margin:12px 0}.dialog-panel p{color:var(--muted)}.dialog-close{position:absolute;inset-inline-end:18px;top:18px;background:transparent;color:var(--fg);border-color:var(--border)}.dialog-actions{display:flex;gap:10px;margin-top:28px}.dialog-actions .btn{flex:1}.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
@media(max-width:920px){.page-subscription .nav-links{display:none}.page-subscription .hero-grid,.page-subscription .pricing-grid,.page-subscription .faq-wrap,.page-subscription .outcome-grid{grid-template-columns:1fr}.page-subscription .benefit-grid,.page-subscription .guide-grid{grid-template-columns:1fr}.page-subscription .benefit+.benefit{margin-inline-start:0}.page-subscription .guide-card{min-height:0}.page-subscription .footer-grid{grid-template-columns:1fr 1fr}.page-subscription .section-head{align-items:start;flex-direction:column}.page-subscription .hero-note{max-width:520px}} @media(max-width:920px){:root{--gutter:22px}.nav-links{display:none}.hero-grid,.pricing-grid,.faq-wrap,.outcome-grid{grid-template-columns:1fr}.benefit-grid,.guide-grid{grid-template-columns:1fr}.benefit+.benefit{margin-inline-start:0}.guide-card{min-height:0}.footer-grid{grid-template-columns:1fr 1fr}.section-head{align-items:start;flex-direction:column}.hero-note{max-width:520px}}
@media(max-width:620px){.page-subscription .nav-login{display:none}.page-subscription .hero h1{font-size:50px}.page-subscription .benefits,.page-subscription .guide-samples,.page-subscription .outcomes,.page-subscription .pricing,.page-subscription .compare,.page-subscription .faq{padding-block:60px}.page-subscription .footer-grid{grid-template-columns:1fr;gap:28px}.page-subscription .plan-head,.page-subscription .team-contact{flex-direction:column;align-items:stretch}.page-subscription .team-contact .btn{width:100%}.page-subscription .outcome-card{grid-template-columns:48px 1fr}.page-subscription .price{text-align:right}.page-subscription .dialog-actions{flex-direction:column}} @media(max-width:620px){:root{--gutter:18px}.nav-login{display:none}.hero h1{font-size:50px}.benefits,.guide-samples,.outcomes,.pricing,.compare,.faq{padding-block:60px}.footer-grid{grid-template-columns:1fr;gap:28px}.plan-head,.team-contact{flex-direction:column;align-items:stretch}.team-contact .btn{width:100%}.outcome-card{grid-template-columns:48px 1fr}.price{text-align:right}.dialog-actions{flex-direction:column}}
@media(prefers-reduced-motion:reduce){.page-subscription *{scroll-behavior:auto!important;transition-duration:.01ms!important}} @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
/* QA-R01/R07: hero join CTA + price on the first fold. */ /* QA-R01/R07: hero join CTA + price on the first fold. */
.page-subscription .hero-join{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:24px} .hero-join{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:24px}
.page-subscription .hero-join .price{font-size:34px} .hero-join .price{font-size:34px}
.page-subscription .hero-join .price small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:8px} .hero-join .price small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:8px}
.page-subscription .cta-pair{display:inline-flex;gap:10px;flex-wrap:wrap;align-items:center} .cta-pair{display:inline-flex;gap:10px;flex-wrap:wrap;align-items:center}
.page-subscription .trust-line{margin:14px 0 0;color:var(--muted);font-size:14px} .trust-line{margin:14px 0 0;color:var(--muted);font-size:14px}
+118 -110
View File
@@ -1,114 +1,122 @@
/* Page-scoped styles: .page-vip-onboarding. Shared tokens live in globals.css. */ :root {
.page-vip-onboarding *,.page-vip-onboarding *::before,.page-vip-onboarding *::after{box-sizing:border-box} --font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-vip-onboarding{-webkit-text-size-adjust:100%} --font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.page-vip-onboarding{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
.page-vip-onboarding.locked{overflow:hidden} --container: 1180px;
.page-vip-onboarding a{color:inherit;text-decoration:none} --gutter: 32px;
.page-vip-onboarding button,.page-vip-onboarding input{font:inherit} }
.page-vip-onboarding button{cursor:pointer} *,*::before,*::after{box-sizing:border-box}
.page-vip-onboarding h1,.page-vip-onboarding h2,.page-vip-onboarding h3{font-family:var(--font-display);text-wrap:balance} html{-webkit-text-size-adjust:100%}
.page-vip-onboarding p{text-wrap:pretty} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
.page-vip-onboarding :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px} body.locked{overflow:hidden}
.page-vip-onboarding .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} a{color:inherit;text-decoration:none}
.page-vip-onboarding .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)} button,input{font:inherit}
.page-vip-onboarding .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px} button{cursor:pointer}
.page-vip-onboarding .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700} h1,h2,h3{font-family:var(--font-display);text-wrap:balance}
.page-vip-onboarding .nav-links{display:flex;align-items:center;gap:28px} p{text-wrap:pretty}
.page-vip-onboarding .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-vip-onboarding .nav-links a:hover{color:var(--fg)} .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.page-vip-onboarding .nav-tools{display:flex;align-items:center;gap:8px} .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-vip-onboarding .nav-account{min-height:44px;display:inline-flex;align-items:center;padding-inline:16px;border:1px solid var(--border);font-weight:700} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.page-vip-onboarding .nav-account:hover{border-color:var(--fg);background:var(--fg-soft)} .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}
.page-vip-onboarding .icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--border);background:transparent;color:var(--fg)} .nav-links{display:flex;align-items:center;gap:28px}
.page-vip-onboarding .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)} .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
.page-vip-onboarding .member-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px var(--gutter);text-align:center} .nav-links a:hover{color:var(--fg)}
.page-vip-onboarding .onboarding-head{padding-block:clamp(48px,7vw,82px) 42px;border-bottom:1px solid var(--border)} .nav-tools{display:flex;align-items:center;gap:8px}
.page-vip-onboarding .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px} .nav-account{min-height:44px;display:inline-flex;align-items:center;padding-inline:16px;border:1px solid var(--border);font-weight:700}
.page-vip-onboarding .onboarding-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0;max-width:13ch} .nav-account:hover{border-color:var(--fg);background:var(--fg-soft)}
.page-vip-onboarding .lead{font-size:clamp(18px,2vw,22px);color:var(--muted);max-width:58ch;margin:18px 0 0} .icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--border);background:transparent;color:var(--fg)}
.page-vip-onboarding .progress-wrap{margin-top:34px;max-width:720px} .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
.page-vip-onboarding .progress-copy{display:flex;justify-content:space-between;gap:16px;color:var(--muted);font:12px/1.5 var(--font-mono);margin-bottom:10px} .member-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px var(--gutter);text-align:center}
.page-vip-onboarding .progress-track{height:4px;background:var(--border);overflow:hidden} .onboarding-head{padding-block:clamp(48px,7vw,82px) 42px;border-bottom:1px solid var(--border)}
.page-vip-onboarding .progress-bar{height:100%;width:25%;background:var(--accent);transition:width .22s ease} .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}
.page-vip-onboarding .onboarding-layout{display:grid;grid-template-columns:250px minmax(0,1fr);gap:clamp(42px,7vw,90px);align-items:start;padding-block:50px 90px} .onboarding-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0;max-width:13ch}
.page-vip-onboarding .step-nav{position:sticky;top:100px;border-top:1px solid var(--fg)} .lead{font-size:clamp(18px,2vw,22px);color:var(--muted);max-width:58ch;margin:18px 0 0}
.page-vip-onboarding .step-nav button{width:100%;min-height:58px;display:grid;grid-template-columns:34px 1fr;align-items:center;gap:12px;padding:0 4px;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--muted);text-align:right} .progress-wrap{margin-top:34px;max-width:720px}
.page-vip-onboarding .step-nav button[aria-current="step"]{color:var(--fg);font-weight:700} .progress-copy{display:flex;justify-content:space-between;gap:16px;color:var(--muted);font:12px/1.5 var(--font-mono);margin-bottom:10px}
.page-vip-onboarding .step-nav button:disabled{cursor:default} .progress-track{height:4px;background:var(--border);overflow:hidden}
.page-vip-onboarding .step-num{font:12px/1 var(--font-mono)} .progress-bar{height:100%;width:25%;background:var(--accent);transition:width .22s ease}
.page-vip-onboarding .step-nav button[aria-current="step"] .step-num{color:var(--accent)} .onboarding-layout{display:grid;grid-template-columns:250px minmax(0,1fr);gap:clamp(42px,7vw,90px);align-items:start;padding-block:50px 90px}
.page-vip-onboarding .step-nav button.done{color:var(--fg)} .step-nav{position:sticky;top:100px;border-top:1px solid var(--fg)}
.page-vip-onboarding .step-panel{display:none;max-width:720px} .step-nav button{width:100%;min-height:58px;display:grid;grid-template-columns:34px 1fr;align-items:center;gap:12px;padding:0 4px;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--muted);text-align:right}
.page-vip-onboarding .step-panel.active{display:block} .step-nav button[aria-current="step"]{color:var(--fg);font-weight:700}
.page-vip-onboarding .step-panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px} .step-nav button:disabled{cursor:default}
.page-vip-onboarding .step-intro{color:var(--muted);max-width:58ch;margin:0 0 32px} .step-num{font:12px/1 var(--font-mono)}
.page-vip-onboarding .choice-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px} .step-nav button[aria-current="step"] .step-num{color:var(--accent)}
.page-vip-onboarding .choice{position:relative;display:block} .step-nav button.done{color:var(--fg)}
.page-vip-onboarding .choice input{position:absolute;opacity:0;pointer-events:none} .step-panel{display:none;max-width:720px}
.page-vip-onboarding .choice-body{min-height:150px;display:flex;flex-direction:column;padding:22px;border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 90%,transparent);transition:border-color .15s,background .15s,transform .15s} .step-panel.active{display:block}
.page-vip-onboarding .choice-body:hover{border-color:var(--fg);transform:translateY(-1px)} .step-panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px}
.page-vip-onboarding .choice input:checked + .choice-body{border-color:var(--fg);background:var(--fg-soft)} .step-intro{color:var(--muted);max-width:58ch;margin:0 0 32px}
.page-vip-onboarding .choice input:focus-visible + .choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px} .choice-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.page-vip-onboarding .choice-title{font-size:19px;font-weight:700} .choice{position:relative;display:block}
.page-vip-onboarding .choice-copy{color:var(--muted);font-size:14px;margin-top:8px} .choice input{position:absolute;opacity:0;pointer-events:none}
.page-vip-onboarding .choice-mark{width:18px;height:18px;border:1px solid var(--muted);margin-top:auto;align-self:flex-end;display:grid;place-items:center} .choice-body{min-height:150px;display:flex;flex-direction:column;padding:22px;border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 90%,transparent);transition:border-color .15s,background .15s,transform .15s}
.page-vip-onboarding .choice input:checked + .choice-body .choice-mark{background:var(--fg);border-color:var(--fg)} .choice-body:hover{border-color:var(--fg);transform:translateY(-1px)}
.page-vip-onboarding .choice input:checked + .choice-body .choice-mark::after{content:"";width:6px;height:10px;border:solid var(--surface);border-width:0 2px 2px 0;transform:rotate(45deg) translate(-1px,-1px)} .choice input:checked + .choice-body{border-color:var(--fg);background:var(--fg-soft)}
.page-vip-onboarding .notice-card{border-block:1px solid var(--fg);padding-block:8px} .choice input:focus-visible + .choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-vip-onboarding .notice-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 0;border-bottom:1px solid var(--border)} .choice-title{font-size:19px;font-weight:700}
.page-vip-onboarding .notice-row:last-child{border-bottom:0} .choice-copy{color:var(--muted);font-size:14px;margin-top:8px}
.page-vip-onboarding .notice-row strong{display:block;font-size:18px} .choice-mark{width:18px;height:18px;border:1px solid var(--muted);margin-top:auto;align-self:flex-end;display:grid;place-items:center}
.page-vip-onboarding .notice-row p{color:var(--muted);font-size:14px;margin:4px 0 0;max-width:48ch} .choice input:checked + .choice-body .choice-mark{background:var(--fg);border-color:var(--fg)}
.page-vip-onboarding .switch{position:relative;width:52px;min-height:32px;flex:none} .choice input:checked + .choice-body .choice-mark::after{content:"";width:6px;height:10px;border:solid var(--surface);border-width:0 2px 2px 0;transform:rotate(45deg) translate(-1px,-1px)}
.page-vip-onboarding .switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer} .notice-card{border-block:1px solid var(--fg);padding-block:8px}
.page-vip-onboarding .track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none} .notice-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 0;border-bottom:1px solid var(--border)}
.page-vip-onboarding .track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s} .notice-row:last-child{border-bottom:0}
.page-vip-onboarding .switch input:checked + .track{background:var(--fg);border-color:var(--fg)} .notice-row strong{display:block;font-size:18px}
.page-vip-onboarding .switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)} .notice-row p{color:var(--muted);font-size:14px;margin:4px 0 0;max-width:48ch}
.page-vip-onboarding .switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px} .switch{position:relative;width:52px;min-height:32px;flex:none}
.page-vip-onboarding .summary{border-top:1px solid var(--fg)} .switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
.page-vip-onboarding .summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:18px 0;border-bottom:1px solid var(--border)} .track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none}
.page-vip-onboarding .summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)} .track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s}
.page-vip-onboarding .summary-row dd{margin:0;font-weight:700} .switch input:checked + .track{background:var(--fg);border-color:var(--fg)}
.page-vip-onboarding .start-card{margin-top:28px;padding:26px;border:1px solid var(--fg);background:var(--surface)} .switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)}
.page-vip-onboarding .start-card h3{font-size:26px;margin:0 0 8px} .switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.page-vip-onboarding .start-card p{color:var(--muted);margin:0 0 22px;max-width:52ch} .summary{border-top:1px solid var(--fg)}
.page-vip-onboarding .actions{display:flex;align-items:center;gap:12px;margin-top:34px} .summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:18px 0;border-bottom:1px solid var(--border)}
.page-vip-onboarding .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700} .summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)}
.page-vip-onboarding .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)} .summary-row dd{margin:0;font-weight:700}
.page-vip-onboarding .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)} .start-card{margin-top:28px;padding:26px;border:1px solid var(--fg);background:var(--surface)}
.page-vip-onboarding .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)} .start-card h3{font-size:26px;margin:0 0 8px}
.page-vip-onboarding .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)} .start-card p{color:var(--muted);margin:0 0 22px;max-width:52ch}
.page-vip-onboarding .btn[disabled]{opacity:.45;cursor:not-allowed;transform:none} .actions{display:flex;align-items:center;gap:12px;margin-top:34px}
.page-vip-onboarding .status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0} .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
.page-vip-onboarding .pagefoot{padding-block:48px;border-top:1px solid var(--border);color:var(--muted)} .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.page-vip-onboarding .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;font-size:13px} .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
.page-vip-onboarding .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none} .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
.page-vip-onboarding .menu-panel.open{visibility:visible;pointer-events:auto} .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
.page-vip-onboarding .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--bg) 72%,transparent)} .btn[disabled]{opacity:.45;cursor:not-allowed;transform:none}
.page-vip-onboarding .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} .status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0}
.page-vip-onboarding .menu-panel.open .menu-drawer{transform:translateX(0)} .pagefoot{padding-block:48px;border-top:1px solid var(--border);color:var(--muted)}
.page-vip-onboarding .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)} .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;font-size:13px}
.page-vip-onboarding .menu-links{display:flex;flex-direction:column;margin-top:24px} .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}
.page-vip-onboarding .menu-links a{min-height:60px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-size:18px} .menu-panel.open{visibility:visible;pointer-events:auto}
.page-vip-onboarding .menu-links a:hover{color:var(--fg);background:var(--fg-soft)} .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--bg) 72%,transparent)}
.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}
.menu-panel.open .menu-drawer{transform:translateX(0)}
.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}
.menu-links{display:flex;flex-direction:column;margin-top:24px}
.menu-links a{min-height:60px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-size:18px}
.menu-links a:hover{color:var(--fg);background:var(--fg-soft)}
@media(max-width:920px){ @media(max-width:920px){
.page-vip-onboarding .onboarding-layout{grid-template-columns:1fr;padding-block:34px 70px} :root{--gutter:22px}
.page-vip-onboarding .step-nav{position:static;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));overflow-x:auto;border-top:0;padding-bottom:4px} .onboarding-layout{grid-template-columns:1fr;padding-block:34px 70px}
.page-vip-onboarding .step-nav button{min-width:150px;border:1px solid var(--border);padding-inline:14px} .step-nav{position:static;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));overflow-x:auto;border-top:0;padding-bottom:4px}
.page-vip-onboarding .choice-grid{grid-template-columns:1fr 1fr} .step-nav button{min-width:150px;border:1px solid var(--border);padding-inline:14px}
.choice-grid{grid-template-columns:1fr 1fr}
} }
@media(max-width:620px){ @media(max-width:620px){
.page-vip-onboarding .member-strip{text-align:right} :root{--gutter:18px}
.page-vip-onboarding .onboarding-head{padding-block:42px 32px} .member-strip{text-align:right}
.page-vip-onboarding .onboarding-head h1{font-size:44px} .onboarding-head{padding-block:42px 32px}
.page-vip-onboarding .choice-grid{grid-template-columns:1fr} .onboarding-head h1{font-size:44px}
.page-vip-onboarding .choice-body{min-height:132px} .choice-grid{grid-template-columns:1fr}
.page-vip-onboarding .notice-row{align-items:flex-start} .choice-body{min-height:132px}
.page-vip-onboarding .summary-row{grid-template-columns:1fr;gap:4px} .notice-row{align-items:flex-start}
.page-vip-onboarding .actions{align-items:stretch;flex-direction:column-reverse} .summary-row{grid-template-columns:1fr;gap:4px}
.page-vip-onboarding .actions .btn{width:100%} .actions{align-items:stretch;flex-direction:column-reverse}
.page-vip-onboarding .start-card{padding:22px} .actions .btn{width:100%}
.page-vip-onboarding .footer-bottom{flex-direction:column} .start-card{padding:22px}
.footer-bottom{flex-direction:column}
} }
@media(prefers-reduced-motion:reduce){.page-vip-onboarding *{scroll-behavior:auto!important;transition-duration:.01ms!important}} @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
+2 -2
View File
@@ -18,9 +18,9 @@ export default function Faq({ items }: { items: { q: string; a: string }[] }) {
aria-expanded={open} aria-expanded={open}
onClick={() => setOpenIdx(open ? null : i)} onClick={() => setOpenIdx(open ? null : i)}
> >
<span>{item.q}</span><span aria-hidden="true">{open ? '−' : '+'}</span> <span>{item.q}</span><span>+</span>
</button> </button>
<div className="faq-answer" hidden={!open}>{item.a}</div> <div className="faq-answer">{item.a}</div>
</article> </article>
) )
})} })}
+157 -134
View File
@@ -1,134 +1,157 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import SiteMenu from '@/components/site-menu'
import SubscribeButton from './subscribe-button' import Footer from '@/components/footer'
import Faq from './faq' import SubscribeButton from './subscribe-button'
import configPromise from '@payload-config' import Faq from './faq'
import Link from 'next/link' import configPromise from '@payload-config'
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format' import '../styles/subscription.css'
import type { Viewer } from '@/lib/viewer'
export const dynamic = 'force-dynamic'
export const dynamic = 'force-dynamic' export const metadata = { title: 'العضوية' }
export const metadata = { title: 'العضوية' }
const FAQ_ITEMS = [
/* Member-library samples — real VIP guides, read from the CMS at request time. */ { q: 'هل أستطيع الإلغاء في أي وقت؟', a: 'نعم. يتوقف التجديد التالي، ويستمر وصولك حتى نهاية الفترة المدفوعة.' },
const SAMPLES = [ { q: 'هل المحتوى مناسب للمبتدئ؟', a: 'توضح كل مادة مستواها ومتطلباتها. ابدأ بمسار الأساسيات ثم انتقل إلى الوكلاء والأتمتة.' },
{ slug: 'agent-loop-stop-condition', cover: 'تشريح وكيل ناجح', variant: '', output: 'قالب حالة الوكيل + قائمة فحص قبل التشغيل' }, { q: 'هل توجد شهادة إكمال؟', a: 'لا حالياً. تركيز أنور بي آي على التطبيق والملفات العملية، لا على شهادات الحضور.' },
{ slug: 'systematic-model-eval', cover: 'قِس قبل أن تبدّل', variant: 'guide-cover-light', output: 'مجموعة صغيرة من اختبارات التقييم قابلة لإعادة التشغيل' }, { q: 'هل يمكنني استخدام الملفات في عملي؟', a: 'نعم للاستخدام الشخصي والمهني داخل مشاريعك. إعادة بيع الملفات أو نشرها كما هي غير مشمول.' },
{ slug: 'prototype-to-production', cover: 'من التجربة إلى الإنتاج', variant: '', output: 'قائمة قرارات ما قبل الإطلاق' }, ]
]
export default async function SubscriptionPage() {
const FAQ_ITEMS = [ const payload = await getPayload({ config: configPromise })
{ q: 'هل أستطيع الإلغاء في أي وقت؟', a: 'نعم. يتوقف التجديد التالي، ويستمر وصولك حتى نهاية الفترة المدفوعة.' }, const { user } = await payload.auth({ headers: await headers() })
{ q: 'هل المحتوى مناسب للمبتدئ؟', a: 'كل دليل يوضح مستواه. ابدأ بالأدلة المجانية في مسار الأساسيات، ثم انتقل إلى الوكلاء والأتمتة.' }, const role = (user?.role as string) || null
{ q: 'هل توجد شهادة إكمال؟', a: 'لا حالياً. تركيز أنور بآي على التطبيق والملفات العملية، لا على شهادات الحضور.' }, const entitled = role === 'vip' || role === 'admin'
{ q: 'هل يمكنني استخدام الملفات في عملي؟', a: 'نعم للاستخدام الشخصي والمهني داخل مشاريعك. إعادة بيع الملفات أو نشرها كما هي غير مشمول.' },
] return (
<>
export default async function SubscriptionPage() { <Topnav
const payload = await getPayload({ config: configPromise }) viewer={user ? { email: user.email } : null}
const { user } = await payload.auth({ headers: await headers() }) menu
const role = (user?.role as string) || null links={[
const entitled = role === 'vip' || role === 'admin' { href: '/articles', label: 'المكتبة' },
const viewer: Viewer = user ? { email: user.email, role: role ?? 'free' } : null { href: '/posts/article-agent-guide', label: 'المقالات' },
const { docs: published } = await payload.find({ { href: '#exclusive-guides', label: 'الأدلة الحصرية' },
collection: 'posts', { href: '#team-contact', label: 'تواصل مباشر' },
where: { status: { equals: 'published' } }, ]}
depth: 0, />
limit: 200, <SiteMenu
select: { title: true, slug: true, dek: true, category: true, readingMinutes: true, access: true }, links={[
}) { href: '/articles', label: 'المكتبة' },
const freeCount = published.filter(doc => doc.access === 'free').length { href: '/posts/article-agent-guide', label: 'مقال الأسبوع' },
const memberCount = published.length - freeCount { href: '/subscription#exclusive-guides', label: 'الأدلة الحصرية' },
const samples = SAMPLES.flatMap(sample => { { href: '/subscription#team-contact', label: 'تواصل مع الفريق' },
const post = published.find(doc => doc.slug === sample.slug) ]}
return post ? [{ ...sample, post }] : [] />
}) <main>
<section className="hero" data-od-id="subscription-hero"><div className="container hero-grid">
return ( <div>
<div id="site" className="site-page v2 v2-membership"> {/* eslint-disable-next-line @next/next/no-img-element */}
<Topnav viewer={viewer} /> <img className="hero-logo" src="/anwarpi-logo.jpg" alt="شعار أنور بي آي" width={1200} height={1352} data-od-id="hero-logo" />
<main id="content"> <p className="eyebrow">عضوية أنور بي آي · للجادّين في البناء</p>
<section className="mem-hero" aria-labelledby="mem-title"> <h1 data-od-id="subscription-title">تعلّم أقل. طبّق أكثر.</h1>
<div className="wrap mem-hero-grid"> <p className="lead">أدلة معمّقة، مسارات فيديو، وملفات عمل تختصر الطريق من الفكرة إلى مشروع يمكنك اختباره.</p>
<div className="mem-hero-copy"> <div className="hero-join" data-od-id="hero-join">
<h1 id="mem-title">كل الأدلة كاملة، مع ملفات العمل.</h1> <span data-od-id="subscribe-cta-hero"><SubscribeButton loggedIn={Boolean(user)} /></span>
<p className="lede">الأدلة المجانية تعرّفك بالمفاهيم. العضوية تفتح الأدلة المعمّقة التي تأخذك من التجربة إلى نظام يعمل، مع القوالب وقوائم الفحص التي نستخدمها فعلاً.</p> <span className="price num"><bdi dir="ltr">10$</bdi><small>شهرياً · ألغِ في أي وقت</small></span>
<ul className="mem-points" role="list"> </div>
<li><strong>{guidesLabel(memberCount)} للأعضاء</strong><span>تُضاف أدلة جديدة كل شهر</span></li> <p className="trust-line" data-od-id="hero-trust">١٣ دليلاً عربياً · ملفات عمل جاهزة للتنزيل · تحديثات شهرية</p>
<li><strong>ملفات قابلة للتنزيل</strong><span>قوالب حالة، قوائم فحص، سجلات أخطاء</span></li> </div>
<li><strong>نقاش مع الفريق</strong><span>اسأل في تعليقات أي دليل</span></li> <div className="hero-note"><strong>لا دورات متراكمة.</strong><p>كل مادة تنتهي بخطوة عملية، وقرار واضح، وملف يمكنك استخدامه في مشروعك.</p></div>
</ul> </div></section>
</div>
<article className="plan" aria-labelledby="plan-title"> <section className="benefits" id="benefits" data-od-id="membership-benefits"><div className="container">
<h2 id="plan-title">عضوية فردية</h2> <div className="section-head"><div><p className="eyebrow">محتوى حصري بمستوى متقدم</p><h2>تعلّم مهارات ترفع قيمتك في السوق.</h2></div><p>أدلة عالية المستوى لا تكتفي بشرح الأدوات؛ تساعدك على بناء خبرة عملية تظهر في عملك، مسارك المهني، أو مشروعك التجاري.</p></div>
<p className="plan-price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi><span>شهرياً</span></p> <div className="benefit-grid">
<p className="plan-note">تلغي متى شئت، ويبقى حسابك المجاني ومحفوظاتك.</p> <article className="benefit" data-od-id="benefit-guides"><span className="benefit-index">01</span><h3>أدلة حصرية عميقة</h3><p>تفكيك عملي للأنظمة الوكيلة، الاسترجاع، الأتمتة، وتشغيل النماذج حتى تفهم كيف تُبنى الحلول فعلاً.</p></article>
<div className="plan-cta"> <article className="benefit" data-od-id="benefit-video"><span className="benefit-index">02</span><h3>مهارات تصنع فرقاً</h3><p>مسارات مركّزة تنقلك من مستخدم للأداة إلى شخص قادر على تصميم حلول ذكاء اصطناعي ذات قيمة.</p></article>
{entitled ? ( <article className="benefit" data-od-id="benefit-files"><span className="benefit-index">03</span><h3>تنفيذ يصلح للعمل</h3><p>قوالب وملفات عملية تساعدك على تحويل المعرفة إلى مشروع، إنجاز مهني، أو فرصة نمو في أعمالك.</p></article>
<a className="btn btn-primary" href="/account-settings">عضويتك فعّالة، أدر حسابك</a> </div>
) : ( </div></section>
<SubscribeButton loggedIn={Boolean(user)} />
)} <section className="guide-samples" id="exclusive-guides" data-od-id="exclusive-guide-samples"><div className="container">
</div> <div className="section-head"><div><p className="eyebrow">نماذج من مكتبة الأعضاء</p><h2>أدلة حقيقية تبدأ من المشكلة وتنتهي بملف عملي.</h2></div><p>هذه مواد موجودة في كتالوج أنور بي آي؛ كل نموذج يوضح ما ستتعلمه وما ستخرج به قبل الاشتراك.</p></div>
<p className="fine">الدفع عبر Stripe. لا نحفظ بيانات بطاقتك.</p> <div className="guide-grid">
</article> <article className="guide-card" data-od-id="exclusive-guide-agent">
</div> <div className="guide-cover"><span>دليل عملي · حصري</span><strong>تشريح وكيل ناجح</strong></div>
</section> <div className="guide-body"><div className="guide-meta"><span>الوكلاء</span><span className="num">١٨ دقيقة</span></div><h3>امنع وكيلك من الدوران في حلقة</h3><p>افصل التخطيط والتنفيذ والتحقق، وحدد متى يتوقف الوكيل ومتى يطلب تدخلاً بشرياً.</p><div className="guide-output"><strong>الملف المرفق</strong><span>قالب حالة الوكيل + قائمة فحص قبل التشغيل</span></div><a className="guide-link" href="/posts/agent-loop-stop-condition"><span>اقرأ نموذجاً كاملاً</span><span aria-hidden="true">←</span></a></div>
</article>
{samples.length > 0 && ( <article className="guide-card" data-od-id="exclusive-guide-automation">
<section className="mem-samples" aria-labelledby="samples-title"> <div className="guide-cover guide-cover-light"><span>فيديو تطبيقي · حصري</span><strong>راقب قبل أن تؤتمت</strong></div>
<div className="wrap"> <div className="guide-body"><div className="guide-meta"><span>الأتمتة</span><span className="num">٢٤ دقيقة</span></div><h3>اكتشف فشل سير العمل قبل المستخدم</h3><p>راقب التكلفة، زمن الاستجابة، ونقاط الانقطاع بإشارات قليلة يمكن لفريقك فهمها والتصرف بناءً عليها.</p><div className="guide-output"><strong>الملف المرفق</strong><span>قائمة مراقبة لسير العمل + نموذج سجل الأخطاء</span></div><a className="guide-link" href="/articles?topic=automation"><span>شاهد تفاصيل الدليل</span><span aria-hidden="true">←</span></a></div>
<header className="section-head"> </article>
<h2 id="samples-title">من أدلة الأعضاء</h2> <article className="guide-card" data-od-id="exclusive-guide-rag">
<p>كل دليل يبدأ بمشكلة حقيقية وينتهي بشيء تشغّله. اقرأ المقدمة مجاناً قبل أن تقرر.</p> <div className="guide-cover"><span>سلسلة فيديو · حصري</span><strong>قياس <bdi dir="ltr">RAG</bdi> بصدق</strong></div>
</header> <div className="guide-body"><div className="guide-meta"><span>النماذج</span><span className="num">٤ أجزاء</span></div><h3>افصل فشل الاسترجاع عن فشل الإجابة</h3><p>ابنِ مجموعة اختبار صغيرة، راجع جودة المصادر، ثم قِس الإجابة من دون خلط المشكلتين.</p><div className="guide-output"><strong>الملف المرفق</strong><span>ورقة تقييم للاسترجاع والإجابة خطوة بخطوة</span></div><a className="guide-link" href="/articles?topic=models"><span>استكشف السلسلة</span><span aria-hidden="true">←</span></a></div>
<ul className="sample-grid" role="list"> </article>
{samples.map(({ slug, output, post }) => ( </div>
<li key={slug}> </div></section>
<Link href={`/posts/${slug}`} className="sample">
<span className="meta-row"><span>{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span></span> <section className="outcomes" data-od-id="career-business-outcomes"><div className="container outcome-grid">
<span className="sample-title">{post.title}</span> <div>
<span className="sample-dek">{post.dek}</span> <p className="eyebrow">من المعرفة إلى التأثير</p>
<span className="sample-output"><span>ما تخرج به</span>{output}</span> <h2>كن الشخص الذي يعرف كيف يحوّل الذكاء الاصطناعي إلى نتيجة.</h2>
</Link> <p className="lead">ابنِ مهارات مطلوبة، قرارات أفضل، ومشاريع يمكن عرضها أو تشغيلها—سواء كنت تطوّر مسارك أو تنمّي أعمالك.</p>
</li> </div>
))} <div className="outcome-list">
</ul> <article className="outcome-card" data-od-id="outcome-career"><span className="benefit-index">01</span><div><h3>لمسارك المهني</h3><p>تعلّم كيف تصمم الوكلاء والأتمتة وتشرح قراراتك التقنية، ثم حوّل ذلك إلى أعمال قوية ترفع مستوى ملفك المهني.</p></div></article>
</div> <article className="outcome-card" data-od-id="outcome-business"><span className="benefit-index">02</span><div><h3>لنمو أعمالك</h3><p>اكتشف أين يضيف الذكاء الاصطناعي قيمة حقيقية، وكيف تختبر الفكرة بسرعة قبل أن تهدر الوقت أو الميزانية.</p></div></article>
</section> <article className="team-contact" id="team-contact" data-od-id="anwar-team-contact">
)} <div><h3>اسأل فريق أنور عنبتاوي مباشرة</h3><p>لديك سؤال عن دليل، تطبيق، مسار مهني، أو فكرة عمل؟ أرسل ما تحتاجه وسيرد عليك الفريق مباشرة.</p></div>
<a className="btn btn-primary" href="mailto:hello@anwarpi.ai?subject=%D8%B3%D8%A4%D8%A7%D9%84%20%D9%84%D9%81%D8%B1%D9%8A%D9%82%20%D8%A3%D9%86%D9%88%D8%B1%20%D8%B9%D9%86%D8%A8%D8%AA%D8%A7%D9%88%D9%8A" data-od-id="contact-team-cta">تواصل مع الفريق</a>
<section className="mem-compare" aria-labelledby="compare-title"> </article>
<div className="wrap narrow"> </div>
<h2 id="compare-title">الفرق بين الحساب المجاني والعضوية</h2> </div></section>
<div className="table-wrap">
<table> <section className="pricing" data-od-id="pricing"><div className="container pricing-grid">
<thead><tr><th scope="col"><span className="sr-only">الميزة</span></th><th scope="col">حساب مجاني</th><th scope="col" className="is-member">العضوية</th></tr></thead> <div>
<tbody> <p className="eyebrow">سعر واحد · وصول كامل</p>
<tr><th scope="row">الأدلة المجانية ({formatNumber(freeCount)})</th><td>كاملة</td><td className="is-member">كاملة</td></tr> <h2>ابدأ بعضوية أنور بي آي.</h2>
<tr><th scope="row">أدلة الأعضاء ({formatNumber(memberCount)})</th><td>المقدمة فقط</td><td className="is-member">كاملة</td></tr> <p className="lead">ألغِ في أي وقت. يبقى حسابك المجاني ومقالاتك المحفوظة بعد الإلغاء.</p>
<tr><th scope="row">ملفات العمل</th><td>—</td><td className="is-member">تنزيل كامل</td></tr> </div>
<tr><th scope="row">التعليق والنقاش</th><td>على الأدلة المجانية</td><td className="is-member">على كل الأدلة</td></tr> <article className="plan" data-od-id="membership-plan">
<tr><th scope="row">حفظ الأدلة للعودة لاحقاً</th><td>—</td><td className="is-member">متاح</td></tr> <div className="plan-head"><div><h3>عضوية فردية</h3><span className="trial-label">سعر تجريبي للمعاينة</span></div><div className="price num"><bdi dir="ltr">10$</bdi><small>شهرياً</small></div></div>
</tbody> <ul className="plan-list">
</table> <li><span>كل الأدلة الحصرية</span><strong>متاح</strong></li>
</div> <li><span>مسارات الفيديو</span><strong>متاح</strong></li>
</div> <li><span>ملفات العمل والتنزيلات</span><strong>متاح</strong></li>
</section> <li><span>تحديثات المحتوى</span><strong>مستمرة</strong></li>
</ul>
<section className="mem-faq" aria-labelledby="faq-title"> {entitled ? (
<div className="wrap faq-grid"> <a className="btn btn-primary" href="/account-settings" data-od-id="subscribe-cta">عضويتك فعّالة — إدارة الحساب</a>
<div> ) : (
<h2 id="faq-title">أسئلة قبل الاشتراك</h2> <span data-od-id="subscribe-cta"><SubscribeButton loggedIn={Boolean(user)} /></span>
<p>لم تجد إجابتك؟ راسلنا على <a className="text-link" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> ويرد عليك الفريق مباشرة.</p> )}
</div> <p className="plan-fine">الدفع عبر Stripe · إلغاء فوري في أي وقت.</p>
<Faq items={FAQ_ITEMS} /> </article>
</div> </div></section>
</section>
</main> <section className="compare" data-od-id="plan-comparison"><div className="container">
<Footer /> <p className="eyebrow">قارن الوصول</p>
</div> <h2>المجاني يعرّفك. العضوية تساعدك على البناء.</h2>
) <div className="table-wrap"><table className="table">
} <thead><tr><th>المحتوى</th><th>حساب مجاني</th><th className="member-col">عضوية أنور بي آي</th></tr></thead>
<tbody>
<tr><td>المقالات التمهيدية</td><td>كاملة</td><td className="member-col">كاملة</td></tr>
<tr><td>الأدلة المتقدمة</td><td>معاينة</td><td className="member-col">كاملة</td></tr>
<tr><td>مسارات الفيديو</td><td>المقدمة</td><td className="member-col">كل الأجزاء</td></tr>
<tr><td>ملفات العمل</td><td>—</td><td className="member-col">قابلة للتنزيل</td></tr>
<tr><td>حفظ التقدّم</td><td>متاح</td><td className="member-col">متاح</td></tr>
</tbody>
</table></div>
</div></section>
<section className="faq" id="faq" data-od-id="faq"><div className="container faq-wrap">
<div>
<p className="eyebrow">قبل أن تبدأ</p>
<h2>أسئلة شائعة</h2>
<p className="lead">إجابات مباشرة عن الوصول، الإلغاء، وطريقة تقديم المحتوى.</p>
</div>
<Faq items={FAQ_ITEMS} />
</div></section>
</main>
<Footer />
</>
)
}
@@ -23,7 +23,7 @@ export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
} }
if (comingSoon) { if (comingSoon) {
return <p className="plan-soon" role="status">الدفع الإلكتروني متاح قريباً. راسلنا على <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> لتفعيل عضويتك يدوياً.</p> return <button className="btn btn-primary" disabled>قريباً</button>
} }
if (!loggedIn) { if (!loggedIn) {
@@ -32,7 +32,7 @@ export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
return ( return (
<span className="cta-pair"> <span className="cta-pair">
<Link className="btn btn-primary" href="/signup?next=%2Fsubscription">أنشئ حساباً واشترك</Link> <Link className="btn btn-primary" href="/signup?next=%2Fsubscription">أنشئ حساباً واشترك</Link>
<Link className="btn btn-quiet" href="/login?next=%2Fsubscription">لدي حساب، تسجيل الدخول</Link> <Link className="btn btn-secondary" href="/login?next=%2Fsubscription">لدي حساب — تسجيل الدخول</Link>
</span> </span>
) )
} }
+51 -35
View File
@@ -1,35 +1,51 @@
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import Footer from '@/components/footer' import SiteMenu from '@/components/site-menu'
import '../styles/privacy.css' import '../styles/privacy.css'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { title: 'شروط الاستخدام' } export const metadata = { title: 'شروط الاستخدام' }
export default function TermsPage() { export default function TermsPage() {
return ( return (
<div id="site" className="site-page page-terms"> <>
<Topnav /> <Topnav
<main id="content"> viewer={null}
<section className="hero" data-od-id="terms-hero"> menu
<div className="container"> links={[
<p className="eyebrow">شروط الاستخدام · مسودة ما قبل الإطلاق</p> { href: '/articles', label: 'المكتبة' },
<h1 data-od-id="terms-title">شروط استخدام أنور بآي.</h1> { href: '/subscription', label: 'العضوية' },
<p className="lead">توضح هذه المسودة القواعد الأساسية لاستخدام مكتبة أنور بآي والاشتراك فيها.</p> { href: '/terms', label: 'الشروط' },
<p className="notice" role="note">هذه مسودة للمراجعة القانونية وليست نصاً قانونياً نهائياً. يجب اعتماد النسخة النهائية قبل نشر الموقع.</p> ]}
</div> />
</section> <SiteMenu
<div className="container policy" data-od-id="terms-content"> links={[
<section data-od-id="terms-service"><h2>ما هي الخدمة؟</h2><p>أنور بآي مكتبة محتوى عربية تقدم أدلة ومسارات تعليمية، بعضها مجاني وبعضها متاح لمشتركي العضوية. يمنحك الاشتراك وصولاً شخصياً إلى المحتوى المدفوع طوال فترة الاشتراك الفعّالة.</p></section> { href: '/articles', label: 'المكتبة' },
<section data-od-id="terms-account"><h2>حسابك</h2><p>أنت مسؤول عن دقة بيانات التسجيل والاحتفاظ بكلمة المرور في سرية، وعن أي نشاط يجري عبر حسابك. يمكنك حذف حسابك في أي وقت بمراسلة <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>، وسنحذف بياناتك وفق سياسة الخصوصية.</p></section> { href: '/posts/article-agent-guide', label: 'مقال الأسبوع' },
<section data-od-id="terms-billing"><h2>الاشتراك والدفع</h2><p>العضوية بـ <bdi dir="ltr">$10</bdi> شهرياً وتُدفع عبر مزوّد الدفع (Stripe). يمكنك الإلغاء في أي وقت ويتوقف التجديد التالي فوراً مع بقاء وصولك حتى نهاية الفترة المدفوعة. السعر الحالي سعر تجريبي للمعاينة وقد يتغير قبل الإطلاق مع إشعار مسبق.</p></section> { href: '/subscription', label: 'العضوية والأسعار' },
<section data-od-id="terms-content"><h2>المحتوى والملكية</h2><p>حقوق المحتوى والملفات محفوظة لأصحابها. حصولك على الاشتراك يمنحك حق الاستخدام الشخصي والمهني داخل مشاريعك، ولا يشمل إعادة نشر المحتوى أو بيعه أو توزيعه كما هو.</p></section> ]}
<section data-od-id="terms-conduct"><h2>الاستخدام المقبول</h2><p>لا يجوز استخدام الخدمة لاستخراج البيانات آلياً أو إعادة نشر المحتوى على نطاق واسع أو أي استخدام يضر بالمنصة أو بمستخدميها. قد نوقف الحسابات المخالفة بعد تنبيه صاحبها.</p></section> foot={{ href: '/login', label: '', note: 'محتوى عربي عملي، بلا ضجيج.', variant: 'hidden' }}
<section data-od-id="terms-disclaimer"><h2>إخلاء المسؤولية</h2><p>المحتوى تعليمي ويُقدَّم «كما هو» دون ضمانات لنتائج محددة. أنت مسؤول عن قراراتك ومشاريعك، وينبغي اختبار أي قالب أو ملف قبل استخدامه في بيئة إنتاج.</p></section> />
<section data-od-id="terms-changes"><h2>تعديل الشروط</h2><p>قد نحدّث هذه الشروط مع نمو الخدمة. عند أي تغيير جوهري سنبلّغ المستخدمين المسجلين عبر البريد الإلكتروني قبل سريان التعديل، ونحدّث تاريخ السريان أعلى الصفحة.</p></section> <main>
<section data-od-id="terms-law"><h2>التواصل</h2><p>لأي سؤال عن هذه الشروط راسلنا على <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>. القانون الحاكم واختصاص المحاكم يحدَّدان في النسخة القانونية النهائية بعد المراجعة.</p></section> <section className="hero" data-od-id="terms-hero">
</div> <div className="container">
</main> <p className="eyebrow">شروط الاستخدام · سارية من ٢٠٢٦-١٠-٠٣</p>
<Footer /> <h1 data-od-id="terms-title">شروط استخدام أنور بي آي.</h1>
</div> <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@anwarpi.ai">hello@anwarpi.ai</a>، وسنحذف بياناتك وفق سياسة الخصوصية.</p></section>
<section data-od-id="terms-billing"><h2>الاشتراك والدفع</h2><p>العضوية بـ ١٠$ شهرياً وتُدفع عبر مزوّد الدفع (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@anwarpi.ai">hello@anwarpi.ai</a>. القانون الحاكم واختصاص المحاكم يحدَّدان في النسخة القانونية النهائية بعد المراجعة.</p></section>
</div>
</main>
<footer className="pagefoot" data-od-id="footer"><div className="container footer-bottom"><span>© أنور بي آي ٢٠٢٦</span><a href="mailto:hello@anwarpi.ai">تواصل معنا</a></div></footer>
</>
)
}
+66 -60
View File
@@ -1,60 +1,66 @@
import Topnav from '@/components/topnav' import { getPayload } from 'payload'
import Footer from '@/components/footer' import { headers } from 'next/headers'
import { getPayload } from 'payload' import configPromise from '@payload-config'
import { headers } from 'next/headers' import Link from 'next/link'
import configPromise from '@payload-config' import VerifyActions from './verify-actions'
import Link from 'next/link' import '../styles/email-verification.css'
import VerifyActions from './verify-actions'
import '../styles/email-verification.css' export const dynamic = 'force-dynamic'
export const metadata = { title: 'أكد بريدك' }
export const dynamic = 'force-dynamic'
export const metadata = { title: 'تأكيد البريد الإلكتروني' } type Props = { searchParams: Promise<{ invalid?: string }> }
type Props = { searchParams: Promise<{ invalid?: string }> } /* Email verification step — design from email-verification.html. The account
was created; the user either clicks the emailed link (real SMTP at deploy)
/* Email verification step — design from email-verification.html. The account or proceeds to onboarding via the CTA. */
was created; the user either clicks the emailed link (real SMTP at deploy) export default async function VerifyEmailPage({ searchParams }: Props) {
or proceeds to onboarding via the CTA. */ const { invalid } = await searchParams
export default async function VerifyEmailPage({ searchParams }: Props) { let email: string | null = null
const { invalid } = await searchParams let verified = false
let email: string | null = null try {
let verified = false const payload = await getPayload({ config: configPromise })
try { const { user } = await payload.auth({ headers: await headers() })
const payload = await getPayload({ config: configPromise }) if (user) {
const { user } = await payload.auth({ headers: await headers() }) email = user.email
if (user) { verified = !!user.emailVerified
email = user.email }
verified = !!user.emailVerified } catch {
} /* no db / no session — page still renders */
} catch { }
/* no db / no session — page still renders */
} return (
<>
return ( <header className="topnav" data-od-id="topnav">
<div id="site" className="site-page page-verify-email"> <div className="container nav-inner">
<Topnav /> <Link className="logo" href="/" data-od-id="brand">
<main id="content" className="verification"> {/* eslint-disable-next-line @next/next/no-img-element */}
<section className="container" data-od-id="email-verification"> <img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
<div className="card"> أنور بي آي
<p className="eyebrow">الخطوة التالية · تأكيد البريد</p> </Link>
<h1 data-od-id="verification-title">افتح بريدك لتفعيل الحساب.</h1> <Link className="nav-return" href="/login" data-od-id="login-link">سجّل الدخول</Link>
<p className="lead">أرسلنا رابط التأكيد إلى:<strong className="email" id="signupEmail">{email ?? 'بريدك الإلكتروني'}</strong></p> </div>
{invalid === '1' && ( </header>
<p className="status" role="alert">رابط التأكيد غير صالح أو استُخدم من قبل. اطلب رسالة جديدة.</p> <main className="verification">
)} <section className="container" data-od-id="email-verification">
{verified && ( <div className="card">
<p className="status" role="status">بريدك الإلكتروني مؤكَّد بالفعل ✓ — تابع إلى الإعداد.</p> <p className="eyebrow">الخطوة التالية · تأكيد البريد</p>
)} <h1 data-od-id="verification-title">افتح بريدك لتفعيل الحساب.</h1>
<ol className="steps" aria-label="خطوات تأكيد البريد"> <p className="lead">أرسلنا رابط التأكيد إلى:<strong className="email" id="signupEmail">{email ?? 'بريدك الإلكتروني'}</strong></p>
<li><span className="step-num">01</span><span>افتح رسالة أنور بآي في صندوق الوارد.</span></li> {invalid === '1' && (
<li><span className="step-num">02</span><span>اضغط رابط تأكيد البريد الإلكتروني داخل الرسالة.</span></li> <p className="status" role="alert">رابط التأكيد غير صالح أو استُخدم من قبل. اطلب رسالة جديدة.</p>
<li><span className="step-num">03</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li> )}
</ol> {verified && (
<VerifyActions verified={verified} /> <p className="status" role="status">بريدك مؤكَّد بالفعل ✓ — تابع إلى الإعداد.</p>
</div> )}
</section> <ol className="steps" aria-label="خطوات تأكيد البريد">
</main> <li><span className="step-num">٠١</span><span>افتح رسالة أنور بي آي في صندوق الوارد.</span></li>
<Footer /> <li><span className="step-num">٠٢</span><span>اضغط رابط تأكيد البريد داخل الرسالة.</span></li>
</div> <li><span className="step-num">٠٣</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
) </ol>
} <VerifyActions verified={verified} />
</div>
</section>
</main>
</>
)
}
@@ -11,7 +11,7 @@ export default function VerifyActions({ verified }: { verified: boolean }) {
return ( return (
<> <>
<div className="actions"> <div className="actions">
<Link className="btn btn-primary" href="/onboarding" data-od-id="continue-onboarding-cta">متابعة إعداد الحساب</Link> <Link className="btn btn-primary" href="/onboarding" data-od-id="continue-onboarding-cta">تم التأكيد، تابع الإعداد</Link>
{!verified && ( {!verified && (
<button className="btn btn-secondary" id="resendButton" type="button" data-od-id="resend-email-button" <button className="btn btn-secondary" id="resendButton" type="button" data-od-id="resend-email-button"
onClick={async (e) => { onClick={async (e) => {
@@ -16,12 +16,12 @@ const GOALS = [
const LEVELS = [ const LEVELS = [
{ value: 'مبتدئ', label: 'أبدأ من الأساسيات', copy: 'استخدمت أدوات الذكاء الاصطناعي، لكنني لم أبنِ مشروعاً بعد.' }, { value: 'مبتدئ', label: 'أبدأ من الأساسيات', copy: 'استخدمت أدوات الذكاء الاصطناعي، لكنني لم أبنِ مشروعاً بعد.' },
{ value: 'متوسط', label: 'بنيت تجارب بسيطة', copy: 'جرّبت واجهات برمجة التطبيقات (API) أو أتمتة أو تطبيقاً صغيراً.' }, { value: 'متوسط', label: 'بنيت تجارب بسيطة', copy: 'جرّبت واجهات API أو أتمتة أو تطبيقاً صغيراً.' },
{ value: 'متقدم', label: 'أعمل على أنظمة فعلية', copy: 'أحتاج أنماطاً أعمق للقياس والموثوقية والتشغيل.' }, { value: 'متقدم', label: 'أعمل على أنظمة فعلية', copy: 'أحتاج أنماطاً أعمق للقياس والموثوقية والتشغيل.' },
] ]
const LABELS = ['الخطوة 1 من 4', 'الخطوة 2 من 4', 'الخطوة 3 من 4', 'الخطوة 4 من 4'] const LABELS = ['الخطوة ١ من ٤', 'الخطوة ٢ من ٤', 'الخطوة ٣ من ٤', 'الخطوة ٤ من ٤']
const PERCENTAGES = ['25٪', '50٪', '75٪', '100٪'] const PERCENTAGES = ['٢٥٪', '٥٠٪', '٧٥٪', '١٠٠٪']
const STEP_TITLES = ['هدفك الآن', 'مستواك العملي', 'رسائلك', 'ابدأ مسارك'] const STEP_TITLES = ['هدفك الآن', 'مستواك العملي', 'رسائلك', 'ابدأ مسارك']
type State = { type State = {
@@ -101,7 +101,7 @@ export default function OnboardingWizard({ viewerId }: { viewerId: number | null
<> <>
<section className="onboarding-head" data-od-id="onboarding-hero"> <section className="onboarding-head" data-od-id="onboarding-hero">
<div className="container"> <div className="container">
<p className="eyebrow">عضو جديد · إعداد سريع</p> <p className="eyebrow">عضو VIP جديد · إعداد سريع</p>
<h1 data-od-id="onboarding-title">مرحباً بك. لنبدأ بما يخدم هدفك.</h1> <h1 data-od-id="onboarding-title">مرحباً بك. لنبدأ بما يخدم هدفك.</h1>
<p className="lead">أجب عن سؤالين وحدد رسائلك المفضلة. سنقترح نقطة بداية واضحة بدلاً من تركك أمام مكتبة كبيرة.</p> <p className="lead">أجب عن سؤالين وحدد رسائلك المفضلة. سنقترح نقطة بداية واضحة بدلاً من تركك أمام مكتبة كبيرة.</p>
<div className="progress-wrap" aria-label="تقدم الإعداد"> <div className="progress-wrap" aria-label="تقدم الإعداد">
@@ -115,7 +115,7 @@ export default function OnboardingWizard({ viewerId }: { viewerId: number | null
<nav className="step-nav" aria-label="خطوات إعداد العضوية" data-od-id="onboarding-steps"> <nav className="step-nav" aria-label="خطوات إعداد العضوية" data-od-id="onboarding-steps">
{STEP_TITLES.map((title, i) => ( {STEP_TITLES.map((title, i) => (
<button type="button" key={i} aria-current={state.step === i ? 'step' : undefined} disabled className={i < state.step ? 'done' : ''}> <button type="button" key={i} aria-current={state.step === i ? 'step' : undefined} disabled className={i < state.step ? 'done' : ''}>
<span className="step-num">{'01020304'.slice(i * 2, i * 2 + 2)}</span><span>{title}</span> <span className="step-num">{'٠١٠٢٠٣٠٤'.slice(i * 2, i * 2 + 2)}</span><span>{title}</span>
</button> </button>
))} ))}
</nav> </nav>
@@ -137,7 +137,7 @@ export default function OnboardingWizard({ viewerId }: { viewerId: number | null
</section> </section>
<section className={`step-panel${state.step === 1 ? ' active' : ''}`} data-step={1} data-od-id="step-level"> <section className={`step-panel${state.step === 1 ? ' active' : ''}`} data-step={1} data-od-id="step-level">
<h2>ما مستوى خبرتك العملية؟</h2> <h2>ما مستوى خبرتك العملي؟</h2>
<p className="step-intro">لا نختبرك. الإجابة تضبط ترتيب المواد وكمية الشرح في اقتراح البداية.</p> <p className="step-intro">لا نختبرك. الإجابة تضبط ترتيب المواد وكمية الشرح في اقتراح البداية.</p>
<div className="choice-grid" role="radiogroup" aria-label="مستواك"> <div className="choice-grid" role="radiogroup" aria-label="مستواك">
{LEVELS.map(l => ( {LEVELS.map(l => (
@@ -156,7 +156,7 @@ export default function OnboardingWizard({ viewerId }: { viewerId: number | null
<section className={`step-panel${state.step === 2 ? ' active' : ''}`} data-step={2} data-od-id="step-notifications"> <section className={`step-panel${state.step === 2 ? ' active' : ''}`} data-step={2} data-od-id="step-notifications">
<h2>اختر ما يستحق الوصول إلى بريدك.</h2> <h2>اختر ما يستحق الوصول إلى بريدك.</h2>
<p className="step-intro">كل الخيارات قابلة للتغيير. لا يؤثر إيقافها على وصولك إلى محتوى العضوية.</p> <p className="step-intro">كل الخيارات قابلة للتغيير. لا يؤثر إيقافها على وصولك إلى محتوى VIP.</p>
<div className="notice-card"> <div className="notice-card">
<div className="notice-row"><div><strong>خلاصة الأحد</strong><p>أفضل ما نُشر هذا الأسبوع مع نقطة تطبيق واحدة.</p></div> <div className="notice-row"><div><strong>خلاصة الأحد</strong><p>أفضل ما نُشر هذا الأسبوع مع نقطة تطبيق واحدة.</p></div>
<label className="switch"><input type="checkbox" name="weekly" checked={state.weekly} onChange={e => setState(s => ({ ...s, weekly: e.target.checked }))} aria-label="تفعيل خلاصة الأحد" /><span className="track"></span></label></div> <label className="switch"><input type="checkbox" name="weekly" checked={state.weekly} onChange={e => setState(s => ({ ...s, weekly: e.target.checked }))} aria-label="تفعيل خلاصة الأحد" /><span className="track"></span></label></div>
+23 -6
View File
@@ -1,4 +1,5 @@
import Topnav from '@/components/topnav' import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
import Footer from '@/components/footer' import Footer from '@/components/footer'
import OnboardingWizard from './onboarding-wizard' import OnboardingWizard from './onboarding-wizard'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
@@ -6,20 +7,36 @@ import { getViewer } from '@/lib/viewer'
import '../styles/vip-onboarding.css' import '../styles/vip-onboarding.css'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const metadata = { title: 'إعداد العضوية' } export const metadata = { title: 'تهيئة العضوية' }
export default async function VipOnboardingPage() { export default async function VipOnboardingPage() {
const viewer = await getViewer() const viewer = await getViewer()
if (!viewer) redirect('/login?next=%2Fvip-onboarding') if (!viewer) redirect('/login?next=%2Fvip-onboarding')
if (viewer.role !== 'vip' && viewer.role !== 'admin') redirect('/subscription') if (viewer.role !== 'vip' && viewer.role !== 'admin') redirect('/subscription')
return ( return (
<div id="site" className="site-page page-vip-onboarding"> <>
<Topnav viewer={viewer} /> <Topnav
<main id="content"> viewer={{ email: viewer.email }}
<p className="member-strip" role="status">تم تفعيل عضويتك. خمس دقائق لإعداد مسارك الأول.</p> accountLabel="حسابي"
menu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'المقالات' },
{ href: '/subscription', label: 'العضوية' },
]}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/agent-loop-stop-condition', label: 'دليل الأعضاء' },
{ href: '/account-settings', label: 'إعدادات الحساب' },
]}
/>
<main>
<p className="member-strip" data-od-id="member-status">تم تفعيل وصول VIP — لنستثمر أول خمس دقائق في إعداد مسارك.</p>
<OnboardingWizard viewerId={viewer.id ?? null} /> <OnboardingWizard viewerId={viewer.id ?? null} />
</main> </main>
<Footer /> <Footer />
</div> </>
) )
} }
+62 -68
View File
@@ -1,89 +1,83 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import type { Media } from '@payload-types'
import { resolveCoverImage } from '@/lib/agent-media' import { decodeCoverImage, parsePostPatch, postResponse } from '../post-contract'
import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** Agent update endpoint: PUT /api/agent/posts/:slug */ function unauthorized() {
export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
function validKey(req: NextRequest) {
const key = process.env.AGENT_API_KEY const key = process.env.AGENT_API_KEY
if (!key || req.headers.get('x-agent-key') !== key) { return Boolean(key && req.headers.get('x-agent-key') === key)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
}
function badRequest(error: unknown) {
const message = error instanceof Error ? error.message : 'invalid request'
return NextResponse.json({ error: message }, { status: 400 })
}
async function findPost(slug: string) {
const payload = await getPayload({ config: configPromise }) const payload = await getPayload({ config: configPromise })
const { slug } = await ctx.params const result = await payload.find({
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
const existing = await payload.find({
collection: 'posts', collection: 'posts',
where: { slug: { equals: slug } }, where: { slug: { equals: slug } },
limit: 1, limit: 1,
depth: 0,
overrideAccess: true,
}) })
const post = existing.docs[0] return { payload, post: result.docs[0] }
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
if (!validKey(req)) return unauthorized()
const { slug } = await ctx.params
const { post } = await findPost(slug)
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
return NextResponse.json(postResponse(post))
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
if (!validKey(req)) return unauthorized()
const { slug } = await ctx.params
let patch
try {
patch = parsePostPatch(await req.json(), slug)
} catch (error) {
return badRequest(error)
}
const { payload, post } = await findPost(slug)
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 }) if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
// Only writable fields; status is admin-only (except the explicit publish flag) const { coverImage, publish: _publish, ...fields } = patch
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const const data: Record<string, unknown> = { ...fields }
const data: Record<string, unknown> = {} if (coverImage) {
for (const field of allowed) { const file = decodeCoverImage(coverImage)
if (field in body) data[field] = body[field] const media = await payload.create({
collection: 'media',
data: {},
file,
depth: 0,
overrideAccess: true,
})
data.cover = media.id
} }
if (patch.publish) {
// Explicit image payload (inline base64 or remote URL) → stored as a media document.
let coverUrl: string | null = null
if ('coverImage' in body && body.coverImage != null) {
try {
const cover = await resolveCoverImage(payload, body.coverImage)
data.cover = cover.id
coverUrl = cover.url
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
}
}
// Per-post work files (inline base64) → stored as gated `files` documents.
// Like coverImage, `attachments` is an input transform, not a raw field.
// Sending it REPLACES the post's file set; omit it to keep existing files.
let attachments: Awaited<ReturnType<typeof resolveAttachments>> = []
if ('attachments' in body && body.attachments != null) {
try {
attachments = await resolveAttachments(payload, body.attachments)
data.attachments = attachments.map(a => a.id)
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
}
}
// `publish` is an explicit opt-in flag; unpublishing stays admin-only.
if (body.publish === true) {
data.status = 'published' data.status = 'published'
if (!post.publishedAt) data.publishedAt = new Date().toISOString() data.publishedAt = post.publishedAt || new Date().toISOString()
} }
let doc const doc = await payload.update({
try { collection: 'posts',
doc = await payload.update({ collection: 'posts', id: post.id, data, depth: 0 }) id: post.id,
} catch (err) { data: data as never,
// The post write failed — don't orphan the file docs created above depth: 0,
// (the post keeps its previous attachments, which are untouched). overrideAccess: true,
if (attachments.length) await deleteFileDocs(payload, attachments.map(a => a.id))
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
}
if (!coverUrl && typeof data.cover === 'number') {
const coverDoc: Media = await payload.findByID({ collection: 'media', id: data.cover })
coverUrl = coverDoc.url ?? null
}
return NextResponse.json({
id: doc.id, title: doc.title, slug: doc.slug,
status: doc.status, access: doc.access, category: doc.category,
cover: { id: data.cover ?? post.cover, url: coverUrl },
attachments: attachments.map(a => ({ id: a.id, filename: a.filename, access: a.access, url: `/api/files/${a.id}` })),
}) })
return NextResponse.json(postResponse(doc))
} }
@@ -0,0 +1,180 @@
import path from 'node:path'
export const CATEGORIES = ['وكلاء', 'نماذج', 'استرجاع', 'سلامة', 'أدوات', 'أعمال'] as const
export const MAX_COVER_BYTES = 5 * 1024 * 1024
const ALLOWED_FIELDS = new Set([
'title',
'slug',
'dek',
'teaser',
'content',
'category',
'access',
'readingMinutes',
'coverImage',
'publish',
])
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/
type CoverImage = {
dataBase64: string
filename?: string
}
export type PostBody = {
title: string
slug: string
dek: string
teaser?: string
content: Record<string, unknown>
category: (typeof CATEGORIES)[number]
access: 'free' | 'vip'
readingMinutes: number
coverImage?: CoverImage
publish: boolean
}
export type UploadedCover = {
data: Buffer
mimetype: 'image/jpeg'
name: string
size: number
}
function nonEmptyString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${field} is required`)
}
return value.trim()
}
export function parsePostBody(input: unknown): PostBody {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('JSON object body is required')
}
const body = input as Record<string, unknown>
for (const field of Object.keys(body)) {
if (!ALLOWED_FIELDS.has(field)) throw new Error(`unsupported field: ${field}`)
}
const slug = nonEmptyString(body.slug, 'slug')
if (!SLUG_RE.test(slug)) throw new Error('slug must be lowercase ASCII kebab-case')
const access = body.access
if (access !== 'free' && access !== 'vip') throw new Error('access must be free or vip')
const category = body.category
if (typeof category !== 'string' || !CATEGORIES.includes(category as PostBody['category'])) {
throw new Error('category is invalid')
}
if (!body.content || typeof body.content !== 'object' || Array.isArray(body.content)) {
throw new Error('content must be a Lexical object')
}
const readingMinutes = body.readingMinutes
if (!Number.isInteger(readingMinutes) || Number(readingMinutes) < 1 || Number(readingMinutes) > 120) {
throw new Error('readingMinutes must be an integer from 1 to 120')
}
const publish = body.publish ?? false
if (typeof publish !== 'boolean') throw new Error('publish must be boolean')
const teaser = body.teaser
if (teaser !== undefined && typeof teaser !== 'string') throw new Error('teaser must be a string')
if (access === 'vip' && (typeof teaser !== 'string' || teaser.trim() === '')) {
throw new Error('VIP teaser is required')
}
if (body.coverImage !== undefined) {
if (!body.coverImage || typeof body.coverImage !== 'object' || Array.isArray(body.coverImage)) {
throw new Error('coverImage must be an object')
}
const cover = body.coverImage as Record<string, unknown>
if (typeof cover.dataBase64 !== 'string') throw new Error('coverImage.dataBase64 is required')
if (cover.filename !== undefined && typeof cover.filename !== 'string') {
throw new Error('coverImage.filename must be a string')
}
}
return {
title: nonEmptyString(body.title, 'title'),
slug,
dek: nonEmptyString(body.dek, 'dek'),
...(teaser === undefined ? {} : { teaser: teaser.trim() }),
content: body.content as Record<string, unknown>,
category: category as PostBody['category'],
access,
readingMinutes: Number(readingMinutes),
...(body.coverImage === undefined ? {} : { coverImage: body.coverImage as CoverImage }),
publish,
}
}
export function parsePostPatch(input: unknown, expectedSlug: string): Partial<PostBody> {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('JSON object body is required')
}
const body = input as Record<string, unknown>
const fields = Object.keys(body)
if (fields.length === 0) throw new Error('at least one field is required')
for (const field of fields) {
if (!ALLOWED_FIELDS.has(field)) throw new Error(`unsupported field: ${field}`)
}
if (fields.length === 1 && fields[0] === 'publish') {
if (body.publish !== true) throw new Error('publish-only update must set publish to true')
return { publish: true }
}
const parsed = parsePostBody(body)
if (!parsed.publish) throw new Error('republish update must set publish to true')
if (parsed.slug !== expectedSlug) throw new Error('slug must match the URL path')
return parsed
}
export function decodeCoverImage(cover: CoverImage): UploadedCover {
const base64 = cover.dataBase64.trim()
const maxBase64Length = Math.ceil(MAX_COVER_BYTES / 3) * 4
if (base64.length > maxBase64Length) throw new Error('coverImage must be 5MB or smaller')
if (!base64 || base64.length % 4 !== 0 || !BASE64_RE.test(base64)) {
throw new Error('coverImage data is invalid base64')
}
const data = Buffer.from(base64, 'base64')
if (data.length > MAX_COVER_BYTES) throw new Error('coverImage must be 5MB or smaller')
if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8 || data.at(-2) !== 0xff || data.at(-1) !== 0xd9) {
throw new Error('coverImage must be JPEG')
}
const requested = path.basename(cover.filename || 'cover.jpg')
const stem = requested.replace(/\.jpe?g$/i, '').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'cover'
const name = `${stem}.jpg`
return { data, mimetype: 'image/jpeg', name, size: data.length }
}
type PostDocument = {
id: unknown
title?: unknown
slug?: unknown
status?: unknown
access?: unknown
category?: unknown
publishedAt?: unknown
}
export function postResponse(doc: PostDocument) {
const slug = String(doc.slug)
return {
id: doc.id,
title: doc.title,
slug,
status: doc.status,
access: doc.access,
category: doc.category,
publishedAt: doc.publishedAt ?? null,
url: `https://anwarpy.ai/posts/${slug}`,
}
}
+59 -126
View File
@@ -1,34 +1,31 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import type { Payload } from 'payload' import type { Payload } from 'payload'
import type { Media } from '@payload-types'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { resolveCoverImage } from '@/lib/agent-media'
import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files' import { decodeCoverImage, parsePostBody, postResponse, type PostBody } from './post-contract'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
type Body = Record<string, unknown>
function unauthorized() { function unauthorized() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
function validKey(req: NextRequest) { function validKey(req: NextRequest) {
const key = process.env.AGENT_API_KEY const key = process.env.AGENT_API_KEY
if (!key) return false return Boolean(key && req.headers.get('x-agent-key') === key)
return req.headers.get('x-agent-key') === key
} }
/** Fields the agent is allowed to write; status is NEVER included. */ function badRequest(error: unknown) {
function pickFields(body: Body): Body { const message = error instanceof Error ? error.message : 'invalid request'
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const return NextResponse.json({ error: message }, { status: 400 })
const out: Body = {} }
for (const field of allowed) {
if (field in body) out[field] = body[field] function isUniqueSlugError(error: unknown) {
} if (!error || typeof error !== 'object') return false
// cover omitted → per-category default assigned below (seeded covers) const value = error as { code?: unknown; message?: unknown; detail?: unknown }
return out const text = `${String(value.message ?? '')} ${String(value.detail ?? '')}`.toLowerCase()
return value.code === '23505' && text.includes('slug')
} }
const CATEGORY_COVER_SLUGS: Record<string, string> = { const CATEGORY_COVER_SLUGS: Record<string, string> = {
@@ -40,138 +37,74 @@ const CATEGORY_COVER_SLUGS: Record<string, string> = {
'أعمال': 'cover-business', 'أعمال': 'cover-business',
} }
async function defaultCoverId(payload: Payload, category: unknown) { async function defaultCoverId(payload: Payload, category: string) {
const filename = CATEGORY_COVER_SLUGS[String(category)] || 'cover-default' const filename = CATEGORY_COVER_SLUGS[category] || 'cover-default'
const res = await payload.find({ const result = await payload.find({
collection: 'media', collection: 'media',
where: { filename: { equals: `${filename}.jpg` } }, where: { filename: { equals: `${filename}.jpg` } },
limit: 1, limit: 1,
depth: 0,
overrideAccess: true,
}) })
return res.docs[0]?.id const id = result.docs[0]?.id
if (!id) throw new Error(`default cover is missing for category: ${category}`)
return id
}
async function coverId(payload: Payload, body: PostBody) {
if (!body.coverImage) return defaultCoverId(payload, body.category)
const file = decodeCoverImage(body.coverImage)
const media = await payload.create({
collection: 'media',
data: {},
file,
depth: 0,
overrideAccess: true,
})
return media.id
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!validKey(req)) return unauthorized() if (!validKey(req)) return unauthorized()
const payload = await getPayload({ config: configPromise })
const body = (await req.json().catch(() => null)) as Body | null let body: PostBody
if (!body || typeof body.slug !== 'string' || !body.slug) { try {
return NextResponse.json({ error: 'slug is required' }, { status: 400 }) body = parsePostBody(await req.json())
} catch (error) {
return badRequest(error)
} }
// Slug conflict → 409 const payload = await getPayload({ config: configPromise })
const existing = await payload.find({ const existing = await payload.find({
collection: 'posts', collection: 'posts',
where: { slug: { equals: body.slug } }, where: { slug: { equals: body.slug } },
limit: 1, limit: 1,
depth: 0,
overrideAccess: true,
}) })
if (existing.docs.length > 0) { if (existing.docs.length > 0) {
return NextResponse.json({ error: 'slug already exists' }, { status: 409 }) return NextResponse.json({ error: 'slug already exists' }, { status: 409 })
} }
const data = pickFields(body)
// Per-post work files (inline base64) → stored as gated `files` documents.
// Like coverImage, `attachments` is an input transform, not a raw field.
let attachments: Awaited<ReturnType<typeof resolveAttachments>> = []
if ('attachments' in body && body.attachments != null) {
try {
attachments = await resolveAttachments(payload, body.attachments)
data.attachments = attachments.map(a => a.id)
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
}
}
// Explicit image payload (inline base64 or remote URL) → stored as a media document.
let coverUrl: string | null = null
if ('coverImage' in body && body.coverImage != null) {
try {
const cover = await resolveCoverImage(payload, body.coverImage)
data.cover = cover.id
coverUrl = cover.url
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
}
}
if (!data.cover) data.cover = await defaultCoverId(payload, data.category)
// `publish` is an explicit opt-in flag — the agent still cannot pass `status`
// directly (unpublished content requires a deliberate admin action otherwise).
const publish = body.publish === true
if (publish && !data.publishedAt) data.publishedAt = new Date().toISOString()
if (!data.content || typeof data.content !== 'object') {
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later
data.content = {
root: {
type: 'root', format: '', indent: 0, version: 1, direction: 'rtl',
children: [{ type: 'paragraph', version: 1, children: [{ type: 'text', version: 1, text: '…' }] }],
},
}
}
let doc
try { try {
doc = await payload.create({ const { coverImage: _coverImage, publish, ...fields } = body
const publishedAt = publish ? new Date().toISOString() : undefined
const doc = await payload.create({
collection: 'posts', collection: 'posts',
data: { ...data, status: publish ? 'published' : 'draft' }, data: {
draft: true, ...fields,
cover: await coverId(payload, body),
status: publish ? 'published' : 'draft',
...(publishedAt ? { publishedAt } : {}),
} as never,
depth: 0, depth: 0,
overrideAccess: true,
}) })
} catch (err) { return NextResponse.json(postResponse(doc), { status: 201 })
// The post write failed — don't orphan the file docs created above. } catch (error) {
if (attachments.length) await deleteFileDocs(payload, attachments.map(a => a.id)) if (isUniqueSlugError(error)) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 }) return NextResponse.json({ error: 'slug already exists' }, { status: 409 })
}
throw error
} }
if (!coverUrl && data.cover) {
const coverDoc: Media = await payload.findByID({ collection: 'media', id: data.cover as number })
coverUrl = coverDoc.url ?? null
}
return NextResponse.json({
id: doc.id,
title: doc.title,
slug: doc.slug,
status: doc.status,
access: doc.access,
category: doc.category,
cover: { id: data.cover, url: coverUrl },
attachments: attachments.map(a => ({ id: a.id, filename: a.filename, access: a.access, url: `/api/files/${a.id}` })),
}, { status: 201 })
}
export async function PUT(req: NextRequest) {
if (!validKey(req)) return unauthorized()
const payload = await getPayload({ config: configPromise })
const slug = req.nextUrl.pathname.split('/').filter(Boolean).pop() as string
const body = (await req.json().catch(() => null)) as Body | null
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
const existing = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
limit: 1,
overrideAccess: true,
})
const post = existing.docs[0]
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
const data = pickFields(body)
delete (data as Body).status // agent can never publish/unpublish
const doc = await payload.update({
collection: 'posts',
id: post.id,
data,
depth: 0,
})
return NextResponse.json({
id: doc.id,
title: doc.title,
slug: doc.slug,
status: doc.status,
access: doc.access,
category: doc.category,
})
} }
@@ -21,7 +21,6 @@ export async function POST() {
id: user.id, id: user.id,
data: { verificationToken: token }, data: { verificationToken: token },
overrideAccess: true, overrideAccess: true,
context: { systemActivation: true },
}) })
const { sent } = await sendVerificationEmail(user.email, token) const { sent } = await sendVerificationEmail(user.email, token)
@@ -27,7 +27,6 @@ export async function GET(request: Request) {
id: user.id, id: user.id,
data: { emailVerified: true, verificationToken: null }, data: { emailVerified: true, verificationToken: null },
overrideAccess: true, overrideAccess: true,
context: { systemActivation: true },
}) })
redirect('/onboarding') redirect('/onboarding')
-49
View File
@@ -1,49 +0,0 @@
import { NextResponse } from 'next/server'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { FILES_DIR } from '@/collections/Files'
import { getViewer } from '@/lib/viewer'
export const dynamic = 'force-dynamic'
type FileDoc = { id: number; access: 'free' | 'vip'; filename: string; mimeType: string }
/* GET /api/files/[id] — per-post work files, gated per file.
`free` streams to anyone; `vip` requires a signed-in vip/admin (same style
as the comments route). The Files collection read rule is defense in depth;
this route is the enforcement point for the bytes. */
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const payload = await getPayload({ config: configPromise })
let doc: FileDoc | null = null
try {
doc = (await payload.findByID({ collection: 'files', id, depth: 0, overrideAccess: true })) as unknown as FileDoc
} catch {
doc = null // unknown id / DB error → not found
}
if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
if (doc.access === 'vip') {
const viewer = await getViewer()
if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 })
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
return NextResponse.json({ error: 'التنزيل متاح لأعضاء العضوية.' }, { status: 403 })
}
}
try {
const buf = await readFile(path.join(FILES_DIR, doc.filename))
return new NextResponse(new Uint8Array(buf), {
headers: {
'Content-Type': doc.mimeType,
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(doc.filename)}`,
},
})
} catch {
// Row exists but the file is missing on disk — do not leak a fake download.
return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
}
}
@@ -16,15 +16,6 @@ export async function POST(_request: Request, ctx: { params: Promise<{ slug: str
const { user } = await payload.auth({ headers: await headers() }) const { user } = await payload.auth({ headers: await headers() })
if (!user) return NextResponse.json({ error: 'سجّل الدخول للتعليق.' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'سجّل الدخول للتعليق.' }, { status: 401 })
// Verified-email gate (pentest #7 remediation): admins exempt.
const { role, emailVerified } = user as { role?: string; emailVerified?: boolean }
if (!emailVerified && role !== 'admin') {
return NextResponse.json(
{ error: 'أكّد بريدك الإلكتروني قبل التعليق — راجع بريدك الوارد.' },
{ status: 403 },
)
}
const parsed = await _request.json().catch(() => null) const parsed = await _request.json().catch(() => null)
const text = typeof parsed?.body === 'string' ? parsed.body.trim() : '' const text = typeof parsed?.body === 'string' ? parsed.body.trim() : ''
if (text.length < 8) return NextResponse.json({ error: 'اكتب تعليقاً من ٨ أحرف على الأقل.' }, { status: 400 }) if (text.length < 8) return NextResponse.json({ error: 'اكتب تعليقاً من ٨ أحرف على الأقل.' }, { status: 400 })
@@ -40,6 +31,7 @@ export async function POST(_request: Request, ctx: { params: Promise<{ slug: str
if (!post) return NextResponse.json({ error: 'المقال غير موجود.' }, { status: 404 }) if (!post) return NextResponse.json({ error: 'المقال غير موجود.' }, { status: 404 })
// Mirror the UI paywall: commenting on a VIP post requires entitlement. // Mirror the UI paywall: commenting on a VIP post requires entitlement.
const role = (user as { role?: string }).role
if (post.access === 'vip' && role !== 'vip' && role !== 'admin') { if (post.access === 'vip' && role !== 'vip' && role !== 'admin') {
return NextResponse.json({ error: 'التعليق على هذا الدليل متاح لأعضاء العضوية.' }, { status: 403 }) return NextResponse.json({ error: 'التعليق على هذا الدليل متاح لأعضاء العضوية.' }, { status: 403 })
} }
-61
View File
@@ -1,61 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { REST_DELETE, REST_GET, REST_PATCH, REST_PUT } from '@payloadcms/next/routes'
import { getViewer } from '@/lib/viewer'
export const dynamic = 'force-dynamic'
type Ctx = { params: Promise<{ slug: string }> }
const restGet = REST_GET(configPromise)
const restPatch = REST_PATCH(configPromise)
const restPut = REST_PUT(configPromise)
const restDelete = REST_DELETE(configPromise)
/* Shadows the Payload REST catch-all for /api/posts/:id-or-slug.
- Numeric ids: plain REST passthrough — the /admin edit view depends on them.
- String slugs: by-slug lookup with the VIP gate, instead of the catch-all
casting the slug as an id (postgres error → 500, QA-01).
Mutations pass through to REST for numeric ids (the /admin SPA saves via
REST PATCH/PUT/DELETE on /api/posts/:id); the collection's own access
control still applies. No string-slug mutations exist. */
export async function GET(req: NextRequest, ctx: Ctx) {
const { slug } = await ctx.params
if (/^\d+$/.test(slug)) {
return restGet(req, { params: Promise.resolve({ slug: ['posts', slug] }) })
}
const payload = await getPayload({ config: configPromise })
// Published only — drafts must not leak by slug (numeric-id passthrough
// stays for the /admin SPA; its read access already constrains it).
const { docs } = await payload.find({
collection: 'posts',
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
depth: 0, limit: 1, overrideAccess: true,
})
const post = docs[0]
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (post.access === 'vip') {
const viewer = await getViewer()
if (!viewer) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
return NextResponse.json({ error: 'VIP membership required' }, { status: 403 })
}
}
return NextResponse.json(post)
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
const { slug } = await ctx.params
return restPatch(req, { params: Promise.resolve({ slug: ['posts', slug] }) })
}
export async function PUT(req: NextRequest, ctx: Ctx) {
const { slug } = await ctx.params
return restPut(req, { params: Promise.resolve({ slug: ['posts', slug] }) })
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
const { slug } = await ctx.params
return restDelete(req, { params: Promise.resolve({ slug: ['posts', slug] }) })
}
-20
View File
@@ -1,20 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server'
import configPromise from '@payload-config'
import { REST_GET, REST_POST } from '@payloadcms/next/routes'
import { getViewer } from '@/lib/viewer'
export const dynamic = 'force-dynamic'
/* Shadows the Payload REST catch-all for /api/posts: the REST list returned
full bodies of all published posts (incl. VIP) to anonymous — a paid-content
leak. List reads are now admin-only. Create (POST) stays Payload REST —
access.create: isAdmin already rejects non-admins. The app frontend and
agent API use the local API / custom routes, never the REST list. */
export async function GET(req: NextRequest) {
const viewer = await getViewer()
if (!viewer) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
if (viewer.role !== 'admin') return NextResponse.json({ error: 'Admin only' }, { status: 403 })
return REST_GET(configPromise)(req, { params: Promise.resolve({ slug: ['posts'] }) })
}
export const POST = REST_POST(configPromise)

Some files were not shown because too many files have changed in this diff Show More