Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0161924088 | ||
|
|
82414e8a93 | ||
|
|
165efc3269 | ||
|
|
e69e419721 | ||
|
|
8e577165cf | ||
|
|
5ee706ebd6 | ||
|
|
c34611dc36 | ||
|
|
9cfffa4c66 | ||
|
|
6c8632315f | ||
|
|
a1ba197910 | ||
|
|
2cacdfb926 |
@@ -0,0 +1,160 @@
|
|||||||
|
# AGENT.md — AnwarPi for AI agents
|
||||||
|
|
||||||
|
Orientation doc for AI agents (coding assistants, autonomous maintainers, content
|
||||||
|
pipelines) working on this repository. Read this before changing anything.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
**AnwarPi (أنور بي آي)** — an Arabic-first, RTL platform for learning practical
|
||||||
|
AI at **https://anwarpy.ai**. Free guides for everyone, VIP-gated deep-dives, a
|
||||||
|
searchable library/catalog, moderated comments, member accounts, and a Stripe
|
||||||
|
subscription that is fully wired but public-facing "coming soon" until keys are
|
||||||
|
set. Content is also published programmatically by an external agent pipeline
|
||||||
|
through a key-authenticated API.
|
||||||
|
|
||||||
|
Canonical domain: `anwarpy.ai` (`anwarpi.ai` is legacy). Owner: Ahmed
|
||||||
|
(ahmedalkhair.sa@gmail.com). Owner constraint: **never change site flow/UX
|
||||||
|
without explicit approval** (e.g. no CAPTCHA, no aggressive limits).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Component | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Framework | Next.js 15 (App Router, `output: 'standalone'`), React 19 |
|
||||||
|
| CMS/Auth | Payload 3.90 (`@payloadcms/next`), Lexical editor |
|
||||||
|
| Database | PostgreSQL 16 via `@payloadcms/db-postgres` |
|
||||||
|
| Styling | Plain CSS files per page (see `web/src/app/(frontend)/styles/`), dark-first brand (see `brand-spec.md`) |
|
||||||
|
| Email | Cloudflare Email Routing (inbound) + Cloudflare Email Sending REST (outbound) — **not SMTP** (docs/adr/0003) |
|
||||||
|
| Payments | Stripe SDK, webhook at `/api/stripe/webhook` |
|
||||||
|
| Edge | Traefik 2/3 with Docker provider (labels in `web/docker-compose.vps.yml`) |
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
web/ ← the entire application (deploy unit)
|
||||||
|
src/
|
||||||
|
payload.config.ts ← Payload config: collections, postgres adapter
|
||||||
|
seed.ts ← `npm run seed`
|
||||||
|
middleware.ts ← Next middleware: auth rate limits (F2)
|
||||||
|
collections/ ← Users, Posts, Media, Files, PostLikes, Comments
|
||||||
|
migrations/ ← Payload migrations (`payload migrate` runs on boot)
|
||||||
|
lib/ ← viewer.ts (session helper), mail.ts, format.ts, topics.ts, agent-*
|
||||||
|
app/
|
||||||
|
(frontend)/ ← public Arabic site (page.tsx per route, styles/ css)
|
||||||
|
(payload)/ ← Payload admin + API catch-all
|
||||||
|
api/ ← custom REST routes (posts, auth, subscribe, stripe, agent, files)
|
||||||
|
public/ ← brand/, covers/, og-default.png
|
||||||
|
Dockerfile ← standalone build; CMD runs `npx payload migrate` first
|
||||||
|
docker-compose.vps.yml ← prod: web + postgres, Traefik labels (rate limits)
|
||||||
|
GLOSSARY.md ← canonical domain vocabulary — USE THESE TERMS
|
||||||
|
docs/adr/ ← architecture decision records — do not contradict
|
||||||
|
brand-spec.md ← visual identity: dark blue-black, silver beams, cyan-teal accent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data model (Payload collections)
|
||||||
|
|
||||||
|
- **Users** — auth collection. `role: 'free' | 'vip' | 'admin'` (server-forced),
|
||||||
|
`emailVerified`, `onboarded`, `termsVersion`. Login lockout: 5 attempts /
|
||||||
|
15 min; admin accounts never lock (hook clears lock before login).
|
||||||
|
- **Posts** — guides. `access: 'free' | 'vip'`, `slug`, category (وكلاء، نماذج،
|
||||||
|
استرجاع، سلامة، أدوات، أعمال), cover image (1200×630), Lexical body, likes/views.
|
||||||
|
- **Comments** — moderated: `pending → approved | rejected`. Only `approved` is
|
||||||
|
public. Authors cannot edit/delete their own comments (moderated model,
|
||||||
|
docs/adr/0002). Display name is a snapshot of `fullName` at submit time.
|
||||||
|
- **Media / Files** — uploads (files served via `/api/files/[id]`).
|
||||||
|
- **PostLikes** — one like per member per post.
|
||||||
|
|
||||||
|
## Trust model (critical — do not break)
|
||||||
|
|
||||||
|
1. **Roles are server-forced.** `POST /api/users` and any client write cannot
|
||||||
|
set `role`, `emailVerified`, `stripeCustomerId`, or `verificationToken` —
|
||||||
|
the `beforeChange` hook in `Users.ts` deletes them for untrusted operations.
|
||||||
|
Only `req.user.role === 'admin'` or `context.systemActivation === true`
|
||||||
|
(payment activation from server code) counts as trusted.
|
||||||
|
2. **Entitlement is derived, never stored on the user**: viewer may read a
|
||||||
|
post's body iff `post.access === 'free' || role in ('vip','admin')`. Gating
|
||||||
|
happens server-side (locked body is stripped from API responses).
|
||||||
|
3. **Comments require verification** (`emailVerified`), admins exempt.
|
||||||
|
4. Client requests can never mint VIP (free `/payment-success` visits redirect
|
||||||
|
to `/subscription`; docs/adr/0004, 0005).
|
||||||
|
|
||||||
|
## Auth & rate limiting
|
||||||
|
|
||||||
|
- Passwords: Payload local auth → **PBKDF2-SHA256, 600 000 iterations, 32
|
||||||
|
bytes** (NOT bcrypt — verified in payload's `generatePasswordSaltHash`).
|
||||||
|
- `web/src/middleware.ts` (F2): per-IP in-memory buckets —
|
||||||
|
`POST /api/users` 5/h, `POST /api/users/login` 10/15min,
|
||||||
|
`POST /api/users/forgot-password` 5/h. Over limit → 429 JSON
|
||||||
|
`{"error":"محاولات كثيرة جدًا. حاول بعد قليل."}` + `Retry-After`.
|
||||||
|
**In-memory is correct for the single-container deployment**; moving to
|
||||||
|
horizontal scaling requires a shared store (Redis) — do not silently assume
|
||||||
|
one replica.
|
||||||
|
- Timing equalization (F7): `Users.ts` `beforeOperation` login hook runs a
|
||||||
|
dummy PBKDF2 (same params, salt `anwarpy-dummy-salt-v1`) when no user
|
||||||
|
matches, so ghost emails are not enumerable by response time. Keep this when
|
||||||
|
touching the hook.
|
||||||
|
- Traefik edge (F1, VPS only): pages `pagelimit` 60/min burst 120 +
|
||||||
|
`connlimit` 50 in-flight per IP; API `apilimit` 90/min burst 180.
|
||||||
|
- `Strict-Transport-Security: max-age=63072000; includeSubDomains` on all
|
||||||
|
routes (`web/next.config.mjs`). Do not add `preload`.
|
||||||
|
|
||||||
|
## Key routes
|
||||||
|
|
||||||
|
Frontend: `/` (home), `/articles`, `/catalog`, `/posts/[slug]` (gated body,
|
||||||
|
engagement, comments), `/signup`, `/login`, `/forgot-password`,
|
||||||
|
`/verify-email`, `/subscription`, `/payment-success`, `/payment-cancelled`,
|
||||||
|
`/vip-onboarding`, `/onboarding`, `/account-settings`, `/about`, `/privacy`,
|
||||||
|
`/terms`. Admin UI: `/admin`.
|
||||||
|
|
||||||
|
Custom API (see `web/src/app/api/`): `posts` (list) + `posts/[slug]` (detail —
|
||||||
|
strips locked bodies), `posts/[slug]/{like,view,comments}`,
|
||||||
|
`auth/{verify-email,resend-verification}`, `users` REST is Payload's own,
|
||||||
|
`subscribe`, `stripe/webhook` (returns 503 until `STRIPE_SECRET_KEY` +
|
||||||
|
`STRIPE_WEBHOOK_SECRET` exist; verifies `stripe-signature` once configured),
|
||||||
|
`agent/posts` (POST/PUT with `x-agent-key` header — external publishing), and
|
||||||
|
`files/[id]`.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
`DATABASE_URI`, `PAYLOAD_SECRET`, `NEXT_PUBLIC_SITE_URL`, `AGENT_API_KEY`,
|
||||||
|
`MAIL_FROM`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, plus optional
|
||||||
|
`STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET`. Prod values live in
|
||||||
|
`/root/anwarpi/.env.production` on the VPS — never commit `.env*`.
|
||||||
|
|
||||||
|
## Development & verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm install
|
||||||
|
npm run dev # localhost:3000, /admin for CMS
|
||||||
|
npm run build # MUST exit 0 — typescript.ignoreBuildErrors is false
|
||||||
|
npm run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
- Regenerates `payload-types.ts` automatically on build
|
||||||
|
(`payload generate:types`).
|
||||||
|
- Schema changes require a migration (`payload migrate:create`); the container
|
||||||
|
CMD runs `payload migrate` on every deploy — no manual DB steps.
|
||||||
|
- Auth endpoints are rate-limited in dev too; restart the dev server to reset
|
||||||
|
buckets when testing.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Single VPS `147.79.102.201`, app at `/root/anwarpi`, managed by
|
||||||
|
`docker compose -f docker-compose.vps.yml` (web + postgres + Traefik proxy
|
||||||
|
network). Update flow: rsync `web/` → `/root/anwarpi/` excluding
|
||||||
|
`node_modules .next .git media files-private '.env*' '*.tsbuildinfo'`, then
|
||||||
|
`docker compose -f docker-compose.vps.yml up -d --build` (label changes force
|
||||||
|
recreate; migration runs on boot). Traefik labels only affect the VPS.
|
||||||
|
|
||||||
|
## House rules
|
||||||
|
|
||||||
|
- Follow the **reuse ladder**: existing helper → stdlib → platform feature →
|
||||||
|
installed dependency → then write the complete version.
|
||||||
|
- **Do not add UX changes** (new limits, captchas, gates) without owner
|
||||||
|
approval; thresholds are deliberately generous for humans.
|
||||||
|
- New decisions → add an ADR in `docs/adr/` (next number, `NNNN-slug.md`).
|
||||||
|
- Use GLOSSARY terms exactly (عضوية/entitlement, مفتوح/unlocked, موثّق/
|
||||||
|
verified, كتابة موثوقة/trusted write).
|
||||||
|
- **Pentest context**: the 2026-10-09 report items F1–F9 are closed; see PR #1
|
||||||
|
and release v1.0.0 on Gitea for what was done and verified. Do not
|
||||||
|
re-implement.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# GLOSSARY — أنور بي آي
|
||||||
|
|
||||||
|
Implementation-free vocabulary shared across code, QA, and product talk.
|
||||||
|
|
||||||
|
## العضوية / Entitlement
|
||||||
|
|
||||||
|
A user's paid access level. Server-derived from `users.role`: `vip` or `admin` counts as entitled; `free` does not. Never cached client-side as a boolean.
|
||||||
|
|
||||||
|
## مفتوح / Unlocked
|
||||||
|
|
||||||
|
Whether the current viewer may read a given post's full body: `post.access === 'free' || entitled`. Derived per request; never stored on the user.
|
||||||
|
|
||||||
|
## تعليق / Comment
|
||||||
|
|
||||||
|
A moderated note by an authenticated member on a published post. Lifecycle `pending → approved | rejected`; only `approved` is publicly visible; only admins move status; authors cannot edit or delete their own comments (moderated model). Authors see their own `pending` comments on the post page tagged «قيد المراجعة».
|
||||||
|
|
||||||
|
## اسم العرض / Display name
|
||||||
|
|
||||||
|
The public author label of a comment: a snapshot of `users.fullName` taken at submit time, or the fixed word «عضو» when absent. Never the email.
|
||||||
|
|
||||||
|
## الهوية / Identity
|
||||||
|
|
||||||
|
- **Canonical domain: `anwarpy.ai`** — the Cloudflare zone (`anwarpi.ai` references are legacy being replaced; see repo history).
|
||||||
|
- **Contact inbox: `hello@anwarpy.ai`** — receives via Cloudflare Email Routing, forwards to ahmedalkhair.sa@gmail.com.
|
||||||
|
- **Sender: `noreply@anwarpy.ai`** — sends via Cloudflare Email Sending (REST).
|
||||||
|
|
||||||
|
## الاشتراك قريبًا / Coming soon
|
||||||
|
|
||||||
|
The public state of the subscribe flow while billing is disabled. From the first paint the UI renders a disabled «قريباً» button (when `LAUNCH_MODE` disables billing there is no active «اشترك الآن» that fails late); if a request is still attempted while Stripe keys are absent, `POST /api/subscribe` returns `{ comingSoon: true }` with no `url`, and the button shows an in-page retry state — **not a redirect** to `/payment-success` and no VIP is minted there either way. No VIP is granted without a Stripe-verified session.
|
||||||
|
|
||||||
|
## الوصول المبكر المجاني / Free early access
|
||||||
|
|
||||||
|
The launch policy (`LAUNCH_MODE=free_early_access`, the compose default): every approved **published** post is readable and its approved files downloadable without an account, regardless of the post's intended tier. Billing stays off; nothing is charged or auto-subscribed. The mode has no date expiry — the ~two-week checkpoint is a review date, not a cutoff.
|
||||||
|
|
||||||
|
## الطبقة المقصودة / Intended tier
|
||||||
|
|
||||||
|
The post's `access` field (`free` | `vip`) — what the content is meant to be once billing is enabled. The launch never rewrites it: a VIP-designated post opened by early access keeps `access: 'vip'` so rollback is instant and cards/comparisons stay truthful.
|
||||||
|
|
||||||
|
## الوصول الفعلي / Effective access
|
||||||
|
|
||||||
|
The decision actually enforced at request time: `status: 'published'` **and** (`access: 'free'` **or** free early access). One policy (`src/lib/launch.ts`) feeds pages, `/api/posts/[slug]`, the file gate, and the llms exports, so visible labels and served bytes can't disagree. A draft is never readable in any mode.
|
||||||
|
|
||||||
|
## كتابة موثوقة / Trusted write
|
||||||
|
|
||||||
|
An operation on a member record performed by server code (email verification, payment activation, billing webhook, seed), as opposed to a client request. Client requests can never set a member's privilege, verification, or billing fields.
|
||||||
|
|
||||||
|
## موثّق / Verified
|
||||||
|
|
||||||
|
Whether the member confirmed their email address. Required to comment; admins are exempt; set only by server code, never by client requests.
|
||||||
@@ -1,37 +1,180 @@
|
|||||||
# أنور بي آي
|
<div align="center">
|
||||||
|
|
||||||
منصة عربية ثابتة لتعلّم الذكاء الاصطناعي، تتضمن محتوى مجانيًا ومقالات VIP وتجربة اشتراك وتهيئة للمشترك الجديد. الواجهة متجاوبة مع الهاتف واللوحي وسطح المكتب.
|
<img src="web/public/brand/icon-256.png" alt="شعار أنور بي آي" width="120"/>
|
||||||
|
|
||||||
## الصفحات الرئيسية
|
# أنور بي آي — AnwarPi
|
||||||
|
|
||||||
- الصفحة الرئيسية والكتالوج التعليمي
|
**محتوى عربي عملي لتعلّم الذكاء الاصطناعي، بلا ضجيج.**
|
||||||
- مقال مجاني ومعاينة مقال VIP ومقال VIP كامل
|
|
||||||
- الاشتراك وتهيئة مشترك VIP جديد
|
|
||||||
- تسجيل الدخول واستعادة كلمة المرور وإعدادات الحساب
|
|
||||||
- سياسة الخصوصية
|
|
||||||
|
|
||||||
## التشغيل محليًا
|
Arabic-first platform for learning AI: free guides, VIP deep-dives, a searchable library, and moderated community — built with **Next.js 15 + Payload 3 + PostgreSQL** behind Traefik.
|
||||||
|
|
||||||
لا يحتاج المشروع إلى تثبيت حزم. شغّل خادمًا محليًا من مجلد المشروع:
|
**[anwarpy.ai](https://anwarpy.ai)** · [دليل الوكيل الآلي (AGENT.md)](AGENT.md) · [مسرد المصطلحات](GLOSSARY.md) · [قرارات التصميم](docs/adr)
|
||||||
|
|
||||||
```bash
|
</div>
|
||||||
python3 -m http.server 8000
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖼️ لقطات من الموقع
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><strong>الصفحة الرئيسية</strong></td>
|
||||||
|
<td align="center"><strong>المكتبة</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/home.png" alt="الصفحة الرئيسية — أنور بي آي" width="470"/></td>
|
||||||
|
<td><img src="docs/screenshots/articles.png" alt="المكتبة — أنور بي آي" width="470"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><strong>صفحة دليل</strong></td>
|
||||||
|
<td align="center"><strong>العضوية</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/post.png" alt="صفحة دليل — أنور بي آي" width="470"/></td>
|
||||||
|
<td><img src="docs/screenshots/subscription.png" alt="العضوية — أنور بي آي" width="470"/></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## ما هو أنور بي آي؟
|
||||||
|
|
||||||
|
منصة عربية لتعلّم الذكاء الاصطناعي بشكل عملي: أدلة مجانية للجميع، وأدلة متعمقة لأعضاء **VIP**، ومكتبة قابلة للبحث والتصفية، وتعليقات مُراقَّبة، ومسارات تعلّم (كتالوج). الواجهة عربية بالكامل (RTL) وتعمل على الهاتف واللوحي وسطح المكتب.
|
||||||
|
|
||||||
|
## ✨ المميزات
|
||||||
|
|
||||||
|
- 📚 **المكتبة** — كل الأدلة مع بحث وتصفية حسب التصنيف (وكلاء، نماذج، استرجاع، سلامة، أدوات، أعمال).
|
||||||
|
- 🔓 **بوابة محتوى** — الأدلة المجانية كاملة؛ أدلة VIP تظهر بمعاينة مقفلة حتى يصبح القارئ مشتركًا.
|
||||||
|
- 👤 **حسابات** — تسجيل، دخول، استعادة كلمة المرور، تحقق البريد، وإعدادات حساب كاملة.
|
||||||
|
- 💬 **تعليقات مُراقَّبة** — دورة حياة `pending → approved`؛ يظهر العلني المعتمَد فقط.
|
||||||
|
- 🗺️ **الكتالوج والمسارات** — رحلة تعلّم مرتّبة حسب المستوى (مبتدئ/متوسط/متقدم).
|
||||||
|
- 🤖 **نشر آلي** — API للوكلاء الخارجيين (`/api/agent/posts` بمفتاح `x-agent-key`) لنشر الأدلة من خط إدارة المحتوى.
|
||||||
|
- 💳 **عضوية VIP** — Stripe جاهز؛ حتى تفعيل المفاتيح يعمل زر الاشتراك بوضع «قريبًا».
|
||||||
|
- 🌓 **هوية بصرية داكنة** — أشعة فضية ولمسة سماوية؛ انظر `brand-spec.md`.
|
||||||
|
|
||||||
|
## 🔄 كيف يعمل الموقع؟ (شرح التدفق)
|
||||||
|
|
||||||
|
### تدفق الزائر
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart RL
|
||||||
|
Z[الزائر] --> A[الصفحة الرئيسية]
|
||||||
|
A --> B[المكتبة / الكتالوج]
|
||||||
|
B --> C{نوع الدليل؟}
|
||||||
|
C -->|مجاني| D[قراءة كاملة + إعجاب وتعليق]
|
||||||
|
C -->|VIP| E[معاينة مقفلة]
|
||||||
|
E --> F[إنشاء حساب / دخول]
|
||||||
|
F --> G{مشترك VIP؟}
|
||||||
|
G -->|نعم| D
|
||||||
|
G -->|لا| H[صفحة العضوية]
|
||||||
|
H --> I[تهيئة مشترك VIP جديد]
|
||||||
|
I --> D
|
||||||
```
|
```
|
||||||
|
|
||||||
ثم افتح `http://localhost:8000`.
|
1. **تصفّح** — الزائر يفتح الرئيسية، يتصفح المكتبة أو الكتالوج، ثم يفتح دليلًا.
|
||||||
|
2. **قراءة** — الدليل المجاني يُقرأ كاملًا. دليل VIP يظهر جزءًا منه فقط مع طبقة قفل.
|
||||||
|
3. **حساب** — التسجيل يمنح دور `free` فقط (الخادم يجبر ذلك؛ طلب العميل لا يستطيع ترقية نفسه). بعد التحقق من البريد يمكن التعليق.
|
||||||
|
4. **ترقية** — صفحة العضوية تقود لـ Stripe؛ بعد الدفع الناجح يُفعَّل دور `vip` عبر مسار موثوق من الخادم، ثم معالجة تهيئة VIP الجديد.
|
||||||
|
|
||||||
## التحقق قبل النشر
|
### تدفق المحتوى (نشر الأدلة)
|
||||||
|
|
||||||
```bash
|
```mermaid
|
||||||
python3 scripts/check_site.py
|
flowchart LR
|
||||||
|
T[مواضيع المالك في AppFlowy] --> P[خط النشر الآلي]
|
||||||
|
P -->|POST/PUT مع x-agent-key| A[API الوكلاء]
|
||||||
|
A --> V[التحقق والتغطية الافتراضية للغلاف]
|
||||||
|
V --> D[(PostgreSQL)]
|
||||||
|
D --> S[anwarpy.ai]
|
||||||
```
|
```
|
||||||
|
|
||||||
يتحقق الفحص من صفحات HTML والروابط والأصول المحلية وبيانات العرض وسياسة أمان المحتوى.
|
- يملك المالك قائمة مواضيع؛ يعمل خط النشر FIFO (دليل واحد لكل تشغيل، الاثنين/الأربعاء/الجمعة).
|
||||||
|
- كل دليل يُوسم `free` أو `vip`؛ غير الموسوم = مجاني.
|
||||||
|
- الأغلفة 1200×630، ولكل تصنيف غلاف افتراضي احتياطي.
|
||||||
|
|
||||||
## النشر
|
### تدفق الطلب عبر الخادم
|
||||||
|
|
||||||
المستودع الرئيسي مستضاف على Gitea، والفرع الجاهز للنشر هو `main`. يحتوي المشروع أيضًا على مسار عمل GitHub Pages عند نسخ المستودع إلى GitHub وتفعيل Pages من GitHub Actions.
|
```
|
||||||
|
المتصفح → Traefik (حدود المعدل: pagelimit / apilimit / connlimit)
|
||||||
|
→ Next.js 15 (الصفحات + /api)
|
||||||
|
→ Payload 3 (التحكم بالوصول + الخطافات)
|
||||||
|
→ PostgreSQL 16
|
||||||
|
```
|
||||||
|
|
||||||
## حدود النسخة الحالية
|
## 🏗️ البنية التقنية
|
||||||
|
|
||||||
الموقع نموذج واجهة ثابتة. تسجيل الدخول واستعادة كلمة المرور والدفع والعضوية وحماية محتوى VIP محاكاة داخل المتصفح وليست حماية إنتاجية. لا تستخدمه لجمع بيانات أو مدفوعات حقيقية قبل إضافة مصادقة وتفويض ودفع من جهة الخادم.
|
| الطبقة | التقنية |
|
||||||
|
|---|---|
|
||||||
|
| الواجهة | Next.js 15 (App Router) + React 19، عربية RTL |
|
||||||
|
| لوحة التحكم | Payload 3 (`/admin`) مع محرر Lexical |
|
||||||
|
| قاعدة البيانات | PostgreSQL 16 عبر `@payloadcms/db-postgres` |
|
||||||
|
| المصادقة | جلسات Payload، تجزئة PBKDF2-SHA256 600k تكرار |
|
||||||
|
| الوسيط | `web/src/middleware.ts` — حدود معدل لمسارات المصادقة |
|
||||||
|
| الحاوية | Docker Compose + Traefik (TLS + حدود معدل الحافة) |
|
||||||
|
| البريد | Cloudflare (توجيه + إرسال REST) |
|
||||||
|
| المدفوعات | Stripe (جاهز، بوضع «قريبًا» حتى المفاتيح) |
|
||||||
|
|
||||||
|
## 🚀 التشغيل محليًا
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
أنشئ `web/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URI=postgres://anwarpi:anwarpi@127.0.0.1:5432/anwarpi
|
||||||
|
PAYLOAD_SECRET=<سري عشوائي طويل>
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
AGENT_API_KEY=<مفتاح نشر الوكلاء>
|
||||||
|
MAIL_FROM=noreply@anwarpy.ai
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # http://localhost:3000 — لوحة التحكم على /admin
|
||||||
|
npm run build # تحقق قبل النشر
|
||||||
|
npm run seed # تعبئة بيانات تجريبية
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 أهم مسارات الـ API
|
||||||
|
|
||||||
|
| المسار | الوصف |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/users` | تسجيل (حد 5/ساعة لكل IP) |
|
||||||
|
| `POST /api/users/login` | دخول (حد 10/15دقيقة) |
|
||||||
|
| `POST /api/users/forgot-password` | استعادة (حد 5/ساعة) |
|
||||||
|
| `GET/PATCH /api/users/me` | حسابي |
|
||||||
|
| `GET /api/posts` و `GET /api/posts/[slug]` | الأدلة (المحتوى المقفل يُقتطع حسب الدور) |
|
||||||
|
| `POST /api/posts/[slug]/like` / `view` / `comments` | تفاعلات |
|
||||||
|
| `POST/PUT /api/agent/posts` | نشر آلي — يتطلب ترويسة `x-agent-key` |
|
||||||
|
| `POST /api/subscribe` | اشتراك Stripe (يعيد `comingSoon` حتى المفاتيح) |
|
||||||
|
| `POST /api/stripe/webhook` | ويبهوك Stripe — يتحقق من التوقيع |
|
||||||
|
|
||||||
|
## 🔐 الأمان
|
||||||
|
|
||||||
|
- **حدود المعدل** (وسيط Next): تسجيل 5/ساعة، دخول 10/15دقيقة، استعادة 5/ساعة — لكل IP، استجابة 429 مع `Retry-After` ورسالة عامة.
|
||||||
|
- **حدود الحافة** (Traefik): 60 طلب/دقيقة للصفحات (انفجار 120) و50 طلبًا جاريًا لكل IP، و`apilimit` للـ API.
|
||||||
|
- **قاعدة الكتابة الموثوقة**: طلبات العميل لا تستطيع تعيين الدور أو التحقق أو حقول الفواتير — تُحذف في خطاف `beforeChange` (`verificationToken`, `stripeCustomerId`, `role`, `emailVerified`).
|
||||||
|
- **HSTS** `max-age=63072000; includeSubDomains` على كل المسارات.
|
||||||
|
- **تكافؤ زمن الدخول**: البريد غير الموجود يستهلك نفس كلفة PBKDF2 (منع تعداد المستخدمين بالزمن).
|
||||||
|
- **التعليقات مُراقَّبة** ولا تُنشر قبل اعتماد المشرف.
|
||||||
|
|
||||||
|
## 📚 الوثائق
|
||||||
|
|
||||||
|
| الملف | المحتوى |
|
||||||
|
|---|---|
|
||||||
|
| [AGENT.md](AGENT.md) | شرح التطبيق والبنية للوكلاء الآليين (AI agents) |
|
||||||
|
| [GLOSSARY.md](GLOSSARY.md) | مسرد المصطلحات الموحّد (عضوية، مفتوح، موثّق…) |
|
||||||
|
| [docs/adr](docs/adr) | قرارات التصميم (ADRs) |
|
||||||
|
| [brand-spec.md](brand-spec.md) | الهوية البصرية والألوان |
|
||||||
|
| [web/docker-compose.vps.yml](web/docker-compose.vps.yml) | تكوين النشر على الـ VPS |
|
||||||
|
|
||||||
|
## 🚢 النشر
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -az --exclude node_modules --exclude .next --exclude .git \
|
||||||
|
--exclude media --exclude files-private --exclude '.env*' --exclude '*.tsbuildinfo' \
|
||||||
|
web/ root@<VPS>:/root/anwarpi/
|
||||||
|
|
||||||
|
ssh root@<VPS> "cd /root/anwarpi && docker compose -f docker-compose.vps.yml up -d --build"
|
||||||
|
```
|
||||||
|
|
||||||
|
أمر التشغيل داخل الحاوية ينفّذ `payload migrate` تلقائيًا قبل الإقلاع.
|
||||||
|
|||||||
@@ -106,6 +106,9 @@
|
|||||||
.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; }
|
||||||
@@ -212,9 +215,10 @@
|
|||||||
<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="#catalog">المكتبة</a>
|
<a href="articles.html" data-od-id="path-0-0-1-0">المكتبة</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>
|
||||||
@@ -237,16 +241,19 @@
|
|||||||
<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>
|
||||||
@@ -274,10 +281,10 @@
|
|||||||
<p class="lead">تصفح المحتوى حسب ما تريد بناءه، لا حسب المصطلحات التي تحفظها.</p>
|
<p class="lead">تصفح المحتوى حسب ما تريد بناءه، لا حسب المصطلحات التي تحفظها.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="topic-grid">
|
<div class="topic-grid">
|
||||||
<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=agents" data-od-id="topic-agents"><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=models" data-od-id="topic-models"><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=automation" data-od-id="topic-automation"><strong>الأتمتة</strong><span>سير عمل · ربط · مراقبة</span></a>
|
||||||
<a class="topic" href="#catalog" data-topic-link="foundations" data-od-id="topic-foundations"><strong>الأساسيات</strong><span>مفاهيم · تطبيق · أمان</span></a>
|
<a class="topic" href="articles.html?topic=foundations" data-od-id="topic-foundations"><strong>الأساسيات</strong><span>مفاهيم · تطبيق · أمان</span></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -369,7 +376,18 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="pagefoot" data-od-id="footer">
|
<footer class="pagefoot" data-od-id="footer">
|
||||||
<div class="container row-between"><span>© أنور بي آي 2026 · تعلّم بوضوح</span><span class="meta">العربية أولاً · خصوصيتك محفوظة</span></div>
|
<div class="container row-between">
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<!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>
|
||||||
@@ -197,12 +197,15 @@
|
|||||||
"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);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# ADR 0001 — Comments endpoint lives at /api/posts/[slug]/comments, not /api/comments
|
||||||
|
|
||||||
|
Date: 2026-10-03
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The comments API needs a route. The conventional-looking path `/api/comments` collides with Payload: it auto-mounts REST CRUD for every collection at `/api/<slug>`. A `comments` collection therefore already serves `/api/comments` for the admin panel, and moderation (approve/reject) runs through that panel. An app-router route at the same path would shadow and break the panel's CRUD.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Comments are created at `POST /api/posts/[slug]/comments` (`src/app/api/posts/[slug]/comments/route.ts`), matching the existing `/api/posts/[slug]/like` pattern. Admin moderation keeps using Payload's own REST/admin panel.
|
||||||
|
|
||||||
|
## Tests for a real ADR
|
||||||
|
|
||||||
|
- **Hard to reverse**: the endpoint is a public contract consumed by the client form; moving it breaks every deployed client.
|
||||||
|
- **Surprising**: the collision with Payload's auto-mounted REST is non-obvious — a developer would naturally reach for `/api/comments`.
|
||||||
|
- **Real trade-off**: a shorter conventional path vs. a working admin moderation panel.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR 0002 — Moderated comments with server-forced author
|
||||||
|
|
||||||
|
Date: 2026-10-03
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Comments are user-generated content on a public site. Risks: identity spoofing (a request forging `author`), spam (no gate), and orphaned identity data (nested user lookups through restrictive read access). QA-D04 required a real backend for comments.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Comments default to `status: 'pending'`; only `approved` are publicly visible. The read access rule returns `{ status: { equals: 'approved' } }` for non-admins.
|
||||||
|
- The author is forced server-side on create, twice: a `beforeChange` hook on the collection and the custom route — REST-created comments can never spoof identity.
|
||||||
|
- `authorName` is denormalized at submit time (snapshot of `fullName`, else «عضو») to avoid nested user-read access issues when rendering; the email is never exposed.
|
||||||
|
- Approval/rejection is admin-only, done in the Payload admin panel. Authors cannot edit or delete their comments (moderated model).
|
||||||
|
- Authors see their own pending comments rendered on the post page tagged «قيد المراجعة» (via `overrideAccess` query filtered to their own author id), so a submitted comment does not "vanish".
|
||||||
|
|
||||||
|
## Tests for a real ADR
|
||||||
|
|
||||||
|
- **Hard to reverse**: the display-name snapshot and status lifecycle are visible in every rendered comment and in the DB.
|
||||||
|
- **Surprising**: forcing author in two places (hook + route) and `overrideAccess` for the pending query are non-obvious.
|
||||||
|
- **Real trade-off**: moderation latency for users vs. spam safety; denormalization vs. live join through restricted user reads.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR 0003 — Cloudflare Email Sending REST, not SMTP
|
||||||
|
|
||||||
|
Date: 2026-10-03
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The site needs exactly one transactional email: the verification mail sent by `POST /api/auth/resend-verification` via `sendVerificationEmail(to, token)` in `src/lib/mail.ts`. The first implementation used nodemailer over generic SMTP. Cloudflare Email Service (the mail provider chosen for anwarpy.ai) has **no SMTP interface** — it exposes a REST endpoint only — so the nodemailer path could never be activated on the target host.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- `src/lib/mail.ts` sends via `POST https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/email/sending/send` using `fetch`, authenticated with `Authorization: Bearer ${CLOUDFLARE_API_TOKEN}`.
|
||||||
|
- The `from` field uses the REST shape `{ address, name }` (key is `address`, **not** `email`); default address `noreply@anwarpy.ai` (override: `MAIL_FROM`).
|
||||||
|
- Active only when `CLOUDFLARE_API_TOKEN` **and** `CLOUDFLARE_ACCOUNT_ID` are set; otherwise `{ sent: false }` (existing degrade-gracefully contract — the resend route answers 503 with a clear message).
|
||||||
|
- Non-OK response or fetch throw: `console.error` the error body, return `{ sent: false }`. No retry logic (single-shot; the resend route re-invokes).
|
||||||
|
- nodemailer is uninstalled; the exported signature `sendVerificationEmail(to: string, token: string): Promise<{ sent: boolean }>` is unchanged.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The API token needs the Email Sending permission; the sender domain must be onboarded in Cloudflare (SPF/DKIM records created via `wrangler email sending dns get`).
|
||||||
|
- `smtpConfigured()` and all `SMTP_*` env vars are gone; anything expecting them must be updated when mail features grow.
|
||||||
|
- Adding more email types later means extending this one module — it is the single mail boundary.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# ADR 0004 — Coming-soon subscribe on public launch (no simulated VIP)
|
||||||
|
|
||||||
|
Date: 2026-10-03
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Until Stripe keys exist, the subscribe flow had two self-grant holes that are fatal on a public site:
|
||||||
|
|
||||||
|
1. `POST /api/subscribe` (no keys) returned `{ url: '/payment-success?simulated=1' }`.
|
||||||
|
2. `/payment-success` without a `session_id` set `activate = true` — i.e. any logged-in user visiting the URL became `role: 'vip'` for free.
|
||||||
|
|
||||||
|
On the public internet this lets anyone self-grant VIP.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- With no Stripe keys, `POST /api/subscribe` returns `{ comingSoon: true }` and no `url`; `SubscribeButton` already renders a disabled «قريباً» button whenever the response has no `url`.
|
||||||
|
- `/payment-success` no longer activates anything without a session: the `else if (!sessionId) { activate = true }` branch is deleted and `simulated` is removed from the page props. After the activation block, free users (`role` not `vip`/`admin`) are redirected to `/subscription`. VIP/admin users pass through to the success copy.
|
||||||
|
- The real Stripe path is unchanged: `session_id` present + `STRIPE_SECRET_KEY` set → session verified with Stripe → VIP activated.
|
||||||
|
- When Stripe keys land, the same pages switch to real Checkout without further flow changes.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- No revenue until Stripe is configured; VIP can still be granted manually by an admin in `/admin`.
|
||||||
|
- Anyone with a saved `?simulated=1` link gets redirected — the query param is simply ignored now.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ADR 0005 — Member trust model (server-only privilege/verification/billing fields)
|
||||||
|
|
||||||
|
Date: 2026-10-09
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The 2026-10-08 pentest (findings #1, #3, #4, #6) confirmed that anonymous or self-authenticated
|
||||||
|
client requests could write privileged fields on `users`:
|
||||||
|
|
||||||
|
1. `POST /api/users` accepted client-supplied `emailVerified`, `role`, `onboarded`,
|
||||||
|
`stripeCustomerId`, `verificationToken` — e.g. instant self-verified admin.
|
||||||
|
2. Self-`PATCH /api/users/{id}` accepted the same fields on update.
|
||||||
|
3. Per-account login lockout (no time-box) let an attacker lock the admin out of the site
|
||||||
|
indefinitely with wrong-password floods.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- `role`, `emailVerified`, `verificationToken`, `stripeCustomerId`, `onboarded` are stripped from
|
||||||
|
every **untrusted write** in `Users.beforeChange` (`web/src/collections/Users.ts`). Untrusted =
|
||||||
|
no authenticated admin **and** no `context.systemActivation === true`. On create, the hook forces
|
||||||
|
`role: 'free'`, `emailVerified: false`, `onboarded: false`.
|
||||||
|
- Server-side writers opt back in with `context: { systemActivation: true }` on their local-API
|
||||||
|
`payload.update`/`create` calls. Enumerated writers: `verify-email`, `resend-verification`,
|
||||||
|
`subscribe`, `stripe/webhook` (×2), `payment-success`, `seed`. **Omitting the context silently
|
||||||
|
no-ops the write (the field is deleted) — that is the intended failure mode**, so a missing
|
||||||
|
context is caught in QA rather than leaking privilege.
|
||||||
|
- Login lockout is per-account, time-boxed: 5 failed attempts → 15-minute lock (`auth:
|
||||||
|
{ maxLoginAttempts: 5, lockTime: 15 * 60 * 1000 }`).
|
||||||
|
- `role === 'admin'` accounts are **exempt from lockout** (owner decision 2026-10-09): a
|
||||||
|
`beforeOperation` hook on `login` pre-clears `lockUntil`/`loginAttempts` via
|
||||||
|
`db.updateOne` (which, like Payload's own `incrementLoginAttempts`, bypasses hooks). Free/VIP
|
||||||
|
accounts keep the normal 5/15-min lockout.
|
||||||
|
- Commenting requires a verified email; admins are exempt (see the 403 gate in
|
||||||
|
`api/posts/[slug]/comments`).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every future server-side writer of these fields MUST pass `context.systemActivation`; a writer
|
||||||
|
that forgets it silently loses its write.
|
||||||
|
- The admin password has **no brute-force throttle** — compensate with a strong password. If that
|
||||||
|
posture changes, delete the `beforeOperation` hook and the account falls back to 5/15-min lockout.
|
||||||
|
- Client-sent profile fields (`fullName`, `termsVersion`, `interest`, `level`, `goal`, `prefs`) stay
|
||||||
|
writable; only privilege/verification/billing fields are protected.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Marketing-skills audit — anwarpy.ai (2026-10-10)
|
||||||
|
|
||||||
|
> Copy of the local audit of 2026-10-10 (source: agent workspace `marketing-skills-audit-findings.md`), committed for the launch PR. Items marked ❌ were fixed the same day in the launch/early-access branch; see `docs/launch/2026-10-10-launch-readiness.md` for per-item evidence.
|
||||||
|
|
||||||
|
Audited against installed playbooks: paywalls, signup, ai-seo, programmatic-seo, aso (`.agents/skills/`). Evidence: repo `web/src` (file:line) + live production HTML. Verdicts: ✅ compliant · ⚠️ partial · ❌ gap.
|
||||||
|
|
||||||
|
## Paywalls (skill: paywalls v2.0)
|
||||||
|
|
||||||
|
**Current state — technically solid.** Gating = `users.role` (free/vip/admin); VIP body has field-level read ACL (`collections/Posts.ts:57-71`) + page-level select — never served to non-VIP. Teaser paragraphs required for VIP posts (`Posts.ts:39-51`) with fade-out mask; paywall box on locked posts has headline, price-in-CTA («اشترك بـ $10 شهرياً», `lib/format.ts:11`), objection line («تلغي في أي وقت»), login secondary. Per-file byte gate on attachments (`/api/files/[id]`). Stripe: Checkout → `/payment-success` re-verification + webhook activation; deletion webhook downgrades. No dark patterns anywhere.
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
- ❌ Value demonstration: locked H2 titles never shown (`posts/[slug]/page.tsx:202` extracts TOC only when unlocked) — paywall promises «الأقسام العملية» generically.
|
||||||
|
- ❌ Social proof absent on paywall and subscription (subscription shows guide counts only). No fabricated quotes allowed; needs owner-provided testimonial or real aggregate.
|
||||||
|
- ❌ No FAQ link or free-escape-hatch inside the paywall box (FAQ lives only on /subscription).
|
||||||
|
- ❌ Coming-soon discovered after failed click (`subscription/subscribe-button.tsx:16-27`): glossary says disabled «قريباً» button; actual renders active «اشترك الآن» and reveals coming-soon only after POST fails.
|
||||||
|
- ❌ No measurement (paywall impression/CTA events) — blocked on analytics-tooling owner decision.
|
||||||
|
- ⚠️ Pricing: monthly $10 only; skill suggests annual toggle — owner policy.
|
||||||
|
- 🧹 Dead code: `styles/post-locked.css`, `styles/post-unlocked.css` imported nowhere (live styles in globals.css:168-190, design.css:271-287).
|
||||||
|
|
||||||
|
## Signup (skill: signup v2.0)
|
||||||
|
|
||||||
|
**Current state.** 5 fields (الاسم required, email, password ≥8 with show-toggle + hint, تأكيد كلمة المرور, terms checkbox) + hidden `termsVersion`. Native constraint validation with Arabic custom messages; paste allowed; visible labels; single column mobile. Post-submit: auto-login → `/verify-email` (can explore unverified; only commenting gated, 403 with Arabic copy) → `/onboarding` preserving `anwarpi:signup-next`. `safeNext` redirect hygiene. Rate limits: 5/h signup, 10/15min login per-IP (`middleware.ts`), Payload lockout 5/15min (admin exempt, PBKDF2 timing-equalizer).
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
- ❌ **Verification email is never sent at signup** — `sendVerificationEmail` has a single call site (`api/auth/resend-verification/route.ts`); `Users.ts` has no afterChange hook. The verify page claims «أرسلنا رابط التأكيد إلى:» falsely until the user clicks resend. Highest-impact fix.
|
||||||
|
- ❌ 429 body mis-parsed: signup reads only `err?.errors?.[0]?.message` (`signup/page.tsx:44-47`), so middleware's `{error}` JSON is ignored → rate-limited users see «قد يكون البريد مستخدماً بالفعل» (wrong). Login shows one generic message for all failures (`login/page.tsx:29-31`). Retry-After never surfaced.
|
||||||
|
- ❌ No spam-folder step and no change-email affordance on `/verify-email` (skill post-submit checklist).
|
||||||
|
- ❌ Field count above skill's ≤3 guidance (confirm-password cuttable; name kept for comment display names).
|
||||||
|
- ❌ No «مجاناً — بلا بطاقة ائتمانية» / privacy microcopy near CTA; no inline duplicate-email error with login link; no email-typo suggestion; static (not live) password hint.
|
||||||
|
- ❌ Social auth absent (Google/Apple) — needs owner (OAuth apps).
|
||||||
|
- ✅ Already compliant: single email field, no placeholder-only labels, value-story panel, terms timestamped (`termsAcceptedAt`).
|
||||||
|
|
||||||
|
## AI-SEO (skill: ai-seo v2.0)
|
||||||
|
|
||||||
|
**Current state — good SSR baseline.** All pages force-dynamic server components (content in initial HTML); `lang="ar" dir="rtl"`; self-canonical everywhere; title template; sitemap (published posts + lastmod, 8 static paths); robots with disallow list + sitemap pointer; WebSite JSON-LD (home) and Article JSON-LD (posts); answer-first seed content; public per-post JSON at `/api/posts/[slug]`.
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
- ❌ No `llms.txt` / `llms-full.txt`.
|
||||||
|
- ❌ No named AI-crawler robots policy (implied `*` only); `/api` disallow hides the agent-readable post JSON.
|
||||||
|
- ❌ Article pages emit **no og:image** — `generateMetadata` (`posts/[slug]/page.tsx:16-33`) defines `openGraph` which *replaces* the root OG object (no siteName/locale/images), though `post.cover.url` exists.
|
||||||
|
- ❌ Draft leak: the metadata query lacks the `status: 'published'` filter the page query has → draft titles/deks render in metadata of 404 pages.
|
||||||
|
- ❌ Article JSON-LD missing `image`, `dateModified`, `description`, `mainEntityOfPage`, `publisher`.
|
||||||
|
- ❌ FAQPage schema absent despite real Q&A (`subscription/page.tsx:16-21` FAQ_ITEMS); BreadcrumbList absent despite breadcrumb UI (`posts/[slug]/page.tsx:179-183`); no Organization schema.
|
||||||
|
- ❌ Lexical renderer has no `table` node case (~`page.tsx:373-387`) — comparison tables in content fall through to `<p>`.
|
||||||
|
- ⚠️ Freshness: sitemap uses updatedAt but the page shows only publishedAt — add «حُدّث» when they differ.
|
||||||
|
- ✅ Answer-first style present; single H1; h2 extraction; agent-accessible free JSON exists.
|
||||||
|
|
||||||
|
## Programmatic SEO (skill: programmatic-seo)
|
||||||
|
|
||||||
|
**Current state.** Indexable surface = `/`, `/articles`, `/posts/[slug]`, 7 static pages. All category/topic navigation is client-side DOM filtering on ONE URL (`articles-client.tsx:11-19, 62-70`) — clicks never update the URL; `?topic=` states share one canonical. `/catalog` permanently redirects to /articles; `catalog-filters.tsx` + `catalog-deck.tsx` exported nowhere (dead).
|
||||||
|
|
||||||
|
**Data available in Postgres** (Posts): title, slug, dek, category (6 values), level (3), access, status, publishedAt/updatedAt, readingMinutes, views, likes, cover, attachments. Public taxonomy already exists: `lib/topics.ts` — 4 topics (`agents` الوكلاء, `models` النماذج, `automation` الأتمتة, `foundations` الأساسيات) mapped from the 6 CMS categories via `topicsFor(post)` + `isTopic()`.
|
||||||
|
|
||||||
|
**Decision (owner-confirmed 2026-10-10): build 4 topic hub pages `/topics/[topic]`** — server-rendered directories with unique intros, counts, breadcrumbs + BreadcrumbList, sitemap entries, internal links from home/articles/post breadcrumbs. Later candidates (not now): topic×level personas (gate on ≥3 posts/combo), templates directory, glossary (needs new field). Comparisons/locations/translations: no data, N/A.
|
||||||
|
|
||||||
|
## ASO (transferable items only — site, not an app)
|
||||||
|
|
||||||
|
- ❌ Home title is brand-only «أنور بآي» — unknown brands need keywords: «أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية».
|
||||||
|
- ❌ Only root + posts have descriptions; give /articles, /subscription, /about their own Arabic descriptions.
|
||||||
|
- ❌ One static OG image for everything → per-post `cover` as og:image (same fix as ai-seo list).
|
||||||
|
- ❌ Surface «حُدّث» freshness date on articles.
|
||||||
|
- ✅ Localization correct (ar locale, RTL, og:locale). Ratings/screenshot fields: N/A.
|
||||||
|
|
||||||
|
## Needs owner (not in this pass)
|
||||||
|
|
||||||
|
1. Social auth providers (Google/Apple or magic links) — OAuth apps + keys.
|
||||||
|
2. Analytics/experimentation tooling (paywall impressions, CTA CTR, form drop-off) — vendor + privacy stance; code ready once chosen.
|
||||||
|
3. Annual plan / money-back guarantee — pricing policy.
|
||||||
|
4. Testimonial content — must be real; nothing fabricated.
|
||||||
|
5. Brand spelling inconsistency: site uses «أنور بآي» everywhere (title, og:site_name, JSON-LD); docs use «أنور بي آي». Pick one canonical spelling.
|
||||||
|
6. Policy: allow free users to comment on VIP posts (currently hard-403) — easy conversion lever if allowed.
|
||||||
|
7. Sitemap `limit: 500` with no pagination — fine at 13 posts; revisit at scale. /login + /signup kept in sitemap.
|
||||||
|
8. `GLOSSARY.md` says free users are *redirected* from /payment-success; actual behavior is an in-page retry state — doc will be aligned to code (no redirect; no VIP minting either way).
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
# Anwarpy.ai launch readiness and developer handoff
|
||||||
|
|
||||||
|
Prepared for Ahmed and the developer team. Basis: public-site audit on 10 October 2026, refreshed membership/terms/privacy pages, Ahmed's confirmation that Stripe is unfinished, and the installed launch skill's readiness and site-QA framework.
|
||||||
|
|
||||||
|
## 1. Decision
|
||||||
|
|
||||||
|
**Paid/VIP launch: NO-GO at present. Free early-access launch: CONDITIONAL GO after the launch-blocking checks below pass.**
|
||||||
|
|
||||||
|
Stripe does not have to delay a useful free content launch. Launch the complete experience of reading useful Arabic posts and approved downloads first. Run an approximately two-week learning period; treat its end as a review checkpoint, not an automatic paywall date. Continue free early access if Stripe or access control remains unready. Do not advertise an exact paid-start date until approved.
|
||||||
|
|
||||||
|
This document is a proposed implementation specification, not evidence that fixes have been applied. No website, Stripe account, source notebook or production database was modified during preparation. Ticket owners are recommended roles; individual assignees and calendar dates remain to be assigned.
|
||||||
|
|
||||||
|
### User decisions incorporated
|
||||||
|
|
||||||
|
- Blog content originates primarily in AppFlowy and Obsidian notes.
|
||||||
|
- An AI agent will help turn approved notes into blog posts.
|
||||||
|
- Stripe integration is unfinished.
|
||||||
|
- Desired initial period: about two weeks with free access, followed by VIP gating after Stripe is ready.
|
||||||
|
|
||||||
|
### Important correction to the earlier audit
|
||||||
|
|
||||||
|
Short notes are a legitimate content type. A 150–300-word note need not become a long tutorial to be useful. The problem is a mismatch between short content and labels promising a complete implementation or nine/ten minutes of reading. Add explicit content types and honest estimates. Expand only posts intended to be implementation guides, not every note.
|
||||||
|
|
||||||
|
## 2. Evidence and status
|
||||||
|
|
||||||
|
Confirmed in the earlier public audit:
|
||||||
|
- All 22 unique sitemap URLs returned 200, including 14 posts: eight public full posts and six VIP previews.
|
||||||
|
- Public text and membership price were present in initial HTML.
|
||||||
|
- Canonicals, Arabic/RTL settings, sitemap and robots.txt exist.
|
||||||
|
- Article and WebSite JSON-LD were observed in initial HTML. Schema is not absent.
|
||||||
|
- Homepage/library titles are generic; eight non-post pages share a generic description.
|
||||||
|
- Login and signup are in the sitemap; no noindex meta was observed in their initial HTML.
|
||||||
|
- www serves a 200 homepage while canonical points to the apex domain.
|
||||||
|
- First-agent and RAG article bodies were approximately 165 and 154 whitespace-delimited words, despite nine/ten-minute reading estimates.
|
||||||
|
- No n8n mention was found in public text of the 14 posts. Paid bodies were not inspected.
|
||||||
|
- Some free posts display download controls, while the paid comparison frames working files as a membership benefit. Actual download behavior was not tested.
|
||||||
|
|
||||||
|
Rechecked for this handoff:
|
||||||
|
- /subscription still advertises $10 monthly, subscription CTAs and Stripe payment.
|
||||||
|
- /terms still declares itself a prelaunch draft and calls the price experimental.
|
||||||
|
- /privacy still contains prelaunch placeholders for actual providers, retention and effective dates.
|
||||||
|
|
||||||
|
Not verified; developer must test:
|
||||||
|
- Full reading/download access when launch-free mode is applied.
|
||||||
|
- Account signup, email verification, password reset, save/comment flows.
|
||||||
|
- Mobile rendering, RTL behavior, browser interactions, accessible controls.
|
||||||
|
- Checkout, webhooks, entitlements, cancellation and renewal.
|
||||||
|
- Paid-body protection in APIs, page source, hydration payloads, search, feeds, assets and caches.
|
||||||
|
- Restore procedures, monitoring, deployed security controls.
|
||||||
|
- Search Console index status, field performance, analytics and actual AI citations.
|
||||||
|
|
||||||
|
The audit browser failed to start and PageSpeed returned 429. These failures do not establish website defects. Public metadata checks do not certify application behavior.
|
||||||
|
|
||||||
|
## 3. Recommended launch policy
|
||||||
|
|
||||||
|
### Free launch mode
|
||||||
|
|
||||||
|
Recommended initial settings, to adapt to the actual application rather than copy as existing field names:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
launch_mode: free_early_access
|
||||||
|
billing_enabled: false
|
||||||
|
vip_enforcement_enabled: false
|
||||||
|
review_checkpoint: approximately_two_weeks_after_actual_launch
|
||||||
|
paid_transition: manual_owner_approval_only
|
||||||
|
reading_requires_account: false
|
||||||
|
newsletter_opt_in_required: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Open approved published content, including posts designated for future VIP access, during the early-access period. Keep drafts, private source notes, internal comments and unsafe attachments private. Guest reading should work without an account; accounts can remain optional for tested features. If signup is not ready, disable its CTA instead of blocking reading.
|
||||||
|
|
||||||
|
**Do not create Stripe trial subscriptions to implement this temporary editorial launch.** A site-level free-access policy is simpler and avoids accidentally collecting cards, issuing charges or making every reader a billing customer.
|
||||||
|
|
||||||
|
### Preserve editorial classification
|
||||||
|
|
||||||
|
Store the intended tier independently from effective access. A post can be designated VIP for the future while temporarily open now. Recommended conceptual fields:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
publication_status: draft | review | published | archived
|
||||||
|
content_type: note | tutorial | case_study | comparison
|
||||||
|
intended_tier: free | vip
|
||||||
|
early_access_eligible: true | false
|
||||||
|
source_reference: private_traceability_record
|
||||||
|
author_id: approved_real_author
|
||||||
|
review_status: pending | approved
|
||||||
|
```
|
||||||
|
|
||||||
|
The global launch override should apply only to approved published posts and approved associated assets. Never rewrite every VIP post to a permanent free tier, grant permanent VIP user roles, or expose raw CMS records in a public launch feed.
|
||||||
|
|
||||||
|
### What happens after the two-week checkpoint?
|
||||||
|
|
||||||
|
- If Stripe is not ready: continue free mode, update messaging and set a new review checkpoint.
|
||||||
|
- If Stripe is ready but tests fail: continue free mode; repair failures before gating.
|
||||||
|
- If tests pass: notify readers, specify the future gated set, have Ahmed approve the transition, then enable billing and gating in a controlled rollout.
|
||||||
|
- Posts designated permanently free remain free.
|
||||||
|
- Posts explicitly labeled as temporary VIP early-access may become previews. Preserve their URLs and public summaries.
|
||||||
|
- Already downloaded public files cannot reliably be recalled. Do not promise otherwise.
|
||||||
|
- Readers are never automatically subscribed or charged. Joining an update list is not consent to recurring billing.
|
||||||
|
|
||||||
|
Recommended trust-preserving alternative: keep the strongest launch tutorials free permanently and monetize new advanced workflows and files. This is an option for Ahmed to choose, not an assumed change to his requested VIP plan.
|
||||||
|
|
||||||
|
## 4. P0 blockers for the free launch
|
||||||
|
|
||||||
|
### L-01 — Replace premature paid messaging
|
||||||
|
|
||||||
|
**Owner:** developer + Ahmed. **Evidence:** /subscription, homepage membership section and VIP CTAs currently promise purchasing through Stripe.
|
||||||
|
|
||||||
|
**Change:** Replace purchase CTAs across home, membership, cards, article gates, menus and account pages. Retain /subscription URL but present early-access status and an optional, functional notification signup. Disable checkout initiation server-side as well as hiding buttons. Never publish a fake working purchase flow.
|
||||||
|
|
||||||
|
Suggested Arabic copy:
|
||||||
|
|
||||||
|
> وصول مبكر مجاني
|
||||||
|
>
|
||||||
|
> يمكنك قراءة المحتوى المنشور خلال فترة الوصول المبكر دون بطاقة دفع. نراجع إطلاق العضوية بعد نحو أسبوعين، وقد تمتد الفترة إذا لم تكتمل خدمة الدفع. بعض المواد محددة للعضوية مستقبلاً، وسنوضح ذلك قبل تفعيلها. لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.
|
||||||
|
|
||||||
|
For an intended-VIP post:
|
||||||
|
|
||||||
|
> متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.
|
||||||
|
|
||||||
|
Use only when this is the actual implemented access policy. Do not put an unapproved fixed end date in the UI. Remove '$10 subscribe now' and completed-payment claims until payment is genuinely enabled. A future-price preview, if retained, must be labeled not currently purchasable and subject to owner confirmation.
|
||||||
|
|
||||||
|
**Acceptance:** Search all rendered templates and emails for obsolete purchase claims; direct checkout calls fail safely with an explicit unavailable response; no card data is requested; /subscription remains a useful page rather than 404.
|
||||||
|
|
||||||
|
### L-02 — Implement consistent launch-free access
|
||||||
|
|
||||||
|
**Owner:** backend developer. **Evidence:** six posts currently show public membership previews; desired open period is new policy.
|
||||||
|
|
||||||
|
**Change:** One server-side policy function controls article bodies and downloads. It checks publication/review status, actual launch mode, intended tier, and valid entitlement when paid mode is eventually enabled. Frontend labels consume the same effective-access decision.
|
||||||
|
|
||||||
|
**Acceptance:** Guest can read every approved launch-open post from its canonical URL, library and related links. Approved downloads work. Draft/private/unreviewed posts remain inaccessible. Original intended tier survives opening and rollback. Repeated config changes are idempotent. Date expiry alone cannot activate a paywall.
|
||||||
|
|
||||||
|
### L-03 — Finalize free-launch terms and privacy
|
||||||
|
|
||||||
|
**Owner:** Ahmed + qualified reviewer + developer. **Evidence:** /terms and /privacy are publicly marked drafts with unfinished instructions.
|
||||||
|
|
||||||
|
**Change:** Document the actual free service, content license, whether future VIP materials are temporary-access, contact channel, real data collection, providers, retention, deletion process and effective date. Distinguish operational email from optional marketing. Remove paid billing provisions that do not apply now or label future features accurately. Do not invent legal entity, governing law, retention duration or compliance claims.
|
||||||
|
|
||||||
|
**Acceptance:** Final pages contain facts approved by the operator, no editorial TODO instructions, and a real effective date. Their account and analytics descriptions match the deployed behavior. If analytics requires consent in the applicable context, blocking and withdrawal work. Legal text is professionally reviewed as appropriate; this document is not legal advice.
|
||||||
|
|
||||||
|
### L-04 — Test the complete reading and download journey
|
||||||
|
|
||||||
|
**Owner:** QA/developer. **Status:** unverified, not a known failure.
|
||||||
|
|
||||||
|
**Change:** Test home → library → each launch article → approved file download on desktop and mobile, including Safari and Telegram in-app browser. Check filters, navigation, pagination if any, table/code readability, Arabic text mixed with English commands, and contact links.
|
||||||
|
|
||||||
|
**Acceptance:** No placeholder/empty files; names and contents match promises; links and images resolve; keyboard controls and labels work; no horizontal page overflow. Code blocks may scroll independently. For meaningful diagrams use descriptive alt; decorative card covers may have empty alt. Store a browser/version test log and example screenshots.
|
||||||
|
|
||||||
|
### L-05 — Protect private notes and publishing permissions
|
||||||
|
|
||||||
|
**Owner:** developer + editorial operator. **Status:** new requirement for the publishing pipeline, not an observed leak.
|
||||||
|
|
||||||
|
**Change:** Separate public artifacts from AppFlowy/Obsidian sources. Restrict the agent to explicitly approved notes and CMS permissions. Scan text, images and downloads for credentials, private URLs, customer data, internal names and unlicensed assets. Sanitize Markdown/HTML, block executable content and unsafe URLs.
|
||||||
|
|
||||||
|
**Acceptance:** Deliberately seeded private markers in test inputs never reach public output. Source notes remain unchanged. An agent cannot publish another author's private note, alter a tier, modify billing, or inject unsafe HTML. Logs contain identifiers and outcomes rather than secrets/full private source text.
|
||||||
|
|
||||||
|
### L-06 — Reliable feedback, monitoring and rollback
|
||||||
|
|
||||||
|
**Owner:** developer + operator. **Status:** unverified.
|
||||||
|
|
||||||
|
**Change:** Ensure at least one monitored support/feedback route works. Add uptime/error visibility, persistence/backups and a tested restore. Test optional signup/reset flows before promoting them; disable unfinished features rather than presenting stubs.
|
||||||
|
|
||||||
|
**Acceptance:** A test feedback request reaches the actual destination and confirmation is truthful. A restore/rollback rehearsal recovers public content and configuration. Transactional notices reach the intended test mailbox without leaking data. Operator and emergency contact are named in the runbook.
|
||||||
|
|
||||||
|
## 5. P1 work during the early-access period
|
||||||
|
|
||||||
|
### L-07 — Content labels and reading estimates
|
||||||
|
|
||||||
|
**Owner:** editorial + developer. Add visible 'ملاحظة تقنية', 'دليل عملي', 'تجربة موثقة' or equivalent content-type labels. Compute reading estimate from article body using a configurable rate validated with Arabic readers; exclude navigation/comments and treat exercise time separately. Do not force a minimum long reading time.
|
||||||
|
|
||||||
|
**Acceptance:** Short notes are labeled honestly; tutorials show prerequisites and usable steps. The first-agent and RAG labels/estimates match actual content. No arbitrary padding to increase word count.
|
||||||
|
|
||||||
|
### L-08 — Metadata and domain hygiene
|
||||||
|
|
||||||
|
**Owner:** developer + editorial. Write individual descriptions for home/library/early-access/about. Improve discoverability-oriented titles without adding unsupported topics. Review noindex for login/signup, remove utility pages from sitemap, retain crawl visibility so noindex can be read. Permanently redirect www to apex while preserving path/query. Test nonexisting paths remain 404 and HTTPS canonical consistency.
|
||||||
|
|
||||||
|
**Acceptance:** Public posts remain indexable, private/draft pages do not enter sitemap, sitemap URLs return canonical 200s, metadata matches actual article and access status. Never ship staging noindex to public content.
|
||||||
|
|
||||||
|
### L-09 — Author, evidence and structured data
|
||||||
|
|
||||||
|
**Owner:** editorial + developer. Display real author/reviewer and sources. Enhance Article/BlogPosting fields with author profile, image, dates and canonical identity where correct. Do not invent credentials, reviews or performance data. Validate initial and rendered markup with suitable validators. Preserve existing useful schema.
|
||||||
|
|
||||||
|
**Acceptance:** Visible author and schema agree. Update date reflects real substantive updates. Early-access availability and future paywall fields match actual current access; no false paid Offer while billing is off. Validators report understood results; no promise of a special Google AI ranking boost.
|
||||||
|
|
||||||
|
### L-10 — Publishing workflow and deduplication
|
||||||
|
|
||||||
|
**Owner:** developer + editorial. Implement the companion agent prompt. Map proposed metadata to real CMS fields, create drafts first, review evidence and redactions, approve a specific revision, then publish and read it back. Use stable source IDs plus revision/hash to prevent duplicate posts; existing slugs should not change silently.
|
||||||
|
|
||||||
|
**Acceptance:** Same source retried produces no duplicate. Missing-source and failed-test inputs stop safely. Live output is verified against the approved draft. Retries after an ambiguous publish first search/read the expected record. Previous revisions can be restored. No note is marked published until verification succeeds.
|
||||||
|
|
||||||
|
### L-11 — Measure the launch
|
||||||
|
|
||||||
|
**Owner:** operator + developer. Configure privacy-respecting analytics and search ownership. Suggested events: article_view, meaningful_read, download_success, signup_confirmed, notification_opt_in_confirmed, feedback_received. Fire confirmations on real success, not button clicks. Define meaningful_read rather than using a page-open event as consumption.
|
||||||
|
|
||||||
|
**Acceptance:** Test event evidence exists, duplicate analytics installation is avoided, consent is respected, no source notes/emails/credentials appear in event parameters. Verify sitemap and priority URL inspection in Search Console; a search-tool miss is not proof of nonindexation.
|
||||||
|
|
||||||
|
### L-12 — Content and launch distribution
|
||||||
|
|
||||||
|
**Owner:** Ahmed/editorial. Select an initial set of useful notes and at least one demonstrated implementation if advertised as an implementation library. Publish a specific n8n guide only when an approved working example exists. Keep related links useful; differentiate overlapping beginner posts.
|
||||||
|
|
||||||
|
Use the launch skill's owned/rented/borrowed channel mix proportionally:
|
||||||
|
- Owned: website and opt-in email updates.
|
||||||
|
- Rented: one LinkedIn announcement and a real demonstration where the audience is present.
|
||||||
|
- Borrowed: ask relevant collaborators for feedback/share only with permission.
|
||||||
|
|
||||||
|
**Acceptance:** Announcement links to an actually accessible post, accurately states free early access, makes no fabricated testimonial or availability claim, and has a monitored feedback path. Product Hunt, affiliate programs and paid ads are optional, not prerequisites for this content launch.
|
||||||
|
|
||||||
|
## 6. Stripe/VIP gate — required only before paid launch
|
||||||
|
|
||||||
|
Do not block the initial free reading experience on these. Do block purchases and paid enforcement until complete.
|
||||||
|
|
||||||
|
### S-01 — Business and account readiness
|
||||||
|
|
||||||
|
Operator checks actual Stripe entity eligibility, verification, settlement/payout setup and enabled capabilities for the business jurisdiction. Do not assume an account is eligible because customers are Saudi or a domain is .sa. No workaround through an unsupported entity. Resolve applicable billing/tax requirements with qualified advice. Do not promise a completion timeline based on this document.
|
||||||
|
|
||||||
|
### S-02 — Product, price and checkout
|
||||||
|
|
||||||
|
Create/verify the approved subscription product and immutable price reference in the correct environment. Confirm currency, monthly interval, actual amount, tax treatment and displayed totals. Use a hosted Checkout subscription flow rather than handling raw card details. Server selects the allowed price, amount and user; reject client-supplied substitutions. Map customer/subscription to the authenticated app user using trusted server-side data.
|
||||||
|
|
||||||
|
**Tests:** correct plan, tampered price, wrong customer, repeated checkout, cancelled checkout, authentication-required payment and declined payment. Returning to a success URL never grants VIP by itself. Early free access is not retroactively billed.
|
||||||
|
|
||||||
|
### S-03 — Verified webhook processing
|
||||||
|
|
||||||
|
Use official signature verification with the raw body and the proper environment-specific signing secret. Persist event IDs, process duplicates safely and reconcile subscription state instead of assuming delivery order. Durably record/enqueue work before acknowledging; maintain retry/dead-letter visibility. Use narrowly selected relevant events such as checkout completion, invoice paid/payment failure and subscription changes/end as required by the chosen integration; account for delayed payment methods if enabled.
|
||||||
|
|
||||||
|
**Tests:** invalid signature, duplicate delivery, out-of-order event, network interruption, delayed event, update/cancellation and recovery. A duplicate event neither duplicates entitlements nor creates additional charges. Test mode never activates production VIP rights.
|
||||||
|
|
||||||
|
### S-04 — Server-side entitlements and lifecycle policy
|
||||||
|
|
||||||
|
Define explicit access rules for active, incomplete, unpaid, past-due, canceled and any genuinely supported trial states. Document grace periods; do not invent one silently. Maintain paid-through timestamps and cancellation-at-period-end behavior consistent with promises. Reconcile against Stripe after missed events; do not revoke valid existing access merely because of a transient retrieval error. New unverifiable purchases stay pending and are not granted based on UI redirects.
|
||||||
|
|
||||||
|
**Tests:** paid user receives correct access; nonpayer cannot gain VIP by changing frontend storage; scheduled cancellation retains access through paid end and expires correctly; renewal extends once; failed renewal follows the approved policy; reversal/refund/dispute handling matches business policy.
|
||||||
|
|
||||||
|
### S-05 — Prevent full-content leaks
|
||||||
|
|
||||||
|
Paid gating must exclude restricted bodies/assets for unauthorized users from server HTML, page hydration/RSC payloads, APIs, GraphQL/REST responses, RSS/search feeds, public files, alternate Markdown and optional llms exports. Do not send full bodies and blur/hide them with CSS. Protect VIP downloads server-side and use scoped delivery where needed.
|
||||||
|
|
||||||
|
Separate guest/free/member caching; invalidate public copies of newly restricted content when switching. Previously public copies on third-party caches cannot be fully recalled.
|
||||||
|
|
||||||
|
**Tests:** guest/free user sees a useful public intro but never the restricted payload; valid member can read/download; expired member is denied; direct asset/API attempts do not bypass checks. If a crawler sampling policy is used, follow applicable search guidance without returning special promotional content or disabling authentication for arbitrary bot strings.
|
||||||
|
|
||||||
|
### S-06 — Customer self-service and notices
|
||||||
|
|
||||||
|
Provide a tested billing-management path for invoices, payment-method updates and cancellation. The app must generate a portal session for the logged-in user's own customer, not arbitrary IDs. Verify actual supported portal language; do not promise Arabic availability without testing. Keep Arabic app guidance around an English portal if needed. Confirm receipt/support contacts, statement details, renewal/cancellation notices and refund policy.
|
||||||
|
|
||||||
|
### S-07 — Sandbox acceptance and production preparation
|
||||||
|
|
||||||
|
Use Stripe sandbox/test resources and official test methods for success, failures, authentication, renewals and time-based lifecycle scenarios. Do not use real card data or arbitrary real purchases for testing. Record environment, SDK/API versions, relevant object/event IDs, expected result, actual result and pass/fail, with secrets removed. Test signing-secret rotation and failure visibility. Production keys and price IDs are stored securely and never inserted into source notes, prompts or repositories.
|
||||||
|
|
||||||
|
### S-08 — Owner-approved transition and rollback
|
||||||
|
|
||||||
|
Before switch, take an access/content snapshot, confirm notification copy and approve a list of specific intended-VIP posts/assets. Keep canonical URLs; retain public introductions and meaningful metadata. Synchronize all counters, cards, comparison tables and policies. Invalidate relevant caches and verify guest/member journeys immediately.
|
||||||
|
|
||||||
|
Rollback should disable new purchases and revert the faulty restriction configuration without deleting Stripe records, canceling paid customers or erasing paid entitlements. Repair paid readers' access quickly and reconcile outstanding events. A kill switch is not authorization to give all private content away.
|
||||||
|
|
||||||
|
## 7. Approximate two-week operating plan
|
||||||
|
|
||||||
|
Relative timing only; actual launch date is not yet set.
|
||||||
|
|
||||||
|
- Before day 1: close L-01 through L-06, approve content/access labels, and complete the free-launch QA gate.
|
||||||
|
- First few days: publish one useful launch post, invite a small set of readers, gather specific feedback, fix broken journeys before wider announcement.
|
||||||
|
- First week: ship reviewed notes and at least one evidence-backed implementation if available; correct metadata/reading estimates; test notifications and source pipeline.
|
||||||
|
- Second week: review read/download/feedback evidence, publish improvements, and complete Stripe sandbox work in parallel.
|
||||||
|
- Around the two-week checkpoint: inspect the free experience and Stripe/VIP test matrix. Either extend early access honestly or schedule an owner-approved paid transition. Do not activate gating solely because a calendar timer expired.
|
||||||
|
|
||||||
|
No automatic schedule or campaign was created by this document.
|
||||||
|
|
||||||
|
## 8. Developer signoff checklist
|
||||||
|
|
||||||
|
### Free launch GO only when
|
||||||
|
|
||||||
|
- [ ] L-01: paid claims and backend purchase paths are disabled consistently.
|
||||||
|
- [ ] L-02: all approved launch-open content/assets work for intended readers; private content stays private.
|
||||||
|
- [ ] L-03: real free-launch terms/privacy/access expectations are approved and published.
|
||||||
|
- [ ] L-04: desktop/mobile/in-app reading and download QA passes.
|
||||||
|
- [ ] L-05: note redaction, safe rendering and agent permissions pass negative tests.
|
||||||
|
- [ ] L-06: feedback, monitoring, backup/restore and rollback work.
|
||||||
|
- [ ] Ahmed approves the precise launch-open set and announcement.
|
||||||
|
|
||||||
|
### Paid launch GO only when
|
||||||
|
|
||||||
|
- [ ] S-01 to S-08 pass with recorded evidence.
|
||||||
|
- [ ] Actual billing currency, amount, tax and refund handling are approved.
|
||||||
|
- [ ] Guest, free, active member and expired member access matrices pass.
|
||||||
|
- [ ] No restricted data appears in anonymous responses/feeds/assets/caches.
|
||||||
|
- [ ] Readers receive honest notice of the transition and no automatic charge.
|
||||||
|
- [ ] Ahmed signs off the transition; operator and rollback owner are assigned.
|
||||||
|
|
||||||
|
For each item record: ticket ID; assignee; environment; PR/deployment ID; test steps; expected/actual result; evidence link; status; remaining risk; reviewer. A checkbox without evidence is not release verification.
|
||||||
|
|
||||||
|
## 9. Decisions Ahmed/developer must settle
|
||||||
|
|
||||||
|
1. Which existing intended-VIP posts/assets are temporarily opened? Recommended: approved published VIP materials only, not raw notes or drafts.
|
||||||
|
2. Which launch tutorials remain permanently free? Recommended: retain core introductory tutorials for discovery.
|
||||||
|
3. What must require an account? Recommended: guest reading; account only for tested save/comment functions.
|
||||||
|
4. Is an email-update feature ready? If not, omit the CTA rather than deploying a fake signup.
|
||||||
|
5. What notice period will be used before restricting temporary VIP early-access materials? Agree and implement; no arbitrary period is treated as approved here.
|
||||||
|
6. Who approves posts and launch switches? Until explicitly delegated, human approval is required for a specific article revision and for paid-mode changes.
|
||||||
|
7. What are the actual retention periods, providers, legal entity and billing jurisdiction? Operator supplies facts; the agent must not invent them.
|
||||||
|
|
||||||
|
## 10. Reference links
|
||||||
|
|
||||||
|
Website evidence:
|
||||||
|
- https://anwarpy.ai/
|
||||||
|
- https://anwarpy.ai/articles
|
||||||
|
- https://anwarpy.ai/subscription
|
||||||
|
- https://anwarpy.ai/terms
|
||||||
|
- https://anwarpy.ai/privacy
|
||||||
|
- https://anwarpy.ai/posts/first-arabic-agent
|
||||||
|
- https://anwarpy.ai/posts/simple-arabic-rag
|
||||||
|
- https://anwarpy.ai/posts/dograh-voice-agent-evaluation
|
||||||
|
- https://anwarpy.ai/sitemap.xml
|
||||||
|
- https://anwarpy.ai/robots.txt
|
||||||
|
|
||||||
|
Stripe primary guidance consulted:
|
||||||
|
- https://docs.stripe.com/billing/subscriptions/webhooks
|
||||||
|
- https://docs.stripe.com/webhooks
|
||||||
|
- https://docs.stripe.com/testing
|
||||||
|
- https://docs.stripe.com/customer-management
|
||||||
|
|
||||||
|
Companion document: anwarpy-blog-publishing-agent-prompt.md. The prompt controls content generation and approval, not billing activation.
|
||||||
|
|
||||||
|
## ملخص عربي
|
||||||
|
|
||||||
|
الإطلاق المدفوع غير جاهز الآن لأن ربط Stripe لم يكتمل. يمكن إطلاق قراءة مجانية مبكرة بعد إصلاح رسائل الاشتراك والسياسات، وفتح المواد المعتمدة فقط، واختبار القراءة والتنزيل وحماية الملاحظات الخاصة. الأسبوعان نقطة مراجعة وليسا موعد حجب تلقائي. الملاحظات القصيرة مقبولة إذا سُمّيت ملاحظات وكان وقت القراءة صادقاً. لا تتحول المواد إلى VIP إلا بعد نجاح اختبارات الدفع والصلاحيات وإبلاغ القراء وموافقة أحمد، ولا يُنشأ اشتراك أو يُحصّل مبلغ تلقائياً.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Evidence — free-launch items executed 2026-10-10
|
||||||
|
|
||||||
|
Launch implemented on branch `launch/early-access-2026-10-10` (step references = execution plan). `LAUNCH_MODE=free_early_access` is the compose default; billing stays off until owner approval. Per the checklist rule, a checkbox without evidence is not verification — prod-verify items below are re-checked at step 48 and the lines updated.
|
||||||
|
|
||||||
|
- **L-01 (paid claims off)** — Steps 8–11, 21, 22: `/subscription` shows an early-access panel + working notify form; all subscribe CTAs render a disabled «قريباً» button when billing is off (`subscribe-button.tsx` comingSoon prop); `POST /api/subscribe` returns 503 server-side with the exact Arabic error; home membership copy swept for purchase claims. *Prod re-check: step 48.*
|
||||||
|
- **L-02 (launch-free access policy)** — Steps 1–6: `src/lib/launch.ts` policy (`LAUNCH_MODE`, fail-safe default = free early access, no date expiry), field-level `content` read ACL in `Posts.ts` (public iff `status=published && (access=free || early)`) — same decision consumed by pages, `/api/posts/[slug]`, file gate, and llms-full. Intended tier (`access`) never rewritten. *Verified: migration chain + step 45/48 ghost-read.*
|
||||||
|
- **L-03 (terms/privacy)** — **NOT DONE — owner facts pending.** `docs/launch/owner-inputs-terms-privacy.md` lists exactly what Ahmed must supply; `/terms` and `/privacy` untouched until then.
|
||||||
|
- **L-04 (reading/download QA)** — Step 45 local smoke + step 48 prod QA (desktop 1280px, mobile 390px Chromium, home → library → topic hub → article → download). Safari and Telegram in-app browsers are owner-manual tests.
|
||||||
|
- **L-05 (private notes / pipeline permissions)** — Steps 28–30: agent API strips `access`/`readingMinutes` from accepted fields; `sourceId` dedup returns the existing post (no duplicate); `scanForSecrets` 422s payloads matching AWS AKIA / PEM / sk- / ghp_ / generic-credential patterns (pattern names only in the response); publish stays an explicit opt-in and PUT cannot set `status`.
|
||||||
|
- **L-06 (feedback, backup, rollback)** — Step 40: `scripts/backup-db.sh` + restore rehearsal on the VPS (2026-10-10): `pg_dump` → gzip 25K, restored into `anwarpi_restore_test` with 0 SQL errors, counts `posts=15, users=6, payload_migrations=6`, temp DB dropped. Feedback: footer `mailto:hello@anwarpy.ai` (opens the visitor's mail client; no server dependency). Production mail transport test via the same Cloudflare Email Sending REST call the app uses → `10203 email.sending_disabled` — **the account feature is off; transactional mail (verification, notify confirmations) will honestly report failure until the owner enables Email Sending**. Monitoring/uptime tooling: owner decision, recorded not built.
|
||||||
|
- **L-07 (labels + reading estimates)** — Steps 24–26: `contentType` (note/tutorial/case_study/comparison) badge, `authorName` byline, «حُدّث» freshness line; `readingMinutes` computed at write time via `countLexicalWords`/`READING_WORDS_PER_MINUTE` (160 wpm) with `scripts/backfill-reading-minutes.ts` (13 scanned, 0 drift on seeded content).
|
||||||
|
- **L-08 (metadata/domain hygiene)** — Steps 32, 37, 38, 39: Arabic descriptions on articles/subscription/about; sitemap gains `/topics/*` and drops `/login`,`/signup`; those pages (plus forgot-password) are `robots: noindex, follow`; Traefik `anwarpi-www` router 301s `https://www.anwarpy.ai/*` → apex with priority above the API router. *Prod re-check: `curl -sI https://www.anwarpy.ai/posts`, step 48.*
|
||||||
|
- **L-09 (author/evidence/structured data)** — Step 33: Article JSON-LD with `image`, `dateModified`, `description`, `mainEntityOfPage`, publisher logo, `author` = Person (authorName) or Organization; `isAccessibleForFree` = actual current access (never a false paid Offer while billing is off); BreadcrumbList (home → topic hub → post); FAQPage on `/subscription`; Organization on home. Visible byline and schema share the same fields.
|
||||||
|
- **L-10 (publishing workflow/dedup)** — Steps 24, 28–31: agent creates drafts (`status` forced draft unless `publish: true`), `sourceId` unique-ish dedup before slug-409, secret scan on POST/PUT.
|
||||||
|
- **L-11 (measurement)** — **NOT BUILT — owner decision** (analytics vendor; recorded only). Suggested events remain in the checklist.
|
||||||
|
- **L-12 (content/distribution)** — **Owner/editorial.** No announcement drafted or sent by the agent.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Owner inputs required for L-03 — /terms and /privacy
|
||||||
|
|
||||||
|
Ahmed must supply the facts below before `/terms` or `/privacy` are edited. The agent will not invent any of them (checklist §9.7). Once provided, the pages get real text, an effective date, and go into the launch checklist evidence.
|
||||||
|
|
||||||
|
## Legal facts (checklist L-03 + §9)
|
||||||
|
|
||||||
|
1. **Legal entity** — registered company name and form, or an explicit statement that the site is operated as a sole proprietorship (and under what name).
|
||||||
|
2. **Governing law / jurisdiction** — the law that governs the terms and the courts or venue for disputes.
|
||||||
|
3. **Retention periods per data type** — accounts and hashed passwords, verification/reset tokens, comments, saved posts, launch-notification emails, server logs. Include "until account deletion" where that is the real answer.
|
||||||
|
4. **Full provider list** — as currently known: Cloudflare (DNS/proxy, Email Routing + Email Sending), VPS host for 147.79.102.201, Postgres (self-hosted on that VPS). Confirm or correct; note anything that processes personal data outside these.
|
||||||
|
5. **Data-deletion process** — how a user requests deletion (email address or in-app), what is deleted vs retained, and the timeframe.
|
||||||
|
6. **Contact channel** — the address that appears in the pages and must actually reach Ahmed (currently the footer `mailto:hello@anwarpy.ai`).
|
||||||
|
7. **Effective date** — the date the final terms/privacy go live (this becomes the published "آخر تحديث").
|
||||||
|
|
||||||
|
## Checklist §9 decisions (needed to finalize the same pages)
|
||||||
|
|
||||||
|
1. Which existing intended-VIP posts/assets are temporarily opened? (Recommended: approved published VIP materials only.)
|
||||||
|
2. Which launch tutorials remain permanently free? (Recommended: core introductory tutorials.)
|
||||||
|
3. What requires an account? (Recommended: guest reading; account only for save/comment.)
|
||||||
|
4. Is an email-update feature ready? (The notify list exists; confirm it may be advertised.)
|
||||||
|
5. Notice period before restricting temporary VIP early-access materials.
|
||||||
|
6. Who approves posts and launch switches (Ahmed until delegated)?
|
||||||
|
7. Actual retention periods, providers, legal entity and billing jurisdiction (see items 1–7 above).
|
||||||
|
|
||||||
|
## Operational blocker found 2026-10-10 (also affects the pages' email claims)
|
||||||
|
|
||||||
|
Cloudflare Email Sending is **disabled** on the account (API test returned `10203 email.sending_disabled`). Before any page or flow promises email delivery: enable Email Sending in the Cloudflare dashboard (Account → Email), then re-test the transport. The footer mailto link is unaffected (it opens the visitor's own mail client).
|
||||||
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -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="subscription.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="signup.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>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
media/
|
||||||
|
files-private/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
payload-types.ts
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# NEXT_PUBLIC_SITE_URL is baked into the client bundle at build time
|
||||||
|
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
|
ENV PAYLOAD_SECRET=build-secret
|
||||||
|
ENV DATABASE_URI=postgres://build:build@127.0.0.1:5432/build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
# full node_modules: the standalone server plus payload CLI for migrate/seed
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/next.config.mjs ./next.config.mjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
||||||
|
|
||||||
|
# seed sources — `npx payload run src/seed.ts` (media dir is created by Payload on upload)
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/src ./src
|
||||||
|
# media upload dir must be writable by nextjs (mounted as volume in compose)
|
||||||
|
RUN mkdir -p /app/media && chown nextjs:nodejs /app/media
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# Apply migrations, then start
|
||||||
|
CMD ["sh", "-c", "npx payload migrate && npm run start"]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# أنور بي آي — web
|
||||||
|
|
||||||
|
Next.js 15 + Payload 3 app: Arabic RTL paywalled content site.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 15.4 (App Router, standalone output) + React 19
|
||||||
|
- Payload 3.90 (Postgres adapter, Lexical richText)
|
||||||
|
- Stripe subscriptions (35 SAR/month, test mode)
|
||||||
|
|
||||||
|
## Collections
|
||||||
|
|
||||||
|
- **users** — auth; `role: free | vip | admin`, `stripeCustomerId`
|
||||||
|
- **posts** — `title, slug, dek, access (free|vip), teaser, content (lexical), category, cover, readingMinutes, publishedAt, status (draft|published)`
|
||||||
|
- **media** — image uploads
|
||||||
|
|
||||||
|
Access control: public REST reads only `status=published`; VIP `content` is withheld from non-VIP (field-level read rule + paywall page never selects it for them).
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
| Var | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URI` | Postgres connection string |
|
||||||
|
| `PAYLOAD_SECRET` | Payload encryption/ signing secret |
|
||||||
|
| `AGENT_API_KEY` | Agent API auth (`x-agent-key` header) |
|
||||||
|
| `NEXT_PUBLIC_SITE_URL` | Public URL (baked at build) |
|
||||||
|
| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_PRICE_ID` / `STRIPE_WEBHOOK_SECRET` | Optional — unset = "pending" mode |
|
||||||
|
|
||||||
|
## Agent API
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create draft (always draft; publish via /admin only)
|
||||||
|
curl -X POST http://localhost:3000/api/agent/posts \
|
||||||
|
-H "x-agent-key: $AGENT_API_KEY" -H 'Content-Type: application/json' \
|
||||||
|
-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)
|
||||||
|
curl -X PUT http://localhost:3000/api/agent/posts/my-post -H "x-agent-key: $AGENT_API_KEY" ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `cover` assigns the per-category default cover.
|
||||||
|
|
||||||
|
## Seed
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run covers # regenerate public/covers/*.jpg
|
||||||
|
npx payload migrate
|
||||||
|
npx payload run src/seed.ts # idempotent (skips existing slugs)
|
||||||
|
```
|
||||||
|
|
||||||
|
Seeds: 12 posts (6 free + 6 VIP, 6 categories), admin `admin@anwarpi.ai` / `AnwarPi-Admin-2026!`, test users `free@test.anwarpi.ai` / `Free1234!`, `vip@test.anwarpi.ai` / `Vip1234!`.
|
||||||
|
|
||||||
|
## Docker deploy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.production.example .env.production # fill secrets
|
||||||
|
docker compose --env-file .env.production up -d --build
|
||||||
|
docker compose exec web npx payload run src/seed.ts # first time
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stripe webhook (testing on LAN)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
stripe listen --forward-to localhost:3000/api/stripe/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
On production (Railway), point the webhook endpoint at the public URL.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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
|
||||||
|
LAUNCH_MODE: ${LAUNCH_MODE:-free_early_access}
|
||||||
|
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID}
|
||||||
|
MAIL_FROM: noreply@anwarpy.ai
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`)
|
||||||
|
- 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
|
||||||
|
# 39: canonical host is the apex. www hits this router first (priority
|
||||||
|
# above anwarpi-api's 100) and is 301-redirected before any rate limit.
|
||||||
|
- traefik.http.routers.anwarpi-www.rule=Host(`www.anwarpy.ai`)
|
||||||
|
- traefik.http.routers.anwarpi-www.entrypoints=websecure
|
||||||
|
- traefik.http.routers.anwarpi-www.tls.certresolver=mytlschallenge
|
||||||
|
- traefik.http.routers.anwarpi-www.priority=110
|
||||||
|
- traefik.http.routers.anwarpi-www.service=anwarpi
|
||||||
|
- traefik.http.routers.anwarpi-www.middlewares=www-redirect
|
||||||
|
- traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\.anwarpy\.ai/(.*)
|
||||||
|
- traefik.http.middlewares.www-redirect.redirectregex.replacement=https://anwarpy.ai/$$1
|
||||||
|
- traefik.http.middlewares.www-redirect.redirectregex.permanent=true
|
||||||
|
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && Host(`anwarpy.ai`)
|
||||||
|
- traefik.http.routers.anwarpi-api.entrypoints=websecure
|
||||||
|
- traefik.http.routers.anwarpi-api.tls=true
|
||||||
|
- traefik.http.routers.anwarpi-api.tls.certresolver=mytlschallenge
|
||||||
|
- 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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
# Expose to the LAN so local dev (web/.env DATABASE_URI) can reach it.
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
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: ${NEXT_PUBLIC_SITE_URL}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000: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: ${NEXT_PUBLIC_SITE_URL}
|
||||||
|
# --- Stripe (pending mode until these are set) ---
|
||||||
|
# STRIPE_SECRET_KEY: sk_test_...
|
||||||
|
# STRIPE_PUBLISHABLE_KEY: pk_test_...
|
||||||
|
# STRIPE_PRICE_ID: price_...
|
||||||
|
# STRIPE_WEBHOOK_SECRET: whsec_...
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
media-uploads:
|
||||||
|
files-uploads:
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Agent Publish API — /api/agent/posts
|
||||||
|
|
||||||
|
Publish and manage blog posts programmatically. Auth: send the agent key in the
|
||||||
|
`x-agent-key` header. The key lives on the VPS in `/root/anwarpi/.env.production`
|
||||||
|
(`AGENT_API_KEY`).
|
||||||
|
|
||||||
|
- Base URL: `https://anwarpy.ai`
|
||||||
|
- 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
{
|
||||||
|
"id": 113, "title": "دليلي الجديد", "slug": "my-new-guide",
|
||||||
|
"status": "published", "access": "vip", "category": "agents",
|
||||||
|
"cover": { "id": 113, "url": "/api/media/file/my-cover.jpg" },
|
||||||
|
"attachments": [
|
||||||
|
{ "id": 42, "filename": "agent-state.md", "access": "vip", "url": "/api/files/42" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update a post
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT https://anwarpy.ai/api/agent/posts/my-new-guide \
|
||||||
|
-H "x-agent-key: $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"access": "free", "publish": true,
|
||||||
|
"coverImage": {"url": "https://example.com/cover.png"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `coverImage` accepts `{ "dataBase64": "…" }` or `{ "url": "https://…" }`
|
||||||
|
(server fetches and stores it; `filename`/`mimeType` optional).
|
||||||
|
- `cover` may also be an existing media ID (integer).
|
||||||
|
|
||||||
|
## Where the image appears
|
||||||
|
|
||||||
|
The cover is rendered on:
|
||||||
|
|
||||||
|
1. **Post page** (`/posts/<slug>`) — full-width cover under the header.
|
||||||
|
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.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { withPayload } from '@payloadcms/next/withPayload'
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
poweredByHeader: false,
|
||||||
|
async headers() {
|
||||||
|
return [{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [
|
||||||
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' },
|
||||||
|
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
typescript: { ignoreBuildErrors: false },
|
||||||
|
experimental: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withPayload(nextConfig)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "anwarpi-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "payload generate:types && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"generate:types": "payload generate:types",
|
||||||
|
"seed": "payload run src/seed.ts",
|
||||||
|
"covers": "node scripts/generate-covers.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@payloadcms/db-postgres": "^3.60.0",
|
||||||
|
"@payloadcms/next": "^3.60.0",
|
||||||
|
"@payloadcms/richtext-lexical": "^3.60.0",
|
||||||
|
"next": "15.4.11",
|
||||||
|
"payload": "^3.60.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"sharp": "^0.34.0",
|
||||||
|
"stripe": "^18.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Backfill `readingMinutes` on every post from its body (L-07).
|
||||||
|
* No schema change — safe to re-run any time.
|
||||||
|
*
|
||||||
|
* Usage: cd web && npx tsx scripts/backfill-reading-minutes.ts
|
||||||
|
*/
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import { countLexicalWords } from '../src/lib/lexical'
|
||||||
|
import { READING_WORDS_PER_MINUTE } from '../src/lib/launch'
|
||||||
|
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
limit: 500,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
for (const post of docs) {
|
||||||
|
const words = countLexicalWords(post.content)
|
||||||
|
const readingMinutes = Math.max(1, Math.round(words / READING_WORDS_PER_MINUTE))
|
||||||
|
if (readingMinutes !== post.readingMinutes) {
|
||||||
|
await payload.update({
|
||||||
|
collection: 'posts',
|
||||||
|
id: post.id,
|
||||||
|
data: { readingMinutes },
|
||||||
|
overrideAccess: true,
|
||||||
|
context: { systemBackfill: true },
|
||||||
|
})
|
||||||
|
payload.logger.info(`backfill: ${post.slug} ${post.readingMinutes} → ${readingMinutes} (${words} words)`)
|
||||||
|
updated += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.logger.info(`backfill complete: ${docs.length} scanned, ${updated} updated`)
|
||||||
|
// Postgres pool keeps the loop alive — release it so the script exits.
|
||||||
|
await payload.db.destroy?.()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# anwarpy.ai — Postgres backup (L-06)
|
||||||
|
#
|
||||||
|
# Run ON THE VPS (needs the `proxy`/compose network up):
|
||||||
|
# ./scripts/backup-db.sh
|
||||||
|
#
|
||||||
|
# Cron (nightly 03:15, keep dir writable):
|
||||||
|
# 15 3 * * * cd /root/anwarpi && ./scripts/backup-db.sh >> backups/backup.log 2>&1
|
||||||
|
#
|
||||||
|
# Restore a dump into the running db container:
|
||||||
|
# gunzip -c backups/anwarpi-YYYYMMDD-HHMMSS.sql.gz | \
|
||||||
|
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi anwarpi
|
||||||
|
#
|
||||||
|
# Restore-rehearsal (verify a backup is loadable BEFORE you need it):
|
||||||
|
# gunzip -c backups/<file>.sql.gz | \
|
||||||
|
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -c "CREATE DATABASE anwarpi_restore_test;"
|
||||||
|
# gunzip -c backups/<file>.sql.gz | \
|
||||||
|
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -d anwarpi_restore_test -q
|
||||||
|
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -d anwarpi_restore_test -tAc \
|
||||||
|
# "SELECT (SELECT count(*) FROM posts) AS posts, (SELECT count(*) FROM users) AS users;"
|
||||||
|
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -c "DROP DATABASE anwarpi_restore_test;"
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
mkdir -p backups
|
||||||
|
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
OUT="backups/anwarpi-${STAMP}.sql.gz"
|
||||||
|
|
||||||
|
docker compose -f docker-compose.vps.yml exec -T db pg_dump -U anwarpi anwarpi | gzip > "$OUT"
|
||||||
|
echo "wrote $OUT ($(du -h "$OUT" | cut -f1))"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* Cover generator — run: npm run covers
|
||||||
|
Produces 12 JPGs (1600x900) in public/covers/ plus per-category defaults. */
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const outDir = path.resolve(dirname, '../public/covers')
|
||||||
|
mkdirSync(outDir, { recursive: true })
|
||||||
|
|
||||||
|
const W = 1600
|
||||||
|
const H = 900
|
||||||
|
|
||||||
|
const POSTS = [
|
||||||
|
// [file, title, category, kind: free|vip]
|
||||||
|
['agents-free', 'بناء أول وكيل بالعربية', 'وكلاء'],
|
||||||
|
['agents-vip', 'حلقة الوكيل: تخطيط وتنفيذ وتحقق', 'وكلاء'],
|
||||||
|
['models-free', 'كيف تختار نموذجك', 'نماذج'],
|
||||||
|
['models-vip', 'تقييم النماذج منهجياً', 'نماذج'],
|
||||||
|
['retrieval-free', 'الاسترجاع بلا تعقيد', 'استرجاع'],
|
||||||
|
['retrieval-vip', 'قواعد بيانات المتجهات عملياً', 'استرجاع'],
|
||||||
|
['safety-free', 'حدود الوكيل الآمنة', 'سلامة'],
|
||||||
|
['safety-vip', 'مراجعة مخرجات الوكلاء', 'سلامة'],
|
||||||
|
['tools-free', 'أدوات لا غنى عنها', 'أدوات'],
|
||||||
|
['tools-vip', 'أنابيب الأدوات والصلاحيات', 'أدوات'],
|
||||||
|
['business-free', 'الذكاء الاصطناعي في عملك', 'أعمال'],
|
||||||
|
['business-vip', 'من نموذج أولي إلى إنتاج', 'أعمال'],
|
||||||
|
// fallback default cover for the agent API
|
||||||
|
['cover-default', 'أنور بي آي', 'وكلاء'],
|
||||||
|
// per-category default covers for the agent API
|
||||||
|
['cover-agents', 'وكلاء', 'وكلاء'],
|
||||||
|
['cover-models', 'نماذج', 'نماذج'],
|
||||||
|
['cover-retrieval', 'استرجاع', 'استرجاع'],
|
||||||
|
['cover-safety', 'سلامة', 'سلامة'],
|
||||||
|
['cover-tools', 'أدوات', 'أدوات'],
|
||||||
|
['cover-business', 'أعمال', 'أعمال'],
|
||||||
|
['cover-default', 'أنور بي آي', 'وكلاء'],
|
||||||
|
]
|
||||||
|
|
||||||
|
function svgCover(title, category) {
|
||||||
|
const titleEsc = title.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
const catEsc = category.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
return Buffer.from(`
|
||||||
|
<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg" dir="rtl">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="g" cx="20%" cy="15%" r="90%">
|
||||||
|
<stop offset="0%" stop-color="#16202a"/>
|
||||||
|
<stop offset="55%" stop-color="#0c1016"/>
|
||||||
|
<stop offset="100%" stop-color="#050708"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="beam" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#7fd8cc"/>
|
||||||
|
<stop offset="100%" stop-color="#3a6a8a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="${W}" height="${H}" fill="url(#g)"/>
|
||||||
|
<g opacity="0.5" stroke="#31414f" stroke-width="1">
|
||||||
|
${Array.from({ length: 9 }, (_, i) => `<line x1="0" y1="${(i + 1) * 90}" x2="${W}" y2="${(i + 1) * 90}"/>`).join('')}
|
||||||
|
</g>
|
||||||
|
<g transform="translate(${W - 140}, 110)">
|
||||||
|
<rect x="-26" y="-26" width="52" height="52" transform="rotate(45)" fill="none" stroke="url(#beam)" stroke-width="5"/>
|
||||||
|
<rect x="-9" y="-9" width="18" height="18" transform="rotate(45)" fill="#e8f6f3"/>
|
||||||
|
</g>
|
||||||
|
<text x="${W - 80}" y="480" text-anchor="end" fill="#f2f7f6"
|
||||||
|
font-family="SF Arabic, Noto Sans Arabic, sans-serif" font-weight="700" font-size="72">${titleEsc}</text>
|
||||||
|
<text x="${W - 80}" y="560" text-anchor="end" fill="#8fa3ad"
|
||||||
|
font-family="SF Mono, monospace" font-size="30" letter-spacing="4">${catEsc} · أنور بي آي</text>
|
||||||
|
<rect x="${W - 80 - 180}" y="620" width="180" height="3" fill="url(#beam)"/>
|
||||||
|
</svg>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, title, category] of POSTS) {
|
||||||
|
await sharp(svgCover(title, category))
|
||||||
|
.jpeg({ quality: 82 })
|
||||||
|
.toFile(path.join(outDir, `${name}.jpg`))
|
||||||
|
console.log(`✓ ${name}.jpg`)
|
||||||
|
}
|
||||||
|
console.log(`Done: ${POSTS.length} covers → ${outDir}`)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import { getViewer } from '@/lib/viewer'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const metadata = {
|
||||||
|
title: 'عن الفريق',
|
||||||
|
description:
|
||||||
|
'فريق أنور بآي: عرب يكتبون أدلة ذكاء اصطناعي عملية — تجارب موثقة، خطوات قابلة للتطبيق، وملفات عمل حقيقية بلا حشو تسويقي.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AboutPage() {
|
||||||
|
const viewer = await getViewer()
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page v2 v2-about">
|
||||||
|
<Topnav viewer={viewer} />
|
||||||
|
<main id="content">
|
||||||
|
<section className="about-hero" aria-labelledby="about-title">
|
||||||
|
<div className="wrap about-hero-grid">
|
||||||
|
<div>
|
||||||
|
<h1 id="about-title">نكتب عن تجاربنا، ونشارك ما جرّبناه عمليًا</h1>
|
||||||
|
<p className="lede">أنور بآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى أدلة عربية يمكن تنفيذها في عملك.</p>
|
||||||
|
</div>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="about-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="about-team" aria-labelledby="team-title">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 id="team-title">الفريق</h2>
|
||||||
|
<div className="people">
|
||||||
|
<article className="person">
|
||||||
|
<p className="person-role">المؤسس، 20 عاماً في البنية التحتية</p>
|
||||||
|
<h3>أنور عنبتاوي</h3>
|
||||||
|
<p>يقود رؤية المنصة بخبرة عقدين في البنية التحتية لتقنية المعلومات. يهتم بتشغيل النماذج الكبيرة محلياً، واستكشاف النماذج المفتوحة، وبناء بيئات تجعل التجربة التقنية قابلة للتشغيل الحقيقي.</p>
|
||||||
|
<ul className="tags" aria-label="مجالات اهتمام أنور" role="list">
|
||||||
|
<li>البنية التحتية</li><li>النماذج المفتوحة</li><li>التشغيل المحلي</li><li>الأنظمة الموثوقة</li>
|
||||||
|
</ul>
|
||||||
|
<a className="text-link" href="https://www.linkedin.com/in/anwaranabtawi/" target="_blank" rel="noopener noreferrer">أنور على LinkedIn</a>
|
||||||
|
</article>
|
||||||
|
<article className="person">
|
||||||
|
<p className="person-role">الذكاء الاصطناعي التطبيقي وتكامل الأعمال</p>
|
||||||
|
<h3>أحمد محمد</h3>
|
||||||
|
<p>يركز على ضبط النماذج، وأطر الوكلاء وتنسيقها، وأنظمة <bdi dir="ltr">RAG</bdi>. يهمه أن يصبح الذكاء الاصطناعي جزءاً فعّالاً من طريقة إنجاز العمل، لا تجربة منفصلة عنه.</p>
|
||||||
|
<ul className="tags" aria-label="مجالات اهتمام أحمد" role="list">
|
||||||
|
<li>ضبط النماذج</li><li>أطر الوكلاء</li><li>تنسيق الوكلاء</li><li><bdi dir="ltr">RAG</bdi></li>
|
||||||
|
</ul>
|
||||||
|
<a className="text-link" href="https://www.linkedin.com/in/ahmed-mohamed-423583151/" target="_blank" rel="noopener noreferrer">أحمد على LinkedIn</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="about-principles" aria-labelledby="principles-title">
|
||||||
|
<div className="wrap principles-grid">
|
||||||
|
<h2 id="principles-title">كيف نكتب كل دليل</h2>
|
||||||
|
<dl className="principles">
|
||||||
|
<div><dt>التطبيق قبل الاستعراض</dt><dd>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</dd></div>
|
||||||
|
<div><dt>نشرح ما تحت الغطاء</dt><dd>نفضّل الأدوات المفتوحة والتشغيل الذي يمنحك فهماً وتحكماً أكبر في بياناتك ومواردك.</dd></div>
|
||||||
|
<div><dt>حدود قبل الإطلاق</dt><dd>الوكيل أو النموذج لا يكتمل دون مراقبة وحدود واضحة تمنع الفشل الصامت.</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="about-contact">
|
||||||
|
<div className="wrap contact-strip">
|
||||||
|
<p>عندك سؤال عن دليل أو فكرة تريد تطبيقها؟</p>
|
||||||
|
<a className="btn btn-quiet" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
/* 4-tab settings — markup from account-settings.html with the real Payload
|
||||||
|
endpoints wired (PATCH /api/users/me for profile + password). Prefs stay
|
||||||
|
client-side (localStorage) faithful to the original. Stripe is pending:
|
||||||
|
the cancel button renders disabled with a note. */
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
email: string
|
||||||
|
userId: number
|
||||||
|
fullName: string
|
||||||
|
role: string
|
||||||
|
billingEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = { ok: boolean; text: string } | null
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'profile', label: 'الملف الشخصي' },
|
||||||
|
{ id: 'password', label: 'كلمة المرور' },
|
||||||
|
{ id: 'prefs', label: 'التفضيلات' },
|
||||||
|
{ id: 'membership', label: 'العضوية والفوترة' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AccountSettingsClient({ email, userId, fullName, role, billingEnabled }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [tab, setTab] = useState('profile')
|
||||||
|
const [name, setName] = useState(fullName)
|
||||||
|
const [profileStatus, setProfileStatus] = useState<Status>(null)
|
||||||
|
const [profileBusy, setProfileBusy] = useState(false)
|
||||||
|
|
||||||
|
const [currentPw, setCurrentPw] = useState('')
|
||||||
|
const [newPw, setNewPw] = useState('')
|
||||||
|
const [confirmPw, setConfirmPw] = useState('')
|
||||||
|
const [pwStatus, setPwStatus] = useState<Status>(null)
|
||||||
|
const [pwBusy, setPwBusy] = useState(false)
|
||||||
|
|
||||||
|
const [prefs, setPrefs] = useState({ weekly: true, replies: true, updates: false })
|
||||||
|
const [prefsStatus, setPrefsStatus] = useState<Status>(null)
|
||||||
|
const [logoutBusy, setLogoutBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('anw-prefs')
|
||||||
|
if (raw) setPrefs(p => ({ ...p, ...JSON.parse(raw) }))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const rulesOk = {
|
||||||
|
len: newPw.length >= 8,
|
||||||
|
num: /\d/.test(newPw),
|
||||||
|
diff: newPw.length > 0 && newPw !== currentPw,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (name.trim().length < 3) {
|
||||||
|
setProfileStatus({ ok: false, text: 'أدخل اسماً من 3 أحرف على الأقل.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProfileBusy(true)
|
||||||
|
const res = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fullName: name.trim() }),
|
||||||
|
})
|
||||||
|
setProfileStatus(res.ok
|
||||||
|
? { ok: true, text: 'تم حفظ الاسم ✓' }
|
||||||
|
: { ok: false, text: 'تعذّر الحفظ — أعد المحاولة.' })
|
||||||
|
setProfileBusy(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!currentPw) { 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 !== confirmPw) { setPwStatus({ ok: false, text: 'التأكيد لا يطابق كلمة المرور الجديدة.' }); return }
|
||||||
|
setPwBusy(true)
|
||||||
|
const res = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: newPw }),
|
||||||
|
})
|
||||||
|
setPwStatus(res.ok
|
||||||
|
? { ok: true, text: 'تم تحديث كلمة المرور ✓' }
|
||||||
|
: { ok: false, text: 'تعذّر التحديث — تحقق من كلمة المرور الحالية أو أعد المحاولة.' })
|
||||||
|
if (res.ok) {
|
||||||
|
setCurrentPw(''); setNewPw(''); setConfirmPw('')
|
||||||
|
}
|
||||||
|
setPwBusy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs() {
|
||||||
|
try { localStorage.setItem('anw-prefs', JSON.stringify(prefs)) } catch { /* ignore */ }
|
||||||
|
setPrefsStatus({ ok: true, text: 'تم حفظ التفضيلات ✓' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
setLogoutBusy(true)
|
||||||
|
await fetch('/api/users/logout', { method: 'POST' })
|
||||||
|
router.refresh()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const entitled = role === 'vip' || role === 'admin'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav className="settings-nav" role="tablist" aria-label="أقسام الإعدادات" data-od-id="settings-nav">
|
||||||
|
{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)}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="panels">
|
||||||
|
<section className={`panel${tab === 'profile' ? ' active' : ''}`} id="panel-profile" role="tabpanel" aria-labelledby="tab-profile" data-od-id="panel-profile">
|
||||||
|
<h2>الملف الشخصي</h2>
|
||||||
|
<p className="panel-sub">الاسم كما يظهر في تعليقاتك وقوائم الحفظ. البريد الإلكتروني هو معرّف الدخول ولا يتغير من هنا.</p>
|
||||||
|
<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="email">البريد الإلكتروني</label><input className="input" id="email" value={email} disabled /><span className="hint">لتغيير البريد تواصل معنا على <bdi>hello@anwarpy.ai</bdi></span></div>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`panel${tab === 'password' ? ' active' : ''}`} id="panel-password" role="tabpanel" aria-labelledby="tab-password" data-od-id="panel-password">
|
||||||
|
<h2>تغيير كلمة المرور</h2>
|
||||||
|
<p className="panel-sub">بعد التغيير تبقى جلستك الحالية مفتوحة، وتُطلب كلمة المرور الجديدة عند الدخول من جهاز آخر.</p>
|
||||||
|
<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="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">
|
||||||
|
<li data-rule="len" className={rulesOk.len ? 'ok' : ''}>8 أحرف على الأقل</li>
|
||||||
|
<li data-rule="num" className={rulesOk.num ? 'ok' : ''}>تحتوي رقماً واحداً على الأقل</li>
|
||||||
|
<li data-rule="diff" className={rulesOk.diff ? 'ok' : ''}>تختلف عن كلمة المرور الحالية</li>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`panel${tab === 'prefs' ? ' active' : ''}`} id="panel-prefs" role="tabpanel" aria-labelledby="tab-prefs" data-od-id="panel-prefs">
|
||||||
|
<h2>التفضيلات</h2>
|
||||||
|
<p className="panel-sub">رسائل أنور بآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p>
|
||||||
|
<div className="prefs">
|
||||||
|
<div className="pref-row"><div><strong>الرسالة الأسبوعية</strong><p>خلاصة ما نُشر من أدلة ومقالات، كل يوم أحد.</p></div>
|
||||||
|
<label className="switch"><input type="checkbox" id="prefWeekly" aria-label="تفعيل الرسالة الأسبوعية" checked={prefs.weekly} onChange={e => setPrefs(p => ({ ...p, weekly: e.target.checked }))} /><span className="track"></span></label></div>
|
||||||
|
<div className="pref-row"><div><strong>إشعارات التعليقات</strong><p>رسالة عند ردّ أحد الأعضاء على تعليقك.</p></div>
|
||||||
|
<label className="switch"><input type="checkbox" id="prefReplies" aria-label="تفعيل إشعارات التعليقات" checked={prefs.replies} onChange={e => setPrefs(p => ({ ...p, replies: e.target.checked }))} /><span className="track"></span></label></div>
|
||||||
|
<div className="pref-row"><div><strong>تحديثات الأدلة المحفوظة</strong><p>إشعار عندما يُحدَّث دليل حفظته للعودة إليه.</p></div>
|
||||||
|
<label className="switch"><input type="checkbox" id="prefUpdates" aria-label="تفعيل تحديثات الأدلة المحفوظة" checked={prefs.updates} onChange={e => setPrefs(p => ({ ...p, updates: e.target.checked }))} /><span className="track"></span></label></div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`panel${tab === 'membership' ? ' active' : ''}`} id="panel-membership" role="tabpanel" aria-labelledby="tab-membership" data-od-id="panel-membership">
|
||||||
|
<h2>العضوية والفوترة</h2>
|
||||||
|
<p className="panel-sub">خطتك وحالة وصولك. الإلغاء يوقف التجديد التالي ويحفظ وصولك حتى نهاية الفترة المدفوعة.</p>
|
||||||
|
<article className="membership-card" data-od-id="membership-card">
|
||||||
|
<div className="membership-head">
|
||||||
|
<div><h3 id="planName">{entitled ? 'عضوية فردية' : 'حساب مجاني'}</h3><span className="hint">الحالة الحقيقية لحسابك</span></div>
|
||||||
|
<span className={`state-chip${entitled ? '' : ' cancelled'}`} id="planState">{entitled ? 'نشطة' : role === 'admin' ? 'إداري' : 'مجاني'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="plan-details">
|
||||||
|
{billingEnabled
|
||||||
|
? <div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
|
||||||
|
: <div><span className="price-tag price-tag-early">وصول مبكر مجاني</span></div>}
|
||||||
|
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : billingEnabled ? 'الأدلة المجانية ومعاينة الأدلة' : 'كل المحتوى المنشور مؤقتاً — الوصول المبكر المجاني'}</dd></dl>
|
||||||
|
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">{billingEnabled ? 'ستُربط عبر Stripe' : 'لا يوجد — لن يُحصَّل أي مبلغ خلال الوصول المبكر'}</dd></dl>
|
||||||
|
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
|
||||||
|
</div>
|
||||||
|
<div className="membership-actions" id="membershipActions">
|
||||||
|
{!entitled && billingEnabled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div className="cancel-zone" id="cancelZone" data-od-id="cancel-zone">
|
||||||
|
<h3>إلغاء الاشتراك</h3>
|
||||||
|
{entitled ? (
|
||||||
|
<>
|
||||||
|
<p>ربط الدفع (Stripe) غير مكتمل بعد — الإلغاء الذاتي سيتاح بعد ربط الدفع. حتى ذلك الحين تواصل معنا على <bdi>hello@anwarpy.ai</bdi> لأي تغيير.</p>
|
||||||
|
<button className="text-danger" type="button" id="cancelOpen" data-od-id="cancel-open" disabled>إلغاء الاشتراك</button>
|
||||||
|
<p className="hint">سيتاح بعد ربط الدفع</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>لا يوجد اشتراك مدفوع لإلغائه. يمكنك الترقية في أي وقت من صفحة <Link className="text-link" href="/subscription">العضوية</Link>.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="billing" data-od-id="billing-history">
|
||||||
|
<h3>سجل الفواتير</h3>
|
||||||
|
<p className="billing-sub">كل دفعة تُصدر فاتورة ضريبية فور اكتمالها وترسل إلى بريدك المسجّل. الفواتير متاحة للتنزيل في أي وقت.</p>
|
||||||
|
<div className="billing-table" role="table" aria-label="سجل الفواتير">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="billing-row" role="row">
|
||||||
|
<span className="bdate" role="cell">—</span>
|
||||||
|
<span className="desc" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions" style={{ marginTop: 32 }}>
|
||||||
|
<button className="btn btn-secondary" type="button" onClick={logout} disabled={logoutBusy}>تسجيل الخروج</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import AccountSettingsClient from './client'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
|
import { billingEnabled } from '@/lib/launch'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import '../styles/account-settings.css'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const metadata = { title: 'إعدادات الحساب' }
|
||||||
|
|
||||||
|
export default async function AccountSettingsPage() {
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
const { user } = await payload.auth({ headers: await headers() })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page page-account-settings">
|
||||||
|
<Topnav viewer={user} />
|
||||||
|
{user ? (
|
||||||
|
<main id="content">
|
||||||
|
<section className="page-head" data-od-id="settings-header"><div className="container">
|
||||||
|
<p className="crumb">حسابك في أنور بآي</p>
|
||||||
|
<h1 data-od-id="settings-title">إعدادات الحساب</h1>
|
||||||
|
<p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? formatDate(String(user.createdAt)) : 'اليوم'}</p>
|
||||||
|
</div></section>
|
||||||
|
<section className="container settings-layout" data-od-id="settings-layout">
|
||||||
|
<AccountSettingsClient
|
||||||
|
email={String(user.email)}
|
||||||
|
userId={Number(user.id)}
|
||||||
|
fullName={(user.fullName as string) || ''}
|
||||||
|
role={((user.role as string) || 'free')}
|
||||||
|
billingEnabled={billingEnabled()}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
) : (
|
||||||
|
<main id="content" className="gate">
|
||||||
|
<section className="gate-card">
|
||||||
|
<h1>سجّل الدخول لإدارة حسابك</h1>
|
||||||
|
<p>من هنا تعدّل اسمك وكلمة المرور وتفضيلات الرسائل، وتدير عضويتك.</p>
|
||||||
|
<div className="actions">
|
||||||
|
<a className="btn btn-primary" href="/login?next=%2Faccount-settings">تسجيل الدخول</a>
|
||||||
|
<a className="btn btn-quiet" href="/signup">إنشاء حساب مجاني</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { guidesLabel } from '@/lib/format'
|
||||||
|
|
||||||
|
/* Library controller — search/sort/filters/likes from articles.html, wired to
|
||||||
|
the real backend: likes toggle via /api/posts/[slug]/like (401 → login),
|
||||||
|
views come from the post-page counter, sort reads data-* attributes. */
|
||||||
|
export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const allowedTopics = ['all', 'agents', 'models', 'automation', 'foundations']
|
||||||
|
const allowedAccess = ['all', 'free', 'vip']
|
||||||
|
const state: Record<string, string> = {
|
||||||
|
topic: allowedTopics.includes(params.get('topic') || '') ? params.get('topic')! : 'all',
|
||||||
|
access: allowedAccess.includes(params.get('access') || '') ? params.get('access')! : 'all',
|
||||||
|
query: '',
|
||||||
|
sort: 'latest',
|
||||||
|
}
|
||||||
|
const buttons = [...document.querySelectorAll('[data-filter-kind]')] as HTMLElement[]
|
||||||
|
const posts = [...document.querySelectorAll('.post')] as HTMLElement[]
|
||||||
|
const list = document.getElementById('postList')!
|
||||||
|
const count = document.getElementById('resultCount')
|
||||||
|
const empty = document.getElementById('emptyState')
|
||||||
|
const search = document.getElementById('postSearch') as HTMLInputElement | null
|
||||||
|
const sort = document.getElementById('postSort') as HTMLSelectElement | null
|
||||||
|
const formatNumber = (value: number) => new Intl.NumberFormat('en-US').format(value)
|
||||||
|
|
||||||
|
function normalize(value: string) {
|
||||||
|
return value.toLocaleLowerCase('ar').normalize('NFKD').replace(/[\u064B-\u065F\u0670]/g, '').replace(/[إأآ]/g, 'ا').replace(/ى/g, 'ي')
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMetrics(post: HTMLElement) {
|
||||||
|
const views = Number(post.dataset.viewsCount || 0)
|
||||||
|
const likes = Number(post.dataset.likesCount || 0)
|
||||||
|
const viewsEl = post.querySelector('[data-views]')
|
||||||
|
if (viewsEl) viewsEl.textContent = `${formatNumber(views)} قراءة`
|
||||||
|
const like = post.querySelector('[data-like]') as HTMLElement | null
|
||||||
|
if (like) {
|
||||||
|
like.setAttribute('aria-pressed', post.dataset.liked === '1' ? 'true' : 'false')
|
||||||
|
const span = like.querySelector('span')
|
||||||
|
if (span) span.textContent = formatNumber(likes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const ordered = [...posts].sort((a, b) => {
|
||||||
|
if (state.sort === 'views') return Number(b.dataset.viewsCount || 0) - Number(a.dataset.viewsCount || 0) || Number(b.dataset.order) - Number(a.dataset.order)
|
||||||
|
if (state.sort === 'likes') return Number(b.dataset.likesCount || 0) - Number(a.dataset.likesCount || 0) || 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 || '').split(' ').includes(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
|
||||||
|
})
|
||||||
|
if (count) count.textContent = visible === 0 ? 'لا نتائج' : guidesLabel(visible)
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||||
|
applyFilters()
|
||||||
|
}, { signal: controller.signal }))
|
||||||
|
|
||||||
|
search?.addEventListener('input', () => { state.query = search.value.trim(); applyFilters() }, { signal: controller.signal })
|
||||||
|
sort?.addEventListener('change', () => { state.sort = sort.value; applyFilters() }, { signal: controller.signal })
|
||||||
|
|
||||||
|
posts.forEach(post => {
|
||||||
|
paintMetrics(post)
|
||||||
|
const like = post.querySelector('[data-like]') as HTMLElement | null
|
||||||
|
like?.addEventListener('click', async () => {
|
||||||
|
if (!loggedIn) {
|
||||||
|
location.href = '/login?next=/articles'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const slug = like.dataset.like!
|
||||||
|
const res = await fetch(`/api/posts/${slug}/like`, { method: 'POST' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
post.dataset.likesCount = String(data.likes)
|
||||||
|
post.dataset.liked = data.liked ? '1' : '0'
|
||||||
|
paintMetrics(post)
|
||||||
|
applyFilters()
|
||||||
|
}, { signal: controller.signal })
|
||||||
|
})
|
||||||
|
|
||||||
|
buttons.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.filterValue === state[button.dataset.filterKind!])))
|
||||||
|
applyFilters()
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [loggedIn])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import ArticlesClient from './articles-client'
|
||||||
|
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
|
||||||
|
import { formatNumber, guidesLabel, minutesLabel, contentTypeLabel } from '@/lib/format'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const metadata = {
|
||||||
|
title: 'المكتبة',
|
||||||
|
description:
|
||||||
|
'أدلة الذكاء الاصطناعي العملية بالعربية: وكلاء، نماذج، استرجاع، سلامة، أدوات، وأعمال — بخطوات قابلة للتطبيق وتجارب موثقة وملفات عمل.',
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostDoc = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
dek: string
|
||||||
|
access: 'free' | 'vip'
|
||||||
|
category: string
|
||||||
|
level: string
|
||||||
|
readingMinutes: number
|
||||||
|
views: number
|
||||||
|
likes: number
|
||||||
|
publishedAt: string
|
||||||
|
cover?: { url?: string | null } | number | null
|
||||||
|
contentType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Viewer = { id: number; email: string; role: string; interest: string | null; level: string | null } | null
|
||||||
|
|
||||||
|
export default async function ArticlesPage() {
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
const [res, auth] = await Promise.all([
|
||||||
|
payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { status: { equals: 'published' } },
|
||||||
|
depth: 1,
|
||||||
|
limit: 100,
|
||||||
|
sort: '-publishedAt',
|
||||||
|
select: {
|
||||||
|
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||||
|
readingMinutes: true, views: true, likes: true, publishedAt: true, cover: true,
|
||||||
|
contentType: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
payload.auth({ headers: await headers() }),
|
||||||
|
])
|
||||||
|
const posts = res.docs as unknown as PostDoc[]
|
||||||
|
const user = auth.user as { id: number; email: string; role: string; interest: string | null; level: string | null } | undefined
|
||||||
|
const viewer: Viewer = user
|
||||||
|
? { id: user.id, email: user.email, role: (user.role as string) ?? 'free', interest: (user.interest as string | null) ?? null, level: (user.level as string | null) ?? null }
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Which posts the viewer liked
|
||||||
|
const likedSlugs = new Set<string>()
|
||||||
|
if (viewer) {
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: 'post-likes',
|
||||||
|
where: { user: { equals: viewer.id } },
|
||||||
|
depth: 0,
|
||||||
|
limit: 500,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
for (const like of docs) {
|
||||||
|
const postId = typeof like.post === 'object' ? (like.post as { id: number }).id : like.post
|
||||||
|
const post = posts.find(p => p.id === postId)
|
||||||
|
if (post) likedSlugs.add(post.slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personalization: boost rows matching the viewer's interest + level (order
|
||||||
|
// is a sort weight — matching posts float to the top under «الأحدث أولاً»).
|
||||||
|
const interestTopic = viewer?.interest ? INTEREST_MAP[viewer.interest] : undefined
|
||||||
|
posts.forEach((post, i) => {
|
||||||
|
const el = post as unknown as { _order?: number; _match?: boolean }
|
||||||
|
el._order = posts.length - i
|
||||||
|
el._match = !!interestTopic && topicsFor(post).some(topic => topic === interestTopic) && post.level === viewer!.level
|
||||||
|
if (el._match) el._order += 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
const freeCount = posts.filter(p => p.access === 'free').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page v2 v2-library">
|
||||||
|
<Topnav viewer={viewer} />
|
||||||
|
<ArticlesClient loggedIn={!!viewer} />
|
||||||
|
<main id="content">
|
||||||
|
<section className="lib-head" aria-labelledby="library-title">
|
||||||
|
<div className="wrap">
|
||||||
|
<h1 id="library-title">المكتبة</h1>
|
||||||
|
<p className="lede">تضم المكتبة {guidesLabel(posts.length)}، منها {guidesLabel(freeCount)} متاحة مجاناً بالكامل. ابحث بالموضوع أو صفِّ النتائج حسب المسار.</p>
|
||||||
|
<div className="lib-tools">
|
||||||
|
<label className="lib-search">
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lib-results" aria-label="الأدلة">
|
||||||
|
<div className="wrap">
|
||||||
|
<nav className="topic-strip" aria-label="تصفّح المسارات">
|
||||||
|
{TOPICS.map(topic => <Link key={topic.key} href={`/topics/${topic.key}`}>{topic.label}</Link>)}
|
||||||
|
</nav>
|
||||||
|
<p className="results-count" id="resultCount" aria-live="polite">{guidesLabel(posts.length)}</p>
|
||||||
|
<div className="lib-grid" id="postList">
|
||||||
|
{posts.map(post => {
|
||||||
|
const meta = post as unknown as { _order?: number; _match?: boolean }
|
||||||
|
const topics = topicsFor(post)
|
||||||
|
const coverUrl = typeof post.cover === 'object' ? post.cover?.url ?? null : null
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="post guide-card"
|
||||||
|
key={post.id}
|
||||||
|
data-topic={topics.join(' ')}
|
||||||
|
data-access={post.access}
|
||||||
|
data-level={post.level}
|
||||||
|
data-views-count={post.views ?? 0}
|
||||||
|
data-likes-count={post.likes ?? 0}
|
||||||
|
data-liked={likedSlugs.has(post.slug) ? '1' : '0'}
|
||||||
|
data-order={meta._order}
|
||||||
|
>
|
||||||
|
{coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />}
|
||||||
|
<div className="guide-card-body">
|
||||||
|
<p className="meta-row">
|
||||||
|
<span>{post.category}</span>
|
||||||
|
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||||
|
<span>{minutesLabel(post.readingMinutes)}</span>
|
||||||
|
<span>{post.level}</span>
|
||||||
|
{meta._match && <span className="for-you">يناسب مستواك</span>}
|
||||||
|
</p>
|
||||||
|
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
|
||||||
|
<p className="guide-card-dek">{post.dek}</p>
|
||||||
|
<div className="guide-card-foot">
|
||||||
|
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span>
|
||||||
|
<span className="engagement">
|
||||||
|
<span data-views>{formatNumber(post.views ?? 0)} قراءة</span>
|
||||||
|
<button className="like" type="button" data-like={post.slug} aria-pressed={likedSlugs.has(post.slug)} aria-label="أعجبني">
|
||||||
|
<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>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="empty" id="emptyState">
|
||||||
|
<p>لا يوجد دليل يطابق هذا البحث.</p>
|
||||||
|
<p>جرّب كلمة أقصر أو اختر «كل المسارات».</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
/* QA-07: /catalog was a stale duplicate library — /articles is canonical. */
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function CatalogPage() {
|
||||||
|
redirect('/articles')
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
/* QA-06: page-specific title (the page itself is a client component).
|
||||||
|
Utility pages stay out of search indexes (step 38). */
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'استعادة كلمة المرور',
|
||||||
|
robots: { index: false, follow: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import '../styles/forgot-password.css'
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const form = e.currentTarget
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
setStatus('أدخل بريداً إلكترونياً صحيحاً.')
|
||||||
|
form.reportValidity()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setStatus('')
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Request failed')
|
||||||
|
setStatus('إذا كان البريد مسجلاً، فسيصلك رابط الاستعادة.')
|
||||||
|
setEmail('')
|
||||||
|
} catch {
|
||||||
|
setStatus('تعذّر إرسال الطلب. حاول مرة أخرى بعد قليل.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page page-forgot-password">
|
||||||
|
<Topnav />
|
||||||
|
<main id="content" className="reset" data-od-id="password-reset">
|
||||||
|
<section className="reset-card" data-od-id="reset-card">
|
||||||
|
<p className="eyebrow">استعادة الحساب</p>
|
||||||
|
<h1 data-od-id="reset-title">استعادة كلمة المرور</h1>
|
||||||
|
<p className="lead">أدخل البريد الإلكتروني المرتبط بحسابك لتصلك رسالة إعادة تعيين كلمة المرور.</p>
|
||||||
|
<form id="resetForm" method="post" action="/api/users/forgot-password" noValidate onSubmit={onSubmit} data-od-id="reset-form">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="resetEmail">البريد الإلكتروني</label>
|
||||||
|
<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>
|
||||||
|
<button className="btn btn-primary" type="submit" data-od-id="reset-submit" disabled={busy}>{busy ? 'جارٍ الإرسال…' : 'إرسال رابط الاستعادة'}</button>
|
||||||
|
<p className="status" id="resetStatus" role="status" aria-live="polite">{status}</p>
|
||||||
|
</form>
|
||||||
|
<Link className="back" href="/login">تذكرت كلمة المرور؟ سجّل الدخول</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
/* QA-06: page-specific title (the page itself is a client component).
|
||||||
|
Utility pages stay out of search indexes (step 38). */
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'تسجيل الدخول',
|
||||||
|
robots: { index: false, follow: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import { safeNext } from '@/lib/next'
|
||||||
|
import '../styles/login.css'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [nextPath, setNextPath] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNextPath(safeNext(new URLSearchParams(window.location.search).get('next')))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const form = e.currentTarget
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
setStatus('تحقق من البريد الإلكتروني وكلمة المرور.')
|
||||||
|
form.reportValidity()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setStatus('')
|
||||||
|
const data = new FormData(form)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
router.refresh()
|
||||||
|
router.push(safeNext(new URLSearchParams(window.location.search).get('next')) ?? '/account-settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const err = await res.json().catch(() => null) as { error?: string; errors?: { message?: string }[] } | null
|
||||||
|
// Rate-limit / middleware shape first: { error: string } + optional Retry-After.
|
||||||
|
if (typeof err?.error === 'string') {
|
||||||
|
const retryAfterSec = Number(res.headers.get('Retry-After'))
|
||||||
|
setStatus(
|
||||||
|
res.headers.get('Retry-After') && Number.isFinite(retryAfterSec)
|
||||||
|
? `${err.error} حاول بعد ${Math.max(1, Math.ceil(retryAfterSec / 60))} دقيقة.`
|
||||||
|
: err.error,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const message = err?.errors?.[0]?.message
|
||||||
|
if (message && /locked/i.test(message)) {
|
||||||
|
// Payload lockout: maxLoginAttempts 5 / lockTime 15min (Users.ts).
|
||||||
|
setStatus('تم تجاوز عدد المحاولات المسموح. حاول بعد ١٥ دقيقة.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('تعذّر تسجيل الدخول — تحقّق من البريد وكلمة المرور.')
|
||||||
|
} catch {
|
||||||
|
setStatus('تعذّر الاتصال. حاول تسجيل الدخول مرة أخرى.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page page-login">
|
||||||
|
<Topnav />
|
||||||
|
<main id="content" className="auth">
|
||||||
|
<section className="auth-story">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||||
|
<h1>أكمل من حيث توقفت.</h1>
|
||||||
|
<p>سجّل الدخول لمتابعة قراءة الأدلة والمشاركة في النقاشات.</p>
|
||||||
|
</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">
|
||||||
|
<p className="eyebrow">أهلاً بعودتك</p>
|
||||||
|
<h2 data-od-id="login-title">تسجيل الدخول</h2>
|
||||||
|
<p className="lead">استخدم بريدك وكلمة المرور للوصول إلى حسابك.</p>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="email">البريد الإلكتروني</label>
|
||||||
|
<input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="password">كلمة المرور</label>
|
||||||
|
<div className="input-wrap">
|
||||||
|
<input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="check"><input type="checkbox" name="remember" /> تذكّرني</label>
|
||||||
|
<Link href="/forgot-password">نسيت كلمة المرور؟</Link>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={busy}>{busy ? 'جارٍ تسجيل الدخول…' : 'ادخل إلى حسابك'}</button>
|
||||||
|
<div className="status" id="loginStatus" role="status">{status}</div>
|
||||||
|
<div className="divider">أو</div>
|
||||||
|
<Link className="magic" href="/forgot-password">استعد الوصول إلى حسابك</Link>
|
||||||
|
<p className="signup">ليس لديك حساب؟ <Link href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : '/signup'}>أنشئ حساباً جديداً</Link></p>
|
||||||
|
<p className="fine">بالمتابعة، أنت توافق على <Link href="/terms">شروط الاستخدام</Link> و<Link href="/privacy">سياسة الخصوصية</Link>.</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { safeNext } from '@/lib/next'
|
||||||
|
|
||||||
|
type Viewer = { id: number; email: string; interest: string | null; level: string | null; onboarded: boolean } | null
|
||||||
|
|
||||||
|
const COPY = ['الخطوة 1 من 3', 'الخطوة 2 من 3', 'الخطوة 3 من 3']
|
||||||
|
const PERCENTS = ['33٪', '67٪', '100٪']
|
||||||
|
|
||||||
|
/* Wizard markup/steps from member-onboarding.html. Interest + level PATCH the
|
||||||
|
user record when leaving each step (falls back to localStorage if no session). */
|
||||||
|
export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [interest, setInterest] = useState(viewer?.interest ?? '')
|
||||||
|
const [level, setLevel] = useState(viewer?.level ?? '')
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [nextPath, setNextPath] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { setNextPath(safeNext(sessionStorage.getItem('anwarpi:signup-next'))) } catch { /* private mode */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function show(next: number) {
|
||||||
|
setStep(Math.max(0, Math.min(2, next)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(fields: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('anwarpi:member-onboarding', JSON.stringify({ interest, experience: level }))
|
||||||
|
} catch { /* private mode */ }
|
||||||
|
if (!viewer) return
|
||||||
|
await fetch(`/api/users/${viewer.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
}).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
if (step === 0) {
|
||||||
|
if (!interest) { setStatus('اختر مساراً واحداً للمتابعة.'); return }
|
||||||
|
setStatus('')
|
||||||
|
await persist({ interest })
|
||||||
|
show(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (step === 1) {
|
||||||
|
if (!level) { setStatus('اختر مستوى واحداً للمتابعة.'); return }
|
||||||
|
setStatus('')
|
||||||
|
await persist({ level, onboarded: true })
|
||||||
|
show(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = interest && level ? `اخترت مسار ${interest} بمستوى ${level}. حسابك المجاني جاهز، ويمكنك الترقية الآن أو لاحقاً.` : 'حسابك المجاني جاهز. يمكنك الترقية الآن أو لاحقاً من صفحة العضوية.'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bar = document.getElementById('progressBar')
|
||||||
|
if (bar) bar.style.width = `${((step + 1) / 3) * 100}%`
|
||||||
|
const label = document.getElementById('progressLabel')
|
||||||
|
if (label) label.textContent = COPY[step]
|
||||||
|
const percent = document.getElementById('progressPercent')
|
||||||
|
if (percent) percent.textContent = PERCENTS[step]
|
||||||
|
}, [step])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="container flow" data-od-id="member-onboarding-flow">
|
||||||
|
<nav className="step-nav" aria-label="خطوات إعداد الحساب" data-od-id="onboarding-steps">
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div key={i} className={i === step ? 'active' : i < step ? 'done' : ''} data-step-label={i}>
|
||||||
|
<span>{['01', '02', '03'][i]}</span><span>{['اهتمامك', 'مستواك', 'خيارات العضوية'][i]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<form id="memberOnboarding" noValidate onSubmit={e => e.preventDefault()}>
|
||||||
|
<section className={`panel${step === 0 ? ' active' : ''}`} data-step="0" data-od-id="step-interest">
|
||||||
|
<h2>أي مسار تريد أن يظهر أولاً؟</h2>
|
||||||
|
<p className="intro">اختر مساراً واحداً الآن. ستظل بقية المكتبة متاحة لك.</p>
|
||||||
|
<div className="choices">
|
||||||
|
{[
|
||||||
|
['الوكلاء', 'الوكلاء وتنسيقهم', 'التخطيط والتنفيذ والتحقق وتنسيق عدة وكلاء.'],
|
||||||
|
['RAG', 'التوليد المعزّز بالاسترجاع (RAG)', 'اختيار المصادر وقياس جودة الاسترجاع والإجابة.'],
|
||||||
|
['الأتمتة', 'الأتمتة للأعمال', 'دمج الذكاء الاصطناعي في تدفقات عمل قابلة للمراقبة.'],
|
||||||
|
['النماذج المفتوحة', 'النماذج المفتوحة', 'التشغيل المحلي والضبط الدقيق واختيار النموذج المناسب.'],
|
||||||
|
].map(([value, label, desc]) => (
|
||||||
|
<label className="choice" key={value}>
|
||||||
|
<input type="radio" name="interest" value={value} checked={interest === value} onChange={() => { setInterest(value); setStatus('') }} />
|
||||||
|
<span className="choice-body"><strong>{label}</strong><small>{desc}</small><span className="choice-mark" aria-hidden="true"></span></span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="status" id="interestStatus" role="status" aria-live="polite">{step === 0 ? status : ''}</p>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn btn-primary" type="button" data-next onClick={next}>تابع إلى مستواك</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={`panel${step === 1 ? ' active' : ''}`} data-step="1" data-od-id="step-experience">
|
||||||
|
<h2>ما مستوى خبرتك العملية؟</h2>
|
||||||
|
<p className="intro">سنستخدمه فقط لترتيب نقطة البداية، لا لإخفاء أي محتوى مجاني.</p>
|
||||||
|
<div className="choices">
|
||||||
|
{[
|
||||||
|
['مبتدئ', 'أبدأ من الأساسيات', 'أستخدم أدوات الذكاء الاصطناعي ولم أبنِ مشروعاً بعد.'],
|
||||||
|
['متوسط', 'بنيت تجارب بسيطة', 'جرّبت واجهات برمجة التطبيقات (API) أو أتمتة أو تطبيقاً صغيراً.'],
|
||||||
|
['متقدم', 'أعمل على أنظمة فعلية', 'أبحث عن أنماط أعمق للقياس والموثوقية والتشغيل.'],
|
||||||
|
].map(([value, label, desc]) => (
|
||||||
|
<label className="choice" key={value}>
|
||||||
|
<input type="radio" name="experience" value={value} checked={level === value} onChange={() => { setLevel(value); setStatus('') }} />
|
||||||
|
<span className="choice-body"><strong>{label}</strong><small>{desc}</small><span className="choice-mark" aria-hidden="true"></span></span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="status" id="experienceStatus" role="status" aria-live="polite">{step === 1 ? status : ''}</p>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn btn-secondary" type="button" data-back onClick={() => show(0)}>رجوع</button>
|
||||||
|
<button className="btn btn-primary" type="button" data-next onClick={next}>شاهد خيارات الوصول</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={`panel${step === 2 ? ' active' : ''}`} data-step="2" data-od-id="step-access">
|
||||||
|
<p className="eyebrow">اختيارك، بلا ضغط</p>
|
||||||
|
<h2>ابدأ مجاناً أو افتح محتوى العضوية الكامل.</h2>
|
||||||
|
<p className="intro" id="summaryCopy">{summary}</p>
|
||||||
|
<div className="benefits" data-od-id="vip-benefits">
|
||||||
|
<article className="benefit"><span className="benefit-num">01</span><div><h3>الأدلة المتقدمة كاملة</h3><p>اقرأ ما بعد المعاينة في أدلة الوكلاء وRAG والأتمتة والنماذج.</p></div></article>
|
||||||
|
<article className="benefit"><span className="benefit-num">02</span><div><h3>ملفات العمل</h3><p>نزّل القوالب وقوائم الفحص المرتبطة بكل دليل.</p></div></article>
|
||||||
|
<article className="benefit"><span className="benefit-num">03</span><div><h3>نقطة بداية مخصصة</h3><p>إعداد العضوية ترتّب دليلك الأول ورسائلك بناءً على هدفك.</p></div></article>
|
||||||
|
</div>
|
||||||
|
<div className="upgrade-note">
|
||||||
|
<strong>الحساب المجاني لا يختفي.</strong>
|
||||||
|
<p>يمكنك قراءة الأدلة المجانية وحفظها، والترقية عندما تحتاج الأدلة الحصرية.</p>
|
||||||
|
</div>
|
||||||
|
<div className="actions" data-od-id="final-actions">
|
||||||
|
{nextPath ? (
|
||||||
|
<Link className="btn btn-primary" href={nextPath} data-od-id="continue-next-cta">تابع إلى وجهتك</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link className="btn btn-secondary" href="/articles?access=free" data-od-id="continue-free-cta">ابدأ بالأدلة المجانية</Link>
|
||||||
|
<Link className="btn btn-primary" href="/subscription" data-od-id="view-membership-cta">شاهد العضوية والأسعار</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import OnboardingWizard from './client'
|
||||||
|
import '../styles/member-onboarding.css'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const metadata = { title: 'إعداد حسابك' }
|
||||||
|
|
||||||
|
/* Signup onboarding — design from member-onboarding.html. Interest + level
|
||||||
|
persist to the user record (backend-aware, per locked decision). */
|
||||||
|
export default async function OnboardingPage() {
|
||||||
|
let viewer: { id: number; email: string; interest: string | null; level: string | null; onboarded: boolean } | null = null
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
const { user } = await payload.auth({ headers: await headers() })
|
||||||
|
if (user) {
|
||||||
|
viewer = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
interest: (user.interest as string | null) ?? null,
|
||||||
|
level: (user.level as string | null) ?? null,
|
||||||
|
onboarded: !!user.onboarded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* render wizard without persistence */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!viewer) redirect('/login?next=%2Fonboarding')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page page-onboarding">
|
||||||
|
<Topnav viewer={viewer} />
|
||||||
|
<main id="content">
|
||||||
|
<section className="head" data-od-id="onboarding-hero">
|
||||||
|
<div className="container">
|
||||||
|
<p className="eyebrow">حسابك جاهز · إعداد سريع</p>
|
||||||
|
<h1 data-od-id="onboarding-title">اختر ما تريد تعلّمه أولاً.</h1>
|
||||||
|
<p className="lead">سؤالان فقط لترتيب المكتبة حول هدفك، ثم نوضح الفرق بين الحساب المجاني والعضوية المدفوعة.</p>
|
||||||
|
<div className="progress" aria-label="تقدم الإعداد">
|
||||||
|
<div className="progress-copy"><span id="progressLabel">الخطوة 1 من 3</span><span id="progressPercent">33٪</span></div>
|
||||||
|
<div className="progress-track"><div className="progress-bar" id="progressBar"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<OnboardingWizard viewer={viewer} />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { getViewer } from '@/lib/viewer'
|
||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
|
||||||
|
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||||
|
import { freeEarlyAccess } from '@/lib/launch'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/* Editorial picks for «ابدأ من هنا». */
|
||||||
|
const START_HERE = ['first-arabic-agent', 'choosing-your-model', 'prototype-to-production', 'simple-arabic-rag', 'safe-agent-guardrails', 'agent-loop-stop-condition']
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
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 (
|
||||||
|
<div id="site" className="site-page v2 v2-home">
|
||||||
|
{/* Organization structured data (home page only; WebSite LD lives in layout). */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'أنور بآي',
|
||||||
|
url: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai',
|
||||||
|
logo: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'}/brand/icon-256.png`,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Topnav viewer={viewer} />
|
||||||
|
<main id="content">
|
||||||
|
<section className="home-hero">
|
||||||
|
<div className="wrap home-hero-grid">
|
||||||
|
<div className="home-hero-copy">
|
||||||
|
<p className="kicker">مكتبة عربية للذكاء الاصطناعي العملي</p>
|
||||||
|
<h1>افهم الذكاء الاصطناعي، ثم ابنِ به.</h1>
|
||||||
|
<p className="lede">أدلة عربية عملية عن الوكلاء والنماذج والأتمتة، يكتبها مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p>
|
||||||
|
<div className="actions">
|
||||||
|
<a className="btn btn-primary" href="/articles?access=free">ابدأ بدليل مجاني</a>
|
||||||
|
<Link className="btn btn-quiet" href="/articles">تصفح المكتبة</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hero.length > 0 && (
|
||||||
|
<aside className="hero-panel" aria-labelledby="new-title">
|
||||||
|
<h2 id="new-title">أحدث الأدلة</h2>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="home-paths" id="topics" aria-labelledby="paths-title">
|
||||||
|
<div className="wrap">
|
||||||
|
<header className="section-head">
|
||||||
|
<h2 id="paths-title">اختر مساراً حسب ما تريد بناءه</h2>
|
||||||
|
<p>كل دليل ينتمي إلى مسار، وأدلة المبتدئين تظهر أيضاً في الأساسيات.</p>
|
||||||
|
</header>
|
||||||
|
<ol className="paths" role="list">
|
||||||
|
{TOPICS.map(topic => (
|
||||||
|
<li key={topic.key}>
|
||||||
|
<Link href={`/topics/${topic.key}`} className="path">
|
||||||
|
<span className="path-name">{topic.label}</span>
|
||||||
|
<span className="path-hint">{topic.hint}</span>
|
||||||
|
<span className="path-count">{guidesLabel(counts[topic.key])}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{startHere.length > 0 && (
|
||||||
|
<section className="home-start" aria-labelledby="start-title">
|
||||||
|
<div className="wrap">
|
||||||
|
<header className="section-head">
|
||||||
|
<h2 id="start-title">ابدأ من هنا</h2>
|
||||||
|
<Link className="text-link" href="/articles">كل الأدلة ({formatNumber(docs.length)})</Link>
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="home-membership" id="membership" aria-labelledby="membership-title">
|
||||||
|
<div className="wrap membership-panel">
|
||||||
|
<div>
|
||||||
|
<h2 id="membership-title">العضوية تفتح الأدلة المتقدمة وملفات العمل.</h2>
|
||||||
|
<p>الأدلة المجانية كاملة دائماً. العضوية تضيف الأدلة المعمّقة، القوالب الجاهزة للتنزيل، والنقاش مع الفريق.</p>
|
||||||
|
</div>
|
||||||
|
<div className="membership-offer">
|
||||||
|
{freeEarlyAccess() ? (
|
||||||
|
<>
|
||||||
|
<p className="price price-early">وصول مبكر مجاني</p>
|
||||||
|
<p className="early-line">يمكنك قراءة المحتوى المنشور خلال الوصول المبكر دون بطاقة دفع.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
|
||||||
|
)}
|
||||||
|
<ul role="list">
|
||||||
|
<li>{guidesLabel(docs.length - freeCount)} للأعضاء، وتُضاف أدلة جديدة باستمرار</li>
|
||||||
|
<li>قوالب وقوائم فحص قابلة للتنزيل</li>
|
||||||
|
<li>التعليق والنقاش على كل دليل</li>
|
||||||
|
</ul>
|
||||||
|
<Link className="btn btn-primary" href="/subscription">اطّلع على العضوية</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import '../styles/payment-cancelled.css'
|
||||||
|
|
||||||
|
export const metadata = { title: 'لم يكتمل الدفع' }
|
||||||
|
|
||||||
|
/* Payment cancelled — design from payment-cancelled.html. */
|
||||||
|
export default function PaymentCancelledPage() {
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page page-payment-cancelled">
|
||||||
|
<Topnav />
|
||||||
|
<main id="content">
|
||||||
|
<section className="section" data-od-id="payment-cancelled">
|
||||||
|
<div className="container">
|
||||||
|
<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="M9 9l14 14M23 9 9 23" /></svg></div>
|
||||||
|
<p className="eyebrow">لم يكتمل الدفع</p>
|
||||||
|
<h1 data-od-id="cancelled-title">لم تُخصم أي دفعة.</h1>
|
||||||
|
<p className="lead">ألغيت عملية الدفع أو عدت قبل إكمالها. حسابك المجاني وأدلتك المحفوظة لم تتغير.</p>
|
||||||
|
<div className="notice" data-od-id="account-safety-note">
|
||||||
|
<h2 data-od-id="account-safety-title">يمكنك المتابعة وقتما يناسبك</h2>
|
||||||
|
<p>ارجع إلى صفحة العضوية لمراجعة المزايا أو أكمل استخدام المكتبة المجانية الآن.</p>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<Link className="btn btn-primary" href="/subscription" data-od-id="retry-payment-cta">العودة إلى العضوية</Link>
|
||||||
|
<Link className="btn btn-secondary" href="/articles" data-od-id="browse-free-articles-cta">تصفح الأدلة المجانية</Link>
|
||||||
|
</div>
|
||||||
|
<p className="help">واجهت مشكلة أثناء الدفع؟ <a href="mailto:hello@anwarpy.ai?subject=%D9%85%D8%B4%D9%83%D9%84%D8%A9%20%D9%81%D9%8A%20%D8%A7%D9%84%D8%AF%D9%81%D8%B9" data-od-id="payment-help-link">تواصل مع الفريق</a>.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import '../styles/payment-success.css'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const metadata = { title: 'تم تفعيل العضوية' }
|
||||||
|
|
||||||
|
type Props = { searchParams: Promise<{ session_id?: string }> }
|
||||||
|
|
||||||
|
/* Payment success — design from payment-success.html.
|
||||||
|
- real Stripe: verifies the checkout session_id with Stripe before
|
||||||
|
activating (paid + the checkout email matches the signed-in account)
|
||||||
|
- no session: nobody is activated here — /api/stripe/webhook is the primary
|
||||||
|
activator; this page only re-verifies a session it can prove */
|
||||||
|
export default async function PaymentSuccessPage({ searchParams }: Props) {
|
||||||
|
const { session_id: sessionId } = await searchParams
|
||||||
|
const stripeKey = process.env.STRIPE_SECRET_KEY
|
||||||
|
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
const { user } = await payload.auth({ headers: await headers() })
|
||||||
|
if (!user) redirect('/login?next=/payment-success')
|
||||||
|
|
||||||
|
let activate = false
|
||||||
|
if (sessionId && stripeKey) {
|
||||||
|
try {
|
||||||
|
const stripe = new Stripe(stripeKey)
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(sessionId)
|
||||||
|
activate =
|
||||||
|
session.payment_status === 'paid' &&
|
||||||
|
(session.customer_details?.email ?? '').toLowerCase() === user.email.toLowerCase()
|
||||||
|
} catch {
|
||||||
|
// Any Stripe failure (bad key, unknown session, network) → no activation.
|
||||||
|
activate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activate && user.role !== 'vip' && user.role !== 'admin') {
|
||||||
|
await payload.update({
|
||||||
|
collection: 'users',
|
||||||
|
id: user.id,
|
||||||
|
data: { role: 'vip' },
|
||||||
|
overrideAccess: true,
|
||||||
|
context: { systemActivation: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const vipNow = user.role === 'vip' || user.role === 'admin' || activate
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page page-payment-success">
|
||||||
|
<Topnav viewer={user} />
|
||||||
|
<main id="content">
|
||||||
|
<section className="section" data-od-id="payment-success">
|
||||||
|
<div className="container">
|
||||||
|
<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>
|
||||||
|
{vipNow ? (
|
||||||
|
<>
|
||||||
|
<p className="eyebrow">تم الدفع · العضوية فعّالة</p>
|
||||||
|
<h1 data-od-id="success-title">مرحباً بك في عضوية أنور بآي.</h1>
|
||||||
|
<p className="lead">تم تفعيل وصولك إلى الأدلة الحصرية وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p>
|
||||||
|
<dl className="summary" data-od-id="payment-summary">
|
||||||
|
<div className="summary-row"><dt>الحالة</dt><dd>عضوية فعّالة</dd></div>
|
||||||
|
<div className="summary-row"><dt>الخطة</dt><dd>عضوية فردية · شهرياً · <bdi dir="ltr">$10</bdi></dd></div>
|
||||||
|
<div className="summary-row"><dt>الوصول</dt><dd>كل محتوى العضوية وملفات الأعضاء</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div className="next-step" data-od-id="next-step">
|
||||||
|
<h2 data-od-id="next-step-title">جهّز مسارك الأول</h2>
|
||||||
|
<p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بدليل واحد يناسبك.</p>
|
||||||
|
<div className="actions">
|
||||||
|
<Link className="btn btn-primary" href="/vip-onboarding" data-od-id="start-onboarding-cta">ابدأ إعداد العضوية</Link>
|
||||||
|
<Link className="btn btn-secondary" href="/posts/agent-loop-stop-condition" data-od-id="open-member-guide-cta">افتح دليل الأعضاء</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="eyebrow">لم تكتمل عملية الدفع</p>
|
||||||
|
<h1 data-od-id="payment-incomplete-title">لم يتم تأكيد الدفع بعد</h1>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/* Views ping — counts one view per browser session per post (global counter
|
||||||
|
on the backend). */
|
||||||
|
export function ViewPing({ slug }: { slug: string }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const key = `anw-viewed:${slug}`
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(key)) return
|
||||||
|
sessionStorage.setItem(key, '1')
|
||||||
|
} catch { /* private mode — count anyway */ }
|
||||||
|
fetch(`/api/posts/${slug}/view`, { method: 'POST', keepalive: true }).catch(() => null)
|
||||||
|
}, [slug])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Like toggle — login required; one like per user per post. */
|
||||||
|
export function LikeButton({ slug, loggedIn, initialLiked, initialCount }: { slug: string; loggedIn: boolean; initialLiked: boolean; initialCount: number }) {
|
||||||
|
const [liked, setLiked] = useState(initialLiked)
|
||||||
|
const [count, setCount] = useState(initialCount)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
if (!loggedIn) {
|
||||||
|
location.href = `/login?next=/posts/${slug}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
const res = await fetch(`/api/posts/${slug}/like`, { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setLiked(data.liked)
|
||||||
|
setCount(data.likes)
|
||||||
|
}
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="like"
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={busy}
|
||||||
|
aria-pressed={liked}
|
||||||
|
aria-label={liked ? 'ألغِ الإعجاب' : 'أعجبني'}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import PostExtras from './post-extras'
|
||||||
|
import { LikeButton, ViewPing } from './engagement'
|
||||||
|
import { getViewer } from '@/lib/viewer'
|
||||||
|
import { TOPICS, topicsFor } from '@/lib/topics'
|
||||||
|
import { formatDate, formatNumber, minutesLabel, contentTypeLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||||
|
import { freeEarlyAccess } from '@/lib/launch'
|
||||||
|
import { extractH2s, innerText, type Node } from '@/lib/lexical'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/* QA-06: article pages get their own title/description. */
|
||||||
|
export async function generateMetadata({ params }: Args) {
|
||||||
|
const { slug } = await params
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
// Drafts must never leak title/description into metadata.
|
||||||
|
const res = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
select: { title: true, dek: true, publishedAt: true, updatedAt: true, cover: true },
|
||||||
|
})
|
||||||
|
const post = res.docs[0] as unknown as
|
||||||
|
| { title: string; dek: string; publishedAt?: string | null; updatedAt?: string | null; cover?: { url?: string | null } | number }
|
||||||
|
| undefined
|
||||||
|
if (!post) return { title: 'أنور بآي' }
|
||||||
|
|
||||||
|
const ogImage =
|
||||||
|
post.cover && typeof post.cover === 'object' && post.cover.url ? post.cover.url : '/og-default.png'
|
||||||
|
return {
|
||||||
|
title: post.title,
|
||||||
|
description: post.dek,
|
||||||
|
openGraph: {
|
||||||
|
// Next replaces (not merges) the layout-level openGraph — redeclare fully.
|
||||||
|
type: 'article',
|
||||||
|
siteName: 'أنور بآي',
|
||||||
|
locale: 'ar',
|
||||||
|
title: post.title,
|
||||||
|
description: post.dek,
|
||||||
|
publishedTime: post.publishedAt ?? undefined,
|
||||||
|
images: [{ url: ogImage, width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: { card: 'summary_large_image', title: post.title, description: post.dek, images: [ogImage] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Args = { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
|
type PostDoc = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
dek: string
|
||||||
|
access: 'free' | 'vip'
|
||||||
|
category: string
|
||||||
|
level?: string | null
|
||||||
|
views?: number | null
|
||||||
|
likes?: number | null
|
||||||
|
readingMinutes: number
|
||||||
|
publishedAt?: string | null
|
||||||
|
updatedAt?: string | null
|
||||||
|
teaser?: string | null
|
||||||
|
content?: unknown
|
||||||
|
cover?: { url?: string | null }
|
||||||
|
attachments?: (number | { id: number })[]
|
||||||
|
contentType?: string | null
|
||||||
|
authorName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isLikedByViewer(postId: number, viewerId?: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!viewerId) return false
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: 'post-likes',
|
||||||
|
where: { and: [{ user: { equals: viewerId } }, { post: { equals: postId } }] },
|
||||||
|
depth: 0,
|
||||||
|
limit: 1,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
return docs.length > 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PostPage({ params }: Args) {
|
||||||
|
const { slug } = await params
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
|
||||||
|
const res = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
// content selected only for entitled viewers — never reaches non-VIP clients
|
||||||
|
select: {
|
||||||
|
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||||
|
views: true, likes: true,
|
||||||
|
readingMinutes: true, publishedAt: true, updatedAt: true, teaser: true, cover: true,
|
||||||
|
content: true, attachments: true,
|
||||||
|
contentType: true, authorName: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const post = res.docs[0] as unknown as PostDoc | undefined
|
||||||
|
if (!post) notFound()
|
||||||
|
|
||||||
|
// One auth lookup per request; everything below reads from it.
|
||||||
|
const viewer = await getViewer()
|
||||||
|
const role = viewer?.role ?? null
|
||||||
|
const viewerId = viewer?.id
|
||||||
|
const viewerLevel = viewer?.level ?? null
|
||||||
|
// L-02: free early access unlocks every published post (the query above
|
||||||
|
// already filters status: 'published'); paid mode keeps the tier gate.
|
||||||
|
const earlyAccessNotice = freeEarlyAccess() && post.access === 'vip'
|
||||||
|
const unlocked = freeEarlyAccess() || post.access === 'free' || role === 'vip' || role === 'admin'
|
||||||
|
const postTopics = topicsFor(post)
|
||||||
|
// The downloadable templates are agent-workflow files; only offer them on agent guides.
|
||||||
|
const hasAgentFiles = postTopics.includes('agents')
|
||||||
|
const liked = await isLikedByViewer(post.id, viewerId)
|
||||||
|
|
||||||
|
// Attachment metadata (label/access) is safe to expose — the bytes are
|
||||||
|
// gated by GET /api/files/[id]. Fetched with overrideAccess because the
|
||||||
|
// files collection read rule is vip/admin and depth population would
|
||||||
|
// otherwise collapse to bare ids for anonymous/free viewers.
|
||||||
|
const attachmentIds = (post.attachments ?? []).map(a => (typeof a === 'object' ? a.id : a))
|
||||||
|
type Attachment = { id: number; label: string; filename: string; access: 'free' | 'vip' }
|
||||||
|
let attachments: Attachment[] = []
|
||||||
|
if (attachmentIds.length > 0) {
|
||||||
|
const filesRes = await payload.find({
|
||||||
|
collection: 'files',
|
||||||
|
depth: 0,
|
||||||
|
limit: 20,
|
||||||
|
overrideAccess: true,
|
||||||
|
where: { id: { in: attachmentIds } },
|
||||||
|
select: { label: true, filename: true, access: true },
|
||||||
|
})
|
||||||
|
const fileDocs = filesRes.docs as unknown as Attachment[]
|
||||||
|
attachments = attachmentIds
|
||||||
|
.map(id => fileDocs.find(d => d.id === id))
|
||||||
|
.filter((d): d is Attachment => Boolean(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// «اقرأ بعد ذلك»: real published guides, same learning path first.
|
||||||
|
const { docs: others } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { and: [{ status: { equals: 'published' } }, { slug: { not_equals: post.slug } }] },
|
||||||
|
depth: 0,
|
||||||
|
limit: 100,
|
||||||
|
sort: '-publishedAt',
|
||||||
|
select: { title: true, slug: true, category: true, access: true, readingMinutes: true, level: true },
|
||||||
|
})
|
||||||
|
const related = [...others]
|
||||||
|
.sort((a, b) => Number(topicsFor(b).includes(postTopics[0])) - Number(topicsFor(a).includes(postTopics[0])))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
// Approved comments (public read access allows the approved-only filter).
|
||||||
|
const comments = await payload.find({
|
||||||
|
collection: 'comments',
|
||||||
|
depth: 0,
|
||||||
|
limit: 50,
|
||||||
|
sort: '-createdAt',
|
||||||
|
where: { and: [{ post: { equals: post.id } }, { status: { equals: 'approved' } }] },
|
||||||
|
})
|
||||||
|
// Authors see their own pending comments tagged «قيد المراجعة» — needs
|
||||||
|
// overrideAccess because non-admin read access filters to approved only.
|
||||||
|
const myPending = viewerId
|
||||||
|
? await payload.find({
|
||||||
|
collection: 'comments',
|
||||||
|
depth: 0,
|
||||||
|
limit: 10,
|
||||||
|
overrideAccess: true,
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ post: { equals: post.id } },
|
||||||
|
{ author: { equals: viewerId } },
|
||||||
|
{ status: { equals: 'pending' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
// TOC from content h2s (server-extracted from the Lexical tree) — computed
|
||||||
|
// unconditionally; the paywall preview (paid mode) also uses it.
|
||||||
|
const toc = extractH2s(post.content)
|
||||||
|
|
||||||
|
const topic = postTopics[0]
|
||||||
|
const topicLabel = TOPICS.find(t => t.key === topic)?.label ?? post.category
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page v2 v2-article page-posts">
|
||||||
|
<ViewPing slug={post.slug} />
|
||||||
|
{/* L-09: honest Article + Breadcrumb structured data. JSON-LD array = multiple objects in one script. */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify([
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: post.title,
|
||||||
|
description: post.dek,
|
||||||
|
image: typeof post.cover === 'object' && post.cover?.url ? post.cover.url : undefined,
|
||||||
|
datePublished: post.publishedAt ?? undefined,
|
||||||
|
dateModified: post.updatedAt ?? post.publishedAt ?? undefined,
|
||||||
|
author: post.authorName
|
||||||
|
? { '@type': 'Person', name: post.authorName }
|
||||||
|
: { '@type': 'Organization', name: 'أنور بآي' },
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'أنور بآي',
|
||||||
|
logo: { '@type': 'ImageObject', url: `${siteUrl}/brand/icon-256.png` },
|
||||||
|
},
|
||||||
|
mainEntityOfPage: { '@type': 'WebPage', '@id': `${siteUrl}/posts/${post.slug}` },
|
||||||
|
isAccessibleForFree: post.access === 'free' || freeEarlyAccess(),
|
||||||
|
inLanguage: 'ar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{ '@type': 'ListItem', position: 1, name: 'الرئيسية', item: siteUrl },
|
||||||
|
{ '@type': 'ListItem', position: 2, name: topicLabel, item: `${siteUrl}/topics/${topic}` },
|
||||||
|
{ '@type': 'ListItem', position: 3, name: post.title, item: `${siteUrl}/posts/${post.slug}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Topnav viewer={viewer} />
|
||||||
|
<main id="content">
|
||||||
|
<header className="art-head">
|
||||||
|
<div className="wrap art-head-grid">
|
||||||
|
<div className="art-head-copy">
|
||||||
|
<nav className="crumbs" aria-label="مسار التصفح">
|
||||||
|
<Link href="/articles">المكتبة</Link>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<Link href={`/articles?topic=${topic}`}>{topicLabel}</Link>
|
||||||
|
</nav>
|
||||||
|
<h1>{post.title}</h1>
|
||||||
|
<p className="lede">{post.dek}</p>
|
||||||
|
<div className="art-meta">
|
||||||
|
<span className={`badge${post.access === 'vip' ? ' badge-vip' : ''}`}>
|
||||||
|
{post.access === 'vip' && <LockIcon />}
|
||||||
|
{post.access === 'free' ? 'مجاني بالكامل' : unlocked ? 'للأعضاء' : 'معاينة مجانية'}
|
||||||
|
</span>
|
||||||
|
<span>{post.category}</span>
|
||||||
|
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||||
|
{post.authorName && <span>بقلم {post.authorName}</span>}
|
||||||
|
<span>{minutesLabel(post.readingMinutes)} للقراءة</span>
|
||||||
|
<span>{post.level ?? 'مبتدئ'}</span>
|
||||||
|
{post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>}
|
||||||
|
{post.updatedAt && post.publishedAt && new Date(post.updatedAt) > new Date(post.publishedAt) && (
|
||||||
|
<span>· حُدّث {formatDate(post.updatedAt)}</span>
|
||||||
|
)}
|
||||||
|
{(post.level ?? 'مبتدئ') === viewerLevel && <span className="for-you">يناسب مستواك</span>}
|
||||||
|
</div>
|
||||||
|
<div className="art-actions">
|
||||||
|
<LikeButton slug={post.slug} loggedIn={role !== null} initialLiked={liked} initialCount={post.likes ?? 0} />
|
||||||
|
<span className="views" data-views>{formatNumber(post.views)} قراءة</span>
|
||||||
|
{unlocked && post.access === 'vip' && <SaveButton />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{typeof post.cover === 'object' && post.cover?.url && (
|
||||||
|
<div className="wrap"><figure className="art-cover"><img src={post.cover.url} alt={post.title} width={1200} height={630} loading="eager" /></figure></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wrap art-layout">
|
||||||
|
<article className="art-body">
|
||||||
|
{unlocked ? (
|
||||||
|
<>
|
||||||
|
{earlyAccessNotice && (
|
||||||
|
<section className="early-notice" aria-label="إشعار الوصول المبكر">
|
||||||
|
<p>متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<RichText data={post.content} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => (
|
||||||
|
i === arr.length - 1 && arr.length > 1
|
||||||
|
? <p key={i} className="fade-out">{para}</p>
|
||||||
|
: <p key={i} className={i === 0 ? 'intro' : undefined}>{para}</p>
|
||||||
|
))}
|
||||||
|
<section className="paywall" aria-labelledby="paywall-title">
|
||||||
|
<h2 id="paywall-title">بقية هذا الدليل للأعضاء</h2>
|
||||||
|
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل الأدلة الحصرية الأخرى.</p>
|
||||||
|
{toc.length > 0 && (
|
||||||
|
<div className="paywall-preview">
|
||||||
|
<p className="aside-title">ماذا ينتظرك داخل هذا الدليل:</p>
|
||||||
|
<ul role="list">{toc.map((t, i) => <li key={i}><LockIcon />{t}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="paywall-actions">
|
||||||
|
<a className="btn btn-primary" href="/subscription">اشترك بـ <bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> شهرياً</a>
|
||||||
|
<a className="btn btn-quiet" href={`/login?next=/posts/${post.slug}`}>لدي عضوية، تسجيل الدخول</a>
|
||||||
|
</div>
|
||||||
|
<p className="fine">تلغي في أي وقت من صفحة حسابك. <Link href="/subscription#faq">أسئلة قبل الاشتراك</Link> · <Link href="/articles?access=free">أكمل بالأدلة المجانية</Link></p>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<aside className="art-aside" aria-label="داخل هذا الدليل">
|
||||||
|
{unlocked && toc.length > 0 && (
|
||||||
|
<nav className="toc" aria-label="فهرس الدليل">
|
||||||
|
<p className="aside-title">في هذا الدليل</p>
|
||||||
|
<ol>{toc.map((t, i) => <li key={i}><a href={`#sec-${i}`}>{t}</a></li>)}</ol>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
{unlocked && hasAgentFiles && (
|
||||||
|
<div className="files">
|
||||||
|
<p className="aside-title">ملفات العمل</p>
|
||||||
|
<ul>
|
||||||
|
<li><span>قالب حالة الوكيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="agent-state.md">تنزيل</button></li>
|
||||||
|
<li><span>قائمة الفحص قبل التشغيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="pre-run-checklist.md">تنزيل</button></li>
|
||||||
|
<li><span>سجل الأخطاء <bdi dir="ltr">.csv</bdi></span><button className="dl-btn" type="button" data-dl="error-log.csv">تنزيل</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!unlocked && (
|
||||||
|
<div className="files is-locked">
|
||||||
|
<p className="aside-title">مع العضوية</p>
|
||||||
|
<ul>
|
||||||
|
<li><LockIcon />الأقسام العملية كاملة</li>
|
||||||
|
{hasAgentFiles && <li><LockIcon />ملفات العمل القابلة للتنزيل</li>}
|
||||||
|
<li><LockIcon />النقاش مع الفريق</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="files">
|
||||||
|
<p className="aside-title">ملفات هذا الدليل</p>
|
||||||
|
<ul>
|
||||||
|
{attachments.map(item => {
|
||||||
|
// Per-file gate mirrors GET /api/files/[id]: free streams to
|
||||||
|
// anyone; vip needs a vip/admin viewer (post access is
|
||||||
|
// irrelevant — a vip file on a free post stays locked).
|
||||||
|
const canDownload = item.access === 'free' || role === 'vip' || role === 'admin'
|
||||||
|
const accessLabel = item.access === 'free' ? 'مجاني' : 'للأعضاء'
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<span>{item.label || item.filename} <span className="file-access">· {accessLabel}</span></span>
|
||||||
|
{canDownload
|
||||||
|
? <a className="dl-btn" href={`/api/files/${item.id}`} download>تنزيل</a>
|
||||||
|
: <span className="dl-locked"><LockIcon />للأعضاء</span>}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unlocked && (
|
||||||
|
<section className="art-comments" aria-labelledby="comments-title">
|
||||||
|
<div className="wrap narrow">
|
||||||
|
<h2 id="comments-title">النقاش</h2>
|
||||||
|
<div className="comment-list" id="commentList">
|
||||||
|
{comments.docs.map((c) => (
|
||||||
|
<article key={c.id} className="comment">
|
||||||
|
<header><strong>{c.authorName || 'عضو'}</strong><time dateTime={String(c.createdAt)}>{formatDate(String(c.createdAt))}</time></header>
|
||||||
|
<p>{c.body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{myPending?.docs.map((c) => (
|
||||||
|
<article key={c.id} className="comment is-pending">
|
||||||
|
<header><strong>{c.authorName || 'عضو'}</strong><span>بانتظار المراجعة</span></header>
|
||||||
|
<p>{c.body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && (
|
||||||
|
<p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا الدليل.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PostExtras slug={post.slug} loggedIn={role !== null} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{related.length > 0 && (
|
||||||
|
<section className="art-related" aria-labelledby="related-title">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 id="related-title">اقرأ بعد ذلك</h2>
|
||||||
|
<ul className="related-grid" role="list">
|
||||||
|
{related.map(item => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<Link href={`/posts/${item.slug}`} className="related-item">
|
||||||
|
<span className="meta-row"><span>{item.category}</span><span>{minutesLabel(item.readingMinutes)}</span><span>{item.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></span>
|
||||||
|
<span className="related-title">{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LockIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><rect x="3" y="7" width="10" height="7" /><path d="M5 7V5a3 3 0 0 1 6 0v2" /></svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveButton() {
|
||||||
|
return <button className="save-btn" id="saveArticle" type="button" aria-pressed="false">احفظ للعودة لاحقاً</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules.
|
||||||
|
// `Node`, `innerText` and `extractH2s` live in lib/lexical (shared with llms routes).
|
||||||
|
|
||||||
|
function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] {
|
||||||
|
return (nodes || []).map((n, i) => {
|
||||||
|
const key = `${n.type}-${i}`
|
||||||
|
if (n.type === 'text') {
|
||||||
|
const text = n.text || ''
|
||||||
|
return <span key={key}>{text}</span>
|
||||||
|
}
|
||||||
|
if (n.type === 'linebreak') return <br key={key} />
|
||||||
|
if (n.type === 'link') return <a key={key} href={n.url || '#'} className="text-link">{renderChildren(n.children, counter)}</a>
|
||||||
|
const inner = renderChildren(n.children, counter)
|
||||||
|
switch (n.type) {
|
||||||
|
case 'heading': {
|
||||||
|
const Tag = (n.tag || 'h2') as 'h1' | 'h2' | 'h3' | 'h4'
|
||||||
|
const id = Tag === 'h2' ? `sec-${counter.h2++}` : undefined
|
||||||
|
return <Tag key={key} id={id}>{inner}</Tag>
|
||||||
|
}
|
||||||
|
case 'quote': return <blockquote key={key}>{inner}</blockquote>
|
||||||
|
case 'list': {
|
||||||
|
if (n.listType === 'number') return <ol key={key}>{inner}</ol>
|
||||||
|
return <ul key={key}>{inner}</ul>
|
||||||
|
}
|
||||||
|
case 'listitem': return <li key={key}>{inner}</li>
|
||||||
|
case 'paragraph': return <p key={key}>{inner}</p>
|
||||||
|
case 'code': return <pre key={key} aria-label="مثال برمجي"><code>{n.code ?? innerText(n.children)}</code></pre>
|
||||||
|
case 'horizontalrule': return <hr key={key} />
|
||||||
|
case 'table':
|
||||||
|
return (
|
||||||
|
<div key={key} className="post-table" role="region" aria-label="جدول" tabIndex={0}>
|
||||||
|
<table>{inner}</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'tablerow': return <tr key={key}>{inner}</tr>
|
||||||
|
case 'tablecell': return n.headerState ? <th key={key}>{inner}</th> : <td key={key}>{inner}</td>
|
||||||
|
default: return <p key={key}>{inner}</p>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function RichText({ data }: { data: unknown }) {
|
||||||
|
if (!data || typeof data !== 'object' || !('root' in data)) return null
|
||||||
|
const rootUnknown = data.root
|
||||||
|
if (!rootUnknown || typeof rootUnknown !== 'object' || !('children' in rootUnknown)) return null
|
||||||
|
const root = rootUnknown as { children?: Node[] } // named const: same JSON tree the editor produced
|
||||||
|
return <>{renderChildren(root.children, { h2: 0 })}</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/* Save button + file download buttons + comment form — client behaviors
|
||||||
|
ported from post-unlocked.html (client-side only, faithful to the original). */
|
||||||
|
export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn: boolean }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const save = document.getElementById('saveArticle')
|
||||||
|
const onSave = () => {
|
||||||
|
if (!save) return
|
||||||
|
const active = save.getAttribute('aria-pressed') !== 'true'
|
||||||
|
save.setAttribute('aria-pressed', String(active))
|
||||||
|
save.textContent = active ? 'تم الحفظ ✓' : 'احفظ للعودة لاحقاً'
|
||||||
|
}
|
||||||
|
save?.addEventListener('click', onSave)
|
||||||
|
|
||||||
|
/* QA-02: download buttons generate real files (client-side, no backend
|
||||||
|
needed) with proper names and content types. */
|
||||||
|
const DL_FILES: Record<string, { name: string; type: string; body: string }> = {
|
||||||
|
'agent-state.md': {
|
||||||
|
name: 'anwarpi-agent-state-template.md',
|
||||||
|
type: 'text/markdown;charset=utf-8',
|
||||||
|
body: [
|
||||||
|
'# قالب حالة الوكيل',
|
||||||
|
'',
|
||||||
|
'> انسخ هذا القالب لكل وكيل تبنيه؛ الحقول الفارغة تعني منطقاً ناقصاً.',
|
||||||
|
'',
|
||||||
|
'## الهدف الواحد',
|
||||||
|
'- ما الذي ينجزه الوكيل بالضبط؟',
|
||||||
|
'',
|
||||||
|
'## حدود الحلقة',
|
||||||
|
'- أقصى عدد خطوات: ',
|
||||||
|
'- شرط التوقف: ',
|
||||||
|
'- ما لا يفعله الوكيل: ',
|
||||||
|
'',
|
||||||
|
'## سجل الحالة',
|
||||||
|
'| الحقل | القيمة | آخر تحديث |',
|
||||||
|
'| --- | --- | --- |',
|
||||||
|
'| المهمة الحالية | | |',
|
||||||
|
'| الخطوة | | |',
|
||||||
|
'| الملاحظات | | |',
|
||||||
|
'',
|
||||||
|
'## الأدوات والصلاحيات',
|
||||||
|
'- الأداة: الصلاحية الممنوحة: التأكيد البشري المطلوب: ',
|
||||||
|
'',
|
||||||
|
'## سجل التدقيق',
|
||||||
|
'- الوقت: الفعل: النتيجة: ',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
'pre-run-checklist.md': {
|
||||||
|
name: 'anwarpi-pre-run-checklist.md',
|
||||||
|
type: 'text/markdown;charset=utf-8',
|
||||||
|
body: [
|
||||||
|
'# قائمة الفحص قبل التشغيل',
|
||||||
|
'',
|
||||||
|
'- [ ] الهدف مكتوب في جملة واحدة قابلة للقياس',
|
||||||
|
'- [ ] شرط توقف واضح يمنع الحلقة اللانهائية',
|
||||||
|
'- [ ] كل أداة لها صلاحية محددة وشخص مسؤول عن الموافقة',
|
||||||
|
'- [ ] بيانات الاختبار جاهزة وليست بيانات إنتاج',
|
||||||
|
'- [ ] سجل تدقيق يكتب كل فعل يقوم به الوكيل',
|
||||||
|
'- [ ] سقف تكلفة وزمن محدد لكل تشغيل',
|
||||||
|
'- [ ] خطة مراجعة بشرية للمخرجات قبل نشرها',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
'error-log.csv': {
|
||||||
|
name: 'anwarpi-error-log.csv',
|
||||||
|
type: 'text/csv;charset=utf-8',
|
||||||
|
body: '\uFEFF' + [
|
||||||
|
'الوقت,المرحلة,نوع الخطأ,الوصف,الإجراء',
|
||||||
|
',استرجاع,,,',
|
||||||
|
',تنفيذ,,,',
|
||||||
|
',تحقق,,,',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const dlHandler = (e: Event) => {
|
||||||
|
const b = e.currentTarget as HTMLButtonElement
|
||||||
|
const file = DL_FILES[b.dataset.dl ?? '']
|
||||||
|
if (file) {
|
||||||
|
const blob = new Blob([file.body], { type: file.type })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = file.name
|
||||||
|
document.body.append(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
|
b.textContent = 'تم التنزيل ✓'
|
||||||
|
setTimeout(() => { b.textContent = 'تنزيل' }, 1600)
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-dl]').forEach(b => b.addEventListener('click', dlHandler))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
save?.removeEventListener('click', onSave)
|
||||||
|
document.querySelectorAll('[data-dl]').forEach(b => b.removeEventListener('click', dlHandler))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const form = e.currentTarget
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
setStatus('اكتب تعليقاً من 8 أحرف على الأقل.')
|
||||||
|
form.reportValidity()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = (form.elements.namedItem('comment') as HTMLTextAreaElement).value.trim()
|
||||||
|
setBusy(true)
|
||||||
|
setStatus('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/posts/${slug}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body: text }),
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
if (res.status === 201) {
|
||||||
|
form.reset()
|
||||||
|
setStatus('شكراً — وصل تعليقك وسيظهر بعد المراجعة.')
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
setStatus('سجّل الدخول كي تعلّق.')
|
||||||
|
} else {
|
||||||
|
setStatus(data?.error || 'تعذّر الإرسال — حاول مجدداً.')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus('تعذّر الإرسال — حاول مجدداً.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<form className="comment-form" id="commentForm" method="post" noValidate onSubmit={onSubmit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="commentBody">تعليقك</label>
|
||||||
|
<textarea className="textarea" id="commentBody" name="comment" required minLength={8} maxLength={2000}></textarea>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={busy}>{busy ? 'جارٍ الإرسال…' : 'أرسل التعليق'}</button>
|
||||||
|
<p className="status" id="commentStatus" role="status">{status}</p>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import '../styles/privacy.css'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const metadata = { title: 'الخصوصية' }
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page page-privacy">
|
||||||
|
<Topnav />
|
||||||
|
<main id="content">
|
||||||
|
<section className="hero" data-od-id="privacy-hero">
|
||||||
|
<div className="container">
|
||||||
|
<p className="eyebrow">الخصوصية · مسودة ما قبل الإطلاق</p>
|
||||||
|
<h1 data-od-id="privacy-title">بياناتك ليست مادة للمحتوى.</h1>
|
||||||
|
<p className="lead">توضح هذه المسودة الحد الأدنى المقترح لجمع البيانات واستخدامها داخل أنور بآي.</p>
|
||||||
|
<p className="notice" role="note">هذه سياسة تجريبية للواجهة وليست نصاً قانونياً نهائياً. يجب مراجعتها واعتمادها قبل نشر الموقع.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="container policy" data-od-id="privacy-content">
|
||||||
|
<section data-od-id="privacy-data"><h2>ما الذي نجمعه؟</h2><p>عند تشغيل الحسابات فعلياً قد نحتاج إلى الاسم والبريد الإلكتروني، وبيانات العضوية والفوترة التي يوفرها مزوّد الدفع. لا ينبغي تخزين بيانات البطاقة كاملة داخل المنصة.</p></section>
|
||||||
|
<section data-od-id="privacy-use"><h2>كيف تُستخدم البيانات؟</h2><ul><li>تسجيل الدخول وحفظ التقدّم والتفضيلات.</li><li>إدارة العضوية وإرسال الرسائل التي اختارها المستخدم.</li><li>حماية المنصة وتشخيص الأعطال وتحسين تجربة القراءة.</li></ul></section>
|
||||||
|
<section data-od-id="privacy-sharing"><h2>المشاركة والاحتفاظ</h2><p>لا تُباع البيانات الشخصية. قبل الإطلاق يجب تسمية مزوّدي الاستضافة والبريد والدفع والتحليلات، وتحديد مدة الاحتفاظ بكل نوع من البيانات بوضوح.</p></section>
|
||||||
|
<section data-od-id="privacy-rights"><h2>حقوقك</h2><p>يمكنك طلب نسخة من بياناتك أو تصحيحها أو حذفها بعد تفعيل الحسابات. أرسل طلبك إلى <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>.</p></section>
|
||||||
|
<section data-od-id="privacy-updates"><h2>التحديثات</h2><p>يجب إضافة تاريخ السريان وملخص أي تغيير جوهري عند اعتماد النسخة القانونية النهائية.</p></section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'إنشاء حساب',
|
||||||
|
robots: { index: false, follow: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignupLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
'use client'
|
||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { safeNext } from '@/lib/next'
|
||||||
|
import '../styles/signup.css'
|
||||||
|
|
||||||
|
/* Signup — markup from signup.html, wired to real Payload registration
|
||||||
|
(create + auto-login) instead of the simulated client-only flow. */
|
||||||
|
export default function SignupPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [emailError, setEmailError] = useState<string | null>(null)
|
||||||
|
const [suggestion, setSuggestion] = useState<string | null>(null)
|
||||||
|
const [pwOk, setPwOk] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [nextPath, setNextPath] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* Common typos → canonical domain (step 16; on blur of the email input). */
|
||||||
|
const EMAIL_TYPOS: Record<string, string> = {
|
||||||
|
'gmial.com': 'gmail.com',
|
||||||
|
'gmai.com': 'gmail.com',
|
||||||
|
'gmail.co': 'gmail.com',
|
||||||
|
'hotmial.com': 'hotmail.com',
|
||||||
|
'outlook.co': 'outlook.com',
|
||||||
|
'yahoo.co': 'yahoo.com',
|
||||||
|
}
|
||||||
|
function domainSuggestion(raw: string): string | null {
|
||||||
|
const at = raw.lastIndexOf('@')
|
||||||
|
if (at < 0) return null
|
||||||
|
return EMAIL_TYPOS[raw.slice(at + 1).toLowerCase().trim()] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNextPath(safeNext(new URLSearchParams(window.location.search).get('next')))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const form = e.currentTarget
|
||||||
|
const data = new FormData(form)
|
||||||
|
setEmailError(null)
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
setStatus('تحقق من جميع الحقول المطلوبة.')
|
||||||
|
form.reportValidity()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setStatus('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: data.get('email'), password: data.get('password'), fullName: data.get('name'), termsVersion: '2026-10-03' }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null) as { error?: string; errors?: { message?: string; path?: string; field?: string }[] } | null
|
||||||
|
// Middleware 429 shape: { error: 'محاولات كثيرة جدًا. حاول بعد قليل.' }
|
||||||
|
// + Retry-After seconds → show verbatim, append «حاول بعد X دقيقة.»
|
||||||
|
const retryAfterHeader = res.headers.get('Retry-After')
|
||||||
|
if (typeof err?.error === 'string') {
|
||||||
|
const retryAfterSec = Number(retryAfterHeader)
|
||||||
|
setStatus(
|
||||||
|
retryAfterHeader && Number.isFinite(retryAfterSec)
|
||||||
|
? `${err.error} حاول بعد ${Math.max(1, Math.ceil(retryAfterSec / 60))} دقيقة.`
|
||||||
|
: err.error,
|
||||||
|
)
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const first = err?.errors?.[0]
|
||||||
|
const failedField = first?.path ?? first?.field
|
||||||
|
if (failedField === 'email' && first?.message) {
|
||||||
|
// Duplicate email (unique-constraint ValidationError) → inline under
|
||||||
|
// the email input, not the global status line.
|
||||||
|
setEmailError('هذا البريد مسجَّل لدينا.')
|
||||||
|
} else {
|
||||||
|
setStatus(first?.message || 'تعذّر إنشاء الحساب — قد يكون البريد مستخدماً بالفعل.')
|
||||||
|
}
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const login = await fetch('/api/users/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
|
||||||
|
})
|
||||||
|
if (login.ok) {
|
||||||
|
try { sessionStorage.setItem('anwarpi:signup-email', String(data.get('email'))) } catch { /* private mode */ }
|
||||||
|
try { nextPath ? sessionStorage.setItem('anwarpi:signup-next', nextPath) : sessionStorage.removeItem('anwarpi:signup-next') } catch { /* private mode */ }
|
||||||
|
router.refresh()
|
||||||
|
router.push('/verify-email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('تم إنشاء الحساب — سجّل الدخول للمتابعة.')
|
||||||
|
router.push(nextPath ? '/login?next=' + encodeURIComponent(nextPath) : '/login')
|
||||||
|
} catch {
|
||||||
|
setStatus('تعذّر الاتصال. حاول مرة أخرى بعد التحقق من اتصالك.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page page-signup">
|
||||||
|
<Topnav />
|
||||||
|
<main id="content" className="auth">
|
||||||
|
<section className="auth-story">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||||
|
<h1>ابدأ بحساب مجاني.</h1>
|
||||||
|
<p>الحساب المجاني يفتح كل الأدلة المجانية كاملة والتعليق عليها. يمكنك الاشتراك في العضوية لاحقاً إن احتجتها.</p>
|
||||||
|
</section>
|
||||||
|
<section className="auth-form-wrap" data-od-id="signup-form-section">
|
||||||
|
<form className="auth-form" id="signupForm" method="post" action="/api/users" noValidate onSubmit={onSubmit} data-od-id="signup-form">
|
||||||
|
<p className="eyebrow">حساب جديد</p>
|
||||||
|
<h2 data-od-id="signup-title">إنشاء حساب</h2>
|
||||||
|
<p className="lead">أدخل بياناتك الأساسية للبدء.</p>
|
||||||
|
<div className="field"><label htmlFor="name">الاسم</label><input className="input" id="name" name="name" type="text" autoComplete="name" minLength={2} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل اسمك.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
|
||||||
|
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required value={email} onChange={e => setEmail(e.target.value)} onBlur={e => setSuggestion(domainSuggestion(e.target.value))} onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} />
|
||||||
|
{emailError && (
|
||||||
|
<p className="field-error" role="alert">هذا البريد مسجَّل لدينا. <Link href="/login?next=/verify-email">تسجيل الدخول</Link></p>
|
||||||
|
)}
|
||||||
|
{!emailError && suggestion && (
|
||||||
|
<button type="button" className="typo-suggest" onClick={() => { const at = email.lastIndexOf('@'); setEmail(`${email.slice(0, at + 1)}${suggestion}`); setSuggestion(null) }}>
|
||||||
|
هل تقصد <bdi dir="ltr">{suggestion}</bdi>؟
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field"><label htmlFor="password">كلمة المرور</label><div className="input-wrap"><input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="new-password" minLength={8} aria-describedby="passwordHint" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة مرور من ثمانية أحرف على الأقل.')} onInput={e => { e.currentTarget.setCustomValidity(''); setPwOk(e.currentTarget.value.length >= 8) }} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className={`fine${pwOk ? ' ok' : ''}`} id="passwordHint">ثمانية أحرف على الأقل.</span></div>
|
||||||
|
<label className="check"><input id="terms" name="terms" type="checkbox" required /><span>أوافق على <Link href="/privacy">سياسة الخصوصية</Link> و<Link href="/terms">شروط الاستخدام</Link>.</span></label>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={busy} data-od-id="create-account-cta">{busy ? 'جارٍ إنشاء الحساب…' : 'أنشئ حسابي'}</button>
|
||||||
|
<p className="fine signup-reassure">مجاناً — بلا بطاقة ائتمانية. لن نشارك بريدك أبداً.</p>
|
||||||
|
<div className="status" id="signupStatus" role="status" aria-live="polite">{status}</div>
|
||||||
|
<p className="signin">لديك حساب بالفعل؟ <Link href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : '/login'}>سجّل الدخول</Link></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/* Page-scoped styles: .page-about. Shared tokens live in globals.css. */
|
||||||
|
.page-about *,.page-about *::before,.page-about *::after{box-sizing:border-box}
|
||||||
|
.page-about{-webkit-text-size-adjust:100%}
|
||||||
|
.page-about{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
|
||||||
|
.page-about img,.page-about svg{display:block;max-width:100%;height:auto}
|
||||||
|
.page-about a{color:inherit;text-decoration:none}
|
||||||
|
.page-about h1,.page-about h2,.page-about h3{font-family:var(--font-display);text-wrap:balance}
|
||||||
|
.page-about p{text-wrap:pretty}
|
||||||
|
.page-about :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||||
|
.page-about .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||||
|
.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)}
|
||||||
|
.page-about .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||||
|
.page-about .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}
|
||||||
|
.page-about .logo-mark{width:30px;height:34px;object-fit:contain;flex:none;border-radius:4px}
|
||||||
|
.page-about .nav-links{display:flex;align-items:center;gap:28px}
|
||||||
|
.page-about .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
|
||||||
|
.page-about .nav-links a:hover,.page-about .nav-links a[aria-current="page"]{color:var(--fg)}
|
||||||
|
.page-about .hero{padding-block:clamp(70px,10vw,132px);border-bottom:1px solid var(--border)}
|
||||||
|
.page-about .hero-grid{display:grid;grid-template-columns:1.25fr .75fr;gap:clamp(48px,9vw,112px);align-items:flex-end}
|
||||||
|
.page-about .eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 22px}
|
||||||
|
.page-about .hero h1{font-size:clamp(48px,7.5vw,92px);line-height:1.06;max-width:10ch;margin:0}
|
||||||
|
.page-about .lead{font-size:clamp(19px,2vw,23px);line-height:1.75;color:var(--muted);max-width:54ch;margin:28px 0 0}
|
||||||
|
.page-about .hero-note{border-top:1px solid var(--fg);padding-top:24px}
|
||||||
|
.page-about .hero-note strong{display:block;font-size:clamp(24px,3vw,34px);line-height:1.35}
|
||||||
|
.page-about .hero-note p{color:var(--muted);margin:10px 0 0}
|
||||||
|
.page-about .team{padding-block:clamp(64px,9vw,104px);border-bottom:1px solid var(--border)}
|
||||||
|
.page-about .section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:40px;margin-bottom:46px}
|
||||||
|
.page-about .section-head h2{font-size:clamp(38px,5vw,62px);line-height:1.12;max-width:12ch;margin:0}
|
||||||
|
.page-about .section-head p{color:var(--muted);max-width:48ch;margin:0}
|
||||||
|
.page-about .profile-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:42px;border-top:1px solid var(--fg)}
|
||||||
|
.page-about .profile{padding:34px 0 0;display:flex;flex-direction:column;min-width:0}
|
||||||
|
.page-about .profile+.profile{padding-inline-start:42px;border-inline-start:1px solid var(--border)}
|
||||||
|
.page-about .profile-label{font:12px/1.5 var(--font-mono);color:var(--muted)}
|
||||||
|
.page-about .profile-main .profile-label{color:var(--accent)}
|
||||||
|
.page-about .profile h3{font-size:clamp(32px,4vw,48px);line-height:1.15;margin:20px 0 8px}
|
||||||
|
.page-about .role{color:var(--fg);font-weight:700;margin:0 0 22px}
|
||||||
|
.page-about .bio{color:var(--muted);font-size:17px;margin:0;max-width:58ch}
|
||||||
|
.page-about .topics{display:flex;flex-wrap:wrap;gap:8px;margin:28px 0 32px}
|
||||||
|
.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}
|
||||||
|
.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}
|
||||||
|
.page-about .profile-link:hover{background:var(--fg);color:var(--surface)}
|
||||||
|
.page-about .principles{padding-block:clamp(64px,9vw,104px)}
|
||||||
|
.page-about .principle-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:clamp(48px,9vw,112px);align-items:flex-start}
|
||||||
|
.page-about .principle-grid h2{font-size:clamp(38px,5vw,60px);line-height:1.14;margin:0;max-width:11ch}
|
||||||
|
.page-about .principle-list{border-top:1px solid var(--fg)}
|
||||||
|
.page-about .principle{display:grid;grid-template-columns:56px 1fr;gap:20px;padding:26px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.page-about .principle-index{font:12px/1.5 var(--font-mono);color:var(--muted);padding-top:7px}
|
||||||
|
.page-about .principle h3{font-size:24px;margin:0 0 8px}
|
||||||
|
.page-about .principle p{color:var(--muted);margin:0}
|
||||||
|
.page-about .pagefoot{padding-block:50px;border-top:1px solid var(--border);color:var(--muted)}
|
||||||
|
.page-about .footer-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;flex-wrap:wrap}
|
||||||
|
.page-about .footer-links{display:flex;gap:20px;flex-wrap:wrap}
|
||||||
|
.page-about .footer-links a{min-height:44px;display:inline-flex;align-items:center}
|
||||||
|
.page-about .footer-links a:hover{color:var(--fg);text-decoration:underline;text-underline-offset:4px}
|
||||||
|
@media(max-width:920px){
|
||||||
|
.page-about .hero-grid,.page-about .profile-grid,.page-about .principle-grid{grid-template-columns:1fr}
|
||||||
|
.page-about .profile-grid{gap:0}
|
||||||
|
.page-about .profile+.profile{padding-inline-start:0;border-inline-start:0;border-top:1px solid var(--border);margin-top:42px;padding-top:42px}
|
||||||
|
.page-about .section-head{align-items:flex-start;flex-direction:column}
|
||||||
|
.page-about .hero-note{max-width:560px}
|
||||||
|
}
|
||||||
|
@media(max-width:620px){
|
||||||
|
.page-about .hero{padding-block:54px}
|
||||||
|
.page-about .team,.page-about .principles{padding-block:58px}
|
||||||
|
.page-about .profile h3{font-size:36px}
|
||||||
|
.page-about .principle{grid-template-columns:42px 1fr}
|
||||||
|
.page-about .footer-bottom,.page-about .footer-links{align-items:stretch;flex-direction:column;gap:8px}
|
||||||
|
}
|
||||||
|
@media(prefers-reduced-motion:reduce){.page-about *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||||
|
|
||||||
|
/* Matched team cards, independent of generic card and heading overrides. */
|
||||||
|
#site.page-about .section-head { display: grid; grid-template-columns: 1fr 1fr; align-items: start; gap: 40px; margin-bottom: 32px; }
|
||||||
|
#site.page-about .section-head h2 { max-width: none; margin: 0; font-size: clamp(28px, 3vw, 40px); }
|
||||||
|
#site.page-about .section-head p { max-width: none; margin: 0; line-height: 1.9; }
|
||||||
|
#site.page-about .profile-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; border: 0; align-items: stretch; }
|
||||||
|
#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; }
|
||||||
|
#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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/* Page-scoped styles: .page-account-settings. Shared tokens live in globals.css. */
|
||||||
|
.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}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||||
|
.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)}
|
||||||
|
.page-account-settings .nav-tools{display:flex;align-items:center;gap:8px}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
.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)}
|
||||||
|
.page-account-settings .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
|
||||||
|
.page-account-settings .demo-strip b{color:var(--surface);font-weight:700}
|
||||||
|
.page-account-settings .page-head{padding-block:clamp(48px,7vw,84px) 0}
|
||||||
|
.page-account-settings .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}
|
||||||
|
.page-account-settings .page-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0}
|
||||||
|
.page-account-settings .page-head .sub{color:var(--muted);margin:16px 0 0;max-width:52ch}
|
||||||
|
.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-account-settings .settings-nav{position:sticky;top:100px;border-top:1px solid var(--fg)}
|
||||||
|
.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-account-settings .settings-nav button:hover{color:var(--fg)}
|
||||||
|
.page-account-settings .settings-nav button[aria-selected="true"]{color:var(--fg);font-weight:700}
|
||||||
|
.page-account-settings .settings-nav button[aria-selected="true"]::after{content:"";width:8px;height:8px;background:var(--accent);transform:rotate(45deg)}
|
||||||
|
.page-account-settings .panel{display:none}
|
||||||
|
.page-account-settings .panel.active{display:block}
|
||||||
|
.page-account-settings .panel h2{font-size:clamp(30px,3.4vw,42px);line-height:1.2;margin:0 0 10px}
|
||||||
|
.page-account-settings .panel .panel-sub{color:var(--muted);max-width:56ch;margin:0 0 34px}
|
||||||
|
.page-account-settings .form-grid{display:grid;gap:24px;max-width:560px}
|
||||||
|
.page-account-settings .field{display:grid;gap:7px}
|
||||||
|
.page-account-settings .field label{font-size:13px;color:var(--muted)}
|
||||||
|
.page-account-settings .field .hint{font-size:12px;color:var(--muted)}
|
||||||
|
.page-account-settings .input{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;min-height:48px}
|
||||||
|
.page-account-settings .input:hover{border-color:var(--muted)}
|
||||||
|
.page-account-settings .input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||||||
|
.page-account-settings .input[disabled]{background:var(--fg-soft);color:var(--muted);cursor:not-allowed}
|
||||||
|
.page-account-settings .input.invalid{border-color:var(--accent)}
|
||||||
|
.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}
|
||||||
|
.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)}
|
||||||
|
.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)}
|
||||||
|
.page-account-settings .status{font-size:14px;min-height:24px;display:flex;align-items:center;gap:8px}
|
||||||
|
.page-account-settings .status.ok{color:var(--accent);font-weight:700}
|
||||||
|
.page-account-settings .status.err{color:var(--accent)}
|
||||||
|
.page-account-settings .pw-rules{list-style:none;padding:0;margin:4px 0 0;display:grid;gap:4px}
|
||||||
|
.page-account-settings .pw-rules li{font-size:12px;color:var(--muted);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}
|
||||||
|
.page-account-settings .pw-rules li.ok{color:var(--fg)}
|
||||||
|
.page-account-settings .pw-rules li.ok::before{background:var(--accent);border-color:var(--accent)}
|
||||||
|
.page-account-settings .prefs{border-top:1px solid var(--fg);max-width:640px}
|
||||||
|
.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)}
|
||||||
|
.page-account-settings .pref-row strong{display:block;font-size:17px}
|
||||||
|
.page-account-settings .pref-row p{margin:4px 0 0;color:var(--muted);font-size:14px;max-width:44ch}
|
||||||
|
.page-account-settings .switch{position:relative;width:52px;min-height:32px;flex:none}
|
||||||
|
.page-account-settings .switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .switch input:checked + .track{background:var(--accent);border-color:var(--accent)}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)}
|
||||||
|
.page-account-settings .switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||||
|
.page-account-settings .membership-card{border:1px solid var(--fg);padding:clamp(24px,4vw,38px);max-width:640px}
|
||||||
|
.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)}
|
||||||
|
.page-account-settings .membership-head h3{font-size:28px;margin:0}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .state-chip.cancelled{border-color:var(--muted);color:var(--muted)}
|
||||||
|
.page-account-settings .plan-details{display:grid;grid-template-columns:repeat(2,1fr);gap:20px 32px;padding-block:24px}
|
||||||
|
.page-account-settings .plan-details dt{font:12px/1.6 var(--font-mono);color:var(--muted)}
|
||||||
|
.page-account-settings .plan-details dd{margin:4px 0 0;font-size:16px}
|
||||||
|
.page-account-settings .price-tag{font-family:var(--font-display);font-size:44px;line-height:1}
|
||||||
|
.page-account-settings .price-tag-early{font-size:24px;color:var(--accent,var(--fg))}
|
||||||
|
.page-account-settings .price-tag small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:6px}
|
||||||
|
.page-account-settings .membership-actions{display:flex;gap:14px;flex-wrap:wrap;padding-top:22px;border-top:1px solid var(--border)}
|
||||||
|
.page-account-settings .cancel-zone{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
|
||||||
|
.page-account-settings .cancel-zone h3{font-size:22px;margin:0 0 6px}
|
||||||
|
.page-account-settings .cancel-zone p{color:var(--muted);font-size:14px;margin:0 0 16px;max-width:52ch}
|
||||||
|
.page-account-settings .billing{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
|
||||||
|
.page-account-settings .billing h3{font-size:22px;margin:0 0 6px}
|
||||||
|
.page-account-settings .billing-sub{color:var(--muted);font-size:14px;margin:0 0 18px;max-width:52ch}
|
||||||
|
.page-account-settings .billing-table{border:1px solid var(--border)}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .billing-row:last-child{border-bottom:0}
|
||||||
|
.page-account-settings .billing-row.head{font:11px/1.6 var(--font-mono);color:var(--muted);padding:10px 18px}
|
||||||
|
.page-account-settings .billing-row .amt,.page-account-settings .billing-row .bdate{font-family:var(--font-mono);white-space:nowrap}
|
||||||
|
.page-account-settings .billing-row .desc{color:var(--fg)}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .chip.paid{color:var(--fg);border-color:var(--fg)}
|
||||||
|
.page-account-settings .chip.upcoming{color:var(--accent);border-color:var(--accent)}
|
||||||
|
.page-account-settings .chip.voided{color:var(--muted);border-color:var(--border);text-decoration:line-through}
|
||||||
|
.page-account-settings .inv-link{font:12px/1.6 var(--font-mono);color:var(--fg);text-decoration:underline;text-underline-offset:3px}
|
||||||
|
.page-account-settings .inv-link:hover{color:var(--accent)}
|
||||||
|
.page-account-settings .inv-link:disabled{color:var(--muted);text-decoration:none;cursor:default}
|
||||||
|
.page-account-settings .inv-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .text-danger:hover{color:color-mix(in oklch,var(--accent) 80%,black)}
|
||||||
|
.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}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .dialog.open .dialog-scrim{opacity:1}
|
||||||
|
.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}
|
||||||
|
.page-account-settings .dialog.open .dialog-panel{opacity:1;transform:translateY(-50%)}
|
||||||
|
.page-account-settings .dialog-panel h2{font-size:30px;margin:0 0 12px}
|
||||||
|
.page-account-settings .dialog-panel p{color:var(--muted);margin:0 0 14px}
|
||||||
|
.page-account-settings .dialog-panel .keep-note{border-inline-start:2px solid var(--border);padding-inline-start:14px}
|
||||||
|
.page-account-settings .dialog-actions{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px}
|
||||||
|
.page-account-settings .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
|
||||||
|
.page-account-settings .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
|
||||||
|
.page-account-settings .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
|
||||||
|
.page-account-settings .footer-col a{display:block;min-height:36px}.page-account-settings .footer-col a:hover{color:var(--fg);text-decoration:underline}
|
||||||
|
.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}
|
||||||
|
.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}
|
||||||
|
@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}}
|
||||||
|
@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)}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.page-account-settings *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/* Page-scoped styles: .page-home. Shared tokens live in globals.css. */
|
||||||
|
.page-home *, .page-home *::before, .page-home *::after { box-sizing: border-box; }
|
||||||
|
.page-home { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
|
||||||
|
.page-home {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
line-height: 1.65;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.page-home.modal-open { overflow: hidden; }
|
||||||
|
.page-home img, .page-home svg { display: block; max-width: 100%; }
|
||||||
|
.page-home a { color: inherit; text-decoration: none; }
|
||||||
|
.page-home button, .page-home input { font: inherit; }
|
||||||
|
.page-home button { cursor: pointer; }
|
||||||
|
.page-home p { text-wrap: pretty; }
|
||||||
|
.page-home h1, .page-home h2, .page-home h3 { text-wrap: balance; }
|
||||||
|
.page-home :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); }
|
||||||
|
.page-home .section { padding-block: clamp(64px, 9vw, var(--gap-2xl)); }
|
||||||
|
.page-home .section + .section { border-top: 1px solid var(--border); }
|
||||||
|
.page-home .stack { display: flex; flex-direction: column; }
|
||||||
|
.page-home .stack > * + * { margin-top: var(--gap-md); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .h1, .page-home 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; }
|
||||||
|
.page-home .h3, .page-home 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; }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .logo-mark { width: 12px; height: 12px; background: var(--fg); transform: rotate(45deg); }
|
||||||
|
.page-home .logo:hover { opacity: .72; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .menu-trigger:hover { background: var(--accent); border-color: var(--accent); color: var(--surface); }
|
||||||
|
.page-home .menu-trigger svg { width: 20px; height: 20px; }
|
||||||
|
.page-home .menu-panel { position: fixed; inset: 0; z-index: 60; visibility: hidden; pointer-events: none; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .menu-links a:hover { color: var(--accent); }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .btn:active { transform: translateY(1px); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .btn-ghost { background: transparent; color: var(--fg); border-color: transparent; padding-inline: 8px; }
|
||||||
|
.page-home .btn-ghost:hover { border-color: var(--fg); }
|
||||||
|
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .hero-copy h1 { max-width: 9ch; }
|
||||||
|
.page-home .hero-copy .lead { margin-top: 24px; }
|
||||||
|
.page-home .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; }
|
||||||
|
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .deck-slide-body { align-self: center; }
|
||||||
|
.page-home .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; }
|
||||||
|
.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; }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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%; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .deck-dot { width: 34px; height: 4px; border: 0; padding: 0; background: var(--border); }
|
||||||
|
.page-home .deck-dot.active { background: var(--fg); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .topic strong { font-family: var(--font-display); font-size: 22px; }
|
||||||
|
.page-home .topic span { color: var(--muted); font-size: 13px; }
|
||||||
|
.page-home .topic:hover { background: var(--fg-soft); }
|
||||||
|
|
||||||
|
.page-home .section-head { margin-bottom: 42px; align-items: end; }
|
||||||
|
.page-home .section-head > div { max-width: 54ch; }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .filter:hover { color: var(--fg); border-color: var(--fg); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .card-link:hover { border-color: var(--fg); }
|
||||||
|
.page-home .card-link span:last-child { direction: ltr; }
|
||||||
|
.page-home .empty { display: none; padding: 40px 0; color: var(--muted); }
|
||||||
|
.page-home .empty.show { display: block; }
|
||||||
|
|
||||||
|
.page-home .membership { background: var(--fg); color: var(--surface); }
|
||||||
|
.page-home .membership .eyebrow { color: var(--surface); opacity: .7; }
|
||||||
|
.page-home .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; }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .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); }
|
||||||
|
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .footer-brand p { max-width: 34ch; margin: 14px 0 0; }
|
||||||
|
.page-home .footer-col strong { display: block; color: var(--fg); margin-bottom: 12px; }
|
||||||
|
.page-home .footer-col a { display: block; min-height: 36px; }
|
||||||
|
.page-home .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; }
|
||||||
|
.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); }
|
||||||
|
.page-home .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); }
|
||||||
|
.page-home .modal-close { float: left; margin: -14px -14px 20px 0; }
|
||||||
|
.page-home .modal-panel h2 { clear: both; font-size: clamp(32px, 5vw, 50px); }
|
||||||
|
.page-home .modal-panel p { color: var(--muted); }
|
||||||
|
.page-home .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); }
|
||||||
|
|
||||||
|
@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; }
|
||||||
|
.page-home .topnav nav { display: none; }
|
||||||
|
.page-home .footer-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.page-home .hero-copy h1 { max-width: 11ch; }
|
||||||
|
.page-home .deck { min-height: 440px; }
|
||||||
|
.page-home .deck-frame { min-height: 400px; }
|
||||||
|
.page-home .topic-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.page-home .topic:nth-child(3) { border-inline-start: 0; border-top: 1px solid var(--border); }
|
||||||
|
.page-home .topic:nth-child(4) { border-top: 1px solid var(--border); }
|
||||||
|
.page-home .catalog { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.page-home .nav-action { padding-inline: 12px; }
|
||||||
|
.page-home .hero { padding-top: 42px; }
|
||||||
|
.page-home .deck-slide { padding: 26px; }
|
||||||
|
.page-home .deck-slide-body h2 { font-size: 38px; }
|
||||||
|
.page-home .deck-slide-foot { align-items: start; flex-direction: column; }
|
||||||
|
.page-home .topic-grid, .page-home .catalog { grid-template-columns: 1fr; }
|
||||||
|
.page-home .topic { min-height: 104px; border-inline-start: 0; border-top: 1px solid var(--border); }
|
||||||
|
.page-home .topic:first-child { border-top: 0; }
|
||||||
|
.page-home .section-head { align-items: start; flex-direction: column; }
|
||||||
|
.page-home .catalog-card { min-height: auto; }
|
||||||
|
.page-home .nav-action { display: none; }
|
||||||
|
.page-home .footer-grid { grid-template-columns: 1fr; gap: 28px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.page-home *, .page-home *::before, .page-home *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
|
||||||
|
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
|
||||||
|
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||||
|
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} .page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||||
|
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
|
||||||
|
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}.page-posts .nav-login,.page-posts .icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .nav-login{padding-inline:16px}.page-posts .nav-login:hover,.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .icon-btn{width:46px}.page-posts .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)}
|
||||||
|
.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)}
|
||||||
|
.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)}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
.page-posts .related{padding-block:80px;border-top:1px solid var(--border)}.page-posts .related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.page-posts .related-card{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .related-card+.related-card{margin-inline-start:28px}.page-posts .related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.page-posts .related-card h3{font-size:24px;margin:12px 0}.page-posts .related-card a{font-weight:700}.page-posts .related-card a:hover{color:var(--accent)}
|
||||||
|
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}.page-posts .footer-col a{display:block;min-height:36px}.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
|
||||||
|
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display: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)}
|
||||||
|
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .related-grid{grid-template-columns:1fr}.page-posts .related-card+.related-card{margin-inline-start:0}.page-posts .article-body{font-size:18px}}
|
||||||
|
@media(max-width:620px){.page-posts .nav-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}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/* Page-scoped styles: .page-articles. Shared tokens live in globals.css. */
|
||||||
|
|
||||||
|
.page-articles *, .page-articles *::before, .page-articles *::after { box-sizing: border-box; }
|
||||||
|
.page-articles { -webkit-text-size-adjust: 100%; }
|
||||||
|
.page-articles { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.7 var(--font-body); text-rendering: optimizeLegibility; }
|
||||||
|
.page-articles a { color: inherit; text-decoration: none; }
|
||||||
|
.page-articles button { color: inherit; font: inherit; cursor: pointer; }
|
||||||
|
.page-articles h1, .page-articles h2, .page-articles h3 { margin: 0; font-family: var(--font-display); text-wrap: balance; }
|
||||||
|
.page-articles p { text-wrap: pretty; }
|
||||||
|
.page-articles :focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||||
|
.page-articles .container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
|
||||||
|
.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); }
|
||||||
|
.page-articles .topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
|
||||||
|
.page-articles .logo { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 23px; font-weight: 700; }
|
||||||
|
.page-articles .logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; }
|
||||||
|
.page-articles .topnav nav { display: flex; align-items: center; gap: 28px; }
|
||||||
|
.page-articles .topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); border-bottom: 1px solid transparent; }
|
||||||
|
.page-articles .topnav nav a:hover, .page-articles .topnav nav a[aria-current="page"] { color: var(--fg); border-color: var(--accent); }
|
||||||
|
.page-articles .nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); }
|
||||||
|
.page-articles .nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||||
|
.page-articles .hero { padding-block: clamp(56px, 8vw, 96px); border-bottom: 1px solid var(--border); }
|
||||||
|
.page-articles .hero-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(360px, 1.1fr); gap: clamp(36px, 7vw, 88px); align-items: end; }
|
||||||
|
.page-articles .eyebrow { margin: 0 0 18px; color: var(--accent); font: 11px/1.5 var(--font-mono); }
|
||||||
|
.page-articles h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; }
|
||||||
|
.page-articles .lead { max-width: 56ch; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2vw, 22px); }
|
||||||
|
.page-articles .weekly { display: block; padding: clamp(24px, 4vw, 40px); background: var(--surface); border: 1px solid var(--border); border-top-color: var(--accent); }
|
||||||
|
.page-articles .weekly:hover { border-color: var(--fg); background: color-mix(in oklch, var(--surface) 88%, var(--fg)); }
|
||||||
|
.page-articles .weekly-meta { display: flex; justify-content: space-between; gap: 16px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||||
|
.page-articles .weekly h2 { margin-top: 42px; font-size: clamp(28px, 4vw, 44px); line-height: 1.18; }
|
||||||
|
.page-articles .weekly p { margin: 14px 0 30px; color: var(--muted); }
|
||||||
|
.page-articles .weekly-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); }
|
||||||
|
.page-articles .library { padding-block: clamp(52px, 8vw, 88px); }
|
||||||
|
.page-articles .library-tools { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 16px; margin-bottom: 22px; }
|
||||||
|
.page-articles .control { width: 100%; min-height: 48px; padding: 11px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--fg); font: inherit; }
|
||||||
|
.page-articles .control::placeholder { color: var(--muted); }
|
||||||
|
.page-articles .control:hover { border-color: var(--fg); }
|
||||||
|
.page-articles .control:focus { outline: 3px solid var(--accent-soft); border-color: var(--accent); }
|
||||||
|
.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); }
|
||||||
|
.page-articles .filter-group { min-width: 0; }
|
||||||
|
.page-articles .filter-label { display: block; margin-bottom: 10px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||||
|
.page-articles .filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: none; }
|
||||||
|
.page-articles .filters::-webkit-scrollbar { display: none; }
|
||||||
|
.page-articles .filter { flex: none; min-height: 44px; padding: 9px 15px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
|
||||||
|
.page-articles .filter:hover { color: var(--fg); border-color: var(--fg); }
|
||||||
|
.page-articles .filter[aria-pressed="true"] { background: var(--fg); color: var(--surface); border-color: var(--fg); }
|
||||||
|
.page-articles .results-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
|
||||||
|
.page-articles h2 { font-size: clamp(30px, 4vw, 46px); line-height: 1.15; }
|
||||||
|
.page-articles .foryou { color: var(--accent); font: 11px/1.5 var(--font-mono); }
|
||||||
|
.page-articles .count { color: var(--muted); font: 12px/1.5 var(--font-mono); }
|
||||||
|
.page-articles .posts { border-top: 1px solid var(--fg); }
|
||||||
|
.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); }
|
||||||
|
.page-articles .post[hidden] { display: none; }
|
||||||
|
.page-articles .post-meta { color: var(--muted); font: 12px/1.8 var(--font-mono); }
|
||||||
|
.page-articles .post-meta span { display: block; }
|
||||||
|
.page-articles .post h3 { font-size: clamp(22px, 2.5vw, 30px); line-height: 1.3; }
|
||||||
|
.page-articles .post p { max-width: 62ch; margin: 9px 0 0; color: var(--muted); }
|
||||||
|
.page-articles .post-side { justify-self: stretch; display: grid; gap: 12px; }
|
||||||
|
.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); }
|
||||||
|
.page-articles .access.free { border-color: var(--fg); color: var(--fg); }
|
||||||
|
.page-articles .engagement { display: flex; justify-content: flex-end; gap: 12px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||||
|
.page-articles .like { min-height: 44px; padding: 0 10px; border: 1px solid transparent; background: transparent; color: var(--muted); }
|
||||||
|
.page-articles .like:hover, .page-articles .like[aria-pressed="true"] { color: var(--fg); border-color: var(--border); }
|
||||||
|
.page-articles .post-link { display: inline-flex; align-items: center; min-height: 44px; margin-top: 14px; font-weight: 700; border-bottom: 1px solid transparent; }
|
||||||
|
.page-articles .post-link:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.page-articles .soon { color: var(--muted); cursor: default; }
|
||||||
|
.page-articles .empty { display: none; padding: 42px 0; color: var(--muted); }
|
||||||
|
.page-articles .empty.show { display: block; }
|
||||||
|
.page-articles .pagefoot { padding-block: 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
|
||||||
|
.page-articles .footer-row { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.page-articles .topnav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
|
||||||
|
.page-articles .logo, .page-articles .nav-action { margin-bottom: 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; }
|
||||||
|
.page-articles .topnav nav::-webkit-scrollbar { display: none; }
|
||||||
|
.page-articles .topnav nav a { flex: none; min-height: 52px; padding-inline: 12px; white-space: nowrap; }
|
||||||
|
.page-articles .hero-grid, .page-articles .filter-panel { grid-template-columns: 1fr; gap: 20px; }
|
||||||
|
.page-articles .weekly { margin-top: 8px; }
|
||||||
|
.page-articles .post { grid-template-columns: 112px minmax(0, 1fr); }
|
||||||
|
.page-articles .post-side { justify-self: start; grid-column: 2; }
|
||||||
|
.page-articles .access { justify-self: start; }
|
||||||
|
.page-articles .engagement { justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.page-articles .nav-action { padding-inline: 12px; }
|
||||||
|
.page-articles h1 { font-size: clamp(42px, 13vw, 58px); }
|
||||||
|
.page-articles .library-tools { grid-template-columns: 1fr; }
|
||||||
|
.page-articles .results-head { align-items: start; flex-direction: column; }
|
||||||
|
.page-articles .post { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
.page-articles .post-side { grid-column: 1; }
|
||||||
|
.page-articles .post-link { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.page-articles * { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/* 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)}
|
||||||
|
.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)}
|
||||||
|
.page-verify-email .verification{min-height:calc(100vh - 70px);display:grid;place-items:center;padding-block:clamp(54px,9vw,110px)}.page-verify-email .card{width:min(700px,100%);background:color-mix(in oklch,var(--surface) 92%,transparent);border:1px solid var(--border);padding:clamp(28px,6vw,64px)}.page-verify-email .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-verify-email .card h1{font-size:clamp(42px,7vw,72px);line-height:1.08;margin:0;max-width:11ch}.page-verify-email .lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:54ch;margin:22px 0 0}.page-verify-email .email{display:block;color:var(--fg);font:700 16px/1.6 var(--font-mono);direction:ltr;text-align:right;overflow-wrap:anywhere;margin-top:8px}.page-verify-email .steps{list-style:none;margin:36px 0;padding:0;border-top:1px solid var(--fg)}.page-verify-email .steps li{display:grid;grid-template-columns:34px 1fr;gap:14px;padding:17px 0;border-bottom:1px solid var(--border)}.page-verify-email .step-num{font:12px/1.8 var(--font-mono);color:var(--accent)}.page-verify-email .actions{display:flex;gap:12px;flex-wrap:wrap}.page-verify-email .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.page-verify-email .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-verify-email .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-verify-email .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-verify-email .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.page-verify-email .status{min-height:27px;color:var(--muted);font-size:14px;margin:16px 0 0}.page-verify-email .fine{color:var(--muted);font-size:12px;margin:24px 0 0;padding-top:20px;border-top:1px solid var(--border)}
|
||||||
|
@media(max-width:620px){.page-verify-email .nav-inner{gap:10px}.page-verify-email .logo{font-size:20px}.page-verify-email .nav-return{font-size:13px}.page-verify-email .verification{align-items:start;padding-block:34px}.page-verify-email .card{padding:28px 20px}.page-verify-email .actions{align-items:stretch}.page-verify-email .actions .btn{width:100%}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.page-verify-email *{transition-duration:.01ms!important}}
|
||||||
|
.page-verify-email .change-email{margin-top:18px}
|
||||||
|
.page-verify-email .change-email summary{cursor:pointer;font-size:13px;color:var(--muted)}
|
||||||
|
.page-verify-email .change-email summary:hover{color:var(--fg)}
|
||||||
|
.page-verify-email .change-email-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:center}
|
||||||
|
.page-verify-email .change-email-row .input{min-height:48px;border:1px solid color-mix(in oklch,var(--border) 72%,var(--fg));background:var(--surface);color:var(--fg);padding:0 14px;min-width:min(300px,100%)}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* Page-scoped styles: .page-forgot-password. Shared tokens live in globals.css. */
|
||||||
|
.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)}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
@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}}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/* 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/* Page-scoped styles: .page-login. Shared tokens live in globals.css. */
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
.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)}
|
||||||
|
@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: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(prefers-reduced-motion:reduce){.page-login *{transition-duration:.01ms!important}}
|
||||||
@@ -0,0 +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)}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
@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}}
|
||||||
|
@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(prefers-reduced-motion:reduce){.page-onboarding *{transition-duration:.01ms!important}}
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/* 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}
|
||||||
|
.page-payment-cancelled{-webkit-text-size-adjust:100%}
|
||||||
|
.page-payment-cancelled{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
|
||||||
|
.page-payment-cancelled a{color:inherit;text-decoration:none}
|
||||||
|
.page-payment-cancelled h1,.page-payment-cancelled h2{font-family:var(--font-display);text-wrap:balance}
|
||||||
|
.page-payment-cancelled p{text-wrap:pretty}
|
||||||
|
.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)}
|
||||||
|
.page-payment-cancelled .topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)}
|
||||||
|
.page-payment-cancelled .topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||||
|
.page-payment-cancelled .logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700}
|
||||||
|
.page-payment-cancelled .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}
|
||||||
|
.page-payment-cancelled .nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
|
||||||
|
.page-payment-cancelled .nav-link:hover{color:var(--fg)}
|
||||||
|
.page-payment-cancelled .section{padding-block:clamp(56px,9vw,104px)}
|
||||||
|
.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)}
|
||||||
|
.page-payment-cancelled .status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px}
|
||||||
|
.page-payment-cancelled .status-mark svg{width:28px;height:28px}
|
||||||
|
.page-payment-cancelled .eyebrow{margin:0 0 18px;color:var(--muted);font:12px/1.5 var(--font-mono);}
|
||||||
|
.page-payment-cancelled h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;}
|
||||||
|
.page-payment-cancelled .lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)}
|
||||||
|
.page-payment-cancelled .notice{margin-top:34px;padding-block:22px;border-block:1px solid var(--border)}
|
||||||
|
.page-payment-cancelled .notice h2{margin:0 0 8px;font-size:24px}
|
||||||
|
.page-payment-cancelled .notice p{margin:0;color:var(--muted)}
|
||||||
|
.page-payment-cancelled .actions{display:flex;align-items:center;gap:12px;margin-top:30px}
|
||||||
|
.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}
|
||||||
|
.page-payment-cancelled .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||||
|
.page-payment-cancelled .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
|
||||||
|
.page-payment-cancelled .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
|
||||||
|
.page-payment-cancelled .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
|
||||||
|
.page-payment-cancelled .help{margin:24px 0 0;color:var(--muted);font-size:14px}
|
||||||
|
.page-payment-cancelled .help a{text-decoration:underline;text-underline-offset:4px;color:var(--fg)}
|
||||||
|
.page-payment-cancelled .help a:hover{color:var(--accent)}
|
||||||
|
.page-payment-cancelled .pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}
|
||||||
|
.page-payment-cancelled .footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||||
|
@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}}
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* 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}
|
||||||
|
.page-payment-success{-webkit-text-size-adjust:100%}
|
||||||
|
.page-payment-success{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
|
||||||
|
.page-payment-success a{color:inherit;text-decoration:none}
|
||||||
|
.page-payment-success h1,.page-payment-success h2{font-family:var(--font-display);text-wrap:balance}
|
||||||
|
.page-payment-success p{text-wrap:pretty}
|
||||||
|
.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)}
|
||||||
|
.page-payment-success .topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)}
|
||||||
|
.page-payment-success .topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||||
|
.page-payment-success .logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700}
|
||||||
|
.page-payment-success .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}
|
||||||
|
.page-payment-success .nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
|
||||||
|
.page-payment-success .nav-link:hover{color:var(--fg)}
|
||||||
|
.page-payment-success .section{padding-block:clamp(56px,9vw,104px)}
|
||||||
|
.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)}
|
||||||
|
.page-payment-success .status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px}
|
||||||
|
.page-payment-success .status-mark svg{width:30px;height:30px}
|
||||||
|
.page-payment-success .eyebrow{margin:0 0 18px;color:var(--accent);font:12px/1.5 var(--font-mono);}
|
||||||
|
.page-payment-success h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;}
|
||||||
|
.page-payment-success .lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)}
|
||||||
|
.page-payment-success .summary{margin:34px 0 0;border-top:1px solid var(--fg)}
|
||||||
|
.page-payment-success .summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:17px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.page-payment-success .summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)}
|
||||||
|
.page-payment-success .summary-row dd{margin:0;font-weight:700}
|
||||||
|
.page-payment-success .next-step{margin-top:34px;padding-top:30px;border-top:1px solid var(--border)}
|
||||||
|
.page-payment-success .next-step h2{margin:0 0 8px;font-size:26px}
|
||||||
|
.page-payment-success .next-step p{margin:0;color:var(--muted)}
|
||||||
|
.page-payment-success .actions{display:flex;align-items:center;gap:12px;margin-top:26px}
|
||||||
|
.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}
|
||||||
|
.page-payment-success .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||||
|
.page-payment-success .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
|
||||||
|
.page-payment-success .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
|
||||||
|
.page-payment-success .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
|
||||||
|
.page-payment-success .pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}
|
||||||
|
.page-payment-success .footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||||
|
@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}}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/* Page-scoped styles: :is(.page-privacy, .page-terms). Shared tokens live in globals.css. */
|
||||||
|
: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)}
|
||||||
|
: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)}
|
||||||
|
: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}
|
||||||
|
: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}}
|
||||||
|
@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}}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/* Page-scoped styles: .page-signup. Shared tokens live in globals.css. */
|
||||||
|
.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)}
|
||||||
|
.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)}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
@media(max-width:920px){.page-signup .auth{grid-template-columns:1fr}.page-signup .auth-story{min-height:390px;padding:58px var(--gutter)}.page-signup .auth-form-wrap{padding:58px var(--gutter);justify-content:center}.page-signup .story-foot{margin-top:48px}}
|
||||||
|
@media(max-width:620px){.page-signup .nav-inner{gap:12px}.page-signup .nav-return{font-size:13px}.page-signup .auth-story{min-height:auto}.page-signup .auth-story h1{font-size:46px}.page-signup .auth-form-wrap{padding-block:46px}.page-signup .story-list{margin-top:38px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.page-signup *{transition-duration:.01ms!important}}
|
||||||
|
.page-signup .fine.ok{color:var(--success,#2e9e6b)}
|
||||||
|
.page-signup .field-error{margin:2px 0 0;font-size:13px;color:var(--error,#b4443c)}
|
||||||
|
.page-signup .field-error a{color:inherit;text-decoration:underline}
|
||||||
|
.page-signup .typo-suggest{justify-self:start;margin-top:4px;padding:0;border:0;background:none;color:var(--accent);font:inherit;font-size:13px;cursor:pointer;text-decoration:underline}
|
||||||
|
.page-signup .signup-reassure{margin-top:12px}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/* Page-scoped styles: .page-subscription. Shared tokens live in globals.css. */
|
||||||
|
.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)}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
.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}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
.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}
|
||||||
|
.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)}
|
||||||
|
.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)}
|
||||||
|
.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}
|
||||||
|
.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)}
|
||||||
|
@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: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(prefers-reduced-motion:reduce){.page-subscription *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||||
|
/* 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}
|
||||||
|
.page-subscription .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}
|
||||||
|
.page-subscription .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}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/* Page-scoped styles: .page-vip-onboarding. Shared tokens live in globals.css. */
|
||||||
|
.page-vip-onboarding *,.page-vip-onboarding *::before,.page-vip-onboarding *::after{box-sizing:border-box}
|
||||||
|
.page-vip-onboarding{-webkit-text-size-adjust:100%}
|
||||||
|
.page-vip-onboarding{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
|
||||||
|
.page-vip-onboarding.locked{overflow:hidden}
|
||||||
|
.page-vip-onboarding a{color:inherit;text-decoration:none}
|
||||||
|
.page-vip-onboarding button,.page-vip-onboarding input{font:inherit}
|
||||||
|
.page-vip-onboarding button{cursor:pointer}
|
||||||
|
.page-vip-onboarding h1,.page-vip-onboarding h2,.page-vip-onboarding h3{font-family:var(--font-display);text-wrap:balance}
|
||||||
|
.page-vip-onboarding p{text-wrap:pretty}
|
||||||
|
.page-vip-onboarding :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||||
|
.page-vip-onboarding .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||||
|
.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)}
|
||||||
|
.page-vip-onboarding .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||||
|
.page-vip-onboarding .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}
|
||||||
|
.page-vip-onboarding .nav-links{display:flex;align-items:center;gap:28px}
|
||||||
|
.page-vip-onboarding .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
|
||||||
|
.page-vip-onboarding .nav-links a:hover{color:var(--fg)}
|
||||||
|
.page-vip-onboarding .nav-tools{display:flex;align-items:center;gap:8px}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .nav-account:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||||
|
.page-vip-onboarding .icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--border);background:transparent;color:var(--fg)}
|
||||||
|
.page-vip-onboarding .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .onboarding-head{padding-block:clamp(48px,7vw,82px) 42px;border-bottom:1px solid var(--border)}
|
||||||
|
.page-vip-onboarding .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}
|
||||||
|
.page-vip-onboarding .onboarding-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0;max-width:13ch}
|
||||||
|
.page-vip-onboarding .lead{font-size:clamp(18px,2vw,22px);color:var(--muted);max-width:58ch;margin:18px 0 0}
|
||||||
|
.page-vip-onboarding .progress-wrap{margin-top:34px;max-width:720px}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .progress-track{height:4px;background:var(--border);overflow:hidden}
|
||||||
|
.page-vip-onboarding .progress-bar{height:100%;width:25%;background:var(--accent);transition:width .22s ease}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .step-nav{position:sticky;top:100px;border-top:1px solid var(--fg)}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .step-nav button[aria-current="step"]{color:var(--fg);font-weight:700}
|
||||||
|
.page-vip-onboarding .step-nav button:disabled{cursor:default}
|
||||||
|
.page-vip-onboarding .step-num{font:12px/1 var(--font-mono)}
|
||||||
|
.page-vip-onboarding .step-nav button[aria-current="step"] .step-num{color:var(--accent)}
|
||||||
|
.page-vip-onboarding .step-nav button.done{color:var(--fg)}
|
||||||
|
.page-vip-onboarding .step-panel{display:none;max-width:720px}
|
||||||
|
.page-vip-onboarding .step-panel.active{display:block}
|
||||||
|
.page-vip-onboarding .step-panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px}
|
||||||
|
.page-vip-onboarding .step-intro{color:var(--muted);max-width:58ch;margin:0 0 32px}
|
||||||
|
.page-vip-onboarding .choice-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||||||
|
.page-vip-onboarding .choice{position:relative;display:block}
|
||||||
|
.page-vip-onboarding .choice input{position:absolute;opacity:0;pointer-events:none}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .choice-body:hover{border-color:var(--fg);transform:translateY(-1px)}
|
||||||
|
.page-vip-onboarding .choice input:checked + .choice-body{border-color:var(--fg);background:var(--fg-soft)}
|
||||||
|
.page-vip-onboarding .choice input:focus-visible + .choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||||
|
.page-vip-onboarding .choice-title{font-size:19px;font-weight:700}
|
||||||
|
.page-vip-onboarding .choice-copy{color:var(--muted);font-size:14px;margin-top:8px}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .choice input:checked + .choice-body .choice-mark{background:var(--fg);border-color:var(--fg)}
|
||||||
|
.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)}
|
||||||
|
.page-vip-onboarding .notice-card{border-block:1px solid var(--fg);padding-block:8px}
|
||||||
|
.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)}
|
||||||
|
.page-vip-onboarding .notice-row:last-child{border-bottom:0}
|
||||||
|
.page-vip-onboarding .notice-row strong{display:block;font-size:18px}
|
||||||
|
.page-vip-onboarding .notice-row p{color:var(--muted);font-size:14px;margin:4px 0 0;max-width:48ch}
|
||||||
|
.page-vip-onboarding .switch{position:relative;width:52px;min-height:32px;flex:none}
|
||||||
|
.page-vip-onboarding .switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
|
||||||
|
.page-vip-onboarding .track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none}
|
||||||
|
.page-vip-onboarding .track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s}
|
||||||
|
.page-vip-onboarding .switch input:checked + .track{background:var(--fg);border-color:var(--fg)}
|
||||||
|
.page-vip-onboarding .switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)}
|
||||||
|
.page-vip-onboarding .switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||||
|
.page-vip-onboarding .summary{border-top:1px solid var(--fg)}
|
||||||
|
.page-vip-onboarding .summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:18px 0;border-bottom:1px solid var(--border)}
|
||||||
|
.page-vip-onboarding .summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)}
|
||||||
|
.page-vip-onboarding .summary-row dd{margin:0;font-weight:700}
|
||||||
|
.page-vip-onboarding .start-card{margin-top:28px;padding:26px;border:1px solid var(--fg);background:var(--surface)}
|
||||||
|
.page-vip-onboarding .start-card h3{font-size:26px;margin:0 0 8px}
|
||||||
|
.page-vip-onboarding .start-card p{color:var(--muted);margin:0 0 22px;max-width:52ch}
|
||||||
|
.page-vip-onboarding .actions{display:flex;align-items:center;gap:12px;margin-top:34px}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||||
|
.page-vip-onboarding .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
|
||||||
|
.page-vip-onboarding .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
|
||||||
|
.page-vip-onboarding .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||||
|
.page-vip-onboarding .btn[disabled]{opacity:.45;cursor:not-allowed;transform:none}
|
||||||
|
.page-vip-onboarding .status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0}
|
||||||
|
.page-vip-onboarding .pagefoot{padding-block:48px;border-top:1px solid var(--border);color:var(--muted)}
|
||||||
|
.page-vip-onboarding .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;font-size:13px}
|
||||||
|
.page-vip-onboarding .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}
|
||||||
|
.page-vip-onboarding .menu-panel.open{visibility:visible;pointer-events:auto}
|
||||||
|
.page-vip-onboarding .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--bg) 72%,transparent)}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .menu-panel.open .menu-drawer{transform:translateX(0)}
|
||||||
|
.page-vip-onboarding .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}
|
||||||
|
.page-vip-onboarding .menu-links{display:flex;flex-direction:column;margin-top:24px}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .menu-links a:hover{color:var(--fg);background:var(--fg-soft)}
|
||||||
|
@media(max-width:920px){
|
||||||
|
.page-vip-onboarding .onboarding-layout{grid-template-columns:1fr;padding-block:34px 70px}
|
||||||
|
.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}
|
||||||
|
.page-vip-onboarding .step-nav button{min-width:150px;border:1px solid var(--border);padding-inline:14px}
|
||||||
|
.page-vip-onboarding .choice-grid{grid-template-columns:1fr 1fr}
|
||||||
|
}
|
||||||
|
@media(max-width:620px){
|
||||||
|
.page-vip-onboarding .member-strip{text-align:right}
|
||||||
|
.page-vip-onboarding .onboarding-head{padding-block:42px 32px}
|
||||||
|
.page-vip-onboarding .onboarding-head h1{font-size:44px}
|
||||||
|
.page-vip-onboarding .choice-grid{grid-template-columns:1fr}
|
||||||
|
.page-vip-onboarding .choice-body{min-height:132px}
|
||||||
|
.page-vip-onboarding .notice-row{align-items:flex-start}
|
||||||
|
.page-vip-onboarding .summary-row{grid-template-columns:1fr;gap:4px}
|
||||||
|
.page-vip-onboarding .actions{align-items:stretch;flex-direction:column-reverse}
|
||||||
|
.page-vip-onboarding .actions .btn{width:100%}
|
||||||
|
.page-vip-onboarding .start-card{padding:22px}
|
||||||
|
.page-vip-onboarding .footer-bottom{flex-direction:column}
|
||||||
|
}
|
||||||
|
@media(prefers-reduced-motion:reduce){.page-vip-onboarding *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
/* FAQ accordions — port of the subscription page's faq-button toggle. */
|
||||||
|
export default function Faq({ items }: { items: { q: string; a: string }[] }) {
|
||||||
|
const [openIdx, setOpenIdx] = useState<number | null>(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="faq-list">
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const open = openIdx === i
|
||||||
|
return (
|
||||||
|
<article className={`faq-item${open ? ' open' : ''}`} key={i}>
|
||||||
|
<button
|
||||||
|
className="faq-button"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpenIdx(open ? null : i)}
|
||||||
|
>
|
||||||
|
<span>{item.q}</span><span aria-hidden="true">{open ? '−' : '+'}</span>
|
||||||
|
</button>
|
||||||
|
<div className="faq-answer" hidden={!open}>{item.a}</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
/* Early-access notification signup (L-01). 200 → success copy;
|
||||||
|
503/4xx → the server's Arabic error verbatim; 5xx/network → generic fallback. */
|
||||||
|
export default function NotifyForm() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [done, setDone] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (pending) return
|
||||||
|
setPending(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
const data = (await res.json().catch(() => null)) as { error?: string; alreadySubscribed?: boolean } | null
|
||||||
|
if (res.ok) {
|
||||||
|
setDone(data?.alreadySubscribed ? 'بريدك مسجَّل لدينا بالفعل — سنراسلك عند تفعيل العضوية.' : 'تم! سنراسلك عند تفعيل العضوية.')
|
||||||
|
} else {
|
||||||
|
setError(data?.error ?? 'تعذر الحفظ، حاول مرة أخرى.')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('تعذر الحفظ، حاول مرة أخرى.')
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return <p className="notify-done" role="status">{done}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="notify-form" onSubmit={submit}>
|
||||||
|
<label className="sr-only" htmlFor="notify-email">البريد الإلكتروني</label>
|
||||||
|
<input
|
||||||
|
id="notify-email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={pending || email.trim() === ''}>
|
||||||
|
{pending ? 'جارٍ الحفظ…' : 'أعلمني عند تفعيل العضوية'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="notify-error" role="alert">{error}</p>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import Topnav from '@/components/topnav'
|
||||||
|
import Footer from '@/components/footer'
|
||||||
|
import SubscribeButton from './subscribe-button'
|
||||||
|
import Faq from './faq'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||||
|
import type { Viewer } from '@/lib/viewer'
|
||||||
|
import { freeEarlyAccess } from '@/lib/launch'
|
||||||
|
import NotifyForm from './notify-form'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const metadata = {
|
||||||
|
title: 'العضوية',
|
||||||
|
description:
|
||||||
|
'انضم لقائمة الإشعار: وصول مبكر مجاني لكل الأدلة اليوم، وخطة عضوية مستقبلية بـ 10$ شهرياً تضيف ملفات عمل وملخصات للأعضاء.',
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Member-library samples — real VIP guides, read from the CMS at request time. */
|
||||||
|
const SAMPLES = [
|
||||||
|
{ slug: 'agent-loop-stop-condition', cover: 'تشريح وكيل ناجح', variant: '', output: 'قالب حالة الوكيل + قائمة فحص قبل التشغيل' },
|
||||||
|
{ slug: 'systematic-model-eval', cover: 'قِس قبل أن تبدّل', variant: 'guide-cover-light', output: 'مجموعة صغيرة من اختبارات التقييم قابلة لإعادة التشغيل' },
|
||||||
|
{ slug: 'prototype-to-production', cover: 'من التجربة إلى الإنتاج', variant: '', output: 'قائمة قرارات ما قبل الإطلاق' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FAQ_ITEMS = [
|
||||||
|
{ q: 'هل أستطيع الإلغاء في أي وقت؟', a: 'نعم. يتوقف التجديد التالي، ويستمر وصولك حتى نهاية الفترة المدفوعة.' },
|
||||||
|
{ q: 'هل المحتوى مناسب للمبتدئ؟', a: 'كل دليل يوضح مستواه. ابدأ بالأدلة المجانية في مسار الأساسيات، ثم انتقل إلى الوكلاء والأتمتة.' },
|
||||||
|
{ q: 'هل توجد شهادة إكمال؟', a: 'لا حالياً. تركيز أنور بآي على التطبيق والملفات العملية، لا على شهادات الحضور.' },
|
||||||
|
{ q: 'هل يمكنني استخدام الملفات في عملي؟', a: 'نعم للاستخدام الشخصي والمهني داخل مشاريعك. إعادة بيع الملفات أو نشرها كما هي غير مشمول.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function SubscriptionPage() {
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
const { user } = await payload.auth({ headers: await headers() })
|
||||||
|
const role = (user?.role as string) || null
|
||||||
|
const entitled = role === 'vip' || role === 'admin'
|
||||||
|
const early = freeEarlyAccess()
|
||||||
|
const viewer: Viewer = user ? { email: user.email, role: role ?? 'free' } : null
|
||||||
|
const { docs: published } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { status: { equals: 'published' } },
|
||||||
|
depth: 0,
|
||||||
|
limit: 200,
|
||||||
|
select: { title: true, slug: true, dek: true, category: true, readingMinutes: true, access: true },
|
||||||
|
})
|
||||||
|
const freeCount = published.filter(doc => doc.access === 'free').length
|
||||||
|
const memberCount = published.length - freeCount
|
||||||
|
const samples = SAMPLES.flatMap(sample => {
|
||||||
|
const post = published.find(doc => doc.slug === sample.slug)
|
||||||
|
return post ? [{ ...sample, post }] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="site" className="site-page v2 v2-membership">
|
||||||
|
<Topnav viewer={viewer} />
|
||||||
|
<main id="content">
|
||||||
|
{early && (
|
||||||
|
<section className="early-access" aria-labelledby="early-title">
|
||||||
|
<div className="wrap narrow">
|
||||||
|
<h2 id="early-title">وصول مبكر مجاني</h2>
|
||||||
|
<p>يمكنك قراءة المحتوى المنشور خلال فترة الوصول المبكر دون بطاقة دفع. نراجع إطلاق العضوية بعد نحو أسبوعين، وقد تمتد الفترة إذا لم تكتمل خدمة الدفع. بعض المواد محددة للعضوية مستقبلاً، وسنوضح ذلك قبل تفعيلها. لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.</p>
|
||||||
|
<NotifyForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mem-hero" aria-labelledby="mem-title">
|
||||||
|
<div className="wrap mem-hero-grid">
|
||||||
|
<div className="mem-hero-copy">
|
||||||
|
<h1 id="mem-title">كل الأدلة كاملة، مع ملفات العمل.</h1>
|
||||||
|
<p className="lede">الأدلة المجانية تعرّفك بالمفاهيم. العضوية تفتح الأدلة المعمّقة التي تأخذك من التجربة إلى نظام يعمل، مع القوالب وقوائم الفحص التي نستخدمها فعلاً.</p>
|
||||||
|
<ul className="mem-points" role="list">
|
||||||
|
<li><strong>{guidesLabel(memberCount)} للأعضاء</strong><span>تُضاف أدلة جديدة كل شهر</span></li>
|
||||||
|
<li><strong>ملفات قابلة للتنزيل</strong><span>قوالب حالة، قوائم فحص، سجلات أخطاء</span></li>
|
||||||
|
<li><strong>نقاش مع الفريق</strong><span>اسأل في تعليقات أي دليل</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<article className="plan" aria-labelledby="plan-title">
|
||||||
|
<h2 id="plan-title">عضوية فردية</h2>
|
||||||
|
<p className="plan-price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi><span>شهرياً</span></p>
|
||||||
|
{early
|
||||||
|
? <p className="plan-note">سعر مستقبلي — غير قابل للشراء حالياً.</p>
|
||||||
|
: <p className="plan-note">تلغي متى شئت، ويبقى حسابك المجاني ومحفوظاتك.</p>}
|
||||||
|
<div className="plan-cta">
|
||||||
|
{entitled ? (
|
||||||
|
<a className="btn btn-primary" href="/account-settings">عضويتك فعّالة، أدر حسابك</a>
|
||||||
|
) : (
|
||||||
|
<SubscribeButton loggedIn={Boolean(user)} comingSoon={early} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="fine">{early ? 'لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.' : 'الدفع عبر Stripe. لا نحفظ بيانات بطاقتك.'}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{samples.length > 0 && (
|
||||||
|
<section className="mem-samples" aria-labelledby="samples-title">
|
||||||
|
<div className="wrap">
|
||||||
|
<header className="section-head">
|
||||||
|
<h2 id="samples-title">من أدلة الأعضاء</h2>
|
||||||
|
<p>كل دليل يبدأ بمشكلة حقيقية وينتهي بشيء تشغّله. اقرأ المقدمة مجاناً قبل أن تقرر.</p>
|
||||||
|
</header>
|
||||||
|
<ul className="sample-grid" role="list">
|
||||||
|
{samples.map(({ slug, output, post }) => (
|
||||||
|
<li key={slug}>
|
||||||
|
<Link href={`/posts/${slug}`} className="sample">
|
||||||
|
<span className="meta-row"><span>{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span></span>
|
||||||
|
<span className="sample-title">{post.title}</span>
|
||||||
|
<span className="sample-dek">{post.dek}</span>
|
||||||
|
<span className="sample-output"><span>ما تخرج به</span>{output}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mem-compare" aria-labelledby="compare-title">
|
||||||
|
<div className="wrap narrow">
|
||||||
|
<h2 id="compare-title">الفرق بين الحساب المجاني والعضوية</h2>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th scope="col"><span className="sr-only">الميزة</span></th><th scope="col">حساب مجاني</th><th scope="col" className="is-member">العضوية</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><th scope="row">الأدلة المجانية ({formatNumber(freeCount)})</th><td>كاملة</td><td className="is-member">كاملة</td></tr>
|
||||||
|
<tr><th scope="row">أدلة الأعضاء ({formatNumber(memberCount)})</th><td>المقدمة فقط</td><td className="is-member">كاملة</td></tr>
|
||||||
|
<tr><th scope="row">ملفات العمل</th><td>—</td><td className="is-member">تنزيل كامل</td></tr>
|
||||||
|
<tr><th scope="row">التعليق والنقاش</th><td>على الأدلة المجانية</td><td className="is-member">على كل الأدلة</td></tr>
|
||||||
|
<tr><th scope="row">حفظ الأدلة للعودة لاحقاً</th><td>—</td><td className="is-member">متاح</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mem-faq" id="faq" aria-labelledby="faq-title">
|
||||||
|
{/* L-09: FAQPage structured data mirrors the visible FAQ. */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: FAQ_ITEMS.map(item => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: item.q,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: item.a },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="wrap faq-grid">
|
||||||
|
<div>
|
||||||
|
<h2 id="faq-title">أسئلة قبل الاشتراك</h2>
|
||||||
|
<p>لم تجد إجابتك؟ راسلنا على <a className="text-link" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> ويرد عليك الفريق مباشرة.</p>
|
||||||
|
</div>
|
||||||
|
<Faq items={FAQ_ITEMS} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||