chore: track Payload web application

This commit is contained in:
AHMED
2026-10-09 15:44:19 +03:00
parent e10491ad1c
commit 690c2deed7
124 changed files with 20205 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
.next/
media/
.env
.env.*
!.env.example
*.tsbuildinfo
next-env.d.ts
payload-types.ts
+42
View File
@@ -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"]
+67
View File
@@ -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.
+46
View File
@@ -0,0 +1,46 @@
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
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:
+23
View File
@@ -0,0 +1,23 @@
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=()' },
],
}]
},
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: false },
experimental: {},
}
export default withPayload(nextConfig)
+7237
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"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",
"nodemailer": "^10.0.13",
"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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+79
View File
@@ -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, '&amp;').replace(/</g, '&lt;')
const catEsc = category.replace(/&/g, '&amp;').replace(/</g, '&lt;')
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}`)
+99
View File
@@ -0,0 +1,99 @@
import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
import Footer from '@/components/footer'
import '../styles/about.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'عن الفريق' }
export default function AboutPage() {
return (
<>
<Topnav
viewer={null}
menu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'المقالات' },
{ href: '/subscription', label: 'العضوية' },
{ href: '/about', label: 'عن الفريق' },
]}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'المقالات' },
{ href: '/subscription', label: 'العضوية والأسعار' },
]}
foot={{ href: '/about', label: '', note: 'محتوى عربي عملي، بلا ضجيج.', variant: 'hidden' }}
/>
<main>
<section className="hero" data-od-id="about-hero">
<div className="container hero-grid">
<div>
<p className="eyebrow">عن أنور بي آي · خبرة تبني وتشرح</p>
<h1 data-od-id="about-title">خبرة تقنية تتحول إلى معرفة عملية.</h1>
<p className="lead">يقود أنور عنبتاوي المنصة، ويشاركه أحمد محمد شغف تحويل تقنيات الذكاء الاصطناعي والبنية التحتية إلى تطبيقات مفهومة ونافعة للأعمال.</p>
</div>
<div className="hero-note" data-od-id="team-mission">
<strong>لا نكتفي بعرض الأدوات.</strong>
<p>نشرح كيف تختارها، تربطها بسير العمل، وتشغّلها بوعي على بنية يمكن الاعتماد عليها.</p>
</div>
</div>
</section>
<section className="team" id="team-contact" data-od-id="team">
<div className="container">
<div className="section-head">
<h2>العقول خلف أنور بي آي.</h2>
<p>يجمع الفريق بين خبرة طويلة في البنية التحتية واهتمام تطبيقي بالنماذج المفتوحة والوكلاء وأنظمة الاسترجاع.</p>
</div>
<div className="profile-grid">
<article className="profile profile-main" data-od-id="profile-anwar">
<span className="profile-label">المؤسس الرئيسي · خبرة ٢٠ عاماً</span>
<h3>أنور عنبتاوي</h3>
<p className="role">متخصص بنية تحتية وتقنيات معلومات</p>
<p className="bio">يقود أنور الرؤية الأساسية للمنصة بخبرة تمتد لعقدين في البنية التحتية لتقنية المعلومات. يهتم بتشغيل النماذج الكبيرة محلياً، واستكشاف النماذج المفتوحة، وبناء بيئات قوية تجعل التجربة التقنية قابلة للتشغيل الحقيقي لا للعرض فقط.</p>
<div className="topics" aria-label="مجالات اهتمام أنور">
<span className="topic">البنية التحتية</span>
<span className="topic">النماذج المفتوحة</span>
<span className="topic">تشغيل النماذج محلياً</span>
<span className="topic">الأنظمة الموثوقة</span>
</div>
<a className="profile-link" href="https://www.linkedin.com/in/anwaranabtawi/" target="_blank" rel="noopener noreferrer" data-od-id="anwar-linkedin"><span>ملف أنور على LinkedIn</span><span aria-hidden="true">↗</span></a>
</article>
<article className="profile" data-od-id="profile-ahmed">
<span className="profile-label">ذكاء اصطناعي تطبيقي · تكامل الأعمال</span>
<h3>أحمد محمد</h3>
<p className="role">مهتم بهندسة حلول الذكاء الاصطناعي</p>
<p className="bio">يركز أحمد على ضبط النماذج، وأطر الذكاء الاصطناعي الوكيل، وتنسيق الوكلاء، وأنظمة <bdi dir="ltr">RAG</bdi>. اهتمامه الأساسي هو دمج الذكاء الاصطناعي داخل تدفقات الأعمال بحيث يتحول من تجربة منفصلة إلى جزء فعّال من طريقة إنجاز العمل.</p>
<div className="topics" aria-label="مجالات اهتمام أحمد">
<span className="topic">ضبط النماذج</span>
<span className="topic">أطر الوكلاء</span>
<span className="topic">تنسيق الوكلاء</span>
<span className="topic"><bdi dir="ltr">RAG</bdi></span>
</div>
<a className="profile-link" href="https://www.linkedin.com/in/ahmed-mohamed-423583151/" target="_blank" rel="noopener noreferrer" data-od-id="ahmed-linkedin"><span>ملف أحمد على LinkedIn</span><span aria-hidden="true">↗</span></a>
</article>
</div>
</div>
</section>
<section className="principles" data-od-id="team-principles">
<div className="container principle-grid">
<div>
<p className="eyebrow">طريقة العمل</p>
<h2>من المختبر إلى سير العمل.</h2>
</div>
<div className="principle-list">
<article className="principle" data-od-id="principle-practical"><span className="principle-index">01</span><div><h3>التطبيق قبل الاستعراض</h3><p>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</p></div></article>
<article className="principle" data-od-id="principle-open"><span className="principle-index">02</span><div><h3>فهم ما يحدث تحت الغطاء</h3><p>نفضّل الأدوات المفتوحة والتشغيل الذي يمنح المستخدم فهماً وتحكماً أكبر في بياناته وموارده.</p></div></article>
<article className="principle" data-od-id="principle-reliable"><span className="principle-index">03</span><div><h3>أنظمة يمكن الاعتماد عليها</h3><p>الوكيل أو النموذج لا يكتمل من دون بنية ومراقبة وحدود واضحة تمنع الفشل الصامت.</p></div></article>
</div>
</div>
</section>
</main>
<Footer />
</>
)
}
@@ -0,0 +1,220 @@
'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
}
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 }: 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: 'أدخل اسماً من ٣ أحرف على الأقل.' })
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@anwarpi.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' : ''}>٨ أحرف على الأقل</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">
<div><span className="price-tag"><bdi dir="ltr">10$</bdi><small>شهرياً</small></span></div>
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : 'المقالات المجانية ومعاينة الأدلة'}</dd></dl>
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">ستُربط عبر Stripe</dd></dl>
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
</div>
<div className="membership-actions" id="membershipActions">
{!entitled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
</div>
</article>
<div className="cancel-zone" id="cancelZone" data-od-id="cancel-zone">
<h3>إلغاء الاشتراك</h3>
{entitled ? (
<>
<p>ربط الدفع (Stripe) غير مكتمل بعد — الإلغاء الذاتي سيتاح بعد ربط الدفع. حتى ذلك الحين تواصل معنا على <bdi>hello@anwarpi.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,64 @@
import { getPayload } from 'payload'
import { headers } from 'next/headers'
import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
import Footer from '@/components/footer'
import AccountSettingsClient from './client'
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 (
<>
<Topnav
viewer={user ? { email: String(user.email) } : null}
accountLabel={user ? undefined : undefined}
menu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'المقالات' },
{ href: '/subscription', label: 'العضوية' },
]}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/agent-loop-stop-condition', label: 'دليل الأعضاء' },
{ href: '/subscription', label: 'العضوية' },
]}
/>
{user ? (
<main>
<section className="page-head" data-od-id="settings-header"><div className="container">
<p className="crumb">حسابك في أنور بي آي</p>
<h1 data-od-id="settings-title">إعدادات الحساب</h1>
<p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? new Date(String(user.createdAt)).toLocaleDateString('ar') : 'اليوم'}</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')}
/>
</section>
</main>
) : (
<main>
<section className="page-head" data-od-id="settings-header"><div className="container">
<p className="crumb">حسابك في أنور بي آي</p>
<h1 data-od-id="settings-title">إعدادات الحساب</h1>
<p className="sub">يجب <a className="text-link" href="/login">تسجيل الدخول</a> لعرض إعدادات الحساب.</p>
</div></section>
</main>
)}
<Footer />
</>
)
}
@@ -0,0 +1,101 @@
'use client'
import { useEffect } from 'react'
/* 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 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('ar').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 === 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 = `${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))
})
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
applyFilters()
}))
search?.addEventListener('input', () => { state.query = search.value.trim(); applyFilters() })
sort?.addEventListener('change', () => { state.sort = sort.value; applyFilters() })
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()
})
})
buttons.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.filterValue === state[button.dataset.filterKind!])))
applyFilters()
}, [loggedIn])
return null
}
+222
View File
@@ -0,0 +1,222 @@
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 '../styles/articles.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'المكتبة' }
const TOPIC_MAP: Record<string, string> = {
'وكلاء': 'agents',
'نماذج': 'models',
'استرجاع': 'models',
'سلامة': 'agents',
'أدوات': 'agents',
'أعمال': 'automation',
}
/* Interest (member onboarding) → library topic, for personalization boost */
const INTEREST_MAP: Record<string, string> = {
'الوكلاء': 'agents',
'RAG': 'models',
'الأتمتة': 'automation',
'النماذج المفتوحة': 'models',
}
type PostDoc = {
id: number
title: string
slug: string
dek: string
access: 'free' | 'vip'
category: string
level: string
readingMinutes: number
views: number
likes: number
publishedAt: string
}
type Viewer = { id: number; email: string; role: string; interest: string | null; level: string | null } | null
function toArabic(n: number): string {
return String(n).replace(/\d/g, d => '٠١٢٣٤٥٦٧٨٩'[Number(d)])
}
export default async function ArticlesPage() {
const payload = await getPayload({ config: configPromise })
const [res, auth] = await Promise.all([
payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
depth: 0,
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,
},
}),
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 && TOPIC_MAP[post.category] === interestTopic && post.level === viewer!.level
if (el._match) el._order += 1000
})
const weekly = posts.find(p => p.slug === 'article-agent-guide')
return (
<>
<TopnavStatic loggedIn={!!viewer} />
<ArticlesClient loggedIn={!!viewer} />
<main id="content">
<section className="hero" data-od-id="library-hero">
<div className="container hero-grid">
<div>
<p className="eyebrow">مكتبة أنور بي آي</p>
<h1 data-od-id="library-title">كل المقالات. بلا ضياع.</h1>
<p className="lead">ابحث، اختر مسارك، ورتّب النتائج حسب الأحدث أو التفاعل المسجّل.</p>
</div>
{weekly && (
<a className="weekly" href={`/posts/${weekly.slug}`} data-od-id="weekly-post">
<div className="weekly-meta"><span>مقال هذا الأسبوع</span><span>مجاني · {toArabic(weekly.readingMinutes)} دقيقة</span></div>
<h2>{weekly.title}</h2>
<p>{weekly.dek}</p>
<span className="weekly-link">اقرأ المقال ←</span>
</a>
)}
</div>
</section>
<section className="library" data-od-id="article-library">
<div className="container">
<div className="library-tools" data-od-id="library-search-sort">
<label><span className="filter-label">ابحث في العناوين والوصف</span><input className="control" id="postSearch" type="search" autoComplete="off" placeholder="مثال: الوكلاء، RAG، اختيار النموذج" /></label>
<label><span className="filter-label">الترتيب</span><select className="control" id="postSort"><option value="latest">الأحدث أولاً</option><option value="views">الأكثر مشاهدة</option><option value="likes">الأكثر إعجاباً</option></select></label>
</div>
<div className="filter-panel" data-od-id="library-filters">
<div className="filter-group" role="group" aria-label="تصفية حسب المسار">
<span className="filter-label">المسار / الفئة</span>
<div className="filters">
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="all" aria-pressed="true">الكل</button>
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="agents" aria-pressed="false">الوكلاء</button>
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="models" aria-pressed="false">النماذج</button>
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="automation" aria-pressed="false">الأتمتة</button>
<button className="filter" type="button" data-filter-kind="topic" data-filter-value="foundations" aria-pressed="false">الأساسيات</button>
</div>
</div>
<div className="filter-group" role="group" aria-label="تصفية حسب نوع الوصول">
<span className="filter-label">نوع الوصول</span>
<div className="filters">
<button className="filter" type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
<button className="filter" type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
<button className="filter" type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">VIP</button>
</div>
</div>
</div>
<div className="results-head">
<h2 data-od-id="posts-heading">جميع المواد</h2>
<span className="count" id="resultCount" aria-live="polite">{toArabic(posts.length)} مواد</span>
</div>
<div className="posts" id="postList">
{posts.map(post => {
const meta = post as unknown as { _order?: number; _match?: boolean }
const topic = TOPIC_MAP[post.category] ?? 'agents'
return (
<article
className="post"
key={post.id}
data-topic={topic}
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}
data-od-id={`post-${post.slug}`}
>
<div className="post-meta">
<span>{post.category}</span>
<span className="num">{toArabic(post.readingMinutes)} دقيقة · {post.level}</span>
{meta._match && <span className="foryou">يناسب مستواك</span>}
</div>
<div><h3>{post.title}</h3><p>{post.dek}</p><Link className="post-link" href={`/posts/${post.slug}`}>{post.access === 'free' ? 'ابدأ القراءة ←' : 'شاهد المعاينة ←'}</Link></div>
<div className="post-side">
<span className={`access${post.access === 'free' ? ' free' : ''}`}>{post.access === 'free' ? 'مجاني · كامل' : 'VIP · معاينة'}</span>
<div className="engagement">
<span data-views>{toArabic(post.views ?? 0)} مشاهدة</span>
<button className="like" type="button" data-like={post.slug} aria-pressed={likedSlugs.has(post.slug)}>
إعجاب · <span>{toArabic(post.likes ?? 0)}</span>
</button>
</div>
</div>
</article>
)
})}
</div>
<p className="empty" id="emptyState">لا توجد مواد تطابق البحث والفلاتر الحالية.</p>
</div>
</section>
</main>
<footer className="pagefoot" data-od-id="footer">
<div className="container footer-row"><span>© أنور بي آي ٢٠٢٦ · تعلّم بوضوح</span><span><Link href="/terms">الشروط</Link> · <Link href="/privacy">الخصوصية</Link></span></div>
</footer>
</>
)
}
function TopnavStatic({ loggedIn }: { loggedIn: boolean }) {
return (
<header className="topnav" data-od-id="topnav">
<div className="container topnav-inner">
<Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
أنور بي آي
</Link>
<nav aria-label="التنقل الرئيسي">
<Link href="/articles" aria-current="page" data-od-id="nav-library">المكتبة</Link>
<Link href="/#topics" data-od-id="nav-topics">المسارات</Link>
<Link href="/subscription" data-od-id="nav-subscription">العضوية</Link>
<Link href="/about" data-od-id="nav-about">عن الفريق</Link>
</nav>
{!loggedIn
? <Link className="nav-action" href="/login" data-od-id="login-link">تسجيل الدخول</Link>
: <Link className="nav-action" href="/account-settings" data-od-id="account-link">حسابي</Link>}
</div>
</header>
)
}
@@ -0,0 +1,111 @@
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
export type DeckSlide = {
kicker: string
index: string
title: React.ReactNode
body: string
format: React.ReactNode
link?: { href: string; label: string }
meta?: React.ReactNode
}
const V1_SLIDES: DeckSlide[] = [
{
kicker: 'دليل حصري · المستوى المتوسط',
index: '01',
title: 'ابنِ وكيلك الأول دون فوضى',
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
link: { href: '/posts/agent-loop-stop-condition', label: 'شاهد المعاينة ←' },
},
{
kicker: 'مقال تطبيقي · للمبتدئين',
index: '02',
title: 'كيف تختار نموذجاً لمهمتك؟',
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
format: <>قراءة · 12 دقيقة</>,
link: { href: '/posts/article-agent-guide', label: 'ابدأ القراءة ←' },
},
{
kicker: 'مسار متقدم · للأعضاء',
index: '03',
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
link: { href: '/subscription', label: 'شاهد للأعضاء ←' },
},
]
const DOT_LABELS = ['الشريحة الأولى', 'الشريحة الثانية', 'الشريحة الثالثة']
/* Featured deck — markup and slide logic from arabic-ai-learning-catalog(.v2).html
(autoplay 6.5s, dots, arrows, localStorage persistence). Slides render a
link when given; otherwise the v2 home variant shows a meta tag. */
export default function CatalogDeck({ slides = V1_SLIDES }: { slides?: DeckSlide[] }) {
useEffect(() => {
const slideEls = [...document.querySelectorAll('.deck-slide')] as HTMLElement[]
const dots = [...document.querySelectorAll('.deck-dot')] as HTMLElement[]
let activeSlide = Number(localStorage.getItem('midad-slide') || 0) % slideEls.length
let timer: ReturnType<typeof setInterval> | undefined
function showSlide(index: number) {
activeSlide = (index + slideEls.length) % slideEls.length
slideEls.forEach((slide, i) => slide.classList.toggle('active', i === activeSlide))
dots.forEach((dot, i) => {
dot.classList.toggle('active', i === activeSlide)
dot.setAttribute('aria-current', i === activeSlide ? 'true' : 'false')
})
localStorage.setItem('midad-slide', String(activeSlide))
}
function restartDeck() {
clearInterval(timer)
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) timer = setInterval(() => showSlide(activeSlide + 1), 6500)
}
document.getElementById('prevSlide')?.addEventListener('click', () => { showSlide(activeSlide - 1); restartDeck() })
document.getElementById('nextSlide')?.addEventListener('click', () => { showSlide(activeSlide + 1); restartDeck() })
dots.forEach(dot => dot.addEventListener('click', () => { showSlide(Number((dot as HTMLElement).dataset.dot)); restartDeck() }))
/* QA-05: keep controls stationary — pause autoplay while hovered/focused. */
const deck = document.querySelector('.deck')
deck?.addEventListener('mouseenter', () => clearInterval(timer))
deck?.addEventListener('mouseleave', () => restartDeck())
deck?.addEventListener('focusin', () => clearInterval(timer))
deck?.addEventListener('focusout', () => restartDeck())
showSlide(activeSlide)
restartDeck()
return () => clearInterval(timer)
}, [])
return (
<div className="deck" data-od-id="featured-deck" aria-label="مختارات مميزة">
<div className="deck-frame" aria-live="polite">
{slides.map((s, i) => (
<article className={`deck-slide${i === 0 ? ' active' : ''}`} data-slide={i} key={i}>
<div className="deck-slide-top"><span className="deck-slide-kicker">{s.kicker}</span><span className="deck-slide-index num">{s.index}</span></div>
<div className="deck-slide-body"><h2>{s.title}</h2><p>{s.body}</p></div>
<div className="deck-slide-foot">
<span className="deck-slide-format">{s.format}</span>
{s.link
? <Link className="deck-slide-link" href={s.link.href}>{s.link.label}</Link>
: <span className="meta">{s.meta}</span>}
</div>
</article>
))}
</div>
<div className="deck-controls">
<div className="deck-dots" aria-label="اختيار الشريحة">
{slides.map((_, i) => (
<button className={`deck-dot${i === 0 ? ' active' : ''}`} data-dot={i} aria-label={DOT_LABELS[i]} key={i}></button>
))}
</div>
<div className="deck-arrows">
<button className="icon-btn" id="prevSlide" aria-label="الشريحة السابقة">←</button>
<button className="icon-btn" id="nextSlide" aria-label="الشريحة التالية">→</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,50 @@
'use client'
import { useEffect } from 'react'
/* Filter bar — port of the catalog page's filter logic; also wired to the
topic path cards via [data-topic-link]. Hides non-matching catalog cards. */
export default function CatalogFilters() {
useEffect(() => {
const filters = [...document.querySelectorAll('.filter')] as HTMLElement[]
const cards = [...document.querySelectorAll('.catalog-card')] as HTMLElement[]
const empty = document.getElementById('emptyState')
function filterCatalog(topic: string) {
let visible = 0
filters.forEach(button => {
const selected = (button as HTMLElement).dataset.filter === topic
button.classList.toggle('active', selected)
button.setAttribute('aria-pressed', String(selected))
})
cards.forEach(card => {
const show = topic === 'all' || card.dataset.topic === topic
card.hidden = !show
if (show) visible += 1
})
empty?.classList.toggle('show', visible === 0)
}
filters.forEach(button => button.addEventListener('click', () => {
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
filterCatalog((button as HTMLElement).dataset.filter!)
}))
document.querySelectorAll('[data-topic-link]').forEach(link =>
link.addEventListener('click', () => {
const topic = (link as HTMLElement).dataset.topicLink!
filterCatalog(topic)
filters.find(button => button.dataset.filter === topic)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
})
)
}, [])
return (
<div className="filterbar" role="group" aria-label="تصفية المحتوى" data-od-id="catalog-filters">
<button className="filter active" data-filter="all" aria-pressed="true">الكل</button>
<button className="filter" data-filter="agents" aria-pressed="false">الوكلاء</button>
<button className="filter" data-filter="models" aria-pressed="false">النماذج</button>
<button className="filter" data-filter="automation" aria-pressed="false">الأتمتة</button>
<button className="filter" data-filter="foundations" aria-pressed="false">الأساسيات</button>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from 'next/navigation'
/* QA-07: /catalog was a stale duplicate library — /articles is canonical.
Deck/filter components in this folder are still used by the homepage. */
export const dynamic = 'force-dynamic'
export default function CatalogPage() {
redirect('/articles')
}
@@ -0,0 +1,8 @@
import type { Metadata } from 'next'
/* QA-06: page-specific title (the page itself is a client component). */
export const metadata: Metadata = { title: 'استعادة كلمة المرور' }
export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) {
return children
}
@@ -0,0 +1,59 @@
'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)
await fetch('/api/users/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
setStatus('إذا كان البريد مسجلاً، فسيصلك رابط الاستعادة.')
form.reset()
setBusy(false)
}
return (
<>
<Topnav
viewer={null}
logoHref="/articles"
links={[]}
/>
<main 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" 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 />
</>
)
}
+8
View File
@@ -0,0 +1,8 @@
import type { Metadata } from 'next'
/* QA-06: page-specific title (the page itself is a client component). */
export const metadata: Metadata = { title: 'تسجيل الدخول' }
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children
}
+128
View File
@@ -0,0 +1,128 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
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)
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
}
setStatus('تعذّر تسجيل الدخول — تحقّق من البريد وكلمة المرور.')
setBusy(false)
}
/* رابط الدخول بالبريد — يستخدم مسار استعادة كلمة المرور الحقيقي في Payload. */
async function sendMagicLink() {
const form = document.getElementById('loginForm') as HTMLFormElement
const email = form.elements.namedItem('email') as HTMLInputElement
if (!email.value.trim() || !email.checkValidity()) {
setStatus('أدخل بريداً إلكترونياً صحيحاً أولاً.')
email.focus()
return
}
setStatus('')
const res = await fetch('/api/users/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim() }),
})
setStatus(res.ok ? 'أرسلنا رابط الدخول إلى بريدك إن كان مسجلاً.' : 'تعذّر الإرسال — خدمة البريد غير مربوطة بعد.')
}
return (
<>
<Topnav
viewer={null}
menu
hideAction
logoHref="/"
links={[]}
returnLink={{ href: '/', label: 'العودة إلى المكتبة' }}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'مقال الأسبوع' },
{ href: '/subscription', label: 'العضوية والأسعار' },
]}
foot={{ href: '/login', label: '', note: 'محتوى عربي عملي، بلا ضجيج.', variant: 'hidden' }}
/>
<main className="auth">
<section className="auth-story" data-od-id="login-story">
<div>
<p className="eyebrow">مساحتك في أنور بي آي</p>
<h1 data-od-id="login-story-title">أكمل من حيث توقفت.</h1>
<p>احفظ أدلتك، تابع مسارات الفيديو، وارجع إلى ملاحظاتك من أي جهاز.</p>
<ul className="story-list">
<li><span>المقالات المحفوظة</span><span className="story-mark">مرتبّة</span></li>
<li><span>تقدّم المسارات</span><span className="story-mark">متزامن</span></li>
<li><span>ملفات العضوية</span><span className="story-mark">جاهزة</span></li>
</ul>
</div>
<p className="story-foot">العربية أولاً · خصوصيتك محفوظة</p>
</section>
<section 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" 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>
<button className="magic" id="magicLink" type="button" onClick={sendMagicLink}>أرسل رابط دخول إلى بريدي</button>
<p className="signup">ليس لديك حساب؟ <Link href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : '/signup'}>أنشئ حساباً جديداً</Link></p>
<p className="fine">بالمتابعة، أنت توافق على <Link href="/terms">شروط الاستخدام</Link> و<Link href="/privacy">سياسة الخصوصية</Link>.</p>
</form>
</section>
</main>
</>
)
}
@@ -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 = ['الخطوة ١ من ٣', 'الخطوة ٢ من ٣', 'الخطوة ٣ من ٣']
const PERCENTS = ['٣٣٪', '٦٧٪', '١٠٠٪']
/* 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>{['٠١', '٠٢', '٠٣'][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>ابدأ مجاناً أو افتح تجربة VIP الكاملة.</h2>
<p className="intro" id="summaryCopy">{summary}</p>
<div className="benefits" data-od-id="vip-benefits">
<article className="benefit"><span className="benefit-num">٠١</span><div><h3>الأدلة المتقدمة كاملة</h3><p>اقرأ ما بعد المعاينة في أدلة الوكلاء وRAG والأتمتة والنماذج.</p></div></article>
<article className="benefit"><span className="benefit-num">٠٢</span><div><h3>مسارات الفيديو والملفات</h3><p>تابع كل أجزاء المسار ونزّل قوالب العمل المرتبطة به.</p></div></article>
<article className="benefit"><span className="benefit-num">٠٣</span><div><h3>نقطة بداية مخصصة</h3><p>تهيئة VIP ترتّب دليلك الأول ورسائلك بناءً على هدفك.</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,62 @@
import { getPayload } from 'payload'
import { headers } from 'next/headers'
import configPromise from '@payload-config'
import Link from 'next/link'
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 */
}
return (
<>
<header className="topnav" data-od-id="topnav">
<div className="container nav-inner">
<Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
أنور بي آي
</Link>
<Link className="nav-return" href="/articles" data-od-id="skip-link">تخطي إلى المكتبة</Link>
</div>
</header>
<main>
<section className="head" data-od-id="onboarding-hero">
<div className="container">
<p className="eyebrow">حسابك جاهز · إعداد سريع</p>
<h1 data-od-id="onboarding-title">اختر ما تريد تعلّمه أولاً.</h1>
<p className="lead">سؤالان فقط لترتيب المكتبة حول هدفك، ثم نوضح الفرق بين الحساب المجاني وعضوية VIP.</p>
<div className="progress" aria-label="تقدم الإعداد">
<div className="progress-copy"><span id="progressLabel">الخطوة ١ من ٣</span><span id="progressPercent">٣٣٪</span></div>
<div className="progress-track"><div className="progress-bar" id="progressBar"></div></div>
</div>
</div>
</section>
<OnboardingWizard viewer={viewer} />
</main>
<footer className="pagefoot" data-od-id="footer">
<div className="container footer-row"><span>© أنور بي آي ٢٠٢٦</span><span>يمكنك تغيير المسار لاحقاً من إعدادات الحساب.</span></div>
</footer>
</>
)
}
+168
View File
@@ -0,0 +1,168 @@
import { getViewer } from '@/lib/viewer'
import Topnav from '@/components/topnav'
import CatalogDeck from './catalog/catalog-deck'
import CatalogFilters from './catalog/catalog-filters'
import Link from 'next/link'
import './styles/arabic-ai-learning-catalog.css'
export const dynamic = 'force-dynamic'
const HOME_SLIDES = [
{
kicker: 'دليل حصري · المستوى المتوسط',
index: '01',
title: <>ابنِ وكيلك الأول دون فوضى</>,
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
meta: <bdi dir="ltr">AGENTIC AI</bdi>,
},
{
kicker: 'مقال تطبيقي · للمبتدئين',
index: '02',
title: <>كيف تختار نموذجاً لمهمتك؟</>,
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
format: <>قراءة · 12 دقيقة</>,
meta: <>النماذج</>,
},
{
kicker: 'مسار متقدم · للأعضاء',
index: '03',
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
meta: <bdi dir="ltr">RAG</bdi>,
},
]
export default async function HomePage() {
const viewer = await getViewer()
return (
<>
<Topnav
viewer={viewer}
logoHref="#top"
loginClass="nav-action"
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '#topics', label: 'المسارات' },
{ href: '#membership', label: 'العضوية' },
]}
/>
<main id="content">
<section className="section hero" id="top" data-od-id="hero">
<div className="container hero-split">
<div className="hero-copy">
<p className="eyebrow">مكتبة عربية عملية · <bdi dir="ltr">AI + AGENTS</bdi></p>
<h1 data-od-id="hero-title">افهم الذكاء الاصطناعي. ثم ابنِ به.</h1>
<p className="lead">أدلة واضحة، دروس فيديو، ومسارات متدرجة تساعدك على الانتقال من الفكرة إلى وكيل يعمل.</p>
<div className="hero-cta">
<a className="btn btn-primary" href="/articles" data-od-id="hero-primary-cta">استكشف المكتبة</a>
<a className="btn btn-ghost" href="#membership">شاهد محتوى العضوية ←</a>
</div>
<p className="trust-line">بالعربية أولاً · أمثلة قابلة للتطبيق · تحديثات مرتبطة بما يتغير فعلاً</p>
</div>
<CatalogDeck slides={HOME_SLIDES} />
</div>
</section>
<section className="section" id="topics" data-od-id="learning-paths">
<div className="container">
<div className="row-between section-head">
<div><p className="eyebrow">ابدأ من اهتمامك</p><h2 data-od-id="topics-title">أربعة مسارات. هدف واحد: فهم قابل للاستخدام.</h2></div>
<p className="lead">تصفح المحتوى حسب ما تريد بناءه، لا حسب المصطلحات التي تحفظها.</p>
</div>
<div className="topic-grid">
<a className="topic" href="/articles" data-topic-link="agents" data-od-id="topic-agents"><strong>الوكلاء</strong><span>تخطيط · أدوات · ذاكرة</span></a>
<a className="topic" href="/articles" data-topic-link="models" data-od-id="topic-models"><strong>النماذج</strong><span>اختيار · تقييم · تشغيل</span></a>
<a className="topic" href="/articles" data-topic-link="automation" data-od-id="topic-automation"><strong>الأتمتة</strong><span>سير عمل · ربط · مراقبة</span></a>
<a className="topic" href="/articles" data-topic-link="foundations" data-od-id="topic-foundations"><strong>الأساسيات</strong><span>مفاهيم · تطبيق · أمان</span></a>
</div>
</div>
</section>
<section className="section" id="catalog" data-od-id="catalog">
<div className="container">
<div className="row-between section-head">
<div><p className="eyebrow">مختارات المحررين</p><h2 data-od-id="catalog-title">دليل تقرؤه اليوم، وتستخدمه غداً.</h2></div>
<span className="meta">محتوى عربي · نص وفيديو</span>
</div>
<CatalogFilters />
<div className="catalog" id="catalogGrid">
<article className="catalog-card" data-topic="agents" data-od-id="guide-agent-architecture">
<div className="cover"><span className="cover-label">دليل عملي · حصري</span><span className="cover-title">تشريح وكيل ناجح</span></div>
<div className="card-meta"><span>الوكلاء</span><span className="num">18 دقيقة</span></div>
<h3>البنية التي تمنع وكيلك من الدوران في حلقة</h3>
<p>حدود واضحة بين التخطيط، التنفيذ، والتحقق مع نموذج بسيط لسجلّ الحالة.</p>
<Link className="card-link" href="/posts/agent-loop-stop-condition"><span>شاهد المعاينة</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="models" data-od-id="guide-model-selection">
<div className="cover cover-light"><span className="cover-label">مقال · مجاني</span><span className="cover-title">خريطة اختيار النموذج</span></div>
<div className="card-meta"><span>النماذج</span><span className="num">12 دقيقة</span></div>
<h3>لا تبدأ باسم النموذج؛ ابدأ بطبيعة المهمة</h3>
<p>مصفوفة قرار عملية توازن بين الدقة، السياق، السرعة، والكلفة.</p>
<Link className="card-link" href="/posts/systematic-model-eval"><span>ابدأ القراءة</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="automation" data-od-id="guide-automation-observability">
<div className="cover cover-lined"><span className="cover-label">فيديو · حصري</span><span className="cover-title">راقب قبل أن تؤتمت</span></div>
<div className="card-meta"><span>الأتمتة</span><span className="num">24 دقيقة</span></div>
<h3>كيف تكتشف فشل سير العمل قبل المستخدم؟</h3>
<p>إشارات قليلة تكفي لمراقبة التكلفة، التأخير، ونقاط الانقطاع.</p>
<Link className="card-link" href="/posts/prototype-to-production"><span>شاهد المقدمة</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="foundations" data-od-id="guide-prompt-context">
<div className="cover cover-lined"><span className="cover-label">شرح · مجاني</span><span className="cover-title">السياق قبل البرومبت</span></div>
<div className="card-meta"><span>الأساسيات</span><span className="num">9 دقائق</span></div>
<h3>لماذا لا يصلح تحسين الصياغة بياناتك الرديئة؟</h3>
<p>افصل بين التعليمات، المعرفة، وذاكرة المحادثة لتحصل على نتيجة مستقرة.</p>
<Link className="card-link" href="/posts/first-arabic-agent"><span>ابدأ القراءة</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="agents" data-od-id="guide-tool-safety">
<div className="cover cover-light"><span className="cover-label">قائمة فحص · حصري</span><span className="cover-title">أدوات بلا مفاجآت</span></div>
<div className="card-meta"><span>الوكلاء</span><span className="num">15 دقيقة</span></div>
<h3>قبل أن تمنح الوكيل صلاحية التنفيذ</h3>
<p>حدود الصلاحيات، التأكيد البشري، وسجل تدقيق يمكنك الرجوع إليه.</p>
<Link className="card-link" href="/posts/safe-agent-guardrails"><span>عرض التفاصيل</span><span>←</span></Link>
</article>
<article className="catalog-card" data-topic="models" data-od-id="guide-rag-evaluation">
<div className="cover"><span className="cover-label">سلسلة فيديو · حصري</span><span className="cover-title">قياس RAG بصدق</span></div>
<div className="card-meta"><span>النماذج</span><span className="num">4 أجزاء</span></div>
<h3>اختبر الاسترجاع والإجابة كلّاً على حدة</h3>
<p>منهج تقييم يفصل فشل المصدر عن فشل النموذج ويقودك إلى إصلاح صحيح.</p>
<Link className="card-link" href="/posts/simple-arabic-rag"><span>شاهد المقدمة</span><span>←</span></Link>
</article>
</div>
<p className="empty" id="emptyState">لا توجد مواد في هذا المسار حالياً.</p>
</div>
</section>
<section className="section membership" id="membership" data-od-id="membership">
<div className="container membership-grid">
<div>
<p className="eyebrow">عضوية أنور بي آي</p>
<h2 data-od-id="membership-title">المحتوى الذي يحترم وقتك يبقى معك.</h2>
<p className="lead" style={{ marginTop: 20 }}>وصول إلى الأدلة المتقدمة، مسارات الفيديو، والملفات العملية عند إضافتها.</p>
</div>
<div>
<ul className="membership-list" aria-label="مزايا العضوية">
<li><span>الأدلة الحصرية</span><span>كاملة</span></li>
<li><span>مسارات الفيديو</span><span>متدرجة</span></li>
<li><span>ملفات العمل</span><span>قابلة للتنزيل</span></li>
</ul>
<Link className="btn btn-primary" href="/subscription" data-od-id="membership-cta">شاهد الأسعار والمزايا</Link>
</div>
</div>
</section>
</main>
<footer className="pagefoot" data-od-id="footer">
<div className="container row-between"><span>© أنور بي آي ٢٠٢٦ · تعلّم بوضوح</span><span className="meta"><Link href="/terms">الشروط</Link> · <Link href="/privacy">الخصوصية</Link></span></div>
</footer>
</>
)
}
@@ -0,0 +1,46 @@
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 (
<>
<header className="topnav" data-od-id="topnav">
<div className="container topnav-inner">
<Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
أنور بي آي
</Link>
<Link className="nav-link" href="/account-settings" data-od-id="account-link">حسابي</Link>
</div>
</header>
<main>
<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@anwarpi.ai?subject=%D9%85%D8%B4%D9%83%D9%84%D8%A9%20%D9%81%D9%8A%20%D8%A7%D9%84%D8%AF%D9%81%D8%B9" data-od-id="payment-help-link">تواصل مع الفريق</a>.</p>
</article>
</div>
</section>
</main>
<footer className="pagefoot" data-od-id="footer">
<div className="container footer-row"><span>© أنور بي آي ٢٠٢٦</span><span>لن تتغير حالة الحساب حتى يكتمل الدفع.</span></div>
</footer>
</>
)
}
@@ -0,0 +1,88 @@
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; simulated?: string }> }
/* Payment success — design from payment-success.html.
- simulated gateway (no Stripe keys): activates VIP on the logged-in account
- real Stripe: verifies the checkout session_id with Stripe before activating
- no session: VIP users pass through; logged-out users go to login */
export default async function PaymentSuccessPage({ searchParams }: Props) {
const { session_id: sessionId, simulated } = 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) {
const { default: Stripe } = await import('stripe')
const stripe = new Stripe(stripeKey)
const session = await stripe.checkout.sessions.retrieve(sessionId)
activate = session.payment_status === 'paid' || session.status === 'complete'
} else if (!sessionId) {
// Simulated gateway (pending mode) — activate the logged-in account
activate = true
}
if (activate && user.role !== 'vip' && user.role !== 'admin') {
await payload.update({
collection: 'users',
id: user.id,
data: { role: 'vip' },
overrideAccess: true,
context: { systemActivation: true },
})
}
return (
<>
<header className="topnav" data-od-id="topnav">
<div className="container topnav-inner">
<Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
أنور بي آي
</Link>
<Link className="nav-link" href="/account-settings" data-od-id="account-link">حسابي</Link>
</div>
</header>
<main>
<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>
<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>كل محتوى VIP وملفات الأعضاء</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>
</article>
</div>
</section>
</main>
<footer className="pagefoot" data-od-id="footer">
<div className="container footer-row"><span>© أنور بي آي ٢٠٢٦</span><span>يمكنك إدارة العضوية من صفحة حسابك.</span></div>
</footer>
</>
)
}
@@ -0,0 +1,52 @@
'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}
data-od-id="post-like"
>
إعجاب · <span>{count}</span>
</button>
)
}
@@ -0,0 +1,365 @@
import { getPayload } from 'payload'
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import configPromise from '@payload-config'
import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
import Footer from '@/components/footer'
import SetBodyAttr from '@/components/set-body-attr'
import PostExtras from './post-extras'
import { LikeButton, ViewPing } from './engagement'
import '../../styles/post-locked.css'
import '../../styles/post-unlocked.css'
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 })
const res = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
select: { title: true, dek: true, publishedAt: true },
})
const post = res.docs[0] as unknown as { title: string; dek: string; publishedAt?: string | null } | undefined
return post
? {
title: post.title,
description: post.dek,
openGraph: { type: 'article', title: post.title, description: post.dek, publishedTime: post.publishedAt ?? undefined },
}
: { title: 'أنور بي آي' }
}
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
teaser?: string | null
content?: unknown
cover?: { url?: string | null }
}
async function getViewerRole(): Promise<string | null> {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
return (user?.role as string) ?? null
}
async function getViewerField(field: 'level' | 'id'): Promise<string | number | null> {
try {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return null
return (user as unknown as Record<string, string | number>)[field] ?? null
} catch {
return null
}
}
async function isLikedByViewer(postId: number): Promise<boolean> {
try {
const viewerId = await getViewerField('id')
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, teaser: true, cover: true,
content: true,
},
})
const post = res.docs[0] as unknown as PostDoc | undefined
if (!post) notFound()
const role = await getViewerRole()
const unlocked = post.access === 'free' || role === 'vip' || role === 'admin'
const accessChip = post.access === 'free' ? 'مقال مجاني · وصول كامل' : 'VIP · وصول كامل'
const viewerLevel = await getViewerField('level')
const viewerId = await getViewerField('id')
// 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)
const toc = unlocked ? extractH2s(post.content) : []
const accountEmail = await getViewerEmail().catch(() => null)
return (
<>
<SetBodyAttr attr="data-anw-dim" />
<ViewPing slug={post.slug} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: post.title, datePublished: post.publishedAt ?? undefined, author: { '@type': 'Organization', name: 'أنور بي آي' }, inLanguage: 'ar' }) }} />
<Topnav
viewer={accountEmail ? { email: accountEmail } : null}
menu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'المقالات' },
{ href: '/subscription', label: 'العضوية' },
]}
/>
<SiteMenu />
<main>
<section className="article-head" data-od-id="article-header"><div className="container">
{!unlocked && post.access === 'vip' ? (
<span className="lock-chip" data-od-id="access-status">
<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>
VIP · معاينة مجانية
</span>
) : (
<span className="open-chip" data-od-id="access-status">{accessChip}</span>
)}
<p className="crumb">{post.category} · دليل عملي</p>
<h1 data-od-id="article-title">{post.title}</h1>
<p className="dek">{post.dek}</p>
<div className="article-meta">
<span>فريق تحرير أنور بي آي</span>
<span className="num">قراءة {post.readingMinutes} دقيقة · {post.level ?? 'مبتدئ'}</span>
{(post.level ?? 'مبتدئ') === viewerLevel && <span className="foryou num">يناسب مستواك</span>}
<span className="num" data-views>{post.views ?? 0} مشاهدة</span>
<LikeButton slug={post.slug} loggedIn={role !== null} initialLiked={await isLikedByViewer(post.id)} initialCount={post.likes ?? 0} />
{post.publishedAt && <span>نُشر: {new Date(post.publishedAt).toLocaleDateString('ar')}</span>}
{!unlocked && <span>المتاح مجاناً: المقدمة والقسم الأول</span>}
{unlocked && post.access === 'vip' && <SaveButton />}
</div>
</div></section>
<section className="container article-layout" data-od-id="article-content">
<article className="article-body">
{unlocked ? (
<RichText data={post.content} />
) : (
<>
<p className="free-tag">معاينة مجانية</p>
{(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => (
i === 0 ? <p key={i} className="lede">{para}</p>
: i === arr.length - 1 ? (
<div className="lock-cut" data-od-id="lock-cut" key={i}>
<p className="cut-p">{para}</p>
</div>
)
: <p key={i}>{para}</p>
))}
<div className="paywall" data-od-id="paywall" aria-labelledby="paywall-title">
<p className="crumb">الجزء المتبقي · للأعضاء فقط</p>
<h2 id="paywall-title">أكملت الجزء المجاني من هذا الدليل</h2>
<p>بقية الدليل غير موجودة في صفحة المعاينة، وتُفتح فقط بعد تسجيل الدخول بعضوية فعّالة. اشتراك واحد يفتح الأدلة الحصرية ومسارات الفيديو وملفات العمل.</p>
<ul className="locked-in">
<li><LockIcon />بقية الأقسام العملية للدليل<span className="num">—</span></li>
<li><LockIcon />كل الأدلة الحصرية الأخرى<span className="num">—</span></li>
<li><LockIcon />ملفات العمل قابلة للتنزيل<span className="num">ملفات</span></li>
</ul>
<div className="paywall-actions">
<a className="btn btn-primary" href="/subscription" data-od-id="subscribe-cta">اشترك لفتح الدليل</a>
<a className="text-link" href={`/login?next=/posts/${post.slug}`} data-od-id="login-cta">لدي اشتراك؟ سجّل الدخول</a>
</div>
<p className="price-line"><bdi dir="ltr">10$</bdi> شهرياً · إلغاء فوري في أي وقت</p>
</div>
</>
)}
</article>
<aside className="toc" aria-label="فهرس الدليل" data-od-id="article-toc">
<strong>داخل هذا الدليل</strong>
{unlocked ? (
<>
{toc.map((t, i) => <a key={i} href={`#sec-${i}`}>{t}</a>)}
<div className="progress-card" data-od-id="progress-card">
<strong>تقدّمك في المسار</strong>
<div className="progress-track" role="progressbar" aria-valuenow={40} aria-valuemin={0} aria-valuemax={100} aria-label="تقدم المسار"><div className="progress-bar"></div></div>
<span className="p-label">قرأت ٢ من ٥ أدلة مسار الوكلاء</span>
</div>
<div className="files-card" data-od-id="files-card"><strong>ملفات العمل</strong>
<div className="file-row"><span>قالب حالة الوكيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="agent-state.md">تنزيل</button></div>
<div className="file-row"><span>قائمة الفحص قبل التشغيل</span><button className="dl-btn" type="button" data-dl="pre-run-checklist.md">تنزيل</button></div>
<div className="file-row"><span>سجل الأخطاء <bdi dir="ltr">.csv</bdi></span><button className="dl-btn" type="button" data-dl="error-log.csv">تنزيل</button></div>
</div>
</>
) : (
<>
<span className="toc-locked"><LockIcon />الأقسام العملية</span>
<span className="toc-locked"><LockIcon />ملفات العمل</span>
<div className="member-note"><strong>ملفات الأعضاء</strong><p>قالب حالة الوكيل وقائمة الفحص قبل التشغيل متاحان للتنزيل داخل العضوية.</p><a className="btn btn-primary" href="/subscription">شاهد مزايا العضوية</a></div>
</>
)}
</aside>
</section>
{unlocked && (
<section className="comments" data-od-id="comments"><div className="container comments-wrap">
<p className="crumb">نقاش الأعضاء · هادئ ومفيد</p>
<h2 data-od-id="comments-title">التعليقات</h2>
<div className="comment-list" id="commentList">
{comments.docs.map((c) => (
<article key={c.id} className="comment">
<div className="comment-head">
<strong>{c.authorName || 'عضو'}</strong>
<span className="crumb">{new Date(String(c.createdAt)).toLocaleDateString('ar')}</span>
</div>
<p>{c.body}</p>
</article>
))}
{myPending?.docs.map((c) => (
<article key={c.id} className="comment">
<div className="comment-head">
<strong>{c.authorName || 'عضو'}</strong>
<span className="crumb">قيد المراجعة</span>
</div>
<p>{c.body}</p>
</article>
))}
{comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && (
<p className="crumb">لا تعليقات بعد — كن أول من يشارك.</p>
)}
</div>
<PostExtras slug={post.slug} loggedIn={role !== null} />
</div></section>
)}
<section className="related" data-od-id="related-articles"><div className="container">
<p className="crumb">اقرأ بعد ذلك · ضمن عضويتك</p>
<div className="related-grid">
<article className="related-card" data-od-id="related-monitor"><span>الأتمتة · ٢٤ دقيقة</span><h3>اكتشف فشل سير العمل قبل المستخدم</h3><Link href="/articles?topic=automation" aria-label="افتح الدليل التالي">تصفح المكتبة ←</Link></article>
<article className="related-card" data-od-id="related-rag"><span>النماذج · ٤ أجزاء</span><h3>افصل فشل الاسترجاع عن فشل الإجابة</h3><Link href="/articles?topic=models" aria-label="افتح الدليل التالي">تصفح المكتبة ←</Link></article>
<article className="related-card" data-od-id="related-catalog"><span>المكتبة</span><h3>كل الأدلة والمسارات</h3><Link href="/articles">افتح المكتبة ←</Link></article>
</div>
</div></section>
</main>
<Footer />
</>
)
}
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" data-od-id="save-article">احفظ للعودة لاحقاً</button>
}
async function getViewerEmail(): Promise<string | null> {
try {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
return (user?.email as string) ?? null
} catch {
return null
}
}
// Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules.
type Node = { type?: string; text?: string; children?: Node[]; tag?: string; url?: string; listType?: string; code?: string }
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} />
default: return <p key={key}>{inner}</p>
}
})
}
function innerText(nodes?: Node[]): string {
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
}
function extractH2s(data: unknown): string[] {
try {
const root = (data as { root?: { children?: Node[] } })?.root
if (!root?.children) return []
return root.children
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
.map(n => innerText(n.children))
} catch {
return []
}
}
function RichText({ data }: { data: unknown }) {
if (!data || typeof data !== 'object' || !('root' in data)) return null
const rootUnknown = data.root
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,149 @@
'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('اكتب تعليقاً من ٨ أحرف على الأقل.')
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)
}
}
return (
<form className="comment-form" id="commentForm" method="post" noValidate onSubmit={onSubmit} data-od-id="comment-form">
<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>
{!loggedIn && <p className="crumb">التعليق متاح للأعضاء بعد <a className="text-link" href="/login">تسجيل الدخول</a>.</p>}
</form>
)
}
+48
View File
@@ -0,0 +1,48 @@
import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
import '../styles/privacy.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'الخصوصية' }
export default function PrivacyPage() {
return (
<>
<Topnav
viewer={null}
menu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/subscription', label: 'العضوية' },
{ href: '/privacy', label: 'الخصوصية' },
]}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'مقال الأسبوع' },
{ href: '/subscription', label: 'العضوية والأسعار' },
]}
foot={{ href: '/login', label: '', note: 'محتوى عربي عملي، بلا ضجيج.', variant: 'hidden' }}
/>
<main>
<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@anwarpi.ai">hello@anwarpi.ai</a>.</p></section>
<section data-od-id="privacy-updates"><h2>التحديثات</h2><p>يجب إضافة تاريخ السريان وملخص أي تغيير جوهري عند اعتماد النسخة القانونية النهائية.</p></section>
</div>
</main>
<footer className="pagefoot" data-od-id="footer"><div className="container footer-bottom"><span>© أنور بي آي ٢٠٢٦</span><a href="mailto:hello@anwarpi.ai">تواصل معنا</a></div></footer>
</>
)
}
+7
View File
@@ -0,0 +1,7 @@
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'إنشاء حساب' }
export default function SignupLayout({ children }: { children: React.ReactNode }) {
return children
}
+107
View File
@@ -0,0 +1,107 @@
'use client'
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 [showConfirm, setShowConfirm] = useState(false)
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
const [nextPath, setNextPath] = useState<string | null>(null)
useEffect(() => {
setNextPath(safeNext(new URLSearchParams(window.location.search).get('next')))
}, [])
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
const confirm = form.elements.namedItem('confirmPassword') as HTMLInputElement
confirm.setCustomValidity(data.get('password') === data.get('confirmPassword') ? '' : 'كلمتا المرور غير متطابقتين.')
if (!form.checkValidity()) {
setStatus(data.get('password') !== data.get('confirmPassword') ? 'كلمتا المرور غير متطابقتين.' : 'تحقق من جميع الحقول المطلوبة.')
form.reportValidity()
return
}
setBusy(true)
setStatus('')
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.get('email'), password: data.get('password'), fullName: data.get('name'), termsVersion: '2026-10-03' }),
})
if (!res.ok) {
const err = await res.json().catch(() => null)
setStatus(err?.errors?.[0]?.message || 'تعذّر إنشاء الحساب — قد يكون البريد مستخدماً بالفعل.')
setBusy(false)
return
}
const login = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
})
if (login.ok) {
try { sessionStorage.setItem('anwarpi:signup-email', String(data.get('email'))) } catch { /* private mode */ }
try { nextPath ? sessionStorage.setItem('anwarpi:signup-next', nextPath) : sessionStorage.removeItem('anwarpi:signup-next') } catch { /* private mode */ }
router.refresh()
router.push('/verify-email')
return
}
setStatus('تم إنشاء الحساب — سجّل الدخول للمتابعة.')
router.push(nextPath ? '/login?next=' + encodeURIComponent(nextPath) : '/login')
}
return (
<>
<header className="topnav" data-od-id="topnav">
<div className="container nav-inner">
<Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
أنور بي آي
</Link>
<Link className="nav-return" href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : '/login'} data-od-id="login-link">لديك حساب؟ سجّل الدخول</Link>
</div>
</header>
<main className="auth">
<section className="auth-story" data-od-id="signup-story">
<div>
<p className="eyebrow">ابدأ مسارك</p>
<h1 data-od-id="signup-story-title">ابنِ معرفتك، خطوة بخطوة.</h1>
<p>أنشئ حساباً لحفظ المقالات ومتابعة تقدّمك والوصول إلى مزايا العضوية من أي جهاز.</p>
<ul className="story-list">
<li><span>مكتبتك الشخصية</span><span className="story-mark">محفوظة</span></li>
<li><span>تقدّم المسارات</span><span className="story-mark">متزامن</span></li>
<li><span>إعدادات واضحة</span><span className="story-mark">تحت سيطرتك</span></li>
</ul>
</div>
<p className="story-foot">العربية أولاً · خصوصيتك محفوظة</p>
</section>
<section className="auth-form-wrap" data-od-id="signup-form-section">
<form className="auth-form" id="signupForm" method="post" action="/api/users" noValidate onSubmit={onSubmit} data-od-id="signup-form">
<p className="eyebrow">حساب جديد</p>
<h2 data-od-id="signup-title">إنشاء حساب</h2>
<p className="lead">أدخل بياناتك الأساسية للبدء.</p>
<div className="field"><label htmlFor="name">الاسم</label><input className="input" id="name" name="name" type="text" autoComplete="name" minLength={2} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل اسمك.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
<div className="field"><label htmlFor="password">كلمة المرور</label><div className="input-wrap"><input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="new-password" minLength={8} aria-describedby="passwordHint" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة مرور من ثمانية أحرف على الأقل.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className="fine" id="passwordHint">ثمانية أحرف على الأقل.</span></div>
<div className="field"><label htmlFor="confirmPassword">تأكيد كلمة المرور</label><div className="input-wrap"><input className="input" id="confirmPassword" name="confirmPassword" type={showConfirm ? 'text' : 'password'} autoComplete="new-password" minLength={8} required onInvalid={e => e.currentTarget.setCustomValidity('أعد إدخال كلمة المرور.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="confirmPassword" onClick={() => setShowConfirm(v => !v)}>{showConfirm ? 'إخفاء' : 'إظهار'}</button></div></div>
<label className="check"><input id="terms" name="terms" type="checkbox" required /><span>أوافق على <Link href="/privacy">سياسة الخصوصية</Link> و<Link href="/terms">شروط الاستخدام</Link>.</span></label>
<button className="btn btn-primary" type="submit" disabled={busy} data-od-id="create-account-cta">{busy ? 'جارٍ إنشاء الحساب…' : 'أنشئ حسابي'}</button>
<div className="status" id="signupStatus" role="status" aria-live="polite">{status}</div>
<p className="signin">لديك حساب بالفعل؟ <Link href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : '/login'}>سجّل الدخول</Link></p>
</form>
</section>
</main>
</>
)
}
+77
View File
@@ -0,0 +1,77 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--container: 1120px;
--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.75 var(--font-body);text-rendering:optimizeLegibility}
img,svg{display:block;max-width:100%;height:auto}
a{color:inherit;text-decoration:none}
h1,h2,h3{font-family:var(--font-display);text-wrap:balance}
p{text-wrap:pretty}
:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)}
.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}
.logo-mark{width:30px;height:34px;object-fit:contain;flex:none;border-radius:4px}
.nav-links{display:flex;align-items:center;gap:28px}
.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
.nav-links a:hover,.nav-links a[aria-current="page"]{color:var(--fg)}
.hero{padding-block:clamp(70px,10vw,132px);border-bottom:1px solid var(--border)}
.hero-grid{display:grid;grid-template-columns:1.25fr .75fr;gap:clamp(48px,9vw,112px);align-items:end}
.eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 22px}
.hero h1{font-size:clamp(48px,7.5vw,92px);line-height:1.06;max-width:10ch;margin:0}
.lead{font-size:clamp(19px,2vw,23px);line-height:1.75;color:var(--muted);max-width:54ch;margin:28px 0 0}
.hero-note{border-top:1px solid var(--fg);padding-top:24px}
.hero-note strong{display:block;font-size:clamp(24px,3vw,34px);line-height:1.35}
.hero-note p{color:var(--muted);margin:10px 0 0}
.team{padding-block:clamp(64px,9vw,104px);border-bottom:1px solid var(--border)}
.section-head{display:flex;align-items:end;justify-content:space-between;gap:40px;margin-bottom:46px}
.section-head h2{font-size:clamp(38px,5vw,62px);line-height:1.12;max-width:12ch;margin:0}
.section-head p{color:var(--muted);max-width:48ch;margin:0}
.profile-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:42px;border-top:1px solid var(--fg)}
.profile{padding:34px 0 0;display:flex;flex-direction:column;min-width:0}
.profile+.profile{padding-inline-start:42px;border-inline-start:1px solid var(--border)}
.profile-label{font:12px/1.5 var(--font-mono);color:var(--muted)}
.profile-main .profile-label{color:var(--accent)}
.profile h3{font-size:clamp(32px,4vw,48px);line-height:1.15;margin:20px 0 8px}
.role{color:var(--fg);font-weight:700;margin:0 0 22px}
.bio{color:var(--muted);font-size:17px;margin:0;max-width:58ch}
.topics{display:flex;flex-wrap:wrap;gap:8px;margin:28px 0 32px}
.topic{display:inline-flex;align-items:center;min-height:34px;padding:6px 12px;border:1px solid var(--border);color:var(--muted);font-size:13px}
.profile-link{min-height:50px;display:inline-flex;align-items:center;justify-content:space-between;gap:20px;margin-top:auto;padding:12px 16px;border:1px solid var(--fg);font-weight:700}
.profile-link:hover{background:var(--fg);color:var(--surface)}
.principles{padding-block:clamp(64px,9vw,104px)}
.principle-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:clamp(48px,9vw,112px);align-items:start}
.principle-grid h2{font-size:clamp(38px,5vw,60px);line-height:1.14;margin:0;max-width:11ch}
.principle-list{border-top:1px solid var(--fg)}
.principle{display:grid;grid-template-columns:56px 1fr;gap:20px;padding:26px 0;border-bottom:1px solid var(--border)}
.principle-index{font:12px/1.5 var(--font-mono);color:var(--muted);padding-top:7px}
.principle h3{font-size:24px;margin:0 0 8px}
.principle p{color:var(--muted);margin:0}
.pagefoot{padding-block:50px;border-top:1px solid var(--border);color:var(--muted)}
.footer-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;flex-wrap:wrap}
.footer-links{display:flex;gap:20px;flex-wrap:wrap}
.footer-links a{min-height:44px;display:inline-flex;align-items:center}
.footer-links a:hover{color:var(--fg);text-decoration:underline;text-underline-offset:4px}
@media(max-width:920px){
:root{--gutter:22px}
.hero-grid,.profile-grid,.principle-grid{grid-template-columns:1fr}
.profile-grid{gap:0}
.profile+.profile{padding-inline-start:0;border-inline-start:0;border-top:1px solid var(--border);margin-top:42px;padding-top:42px}
.section-head{align-items:start;flex-direction:column}
.hero-note{max-width:560px}
}
@media(max-width:620px){
:root{--gutter:18px}
.hero{padding-block:54px}
.team,.principles{padding-block:58px}
.profile h3{font-size:36px}
.principle{grid-template-columns:42px 1fr}
.footer-bottom,.footer-links{align-items:stretch;flex-direction:column;gap:8px}
}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
@@ -0,0 +1,112 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
}
*,*::before,*::after{box-sizing:border-box} html{scroll-behavior:smooth;-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} body.locked{overflow:hidden}
a{color:inherit;text-decoration:none} button,input{font:inherit} button{cursor:pointer} h1,h2,h3{font-family:var(--font-display);text-wrap:balance} p{text-wrap:pretty} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.nav-tools{display:flex;align-items:center;gap:8px}
.nav-account{min-height:44px;display:inline-flex;align-items:center;gap:10px;padding-inline:16px;border:1px solid var(--fg);background:var(--fg);color:var(--surface);font-weight:700}.nav-account:hover{background:var(--accent);border-color:var(--accent)}
.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}
.icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
.demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
.demo-strip b{color:var(--surface);font-weight:700}
.page-head{padding-block:clamp(48px,7vw,84px) 0}
.crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}
.page-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0}
.page-head .sub{color:var(--muted);margin:16px 0 0;max-width:52ch}
.settings-layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:clamp(40px,7vw,90px);align-items:start;padding-block:56px 90px}
.settings-nav{position:sticky;top:100px;border-top:1px solid var(--fg)}
.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}
.settings-nav button:hover{color:var(--fg)}
.settings-nav button[aria-selected="true"]{color:var(--fg);font-weight:700}
.settings-nav button[aria-selected="true"]::after{content:"";width:8px;height:8px;background:var(--accent);transform:rotate(45deg)}
.panel{display:none}
.panel.active{display:block}
.panel h2{font-size:clamp(30px,3.4vw,42px);line-height:1.2;margin:0 0 10px}
.panel .panel-sub{color:var(--muted);max-width:56ch;margin:0 0 34px}
.form-grid{display:grid;gap:24px;max-width:560px}
.field{display:grid;gap:7px}
.field label{font-size:13px;color:var(--muted)}
.field .hint{font-size:12px;color:var(--muted)}
.input{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;min-height:48px}
.input:hover{border-color:var(--muted)}
.input:focus{outline:2px solid var(--accent);outline-offset:1px}
.input[disabled]{background:var(--fg-soft);color:var(--muted);cursor:not-allowed}
.input.invalid{border-color:var(--accent)}
.btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
.btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}.btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
.status{font-size:14px;min-height:24px;display:flex;align-items:center;gap:8px}
.status.ok{color:var(--accent);font-weight:700}
.status.err{color:var(--accent)}
.pw-rules{list-style:none;padding:0;margin:4px 0 0;display:grid;gap:4px}
.pw-rules li{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px}
.pw-rules li::before{content:"";width:7px;height:7px;border:1px solid var(--muted);transform:rotate(45deg);flex:none}
.pw-rules li.ok{color:var(--fg)}
.pw-rules li.ok::before{background:var(--accent);border-color:var(--accent)}
.prefs{border-top:1px solid var(--fg);max-width:640px}
.pref-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px 0;border-bottom:1px solid var(--border)}
.pref-row strong{display:block;font-size:17px}
.pref-row p{margin:4px 0 0;color:var(--muted);font-size:14px;max-width:44ch}
.switch{position:relative;width:52px;min-height:32px;flex:none}
.switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
.switch .track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none;transition:background .15s,border-color .15s}
.switch input:checked + .track{background:var(--accent);border-color:var(--accent)}
.switch .track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s}
.switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)}
.switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.membership-card{border:1px solid var(--fg);padding:clamp(24px,4vw,38px);max-width:640px}
.membership-head{display:flex;justify-content:space-between;align-items:start;gap:18px;padding-bottom:22px;border-bottom:1px solid var(--border)}
.membership-head h3{font-size:28px;margin:0}
.state-chip{font:11px/1.5 var(--font-mono);padding:6px 12px;border:1px solid var(--accent);color:var(--accent);white-space:nowrap}
.state-chip.cancelled{border-color:var(--muted);color:var(--muted)}
.plan-details{display:grid;grid-template-columns:repeat(2,1fr);gap:20px 32px;padding-block:24px}
.plan-details dt{font:12px/1.6 var(--font-mono);color:var(--muted)}
.plan-details dd{margin:4px 0 0;font-size:16px}
.price-tag{font-family:var(--font-display);font-size:44px;line-height:1}
.price-tag small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:6px}
.membership-actions{display:flex;gap:14px;flex-wrap:wrap;padding-top:22px;border-top:1px solid var(--border)}
.cancel-zone{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
.cancel-zone h3{font-size:22px;margin:0 0 6px}
.cancel-zone p{color:var(--muted);font-size:14px;margin:0 0 16px;max-width:52ch}
.billing{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
.billing h3{font-size:22px;margin:0 0 6px}
.billing-sub{color:var(--muted);font-size:14px;margin:0 0 18px;max-width:52ch}
.billing-table{border:1px solid var(--border)}
.billing-row{display:grid;grid-template-columns:1.1fr 1.5fr .7fr .8fr auto;gap:12px;align-items:center;padding:13px 18px;border-bottom:1px solid var(--border);font-size:14px}
.billing-row:last-child{border-bottom:0}
.billing-row.head{font:11px/1.6 var(--font-mono);color:var(--muted);padding:10px 18px}
.billing-row .amt,.billing-row .bdate{font-family:var(--font-mono);white-space:nowrap}
.billing-row .desc{color:var(--fg)}
.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}
.chip.paid{color:var(--fg);border-color:var(--fg)}
.chip.upcoming{color:var(--accent);border-color:var(--accent)}
.chip.voided{color:var(--muted);border-color:var(--border);text-decoration:line-through}
.inv-link{font:12px/1.6 var(--font-mono);color:var(--fg);text-decoration:underline;text-underline-offset:3px}
.inv-link:hover{color:var(--accent)}
.inv-link:disabled{color:var(--muted);text-decoration:none;cursor:default}
.inv-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.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}
.text-danger:hover{color:color-mix(in oklch,var(--accent) 80%,black)}
.dialog{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.dialog.open{visibility:visible;pointer-events:auto}
.dialog-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 58%,transparent);opacity:0;transition:opacity .2s}
.dialog.open .dialog-scrim{opacity:1}
.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}
.dialog.open .dialog-panel{opacity:1;transform:translateY(-50%)}
.dialog-panel h2{font-size:30px;margin:0 0 12px}
.dialog-panel p{color:var(--muted);margin:0 0 14px}
.dialog-panel .keep-note{border-inline-start:2px solid var(--border);padding-inline-start:14px}
.dialog-actions{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px}
.pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
.footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
.footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
.footer-col a{display:block;min-height:36px}.footer-col a:hover{color:var(--fg);text-decoration:underline}
.footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:flex;flex-direction:column}.menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
@media(max-width:920px){:root{--gutter:22px}.nav-links{display:none}.settings-layout{grid-template-columns:1fr}.settings-nav{position:static;display:grid;grid-template-columns:1fr 1fr;border-top:0;margin-bottom:10px}.settings-nav button{border:1px solid var(--border)}.footer-grid{grid-template-columns:1fr 1fr}}
@media(max-width:620px){:root{--gutter:18px}.nav-account .name-label{display:none}.page-head h1{font-size:44px}.settings-layout{padding-block:40px 60px}.footer-grid{grid-template-columns:1fr;gap:28px}.plan-details{grid-template-columns:1fr}.dialog-actions{flex-direction:column}.dialog-actions .btn{width:100%}.billing-row{grid-template-columns:1fr 1fr;row-gap:2px}.billing-row.head{display:none}.billing-row .bdate{grid-column:1}.billing-row .desc{grid-column:1 / -1;color:var(--muted)}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
@@ -0,0 +1,212 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--fs-h1: clamp(46px, 6.3vw, 82px);
--fs-h2: clamp(32px, 4vw, 52px);
--fs-h3: 22px;
--fs-lead: 19px;
--fs-body: 16px;
--fs-meta: 12px;
--gap-xs: 8px;
--gap-sm: 12px;
--gap-md: 20px;
--gap-lg: 32px;
--gap-xl: 56px;
--gap-2xl: 96px;
--container: 1180px;
--gutter: 32px;
--radius: 2px;
--radius-lg: 2px;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body {
margin: 0;
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;
}
body.modal-open { overflow: hidden; }
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button, input { font: inherit; }
button { cursor: pointer; }
p { text-wrap: pretty; }
h1, h2, h3 { text-wrap: balance; }
: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); }
.section { padding-block: clamp(64px, 9vw, var(--gap-2xl)); }
.section + .section { border-top: 1px solid var(--border); }
.stack { display: flex; flex-direction: column; }
.stack > * + * { margin-top: var(--gap-md); }
.row { display: flex; align-items: center; gap: var(--gap-md); }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--gap-md); }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap-lg); }
.grid-2-1 { display: grid; grid-template-columns: 2fr 1fr; gap: var(--gap-xl); align-items: start; }
.h1, h1 { font-family: var(--font-display); font-size: var(--fs-h1); line-height: 1.08; margin: 0; }
.h2, h2 { font-family: var(--font-display); font-size: var(--fs-h2); line-height: 1.15; margin: 0; }
.h3, h3 { font-family: var(--font-display); font-size: var(--fs-h3); line-height: 1.35; margin: 0; }
.lead { font-size: var(--fs-lead); line-height: 1.7; color: var(--muted); max-width: 54ch; margin: 0; }
.eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--accent); margin: 0 0 var(--gap-md); }
.meta { font-family: var(--font-mono); font-size: var(--fs-meta); color: var(--muted); }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.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: var(--gap-lg); }
.logo { display: inline-flex; align-items: center; gap: 10px; font-family: var(--font-display); font-size: 23px; font-weight: 700; }
.logo-mark { width: 12px; height: 12px; background: var(--fg); transform: rotate(45deg); }
.logo:hover { opacity: .72; }
.topnav nav { display: flex; align-items: center; gap: var(--gap-lg); }
.topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); font-size: 14px; border-bottom: 1px solid transparent; }
.topnav nav a:hover { color: var(--fg); border-color: var(--fg); }
.nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); background: transparent; color: var(--fg); }
.nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
.nav-tools { display: flex; align-items: center; gap: 8px; }
.menu-trigger { width: 46px; height: 46px; display: grid; place-items: center; border: 1px solid var(--fg); background: var(--fg); color: var(--surface); }
.menu-trigger:hover { background: var(--accent); border-color: var(--accent); color: var(--surface); }
.menu-trigger svg { width: 20px; height: 20px; }
.menu-panel { position: fixed; inset: 0; z-index: 60; visibility: hidden; pointer-events: none; }
.menu-panel.open { visibility: visible; pointer-events: auto; }
.menu-scrim { position: absolute; inset: 0; border: 0; background: color-mix(in oklch, var(--fg) 55%, transparent); opacity: 0; transition: opacity .2s ease; }
.menu-panel.open .menu-scrim { opacity: 1; }
.menu-drawer { position: absolute; inset-block: 0; inset-inline-start: 0; width: min(520px, 92vw); padding: 28px clamp(24px, 5vw, 54px); background: var(--bg); border-inline-end: 1px solid var(--fg); transform: translateX(105%); transition: transform .25s ease; display: flex; flex-direction: column; }
.menu-panel.open .menu-drawer { transform: translateX(0); }
.menu-head { display: flex; align-items: center; justify-content: space-between; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
.menu-links { display: grid; margin-top: 34px; }
.menu-links a { min-height: 70px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); font-family: var(--font-display); font-size: clamp(27px, 4vw, 38px); }
.menu-links a:hover { color: var(--accent); }
.menu-foot { margin-top: auto; padding-top: 24px; color: var(--muted); }
.btn { min-height: 46px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 11px 20px; border-radius: var(--radius); border: 1px solid transparent; font-weight: 700; transition: transform .12s ease, background .16s ease, border-color .16s ease; }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); color: var(--surface); border-color: var(--accent); }
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 86%, black); color: var(--surface); }
.btn-secondary { background: transparent; color: var(--fg); border-color: var(--border); }
.btn-secondary:hover { background: var(--fg); color: var(--surface); border-color: var(--fg); }
.btn-ghost { background: transparent; color: var(--fg); border-color: transparent; padding-inline: 8px; }
.btn-ghost:hover { border-color: var(--fg); }
.hero { padding-block: clamp(56px, 8vw, 112px); overflow: hidden; }
.hero-split { display: grid; grid-template-columns: .85fr 1.15fr; gap: clamp(42px, 7vw, 96px); align-items: center; }
.hero-copy h1 { max-width: 9ch; }
.hero-copy .lead { margin-top: 24px; }
.hero-cta { display: flex; gap: var(--gap-sm); flex-wrap: wrap; margin-top: 32px; }
.trust-line { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.deck { position: relative; min-height: 510px; }
.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.active { display: grid; }
.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-kicker { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.deck-slide-index { font-family: var(--font-display); font-size: 30px; }
.deck-slide-body { align-self: center; }
.deck-slide-body h2 { font-size: clamp(38px, 5vw, 66px); max-width: 9ch; }
.deck-slide-body p { color: var(--muted); max-width: 38ch; margin: 18px 0 0; }
.deck-slide-foot { display: flex; align-items: end; justify-content: space-between; gap: 20px; border-top: 1px solid var(--border); padding-top: 18px; }
.deck-slide-format { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; }
.deck-slide-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); }
.deck-slide-link:hover { color: var(--accent); border-color: var(--accent); }
.play { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--fg); border-radius: 50%; }
.deck-controls { min-height: 44px; display: flex; justify-content: space-between; align-items: center; margin-top: 14px; }
.deck-dots { display: flex; gap: 8px; }
.deck-dot { width: 34px; height: 4px; border: 0; padding: 0; background: var(--border); }
.deck-dot.active { background: var(--fg); }
.deck-arrows { display: flex; gap: 8px; direction: ltr; }
.icon-btn { width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid var(--border); background: transparent; color: var(--fg); }
.icon-btn:hover { border-color: var(--fg); background: var(--fg-soft); }
.topic-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-block: 1px solid var(--border); }
.topic { min-height: 132px; padding: 24px; border-inline-start: 1px solid var(--border); display: flex; flex-direction: column; justify-content: space-between; }
.topic:first-child { border-inline-start: 0; }
.topic strong { font-family: var(--font-display); font-size: 22px; }
.topic span { color: var(--muted); font-size: 13px; }
.topic:hover { background: var(--fg-soft); }
.section-head { margin-bottom: 42px; align-items: end; }
.section-head > div { max-width: 54ch; }
.filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 34px; }
.filter { min-height: 44px; padding: 9px 16px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
.filter:hover { color: var(--fg); border-color: var(--fg); }
.filter.active { background: var(--fg); color: var(--surface); border-color: var(--fg); }
.catalog { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 28px; border-top: 1px solid var(--fg); }
.catalog-card { padding-block: 28px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; min-height: 330px; }
.catalog-card[hidden] { display: none; }
.cover { min-height: 142px; padding: 20px; margin-bottom: 22px; background: var(--fg); color: var(--surface); display: flex; flex-direction: column; justify-content: space-between; position: relative; overflow: hidden; }
.cover::before { content: ""; position: absolute; width: 120px; height: 120px; border: 1px solid color-mix(in oklch, var(--surface) 28%, transparent); border-radius: 50%; inset-inline-end: -28px; top: -35px; }
.cover-light { background: var(--border); color: var(--fg); }
.cover-lined { background: repeating-linear-gradient(0deg, var(--surface), var(--surface) 23px, var(--border) 24px); color: var(--fg); border: 1px solid var(--border); }
.cover-label { position: relative; font-family: var(--font-mono); font-size: 10px; }
.cover-title { position: relative; font-family: var(--font-display); font-size: 27px; line-height: 1.2; max-width: 11ch; }
.card-meta { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; }
.catalog-card h3 { font-size: 24px; }
.catalog-card p { color: var(--muted); margin: 9px 0 20px; font-size: 14px; }
.card-link { margin-top: auto; min-height: 44px; display: inline-flex; align-items: center; justify-content: space-between; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--fg); padding: 14px 0 0; font-weight: 700; text-align: start; }
.card-link:hover { border-color: var(--fg); }
.card-link span:last-child { direction: ltr; }
.empty { display: none; padding: 40px 0; color: var(--muted); }
.empty.show { display: block; }
.membership { background: var(--fg); color: var(--surface); }
.membership .eyebrow { color: var(--surface); opacity: .7; }
.membership .lead { color: color-mix(in oklch, var(--surface) 70%, transparent); }
.membership-grid { display: grid; grid-template-columns: 1.35fr .65fr; gap: var(--gap-xl); align-items: end; }
.membership-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid color-mix(in oklch, var(--surface) 25%, transparent); }
.membership-list li { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid color-mix(in oklch, var(--surface) 15%, transparent); }
.membership .btn-primary { background: var(--surface); color: var(--fg); border-color: var(--surface); margin-top: 24px; width: 100%; }
.membership .btn-primary:hover { background: var(--border); color: var(--fg); }
.pagefoot { padding-block: var(--gap-xl); color: var(--muted); font-size: 13px; border-top: 1px solid var(--border); }
.footer-grid { display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: var(--gap-xl); padding-bottom: var(--gap-xl); }
.footer-brand p { max-width: 34ch; margin: 14px 0 0; }
.footer-col strong { display: block; color: var(--fg); margin-bottom: 12px; }
.footer-col a { display: block; min-height: 36px; }
.footer-col a:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 4px; }
.footer-bottom { padding-top: 22px; border-top: 1px solid var(--border); flex-wrap: wrap; }
.modal { position: fixed; inset: 0; z-index: 50; display: none; place-items: center; padding: 24px; background: color-mix(in oklch, var(--fg) 62%, transparent); }
.modal.open { display: grid; }
.modal-panel { width: min(620px, 100%); max-height: min(680px, 90vh); overflow: auto; background: var(--surface); color: var(--fg); border: 1px solid var(--fg); padding: clamp(26px, 5vw, 52px); }
.modal-close { float: left; margin: -14px -14px 20px 0; }
.modal-panel h2 { clear: both; font-size: clamp(32px, 5vw, 50px); }
.modal-panel p { color: var(--muted); }
.modal-outline { margin: 28px 0; padding: 0; list-style: none; border-top: 1px solid var(--border); }
.modal-outline li { padding: 13px 0; border-bottom: 1px solid var(--border); }
@media (max-width: 920px) {
:root { --gutter: 22px; }
.hero-split, .grid-3, .grid-2-1, .membership-grid { grid-template-columns: 1fr; }
.topnav nav { display: none; }
.footer-grid { grid-template-columns: 1fr 1fr; }
.hero-copy h1 { max-width: 11ch; }
.deck { min-height: 440px; }
.deck-frame { min-height: 400px; }
.topic-grid { grid-template-columns: repeat(2, 1fr); }
.topic:nth-child(3) { border-inline-start: 0; border-top: 1px solid var(--border); }
.topic:nth-child(4) { border-top: 1px solid var(--border); }
.catalog { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 620px) {
:root { --gutter: 18px; }
.nav-action { padding-inline: 12px; }
.hero { padding-top: 42px; }
.deck-slide { padding: 26px; }
.deck-slide-body h2 { font-size: 38px; }
.deck-slide-foot { align-items: start; flex-direction: column; }
.topic-grid, .catalog { grid-template-columns: 1fr; }
.topic { min-height: 104px; border-inline-start: 0; border-top: 1px solid var(--border); }
.topic:first-child { border-top: 0; }
.section-head { align-items: start; flex-direction: column; }
.catalog-card { min-height: auto; }
.nav-action { display: none; }
.footer-grid { grid-template-columns: 1fr; gap: 28px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
@@ -0,0 +1,20 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
}
*,*::before,*::after{box-sizing:border-box} html{scroll-behavior:smooth;-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} body.locked{overflow:hidden}
a{color:inherit;text-decoration:none} button,input,textarea{font:inherit} button{cursor:pointer} h1,h2,h3{font-family:var(--font-display);text-wrap:balance} p{text-wrap:pretty} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)} .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.nav-tools{display:flex;align-items:center;gap:8px}.nav-login,.icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.nav-login{padding-inline:16px}.nav-login:hover,.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.icon-btn{width:46px}.menu-trigger{background:var(--fg);color:var(--surface);border-color:var(--fg)}.menu-trigger:hover{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.article-head{padding-block:clamp(58px,9vw,110px);border-bottom:1px solid var(--border)}.crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.access-chip{display:inline-flex;align-items:center;min-height:34px;padding:6px 14px;border:1px solid var(--accent);color:var(--accent);font:12px/1.5 var(--font-mono);margin-bottom:22px}.article-head h1{font-size:clamp(48px,7.4vw,94px);line-height:1.06;max-width:12ch;margin:0}.dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}.article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
.article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}.article-body{font-size:19px;line-height:2}.article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:72px 0 20px}.article-body h3{font-size:27px;margin:42px 0 14px}.article-body p{margin:0 0 26px}.article-body ul{padding-inline-start:24px}.article-body li{margin-block:10px}.lede{font-family:var(--font-display);font-size:28px;line-height:1.6}.code{direction:ltr;text-align:left;background:var(--fg);color:var(--surface);padding:26px;overflow:auto;font:14px/1.8 var(--font-mono);margin:34px 0}.note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}.note strong{display:block;font-family:var(--font-display);font-size:24px;margin-bottom:8px}.steps{counter-reset:step;list-style:none;padding:0}.steps li{counter-increment:step;display:grid;grid-template-columns:52px 1fr;gap:18px;padding:22px 0;border-top:1px solid var(--border)}.steps li::before{content:counter(step,decimal-leading-zero);font:14px/1.7 var(--font-mono);color:var(--accent)}
.toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}.toc strong{font-family:var(--font-display);font-size:20px}.toc a{display:block;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.toc a:hover{color:var(--fg)}.member-note{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}.member-note p{color:color-mix(in oklch,var(--surface) 72%,transparent);font-size:14px}.btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 20px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
.comments{padding-block:80px;border-top:1px solid var(--border)}.comments-wrap{max-width:740px}.comments h2{font-size:clamp(36px,5vw,54px);margin:0}.comment-empty{color:var(--muted);padding:28px 0;border-bottom:1px solid var(--border)}.comment-list{display:grid}.comment{padding:24px 0;border-bottom:1px solid var(--border)}.comment-head{display:flex;justify-content:space-between;gap:16px}.comment p{margin:8px 0 0}.comment-form{display:grid;gap:18px;margin-top:36px}.field{display:grid;gap:7px}.field label{font-size:13px;color:var(--muted)}.input,.textarea{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.textarea{min-height:130px;resize:vertical}.input:focus,.textarea:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.form-status{min-height:24px;color:var(--accent);font-weight:700}
.related{padding-block:80px;border-top:1px solid var(--border)}.related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.related-card{padding:24px 0;border-bottom:1px solid var(--border)}.related-card+.related-card{margin-inline-start:28px}.related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.related-card h3{font-size:24px;margin:12px 0}.related-card a{font-weight:700}.related-card a:hover{color:var(--accent)}
.pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}.footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}.footer-col strong{display:block;color:var(--fg);margin-bottom:12px}.footer-col a{display:block;min-height:36px}.footer-col a:hover{color:var(--fg);text-decoration:underline}.footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:grid;margin-top:34px}.menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.menu-links a:hover{color:var(--accent)}.menu-foot{margin-top:auto;color:var(--muted)}
@media(max-width:920px){:root{--gutter:22px}.nav-links{display:none}.article-layout{grid-template-columns:1fr}.toc{position:static;order:0;margin-top:24px}.footer-grid{grid-template-columns:1fr 1fr}.related-grid{grid-template-columns:1fr}.related-card+.related-card{margin-inline-start:0}.article-body{font-size:18px}}
@media(max-width:620px){:root{--gutter:18px}.nav-login{display:none}.article-head h1{font-size:48px}.article-layout,.comments,.related{padding-block:56px}.footer-grid{grid-template-columns:1fr;gap:28px}.article-meta{align-items:flex-start;flex-direction:column}.steps li{grid-template-columns:38px 1fr}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
+103
View File
@@ -0,0 +1,103 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--container: 1180px;
--gutter: 32px;
}
*, *::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); }
h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; }
.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; }
.foryou { color: var(--accent); font: 11px/1.5 var(--font-mono); }
.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; }
}
@@ -0,0 +1,8 @@
:root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
*,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);min-height:100vh}a{color:inherit;text-decoration:none}button{font:inherit;cursor:pointer}h1,h2{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:20px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}
.verification{min-height:calc(100vh - 70px);display:grid;place-items:center;padding-block:clamp(54px,9vw,110px)}.card{width:min(700px,100%);background:color-mix(in oklch,var(--surface) 92%,transparent);border:1px solid var(--border);padding:clamp(28px,6vw,64px)}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.card h1{font-size:clamp(42px,7vw,72px);line-height:1.08;margin:0;max-width:11ch}.lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:54ch;margin:22px 0 0}.email{display:block;color:var(--fg);font:700 16px/1.6 var(--font-mono);direction:ltr;text-align:right;overflow-wrap:anywhere;margin-top:8px}.steps{list-style:none;margin:36px 0;padding:0;border-top:1px solid var(--fg)}.steps li{display:grid;grid-template-columns:34px 1fr;gap:14px;padding:17px 0;border-bottom:1px solid var(--border)}.step-num{font:12px/1.8 var(--font-mono);color:var(--accent)}.actions{display:flex;gap:12px;flex-wrap:wrap}.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.status{min-height:27px;color:var(--muted);font-size:14px;margin:16px 0 0}.fine{color:var(--muted);font-size:12px;margin:24px 0 0;padding-top:20px;border-top:1px solid var(--border)}
@media(max-width:620px){:root{--gutter:18px}.nav-inner{gap:10px}.logo{font-size:20px}.nav-return{font-size:13px}.verification{align-items:start;padding-block:34px}.card{padding:28px 20px}.actions{align-items:stretch}.actions .btn{width:100%}}
@media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
@@ -0,0 +1,9 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
}
*,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body)}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}
.reset{min-height:calc(100dvh - 142px);display:grid;place-items:center;padding:64px var(--gutter)}.reset-card{width:min(520px,100%);border:1px solid var(--border);background:var(--surface);padding:clamp(28px,6vw,54px)}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.reset-card h1{font-size:clamp(38px,6vw,58px);line-height:1.12;margin:0}.lead{color:var(--muted);font-size:18px;margin:16px 0 30px}.field{display:grid;gap:8px}.field label{font-size:13px;color:var(--muted)}.input{width:100%;min-height:52px;border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:13px 14px}.input:focus{outline:2px solid var(--accent);outline-offset:1px}.btn{width:100%;min-height:50px;margin-top:18px;border:1px solid var(--accent);background:var(--accent);color:var(--surface);font-weight:700;padding:12px 20px}.btn:hover{background:color-mix(in oklch,var(--accent) 86%,black)}.status{min-height:26px;margin:16px 0 0;color:var(--fg)}.back{min-height:44px;display:inline-flex;align-items:center;margin-top:18px;color:var(--muted);text-decoration:underline;text-underline-offset:4px}.back:hover{color:var(--fg)}.pagefoot{padding-block:26px;border-top:1px solid var(--border);color:var(--muted);text-align:center;font-size:13px}
@media(max-width:620px){:root{--gutter:18px}.nav-return{display:none}.reset{padding-block:40px;align-items:start}.reset-card{padding:26px 20px}}
+9
View File
@@ -0,0 +1,9 @@
:root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
*,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}body.locked{overflow:hidden}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1,h2,h3{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}.icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--fg);background:var(--fg);color:var(--surface)}.icon-btn:hover{background:var(--accent);border-color:var(--accent);color:var(--surface)}.nav-tools{display:flex;align-items:center;gap:14px}
.auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:1fr 1fr}.auth-story{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100vw - var(--container))/2 + var(--gutter)));padding-inline-end:clamp(48px,8vw,100px);background:var(--fg);color:var(--surface);display:flex;flex-direction:column;justify-content:space-between}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.auth-story .eyebrow{color:var(--surface);opacity:.68}.auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;max-width:9ch;margin:0}.auth-story>div>p{color:color-mix(in oklch,var(--surface) 68%,transparent);font-size:19px;max-width:40ch}.story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 24%,transparent)}.story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 15%,transparent);display:flex;justify-content:space-between;gap:20px}.story-mark{font:12px/1.5 var(--font-mono);opacity:.58}.story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 58%,transparent)}
.auth-form-wrap{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100vw - var(--container))/2 + var(--gutter)));padding-inline-start:clamp(48px,8vw,100px);display:flex;align-items:center}.auth-form{width:min(440px,100%)}.auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.lead{color:var(--muted);font-size:18px;margin:12px 0 34px}.field{display:grid;gap:7px;margin-top:18px}.field label{font-size:13px;color:var(--muted)}.input-wrap{position:relative}.input{width:100%;min-height:52px;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.form-row{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:18px 0 24px;font-size:13px}.check{display:flex;align-items:center;gap:8px}.form-row a{text-decoration:underline;text-underline-offset:3px}.form-row a:hover{color:var(--accent)}.btn{min-height:50px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.divider{display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px;margin:26px 0}.divider::before,.divider::after{content:"";height:1px;background:var(--border);flex:1}.magic{min-height:50px;width:100%;border:1px solid var(--border);background:transparent;color:var(--fg);font-weight:700}.magic:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}.signup{text-align:center;color:var(--muted);font-size:14px;margin-top:24px}.signup a{color:var(--fg);font-weight:700;text-decoration:underline;text-underline-offset:3px}.status{min-height:25px;color:var(--accent);font-weight:700;margin-top:14px}.fine{font-size:12px;color:var(--muted);margin-top:28px}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:grid;margin-top:34px}.menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.menu-links a:hover{color:var(--accent)}.menu-foot{margin-top:auto;color:var(--muted)}
@media(max-width:920px){:root{--gutter:22px}.auth{grid-template-columns:1fr}.auth-story{min-height:440px;padding:64px var(--gutter)}.auth-form-wrap{padding:64px var(--gutter);justify-content:center}.story-foot{margin-top:54px}}
@media(max-width:620px){:root{--gutter:18px}.nav-return{display:none}.auth-story{min-height:auto}.auth-story h1{font-size:46px}.auth-form-wrap{padding-block:50px}.form-row{align-items:flex-start;flex-direction:column}}
@media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
@@ -0,0 +1,11 @@
:root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
*,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body)}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1,h2,h3{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:20px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-return:hover{color:var(--fg)}
.head{padding-block:clamp(40px,6vw,64px) 34px;border-bottom:1px solid var(--border)}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.head h1{font-size:clamp(40px,5vw,58px);line-height:1.16;margin:0;max-width:15ch}.lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:58ch;margin:16px 0 0}.progress{margin-top:26px;max-width:720px}.progress-copy{display:flex;justify-content:space-between;color:var(--fg);font:12px/1.5 var(--font-mono);margin-bottom:10px}.progress-track{height:6px;background:var(--border)}.progress-bar{height:100%;width:33.333%;background:var(--accent);transition:width .2s ease}
.flow{display:grid;grid-template-columns:220px minmax(0,1fr);gap:clamp(38px,7vw,84px);align-items:start;padding-block:46px 72px}.step-nav{border-top:1px solid var(--fg)}.step-nav div{min-height:58px;display:grid;grid-template-columns:34px 1fr;align-items:center;gap:12px;border-bottom:1px solid var(--border);color:var(--muted)}.step-nav div.active{color:var(--fg);font-weight:700;border-bottom-color:var(--fg)}.step-nav div.done{color:var(--fg)}.step-nav span:first-child{font:12px/1 var(--font-mono)}.step-nav div.active span:first-child{color:var(--accent)}.panel{display:none;max-width:760px}.panel.active{display:block}.panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px}.intro{color:var(--muted);max-width:58ch;margin:0 0 30px}.choices{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.choice{position:relative;display:block}.choice input{position:absolute;opacity:0}.choice-body{min-height:145px;display:flex;flex-direction:column;padding:22px;border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 90%,transparent)}.choice-body:hover{border-color:var(--fg)}.choice input:checked+.choice-body{border-color:var(--fg);background:var(--fg-soft)}.choice input:focus-visible+.choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.choice strong{font-size:19px;padding-inline-end:34px}.choice small{color:var(--muted);font-size:14px;margin-top:8px}.choice-mark{position:absolute;inset-block-start:24px;inset-inline-end:22px;width:18px;height:18px;border:1px solid var(--fg)}.choice input:checked+.choice-body .choice-mark{background:var(--fg);box-shadow:inset 0 0 0 4px var(--surface)}
.benefits{border-top:1px solid var(--fg)}.benefit{display:grid;grid-template-columns:42px 1fr;gap:16px;padding:20px 0;border-bottom:1px solid var(--border)}.benefit-num{font:12px/1.8 var(--font-mono);color:var(--muted)}.benefit h3{font-size:22px;margin:0 0 5px}.benefit p{color:var(--muted);margin:0}.upgrade-note{margin-top:26px;padding:22px;border:1px solid var(--border);background:var(--surface)}.upgrade-note strong{display:block;font-size:18px}.upgrade-note p{color:var(--muted);margin:6px 0 0}.actions{display:flex;gap:12px;align-items:center;margin-top:32px}.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0}.pagefoot{padding-block:42px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}.footer-row{display:flex;justify-content:space-between;gap:18px;flex-wrap:wrap}
@media(max-width:920px){:root{--gutter:22px}.flow{grid-template-columns:1fr}.step-nav{display:grid;grid-template-columns:repeat(3,1fr);border-top:0}.step-nav div{padding-inline:12px;border:1px solid var(--border)}.choices{grid-template-columns:1fr 1fr}}
@media(max-width:620px){:root{--gutter:18px}.logo{font-size:20px}.nav-return{font-size:13px}.head{padding-block:40px 30px}.flow{padding-block:30px 60px;gap:30px}.step-nav{overflow-x:auto;grid-template-columns:repeat(3,minmax(140px,1fr))}.choices{grid-template-columns:1fr}.choice-body{min-height:130px}.actions{align-items:stretch;flex-direction:column-reverse}.actions .btn{width:100%}.footer-row{flex-direction:column}}
@media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
@@ -0,0 +1,45 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--container: 920px;
--gutter: 32px;
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
a{color:inherit;text-decoration:none}
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{width:min(100%,var(--container));margin-inline:auto;padding-inline:var(--gutter)}
.topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)}
.topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700}
.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}
.nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
.nav-link:hover{color:var(--fg)}
.section{padding-block:clamp(56px,9vw,104px)}
.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)}
.status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px}
.status-mark svg{width:28px;height:28px}
.eyebrow{margin:0 0 18px;color:var(--muted);font:12px/1.5 var(--font-mono);}
h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;}
.lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)}
.notice{margin-top:34px;padding-block:22px;border-block:1px solid var(--border)}
.notice h2{margin:0 0 8px;font-size:24px}
.notice p{margin:0;color:var(--muted)}
.actions{display:flex;align-items:center;gap:12px;margin-top:30px}
.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
.btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
.help{margin:24px 0 0;color:var(--muted);font-size:14px}
.help a{text-decoration:underline;text-underline-offset:4px;color:var(--fg)}
.help a:hover{color:var(--accent)}
.pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}
.footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media(max-width:620px){:root{--gutter:18px}.topnav-inner{min-height:64px}.state-card{padding:26px 20px}.actions{align-items:stretch;flex-direction:column}.actions .btn{width:100%}.footer-row{flex-direction:column;gap:8px}}
@@ -0,0 +1,46 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--container: 920px;
--gutter: 32px;
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
a{color:inherit;text-decoration:none}
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{width:min(100%,var(--container));margin-inline:auto;padding-inline:var(--gutter)}
.topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)}
.topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700}
.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}
.nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
.nav-link:hover{color:var(--fg)}
.section{padding-block:clamp(56px,9vw,104px)}
.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)}
.status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px}
.status-mark svg{width:30px;height:30px}
.eyebrow{margin:0 0 18px;color:var(--accent);font:12px/1.5 var(--font-mono);}
h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;}
.lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)}
.summary{margin:34px 0 0;border-top:1px solid var(--fg)}
.summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:17px 0;border-bottom:1px solid var(--border)}
.summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)}
.summary-row dd{margin:0;font-weight:700}
.next-step{margin-top:34px;padding-top:30px;border-top:1px solid var(--border)}
.next-step h2{margin:0 0 8px;font-size:26px}
.next-step p{margin:0;color:var(--muted)}
.actions{display:flex;align-items:center;gap:12px;margin-top:26px}
.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
.btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
.pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}
.footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media(max-width:620px){:root{--gutter:18px}.topnav-inner{min-height:64px}.state-card{padding:26px 20px}.summary-row{grid-template-columns:1fr;gap:4px}.actions{align-items:stretch;flex-direction:column}.actions .btn{width:100%}.footer-row{flex-direction:column;gap:8px}}
@@ -0,0 +1,67 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
}
*,*::before,*::after{box-sizing:border-box} html{scroll-behavior:smooth;-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} body.locked{overflow:hidden}
a{color:inherit;text-decoration:none} button,input,textarea{font:inherit} button{cursor:pointer} h1,h2,h3{font-family:var(--font-display);text-wrap:balance} p{text-wrap:pretty} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.nav-tools{display:flex;align-items:center;gap:8px}.nav-login,.icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.nav-login{padding-inline:16px}.nav-login:hover,.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.icon-btn{width:46px}
.demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
.demo-strip b{color:var(--surface);font-weight:700}
.article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
.crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
.lock-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--fg);padding:6px 14px;font:12px/1.5 var(--font-mono);color:var(--fg);margin-bottom:22px}
.lock-chip svg{width:14px;height:14px}
.article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
.dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
.article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
.article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
.article-body{font-size:19px;line-height:2}
.article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
.article-body p{margin:0 0 26px}
.lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
.note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
.note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
.free-tag{font:11px/1.5 var(--font-mono);color:var(--muted);margin-bottom:6px;display:block}
.lock-cut{position:relative}
.lock-cut::after{content:"";position:absolute;inset-inline:0;bottom:0;height:120px;background:linear-gradient(to bottom,transparent,var(--bg) 88%);pointer-events:none}
.lock-cut .cut-p{margin-bottom:0}
.paywall{margin-top:-30px;position:relative;border:1px solid var(--fg);background:var(--surface);padding:clamp(28px,4vw,44px);z-index:2}
.paywall .crumb{margin-bottom:12px}
.paywall h2{font-size:clamp(30px,3.6vw,44px);line-height:1.18;margin:0 0 14px;max-width:16ch}
.paywall p{color:var(--muted);max-width:56ch;margin:0 0 24px}
.locked-in{border-top:1px solid var(--fg);margin:0 0 26px}
.locked-in li{display:flex;align-items:center;gap:12px;min-height:52px;border-bottom:1px solid var(--border);color:var(--muted);font-size:16px}
.locked-in svg{width:14px;height:14px;flex:none}
.locked-in .num{margin-inline-start:auto;font:12px/1.5 var(--font-mono)}
.paywall-actions{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
.btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}
.btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
.text-link{font-weight:700;color:var(--fg);text-decoration:underline;text-underline-offset:4px;min-height:44px;display:inline-flex;align-items:center}
.text-link:hover{color:var(--accent)}
.price-line{font:12px/1.6 var(--font-mono);color:var(--muted);margin-top:18px}
.toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
.toc strong{font-family:var(--font-display);font-size:20px}
.toc a,.toc .toc-locked{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}
.toc a:hover{color:var(--fg)}
.toc .toc-locked{color:color-mix(in oklch,var(--muted) 60%,transparent)}
.toc .toc-locked svg{width:12px;height:12px;flex:none}
.member-note{margin-top:28px;border:1px solid var(--fg);padding:22px}
.member-note strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:8px}
.member-note p{color:var(--muted);font-size:14px;margin:0 0 16px}
.pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
.footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
.footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
.footer-col a{display:block;min-height:36px}
.footer-col a:hover{color:var(--fg);text-decoration:underline}
.footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:flex;flex-direction:column}.menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
@media(max-width:920px){:root{--gutter:22px}.nav-links{display:none}.article-layout{grid-template-columns:1fr}.toc{position:static;order:0;margin-top:24px}.footer-grid{grid-template-columns:1fr 1fr}.article-body{font-size:18px}}
@media(max-width:620px){:root{--gutter:18px}.nav-login{display:none}.article-head h1{font-size:46px}.article-layout{padding-block:56px}.footer-grid{grid-template-columns:1fr;gap:28px}.paywall-actions{flex-direction:column;align-items:stretch}.paywall-actions .btn{width:100%}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
@@ -0,0 +1,66 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 1180px; --gutter: 32px;
}
*,*::before,*::after{box-sizing:border-box} html{scroll-behavior:smooth;-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} body.locked{overflow:hidden}
a{color:inherit;text-decoration:none} button,input,textarea{font:inherit} button{cursor:pointer} h1,h2,h3{font-family:var(--font-display);text-wrap:balance} p{text-wrap:pretty} :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.nav-tools{display:flex;align-items:center;gap:8px}
.nav-account{min-height:44px;display:inline-flex;align-items:center;gap:10px;padding-inline:16px;border:1px solid var(--fg);background:var(--fg);color:var(--surface);font-weight:700}.nav-account:hover{background:var(--accent);border-color:var(--accent)}
.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}
.icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
.demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
.demo-strip b{color:var(--surface);font-weight:700}
.article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
.crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
.open-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--accent);color:var(--accent);padding:6px 14px;font:12px/1.5 var(--font-mono);margin-bottom:22px}
.article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
.dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
.article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
.save-btn{min-height:40px;padding:6px 14px;border:1px solid var(--border);background:transparent;color:var(--fg);font:12px/1.5 var(--font-mono)}.save-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.save-btn[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
.article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
.article-body{font-size:19px;line-height:2}
.article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
.article-body h3{font-size:27px;margin:42px 0 14px}
.article-body p{margin:0 0 26px}
.article-body ul{padding-inline-start:24px}.article-body li{margin-block:10px}
.lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
.code{direction:ltr;text-align:left;background:var(--fg);color:var(--surface);padding:26px;overflow:auto;font:14px/1.8 var(--font-mono);margin:34px 0}
.note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
.note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
.steps{counter-reset:step;list-style:none;padding:0}.steps li{counter-increment:step;display:grid;grid-template-columns:56px 1fr;gap:18px;padding:22px 0;border-bottom:1px solid var(--border)}.steps li::before{content:"٠" counter(step,arabic-indic);font-family:var(--font-display);font-size:34px;color:var(--accent);line-height:1}
.steps strong{display:block;font-size:20px;margin-bottom:6px}.steps p{margin:0;color:var(--muted)}
.toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
.toc strong{font-family:var(--font-display);font-size:20px}
.toc a{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.toc a:hover{color:var(--fg)}
.progress-card{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}
.progress-card strong{font-size:18px;display:block;margin-bottom:12px}
.progress-track{height:6px;background:color-mix(in oklch,var(--surface) 25%,transparent);margin-bottom:8px}
.progress-bar{height:100%;background:var(--accent);width:40%}
.progress-card .p-label{font:11px/1.6 var(--font-mono);color:color-mix(in oklch,var(--surface) 72%,transparent)}
.files-card{margin-top:18px;border:1px solid var(--fg);padding:22px}
.files-card strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:12px}
.file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)}
.file-row:last-of-type{border-bottom:0}
.file-row span{font-size:14px}
.dl-btn{min-height:38px;padding:4px 12px;border:1px solid var(--border);background:transparent;color:var(--accent);font:12px/1.5 var(--font-mono);font-weight:700}.dl-btn:hover{border-color:var(--accent);background:var(--accent-soft)}
.comments{padding-block:80px;border-top:1px solid var(--border)}.comments-wrap{max-width:740px}.comments h2{font-size:clamp(36px,5vw,54px);margin:0}
.comment-list{display:grid}.comment{padding:24px 0;border-bottom:1px solid var(--border)}.comment-head{display:flex;justify-content:space-between;gap:16px}.comment p{margin:8px 0 0}
.comment-form{display:grid;gap:18px;margin-top:36px}.field{display:grid;gap:7px}.field label{font-size:13px;color:var(--muted)}
.input,.textarea{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px}.textarea{min-height:130px;resize:vertical}
.btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
.status{font-size:14px;color:var(--muted);min-height:22px}
.related{padding-block:80px;border-top:1px solid var(--border)}.related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.related-card{padding:24px 0;border-bottom:1px solid var(--border)}.related-card+.related-card{margin-inline-start:28px}.related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.related-card h3{font-size:24px;margin:12px 0}.related-card a{font-weight:700}.related-card a:hover{color:var(--accent)}
.pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
.footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
.footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
.footer-col a{display:block;min-height:36px}.footer-col a:hover{color:var(--fg);text-decoration:underline}
.footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open{visibility:visible;pointer-events:auto}.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.menu-panel.open .menu-scrim{opacity:1}.menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:flex;flex-direction:column}.menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
@media(max-width:920px){:root{--gutter:22px}.nav-links{display:none}.article-layout{grid-template-columns:1fr}.toc{position:static;order:0;margin-top:24px}.footer-grid{grid-template-columns:1fr 1fr}.related-grid{grid-template-columns:1fr}.related-card+.related-card{margin-inline-start:0}.article-body{font-size:18px}}
@media(max-width:620px){:root{--gutter:18px}.nav-account .name-label{display:none}.article-head h1{font-size:46px}.article-layout,.comments,.related{padding-block:56px}.footer-grid{grid-template-columns:1fr;gap:28px}.article-meta{align-items:flex-start;flex-direction:column}.steps li{grid-template-columns:38px 1fr}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
+11
View File
@@ -0,0 +1,11 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace; --container: 980px; --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.85 var(--font-body)}a{color:inherit;text-decoration:none}h1,h2{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}
.hero{padding-block:clamp(58px,9vw,110px);border-bottom:1px solid var(--border)}.eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.hero h1{font-size:clamp(44px,7vw,78px);line-height:1.08;margin:0;max-width:12ch}.lead{font-size:19px;color:var(--muted);max-width:58ch;margin:24px 0 0}.notice{margin-top:28px;border:1px solid var(--accent);padding:18px 20px;color:var(--fg);max-width:64ch}
.policy{padding-block:72px 90px}.policy section{padding-block:30px;border-top:1px solid var(--border)}.policy h2{font-size:clamp(28px,4vw,40px);margin:0 0 12px}.policy p,.policy li{color:var(--muted)}.policy ul{margin:0;padding-inline-start:24px}.policy a{color:var(--fg);text-decoration:underline;text-underline-offset:4px}.pagefoot{padding-block:46px;border-top:1px solid var(--border);color:var(--muted)}.footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media(max-width:920px){:root{--gutter:22px}.nav-links{display:flex}}
@media(max-width:620px){:root{--gutter:18px}.hero{padding-block:48px}.policy{padding-block:48px 64px}.notice{padding:16px}}
+8
View File
@@ -0,0 +1,8 @@
:root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
*,*::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);color:var(--accent);margin:0 0 20px}.auth-story .eyebrow{color:var(--surface);opacity:.78}.auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;max-width:10ch;margin:0}.auth-story>div>p{color:color-mix(in oklch,var(--surface) 78%,transparent);font-size:19px;max-width:40ch}.story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 32%,transparent)}.story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 22%,transparent);display:flex;justify-content:space-between;gap:20px}.story-mark{font:12px/1.5 var(--font-mono);opacity:.74}.story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 72%,transparent)}
.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}}
@@ -0,0 +1,21 @@
:root{--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
*,*::before,*::after{box-sizing:border-box}html{scroll-behavior:smooth;-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}body.locked{overflow:hidden}a{color:inherit;text-decoration:none}button{font:inherit;cursor:pointer}h1,h2,h3{font-family:var(--font-display);text-wrap:balance}p{text-wrap:pretty}:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)}.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)}.nav-links{display:flex;align-items:center;gap:28px}.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.nav-links a:hover{color:var(--fg)}.nav-tools{display:flex;align-items:center;gap:8px}.nav-login{min-height:44px;display:inline-flex;align-items:center;padding-inline:16px;border:1px solid var(--border)}.nav-login:hover{border-color:var(--fg);background:var(--fg-soft)}.icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--fg);background:var(--fg);color:var(--surface)}.icon-btn:hover{background:var(--accent);border-color:var(--accent);color:var(--surface)}
.hero{padding-block:clamp(72px,11vw,140px);border-bottom:1px solid var(--border)}.hero-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:clamp(48px,9vw,110px);align-items:end}.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.hero .eyebrow{display:block;margin-bottom:34px}.hero h1{font-size:clamp(52px,8vw,102px);line-height:1.08;max-width:10ch;margin:0}.lead{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:52ch;margin:26px 0 0}.hero-note{border-top:1px solid var(--fg);padding-top:24px}.hero-note strong{font-family:var(--font-display);font-size:28px;display:block}.hero-note p{color:var(--muted);margin:8px 0 0}
.benefits{padding-block:90px;border-bottom:1px solid var(--border)}.section-head{display:flex;justify-content:space-between;align-items:end;gap:40px;margin-bottom:50px}.section-head h2{font-size:clamp(38px,5vw,60px);line-height:1.15;margin:0;max-width:13ch}.section-head p{color:var(--muted);max-width:46ch}.benefit-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.benefit{padding:30px 0;border-bottom:1px solid var(--border)}.benefit+.benefit{margin-inline-start:32px}.benefit-index{font:12px/1.5 var(--font-mono);color:var(--accent)}.benefit h3{font-size:28px;margin:26px 0 10px}.benefit p{color:var(--muted);margin:0}
.guide-samples{padding-block:90px;background:var(--surface);border-bottom:1px solid var(--border)}.guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.guide-card{display:flex;flex-direction:column;min-height:440px;border:1px solid var(--border);background:var(--bg)}.guide-cover{min-height:174px;padding:26px;display:flex;flex-direction:column;justify-content:space-between;background:var(--fg);color:var(--surface)}.guide-cover-light{background:var(--surface);color:var(--fg);border-bottom:1px solid var(--fg)}.guide-cover span:first-child{font:11px/1.5 var(--font-mono);}.guide-cover strong{font-family:var(--font-display);font-size:clamp(28px,3vw,40px);line-height:1.1;max-width:9ch}.guide-body{padding:26px;display:flex;flex:1;flex-direction:column}.guide-meta{display:flex;justify-content:space-between;gap:16px;color:var(--muted);font:12px/1.5 var(--font-mono)}.guide-body h3{font-size:25px;margin:24px 0 10px}.guide-body p{color:var(--muted);margin:0}.guide-output{margin:22px 0 0;padding:18px 0;border-block:1px solid var(--border)}.guide-output strong{display:block;font-size:13px;margin-bottom:6px}.guide-output span{color:var(--muted);font-size:14px}.guide-link{min-height:44px;display:flex;align-items:end;justify-content:space-between;margin-top:auto;padding-top:22px;font-weight:700}.guide-link:hover{color:var(--accent)}
.outcomes{padding-block:90px;border-bottom:1px solid var(--border)}.outcome-grid{display:grid;grid-template-columns:.9fr 1.1fr;gap:clamp(48px,9vw,110px);align-items:start}.outcome-grid h2{font-size:clamp(40px,5vw,64px);line-height:1.12;margin:0}.outcome-list{border-top:1px solid var(--fg)}.outcome-card{display:grid;grid-template-columns:72px 1fr;gap:22px;padding:28px 0;border-bottom:1px solid var(--border)}.outcome-card .benefit-index{padding-top:7px}.outcome-card h3{font-size:27px;margin:0 0 8px}.outcome-card p{color:var(--muted);margin:0}.team-contact{margin-top:34px;padding:30px;border:1px solid var(--fg);display:flex;align-items:center;justify-content:space-between;gap:28px}.team-contact h3{font-size:26px;margin:0 0 6px}.team-contact p{color:var(--muted);margin:0;max-width:48ch}.team-contact .btn{flex:0 0 auto}
.pricing{padding-block:90px;background:var(--fg);color:var(--surface)}.pricing-grid{display:grid;grid-template-columns:1fr 1fr;gap:clamp(48px,9vw,110px);align-items:start}.pricing h2{font-size:clamp(40px,6vw,70px);line-height:1.08;margin:0}.pricing .lead{color:color-mix(in oklch,var(--surface) 70%,transparent)}.plan{background:var(--surface);color:var(--fg);padding:clamp(30px,5vw,54px)}.plan-head{display:flex;justify-content:space-between;align-items:start;gap:20px;padding-bottom:26px;border-bottom:1px solid var(--border)}.plan h3{font-size:32px;margin:0}.price{font-family:var(--font-display);font-size:58px;line-height:1;text-align:left}.price small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:8px}.trial-label{display:inline-block;font:11px/1.5 var(--font-mono);color:var(--accent);margin-top:10px}.plan-list{list-style:none;margin:24px 0;padding:0}.plan-list li{display:flex;justify-content:space-between;gap:16px;padding:13px 0;border-bottom:1px solid var(--border)}.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.plan .btn{width:100%}.plan-fine{font-size:12px;color:var(--muted);text-align:center;margin:12px 0 0}
.compare{padding-block:90px;border-bottom:1px solid var(--border)}.compare h2,.faq h2{font-size:clamp(38px,5vw,58px);margin:0 0 40px}.table-wrap{overflow-x:auto}.table{width:100%;border-collapse:collapse;min-width:620px}.table th,.table td{padding:16px 14px;border-bottom:1px solid var(--border);text-align:right}.table th{font:12px/1.5 var(--font-mono);color:var(--muted)}.table td:not(:first-child),.table th:not(:first-child){text-align:center}.member-col{border-inline:1px solid var(--accent)}
.faq{padding-block:90px}.faq-wrap{display:grid;grid-template-columns:.7fr 1.3fr;gap:clamp(46px,8vw,100px)}.faq-list{border-top:1px solid var(--fg)}.faq-item{border-bottom:1px solid var(--border)}.faq-button{width:100%;min-height:68px;border:0;background:transparent;color:var(--fg);display:flex;justify-content:space-between;align-items:center;gap:20px;text-align:right;font-weight:700}.faq-button:hover{color:var(--accent)}.faq-answer{display:none;color:var(--muted);padding:0 0 22px;max-width:64ch}.faq-item.open .faq-answer{display:block}.faq-item.open .faq-button span:last-child{transform:rotate(45deg)}
.pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}.footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}.footer-col strong{display:block;color:var(--fg);margin-bottom:12px}.footer-col a{display:block;min-height:36px}.footer-col a:hover{color:var(--fg);text-decoration:underline}.footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
.menu-panel,.dialog{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.menu-panel.open,.dialog.open{visibility:visible;pointer-events:auto}.menu-scrim,.dialog-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 58%,transparent);opacity:0}.menu-panel.open .menu-scrim,.dialog.open .dialog-scrim{opacity:1}.menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.menu-panel.open .menu-drawer{transform:translateX(0)}.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.menu-links{display:grid;margin-top:34px}.menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.menu-links a:hover{color:var(--accent)}.menu-foot{margin-top:auto;color:var(--muted)}.dialog-panel{position:relative;width:min(560px,calc(100% - 36px));margin:10vh auto 0;background:var(--surface);padding:clamp(30px,5vw,54px);border:1px solid var(--fg)}.dialog-panel h2{font-size:42px;margin:12px 0}.dialog-panel p{color:var(--muted)}.dialog-close{position:absolute;inset-inline-end:18px;top:18px;background:transparent;color:var(--fg);border-color:var(--border)}.dialog-actions{display:flex;gap:10px;margin-top:28px}.dialog-actions .btn{flex:1}.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
@media(max-width:920px){:root{--gutter:22px}.nav-links{display:none}.hero-grid,.pricing-grid,.faq-wrap,.outcome-grid{grid-template-columns:1fr}.benefit-grid,.guide-grid{grid-template-columns:1fr}.benefit+.benefit{margin-inline-start:0}.guide-card{min-height:0}.footer-grid{grid-template-columns:1fr 1fr}.section-head{align-items:start;flex-direction:column}.hero-note{max-width:520px}}
@media(max-width:620px){:root{--gutter:18px}.nav-login{display:none}.hero h1{font-size:50px}.benefits,.guide-samples,.outcomes,.pricing,.compare,.faq{padding-block:60px}.footer-grid{grid-template-columns:1fr;gap:28px}.plan-head,.team-contact{flex-direction:column;align-items:stretch}.team-contact .btn{width:100%}.outcome-card{grid-template-columns:48px 1fr}.price{text-align:right}.dialog-actions{flex-direction:column}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
/* QA-R01/R07: hero join CTA + price on the first fold. */
.hero-join{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:24px}
.hero-join .price{font-size:34px}
.hero-join .price small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:8px}
.cta-pair{display:inline-flex;gap:10px;flex-wrap:wrap;align-items:center}
.trust-line{margin:14px 0 0;color:var(--muted);font-size:14px}
@@ -0,0 +1,122 @@
:root {
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--container: 1180px;
--gutter: 32px;
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
body.locked{overflow:hidden}
a{color:inherit;text-decoration:none}
button,input{font:inherit}
button{cursor:pointer}
h1,h2,h3{font-family:var(--font-display);text-wrap:balance}
p{text-wrap:pretty}
:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
.topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)}
.nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}
.nav-links{display:flex;align-items:center;gap:28px}
.nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
.nav-links a:hover{color:var(--fg)}
.nav-tools{display:flex;align-items:center;gap:8px}
.nav-account{min-height:44px;display:inline-flex;align-items:center;padding-inline:16px;border:1px solid var(--border);font-weight:700}
.nav-account:hover{border-color:var(--fg);background:var(--fg-soft)}
.icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--border);background:transparent;color:var(--fg)}
.icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
.member-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px var(--gutter);text-align:center}
.onboarding-head{padding-block:clamp(48px,7vw,82px) 42px;border-bottom:1px solid var(--border)}
.eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}
.onboarding-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0;max-width:13ch}
.lead{font-size:clamp(18px,2vw,22px);color:var(--muted);max-width:58ch;margin:18px 0 0}
.progress-wrap{margin-top:34px;max-width:720px}
.progress-copy{display:flex;justify-content:space-between;gap:16px;color:var(--muted);font:12px/1.5 var(--font-mono);margin-bottom:10px}
.progress-track{height:4px;background:var(--border);overflow:hidden}
.progress-bar{height:100%;width:25%;background:var(--accent);transition:width .22s ease}
.onboarding-layout{display:grid;grid-template-columns:250px minmax(0,1fr);gap:clamp(42px,7vw,90px);align-items:start;padding-block:50px 90px}
.step-nav{position:sticky;top:100px;border-top:1px solid var(--fg)}
.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}
.step-nav button[aria-current="step"]{color:var(--fg);font-weight:700}
.step-nav button:disabled{cursor:default}
.step-num{font:12px/1 var(--font-mono)}
.step-nav button[aria-current="step"] .step-num{color:var(--accent)}
.step-nav button.done{color:var(--fg)}
.step-panel{display:none;max-width:720px}
.step-panel.active{display:block}
.step-panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px}
.step-intro{color:var(--muted);max-width:58ch;margin:0 0 32px}
.choice-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.choice{position:relative;display:block}
.choice input{position:absolute;opacity:0;pointer-events:none}
.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}
.choice-body:hover{border-color:var(--fg);transform:translateY(-1px)}
.choice input:checked + .choice-body{border-color:var(--fg);background:var(--fg-soft)}
.choice input:focus-visible + .choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.choice-title{font-size:19px;font-weight:700}
.choice-copy{color:var(--muted);font-size:14px;margin-top:8px}
.choice-mark{width:18px;height:18px;border:1px solid var(--muted);margin-top:auto;align-self:flex-end;display:grid;place-items:center}
.choice input:checked + .choice-body .choice-mark{background:var(--fg);border-color:var(--fg)}
.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)}
.notice-card{border-block:1px solid var(--fg);padding-block:8px}
.notice-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 0;border-bottom:1px solid var(--border)}
.notice-row:last-child{border-bottom:0}
.notice-row strong{display:block;font-size:18px}
.notice-row p{color:var(--muted);font-size:14px;margin:4px 0 0;max-width:48ch}
.switch{position:relative;width:52px;min-height:32px;flex:none}
.switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
.track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none}
.track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s}
.switch input:checked + .track{background:var(--fg);border-color:var(--fg)}
.switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)}
.switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
.summary{border-top:1px solid var(--fg)}
.summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:18px 0;border-bottom:1px solid var(--border)}
.summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)}
.summary-row dd{margin:0;font-weight:700}
.start-card{margin-top:28px;padding:26px;border:1px solid var(--fg);background:var(--surface)}
.start-card h3{font-size:26px;margin:0 0 8px}
.start-card p{color:var(--muted);margin:0 0 22px;max-width:52ch}
.actions{display:flex;align-items:center;gap:12px;margin-top:34px}
.btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
.btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
.btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
.btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
.btn[disabled]{opacity:.45;cursor:not-allowed;transform:none}
.status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0}
.pagefoot{padding-block:48px;border-top:1px solid var(--border);color:var(--muted)}
.footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;font-size:13px}
.menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}
.menu-panel.open{visibility:visible;pointer-events:auto}
.menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--bg) 72%,transparent)}
.menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}
.menu-panel.open .menu-drawer{transform:translateX(0)}
.menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}
.menu-links{display:flex;flex-direction:column;margin-top:24px}
.menu-links a{min-height:60px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-size:18px}
.menu-links a:hover{color:var(--fg);background:var(--fg-soft)}
@media(max-width:920px){
:root{--gutter:22px}
.onboarding-layout{grid-template-columns:1fr;padding-block:34px 70px}
.step-nav{position:static;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));overflow-x:auto;border-top:0;padding-bottom:4px}
.step-nav button{min-width:150px;border:1px solid var(--border);padding-inline:14px}
.choice-grid{grid-template-columns:1fr 1fr}
}
@media(max-width:620px){
:root{--gutter:18px}
.member-strip{text-align:right}
.onboarding-head{padding-block:42px 32px}
.onboarding-head h1{font-size:44px}
.choice-grid{grid-template-columns:1fr}
.choice-body{min-height:132px}
.notice-row{align-items:flex-start}
.summary-row{grid-template-columns:1fr;gap:4px}
.actions{align-items:stretch;flex-direction:column-reverse}
.actions .btn{width:100%}
.start-card{padding:22px}
.footer-bottom{flex-direction:column}
}
@media(prefers-reduced-motion:reduce){*{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>+</span>
</button>
<div className="faq-answer">{item.a}</div>
</article>
)
})}
</div>
)
}
@@ -0,0 +1,157 @@
import { getPayload } from 'payload'
import { headers } from 'next/headers'
import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
import Footer from '@/components/footer'
import SubscribeButton from './subscribe-button'
import Faq from './faq'
import configPromise from '@payload-config'
import '../styles/subscription.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'العضوية' }
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'
return (
<>
<Topnav
viewer={user ? { email: user.email } : null}
menu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'المقالات' },
{ href: '#exclusive-guides', label: 'الأدلة الحصرية' },
{ href: '#team-contact', label: 'تواصل مباشر' },
]}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'مقال الأسبوع' },
{ href: '/subscription#exclusive-guides', label: 'الأدلة الحصرية' },
{ href: '/subscription#team-contact', label: 'تواصل مع الفريق' },
]}
/>
<main>
<section className="hero" data-od-id="subscription-hero"><div className="container hero-grid">
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="hero-logo" src="/anwarpi-logo.jpg" alt="شعار أنور بي آي" width={1200} height={1352} data-od-id="hero-logo" />
<p className="eyebrow">عضوية أنور بي آي · للجادّين في البناء</p>
<h1 data-od-id="subscription-title">تعلّم أقل. طبّق أكثر.</h1>
<p className="lead">أدلة معمّقة، مسارات فيديو، وملفات عمل تختصر الطريق من الفكرة إلى مشروع يمكنك اختباره.</p>
<div className="hero-join" data-od-id="hero-join">
<span data-od-id="subscribe-cta-hero"><SubscribeButton loggedIn={Boolean(user)} /></span>
<span className="price num"><bdi dir="ltr">10$</bdi><small>شهرياً · ألغِ في أي وقت</small></span>
</div>
<p className="trust-line" data-od-id="hero-trust">١٣ دليلاً عربياً · ملفات عمل جاهزة للتنزيل · تحديثات شهرية</p>
</div>
<div className="hero-note"><strong>لا دورات متراكمة.</strong><p>كل مادة تنتهي بخطوة عملية، وقرار واضح، وملف يمكنك استخدامه في مشروعك.</p></div>
</div></section>
<section className="benefits" id="benefits" data-od-id="membership-benefits"><div className="container">
<div className="section-head"><div><p className="eyebrow">محتوى حصري بمستوى متقدم</p><h2>تعلّم مهارات ترفع قيمتك في السوق.</h2></div><p>أدلة عالية المستوى لا تكتفي بشرح الأدوات؛ تساعدك على بناء خبرة عملية تظهر في عملك، مسارك المهني، أو مشروعك التجاري.</p></div>
<div className="benefit-grid">
<article className="benefit" data-od-id="benefit-guides"><span className="benefit-index">01</span><h3>أدلة حصرية عميقة</h3><p>تفكيك عملي للأنظمة الوكيلة، الاسترجاع، الأتمتة، وتشغيل النماذج حتى تفهم كيف تُبنى الحلول فعلاً.</p></article>
<article className="benefit" data-od-id="benefit-video"><span className="benefit-index">02</span><h3>مهارات تصنع فرقاً</h3><p>مسارات مركّزة تنقلك من مستخدم للأداة إلى شخص قادر على تصميم حلول ذكاء اصطناعي ذات قيمة.</p></article>
<article className="benefit" data-od-id="benefit-files"><span className="benefit-index">03</span><h3>تنفيذ يصلح للعمل</h3><p>قوالب وملفات عملية تساعدك على تحويل المعرفة إلى مشروع، إنجاز مهني، أو فرصة نمو في أعمالك.</p></article>
</div>
</div></section>
<section className="guide-samples" id="exclusive-guides" data-od-id="exclusive-guide-samples"><div className="container">
<div className="section-head"><div><p className="eyebrow">نماذج من مكتبة الأعضاء</p><h2>أدلة حقيقية تبدأ من المشكلة وتنتهي بملف عملي.</h2></div><p>هذه مواد موجودة في كتالوج أنور بي آي؛ كل نموذج يوضح ما ستتعلمه وما ستخرج به قبل الاشتراك.</p></div>
<div className="guide-grid">
<article className="guide-card" data-od-id="exclusive-guide-agent">
<div className="guide-cover"><span>دليل عملي · حصري</span><strong>تشريح وكيل ناجح</strong></div>
<div className="guide-body"><div className="guide-meta"><span>الوكلاء</span><span className="num">١٨ دقيقة</span></div><h3>امنع وكيلك من الدوران في حلقة</h3><p>افصل التخطيط والتنفيذ والتحقق، وحدد متى يتوقف الوكيل ومتى يطلب تدخلاً بشرياً.</p><div className="guide-output"><strong>الملف المرفق</strong><span>قالب حالة الوكيل + قائمة فحص قبل التشغيل</span></div><a className="guide-link" href="/posts/agent-loop-stop-condition"><span>اقرأ نموذجاً كاملاً</span><span aria-hidden="true">←</span></a></div>
</article>
<article className="guide-card" data-od-id="exclusive-guide-automation">
<div className="guide-cover guide-cover-light"><span>فيديو تطبيقي · حصري</span><strong>راقب قبل أن تؤتمت</strong></div>
<div className="guide-body"><div className="guide-meta"><span>الأتمتة</span><span className="num">٢٤ دقيقة</span></div><h3>اكتشف فشل سير العمل قبل المستخدم</h3><p>راقب التكلفة، زمن الاستجابة، ونقاط الانقطاع بإشارات قليلة يمكن لفريقك فهمها والتصرف بناءً عليها.</p><div className="guide-output"><strong>الملف المرفق</strong><span>قائمة مراقبة لسير العمل + نموذج سجل الأخطاء</span></div><a className="guide-link" href="/articles?topic=automation"><span>شاهد تفاصيل الدليل</span><span aria-hidden="true">←</span></a></div>
</article>
<article className="guide-card" data-od-id="exclusive-guide-rag">
<div className="guide-cover"><span>سلسلة فيديو · حصري</span><strong>قياس <bdi dir="ltr">RAG</bdi> بصدق</strong></div>
<div className="guide-body"><div className="guide-meta"><span>النماذج</span><span className="num">٤ أجزاء</span></div><h3>افصل فشل الاسترجاع عن فشل الإجابة</h3><p>ابنِ مجموعة اختبار صغيرة، راجع جودة المصادر، ثم قِس الإجابة من دون خلط المشكلتين.</p><div className="guide-output"><strong>الملف المرفق</strong><span>ورقة تقييم للاسترجاع والإجابة خطوة بخطوة</span></div><a className="guide-link" href="/articles?topic=models"><span>استكشف السلسلة</span><span aria-hidden="true">←</span></a></div>
</article>
</div>
</div></section>
<section className="outcomes" data-od-id="career-business-outcomes"><div className="container outcome-grid">
<div>
<p className="eyebrow">من المعرفة إلى التأثير</p>
<h2>كن الشخص الذي يعرف كيف يحوّل الذكاء الاصطناعي إلى نتيجة.</h2>
<p className="lead">ابنِ مهارات مطلوبة، قرارات أفضل، ومشاريع يمكن عرضها أو تشغيلها—سواء كنت تطوّر مسارك أو تنمّي أعمالك.</p>
</div>
<div className="outcome-list">
<article className="outcome-card" data-od-id="outcome-career"><span className="benefit-index">01</span><div><h3>لمسارك المهني</h3><p>تعلّم كيف تصمم الوكلاء والأتمتة وتشرح قراراتك التقنية، ثم حوّل ذلك إلى أعمال قوية ترفع مستوى ملفك المهني.</p></div></article>
<article className="outcome-card" data-od-id="outcome-business"><span className="benefit-index">02</span><div><h3>لنمو أعمالك</h3><p>اكتشف أين يضيف الذكاء الاصطناعي قيمة حقيقية، وكيف تختبر الفكرة بسرعة قبل أن تهدر الوقت أو الميزانية.</p></div></article>
<article className="team-contact" id="team-contact" data-od-id="anwar-team-contact">
<div><h3>اسأل فريق أنور عنبتاوي مباشرة</h3><p>لديك سؤال عن دليل، تطبيق، مسار مهني، أو فكرة عمل؟ أرسل ما تحتاجه وسيرد عليك الفريق مباشرة.</p></div>
<a className="btn btn-primary" href="mailto:hello@anwarpi.ai?subject=%D8%B3%D8%A4%D8%A7%D9%84%20%D9%84%D9%81%D8%B1%D9%8A%D9%82%20%D8%A3%D9%86%D9%88%D8%B1%20%D8%B9%D9%86%D8%A8%D8%AA%D8%A7%D9%88%D9%8A" data-od-id="contact-team-cta">تواصل مع الفريق</a>
</article>
</div>
</div></section>
<section className="pricing" data-od-id="pricing"><div className="container pricing-grid">
<div>
<p className="eyebrow">سعر واحد · وصول كامل</p>
<h2>ابدأ بعضوية أنور بي آي.</h2>
<p className="lead">ألغِ في أي وقت. يبقى حسابك المجاني ومقالاتك المحفوظة بعد الإلغاء.</p>
</div>
<article className="plan" data-od-id="membership-plan">
<div className="plan-head"><div><h3>عضوية فردية</h3><span className="trial-label">سعر تجريبي للمعاينة</span></div><div className="price num"><bdi dir="ltr">10$</bdi><small>شهرياً</small></div></div>
<ul className="plan-list">
<li><span>كل الأدلة الحصرية</span><strong>متاح</strong></li>
<li><span>مسارات الفيديو</span><strong>متاح</strong></li>
<li><span>ملفات العمل والتنزيلات</span><strong>متاح</strong></li>
<li><span>تحديثات المحتوى</span><strong>مستمرة</strong></li>
</ul>
{entitled ? (
<a className="btn btn-primary" href="/account-settings" data-od-id="subscribe-cta">عضويتك فعّالة — إدارة الحساب</a>
) : (
<span data-od-id="subscribe-cta"><SubscribeButton loggedIn={Boolean(user)} /></span>
)}
<p className="plan-fine">الدفع عبر Stripe · إلغاء فوري في أي وقت.</p>
</article>
</div></section>
<section className="compare" data-od-id="plan-comparison"><div className="container">
<p className="eyebrow">قارن الوصول</p>
<h2>المجاني يعرّفك. العضوية تساعدك على البناء.</h2>
<div className="table-wrap"><table className="table">
<thead><tr><th>المحتوى</th><th>حساب مجاني</th><th className="member-col">عضوية أنور بي آي</th></tr></thead>
<tbody>
<tr><td>المقالات التمهيدية</td><td>كاملة</td><td className="member-col">كاملة</td></tr>
<tr><td>الأدلة المتقدمة</td><td>معاينة</td><td className="member-col">كاملة</td></tr>
<tr><td>مسارات الفيديو</td><td>المقدمة</td><td className="member-col">كل الأجزاء</td></tr>
<tr><td>ملفات العمل</td><td>—</td><td className="member-col">قابلة للتنزيل</td></tr>
<tr><td>حفظ التقدّم</td><td>متاح</td><td className="member-col">متاح</td></tr>
</tbody>
</table></div>
</div></section>
<section className="faq" id="faq" data-od-id="faq"><div className="container faq-wrap">
<div>
<p className="eyebrow">قبل أن تبدأ</p>
<h2>أسئلة شائعة</h2>
<p className="lead">إجابات مباشرة عن الوصول، الإلغاء، وطريقة تقديم المحتوى.</p>
</div>
<Faq items={FAQ_ITEMS} />
</div></section>
</main>
<Footer />
</>
)
}
@@ -0,0 +1,45 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
const [pending, setPending] = useState(false)
const [comingSoon, setComingSoon] = useState(false)
async function subscribe() {
setPending(true)
const res = await fetch('/api/subscribe', { method: 'POST' })
if (res.ok) {
const data = await res.json()
if (data?.url) {
window.location.href = data.url as string
return
}
}
// Stripe pending mode (no keys configured) or failure
setComingSoon(true)
setPending(false)
}
if (comingSoon) {
return <button className="btn btn-primary" disabled>قريباً</button>
}
if (!loggedIn) {
/* QA-R01/R07: anonymous visitors get explicit signup + login CTAs instead
of a silent redirect. Both preserve /subscription as the destination. */
return (
<span className="cta-pair">
<Link className="btn btn-primary" href="/signup?next=%2Fsubscription">أنشئ حساباً واشترك</Link>
<Link className="btn btn-secondary" href="/login?next=%2Fsubscription">لدي حساب — تسجيل الدخول</Link>
</span>
)
}
return (
<button className="btn btn-primary" onClick={subscribe} disabled={pending}>
{pending ? 'جارٍ التحويل…' : 'اشترك الآن'}
</button>
)
}
+51
View File
@@ -0,0 +1,51 @@
import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
import '../styles/privacy.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'شروط الاستخدام' }
export default function TermsPage() {
return (
<>
<Topnav
viewer={null}
menu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/subscription', label: 'العضوية' },
{ href: '/terms', label: 'الشروط' },
]}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'مقال الأسبوع' },
{ href: '/subscription', label: 'العضوية والأسعار' },
]}
foot={{ href: '/login', label: '', note: 'محتوى عربي عملي، بلا ضجيج.', variant: 'hidden' }}
/>
<main>
<section className="hero" data-od-id="terms-hero">
<div className="container">
<p className="eyebrow">شروط الاستخدام · سارية من ٢٠٢٦-١٠-٠٣</p>
<h1 data-od-id="terms-title">شروط استخدام أنور بي آي.</h1>
<p className="lead">توضح هذه المسودة القواعد الأساسية لاستخدام مكتبة أنور بي آي والاشتراك فيها.</p>
<p className="notice" role="note">هذه مسودة للمراجعة القانونية وليست نصاً قانونياً نهائياً. يجب اعتماد النسخة النهائية قبل نشر الموقع.</p>
</div>
</section>
<div className="container policy" data-od-id="terms-content">
<section data-od-id="terms-service"><h2>ما هي الخدمة؟</h2><p>أنور بي آي مكتبة محتوى عربية تقدم أدلة ومسارات تعليمية، بعضها مجاني وبعضها متاح لمشتركي العضوية. يمنحك الاشتراك وصولاً شخصياً إلى المحتوى المدفوع طوال فترة الاشتراك الفعّالة.</p></section>
<section data-od-id="terms-account"><h2>حسابك</h2><p>أنت مسؤول عن دقة بيانات التسجيل والاحتفاظ بكلمة المرور في سرية، وعن أي نشاط يجري عبر حسابك. يمكنك حذف حسابك في أي وقت بمراسلة <a href="mailto:hello@anwarpi.ai">hello@anwarpi.ai</a>، وسنحذف بياناتك وفق سياسة الخصوصية.</p></section>
<section data-od-id="terms-billing"><h2>الاشتراك والدفع</h2><p>العضوية بـ ١٠$ شهرياً وتُدفع عبر مزوّد الدفع (Stripe). يمكنك الإلغاء في أي وقت ويتوقف التجديد التالي فوراً مع بقاء وصولك حتى نهاية الفترة المدفوعة. السعر الحالي سعر تجريبي للمعاينة وقد يتغير قبل الإطلاق مع إشعار مسبق.</p></section>
<section data-od-id="terms-content"><h2>المحتوى والملكية</h2><p>حقوق المحتوى والملفات محفوظة لأصحابها. حصولك على الاشتراك يمنحك حق الاستخدام الشخصي والمهني داخل مشاريعك، ولا يشمل إعادة نشر المحتوى أو بيعه أو توزيعه كما هو.</p></section>
<section data-od-id="terms-conduct"><h2>الاستخدام المقبول</h2><p>لا يجوز استخدام الخدمة لاستخراج البيانات آلياً أو إعادة نشر المحتوى بحشد أو أي استخدام يضر بالمنصة أو بمستخدميها. قد نوقف الحسابات المخالفة بعد تنبيه صاحبها.</p></section>
<section data-od-id="terms-disclaimer"><h2>إخلاء المسؤولية</h2><p>المحتوى تعليمي ويُقدَّم «كما هو» دون ضمانات لنتائج محددة. أنت مسؤول عن قراراتك ومشاريعك، وينبغي اختبار أي قالب أو ملف قبل استخدامه في بيئة إنتاج.</p></section>
<section data-od-id="terms-changes"><h2>تعديل الشروط</h2><p>قد نحدّث هذه الشروط مع نمو الخدمة. عند أي تغيير جوهري سنبلّغ المستخدمين المسجلين عبر البريد الإلكتروني قبل سريان التعديل، ونحدّث تاريخ السريان أعلى الصفحة.</p></section>
<section data-od-id="terms-law"><h2>التواصل</h2><p>لأي سؤال عن هذه الشروط راسلنا على <a href="mailto:hello@anwarpi.ai">hello@anwarpi.ai</a>. القانون الحاكم واختصاص المحاكم يحدَّدان في النسخة القانونية النهائية بعد المراجعة.</p></section>
</div>
</main>
<footer className="pagefoot" data-od-id="footer"><div className="container footer-bottom"><span>© أنور بي آي ٢٠٢٦</span><a href="mailto:hello@anwarpi.ai">تواصل معنا</a></div></footer>
</>
)
}
@@ -0,0 +1,66 @@
import { getPayload } from 'payload'
import { headers } from 'next/headers'
import configPromise from '@payload-config'
import Link from 'next/link'
import VerifyActions from './verify-actions'
import '../styles/email-verification.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'أكد بريدك' }
type Props = { searchParams: Promise<{ invalid?: string }> }
/* Email verification step — design from email-verification.html. The account
was created; the user either clicks the emailed link (real SMTP at deploy)
or proceeds to onboarding via the CTA. */
export default async function VerifyEmailPage({ searchParams }: Props) {
const { invalid } = await searchParams
let email: string | null = null
let verified = false
try {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (user) {
email = user.email
verified = !!user.emailVerified
}
} catch {
/* no db / no session — page still renders */
}
return (
<>
<header className="topnav" data-od-id="topnav">
<div className="container nav-inner">
<Link className="logo" href="/" data-od-id="brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-mark" src="/anwarpi-logo.jpg" alt="" width={30} height={34} />
أنور بي آي
</Link>
<Link className="nav-return" href="/login" data-od-id="login-link">سجّل الدخول</Link>
</div>
</header>
<main className="verification">
<section className="container" data-od-id="email-verification">
<div className="card">
<p className="eyebrow">الخطوة التالية · تأكيد البريد</p>
<h1 data-od-id="verification-title">افتح بريدك لتفعيل الحساب.</h1>
<p className="lead">أرسلنا رابط التأكيد إلى:<strong className="email" id="signupEmail">{email ?? 'بريدك الإلكتروني'}</strong></p>
{invalid === '1' && (
<p className="status" role="alert">رابط التأكيد غير صالح أو استُخدم من قبل. اطلب رسالة جديدة.</p>
)}
{verified && (
<p className="status" role="status">بريدك مؤكَّد بالفعل ✓ — تابع إلى الإعداد.</p>
)}
<ol className="steps" aria-label="خطوات تأكيد البريد">
<li><span className="step-num">٠١</span><span>افتح رسالة أنور بي آي في صندوق الوارد.</span></li>
<li><span className="step-num">٠٢</span><span>اضغط رابط تأكيد البريد داخل الرسالة.</span></li>
<li><span className="step-num">٠٣</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
</ol>
<VerifyActions verified={verified} />
</div>
</section>
</main>
</>
)
}
@@ -0,0 +1,38 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
/* Resend button + status — real /api/auth/resend-verification endpoint.
Without SMTP configured the API answers 503 with a clear message. */
export default function VerifyActions({ verified }: { verified: boolean }) {
const [status, setStatus] = useState('')
return (
<>
<div className="actions">
<Link className="btn btn-primary" href="/onboarding" data-od-id="continue-onboarding-cta">تم التأكيد، تابع الإعداد</Link>
{!verified && (
<button className="btn btn-secondary" id="resendButton" type="button" data-od-id="resend-email-button"
onClick={async (e) => {
const button = e.currentTarget
button.disabled = true
setStatus('جارٍ الإرسال…')
const res = await fetch('/api/auth/resend-verification', { method: 'POST' })
const data = await res.json().catch(() => null)
if (res.ok || data?.already) {
setStatus('أرسلنا الرسالة من جديد — افحص صندوق الوارد.')
} else {
setStatus(data?.error || 'تعذّر الإرسال، جرّب بعد قليل.')
}
setTimeout(() => { button.disabled = false }, 3000)
}}
>
إعادة إرسال الرسالة
</button>
)}
</div>
<p className="status" id="resendStatus" role="status" aria-live="polite">{status}</p>
</>
)
}
@@ -0,0 +1,197 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
/* 4-step VIP onboarding wizard — port of vip-onboarding.html step logic
(radio choice-grid, next/back, final summary + recommendation).
Selections persist in localStorage (faithful to the simulated original). */
const GOALS = [
{ value: 'بناء مشروع ذكاء اصطناعي', copy: 'حوّل فكرة إلى نموذج يعمل ويمكن اختباره مع مستخدمين.' },
{ value: 'تطوير مساري المهني', copy: 'ابنِ خبرة عملية ومشروعات تشرح قيمتك بوضوح.' },
{ value: 'أتمتة العمل', copy: 'حدد مهمة متكررة وابنِ لها سير عمل يمكن مراقبته.' },
{ value: 'فهم الوكلاء بعمق', copy: 'صمم التخطيط والتنفيذ والتحقق وحدود التوقف بأمان.' },
]
const LEVELS = [
{ value: 'مبتدئ', label: 'أبدأ من الأساسيات', copy: 'استخدمت أدوات الذكاء الاصطناعي، لكنني لم أبنِ مشروعاً بعد.' },
{ value: 'متوسط', label: 'بنيت تجارب بسيطة', copy: 'جرّبت واجهات API أو أتمتة أو تطبيقاً صغيراً.' },
{ value: 'متقدم', label: 'أعمل على أنظمة فعلية', copy: 'أحتاج أنماطاً أعمق للقياس والموثوقية والتشغيل.' },
]
const LABELS = ['الخطوة ١ من ٤', 'الخطوة ٢ من ٤', 'الخطوة ٣ من ٤', 'الخطوة ٤ من ٤']
const PERCENTAGES = ['٢٥٪', '٥٠٪', '٧٥٪', '١٠٠٪']
const STEP_TITLES = ['هدفك الآن', 'مستواك العملي', 'رسائلك', 'ابدأ مسارك']
type State = {
goal: string
level: string
weekly: boolean
trackUpdates: boolean
replies: boolean
step: number
}
function recommendation(goal: string): [string, string, string] {
if (goal === 'أتمتة العمل') return ['راقب قبل أن تؤتمت', 'ابدأ بتحديد إشارات النجاح والفشل قبل بناء سير العمل، ثم استخدم قائمة المراقبة في أول تجربة.', '/articles?topic=automation']
if (goal === 'بناء مشروع ذكاء اصطناعي') return ['من الفكرة إلى نموذج قابل للاختبار', 'اختر مشكلة واحدة ومستخدمًا واحدًا، ثم حوّل الفرضية إلى نموذج صغير يمكن اختباره هذا الأسبوع.', '/posts/prototype-to-production']
if (goal === 'تطوير مساري المهني') return ['تشريح وكيل ناجح', 'ابنِ نموذجاً صغيراً يوضح التخطيط والتنفيذ والتحقق، ثم وثّق قراراتك كدراسة حالة.', '/posts/agent-loop-stop-condition']
return ['تشريح وكيل ناجح', 'ابدأ بفصل التخطيط عن التنفيذ والتحقق، ثم استخدم قالب حالة الوكيل في مشروع صغير.', '/posts/agent-loop-stop-condition']
}
export default function OnboardingWizard({ viewerId }: { viewerId: number | null }) {
const [state, setState] = useState<State>({ goal: '', level: '', weekly: true, trackUpdates: true, replies: true, step: 0 })
const [goalStatus, setGoalStatus] = useState('')
const [levelStatus, setLevelStatus] = useState('')
const [restored, setRestored] = useState(false)
/* Backend persistence — goal/level/prefs live on the user record. */
useEffect(() => {
if (!viewerId || !state.goal || !state.level) return
fetch(`/api/users/${viewerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
goal: state.goal,
level: state.level,
prefs: { weekly: state.weekly, trackUpdates: state.trackUpdates, replies: state.replies },
}),
}).catch(() => null)
}, [viewerId, state.goal, state.level, state.weekly, state.trackUpdates, state.replies])
useEffect(() => {
try {
const raw = localStorage.getItem('anwarpi:vip-onboarding')
if (raw) {
const s = JSON.parse(raw) as Partial<State>
setState(prev => ({
...prev,
goal: s.goal || '',
level: s.level && LEVELS.some(l => l.value === s.level) ? s.level : '',
weekly: typeof s.weekly === 'boolean' ? s.weekly : true,
trackUpdates: typeof s.trackUpdates === 'boolean' ? s.trackUpdates : true,
replies: typeof s.replies === 'boolean' ? s.replies : true,
step: Math.max(0, Math.min(3, Number(s.step) || 0)),
}))
}
} catch { /* ignore */ }
setRestored(true)
}, [])
useEffect(() => {
if (!restored) return
try { localStorage.setItem('anwarpi:vip-onboarding', JSON.stringify(state)) } catch { /* ignore */ }
}, [state, restored])
function next() {
if (state.step === 0 && !state.goal) { setGoalStatus('اختر هدفاً واحداً للمتابعة.'); return }
if (state.step === 1 && !state.level) { setLevelStatus('اختر مستوى واحداً للمتابعة.'); return }
setState(s => ({ ...s, step: Math.min(3, s.step + 1) }))
}
const notices = [
state.weekly && 'خلاصة الأحد',
state.trackUpdates && 'تحديثات المسار',
state.replies && 'الردود',
].filter(Boolean) as string[]
const rec = recommendation(state.goal)
return (
<>
<section className="onboarding-head" data-od-id="onboarding-hero">
<div className="container">
<p className="eyebrow">عضو VIP جديد · إعداد سريع</p>
<h1 data-od-id="onboarding-title">مرحباً بك. لنبدأ بما يخدم هدفك.</h1>
<p className="lead">أجب عن سؤالين وحدد رسائلك المفضلة. سنقترح نقطة بداية واضحة بدلاً من تركك أمام مكتبة كبيرة.</p>
<div className="progress-wrap" aria-label="تقدم الإعداد">
<div className="progress-copy"><span id="progressLabel">{LABELS[state.step]}</span><span id="progressPercent">{PERCENTAGES[state.step]}</span></div>
<div className="progress-track"><div className="progress-bar" id="progressBar" style={{ width: `${(state.step + 1) * 25}%` }}></div></div>
</div>
</div>
</section>
<section className="container onboarding-layout" data-od-id="onboarding-flow">
<nav className="step-nav" aria-label="خطوات إعداد العضوية" data-od-id="onboarding-steps">
{STEP_TITLES.map((title, i) => (
<button type="button" key={i} aria-current={state.step === i ? 'step' : undefined} disabled className={i < state.step ? 'done' : ''}>
<span className="step-num">{'٠١٠٢٠٣٠٤'.slice(i * 2, i * 2 + 2)}</span><span>{title}</span>
</button>
))}
</nav>
<form id="onboardingForm" noValidate onSubmit={e => e.preventDefault()}>
<section className={`step-panel${state.step === 0 ? ' active' : ''}`} data-step={0} data-od-id="step-goal">
<h2>ما النتيجة التي تريدها أولاً؟</h2>
<p className="step-intro">اختر هدفاً واحداً الآن. يمكنك تغييره لاحقاً من إعدادات الحساب.</p>
<div className="choice-grid" role="radiogroup" aria-label="هدفك">
{GOALS.map(g => (
<label className="choice" key={g.value}>
<input type="radio" name="goal" value={g.value} required checked={state.goal === g.value} onChange={() => { setState(s => ({ ...s, goal: g.value })); setGoalStatus('') }} />
<span className="choice-body"><span className="choice-title">{g.value}</span><span className="choice-copy">{g.copy}</span><span className="choice-mark" aria-hidden="true"></span></span>
</label>
))}
</div>
<p className="status" id="goalStatus" role="status" aria-live="polite">{goalStatus}</p>
<div className="actions"><button className="btn btn-primary" type="button" onClick={next}>تابع إلى مستواك</button></div>
</section>
<section className={`step-panel${state.step === 1 ? ' active' : ''}`} data-step={1} data-od-id="step-level">
<h2>ما مستوى خبرتك العملي؟</h2>
<p className="step-intro">لا نختبرك. الإجابة تضبط ترتيب المواد وكمية الشرح في اقتراح البداية.</p>
<div className="choice-grid" role="radiogroup" aria-label="مستواك">
{LEVELS.map(l => (
<label className="choice" key={l.value}>
<input type="radio" name="level" value={l.value} required checked={state.level === l.value} onChange={() => { setState(s => ({ ...s, level: l.value })); setLevelStatus('') }} />
<span className="choice-body"><span className="choice-title">{l.label}</span><span className="choice-copy">{l.copy}</span><span className="choice-mark" aria-hidden="true"></span></span>
</label>
))}
</div>
<p className="status" id="levelStatus" role="status" aria-live="polite">{levelStatus}</p>
<div className="actions">
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 0 }))}>رجوع</button>
<button className="btn btn-primary" type="button" onClick={next}>تابع إلى الرسائل</button>
</div>
</section>
<section className={`step-panel${state.step === 2 ? ' active' : ''}`} data-step={2} data-od-id="step-notifications">
<h2>اختر ما يستحق الوصول إلى بريدك.</h2>
<p className="step-intro">كل الخيارات قابلة للتغيير. لا يؤثر إيقافها على وصولك إلى محتوى VIP.</p>
<div className="notice-card">
<div className="notice-row"><div><strong>خلاصة الأحد</strong><p>أفضل ما نُشر هذا الأسبوع مع نقطة تطبيق واحدة.</p></div>
<label className="switch"><input type="checkbox" name="weekly" checked={state.weekly} onChange={e => setState(s => ({ ...s, weekly: e.target.checked }))} aria-label="تفعيل خلاصة الأحد" /><span className="track"></span></label></div>
<div className="notice-row"><div><strong>تحديثات المسار</strong><p>رسالة عندما نضيف جزءاً جديداً إلى المسار الذي تتبعه.</p></div>
<label className="switch"><input type="checkbox" name="trackUpdates" checked={state.trackUpdates} onChange={e => setState(s => ({ ...s, trackUpdates: e.target.checked }))} aria-label="تفعيل تحديثات المسار" /><span className="track"></span></label></div>
<div className="notice-row"><div><strong>الردود على تعليقاتك</strong><p>تنبيه فقط عندما يرد عضو أو فريق أنور على سؤالك.</p></div>
<label className="switch"><input type="checkbox" name="replies" checked={state.replies} onChange={e => setState(s => ({ ...s, replies: e.target.checked }))} aria-label="تفعيل إشعارات الردود" /><span className="track"></span></label></div>
</div>
<div className="actions">
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 1 }))}>رجوع</button>
<button className="btn btn-primary" type="button" onClick={() => setState(s => ({ ...s, step: 3 }))}>اعرض نقطة البداية</button>
</div>
</section>
<section className={`step-panel${state.step === 3 ? ' active' : ''}`} data-step={3} data-od-id="step-start">
<h2>مسارك جاهز.</h2>
<p className="step-intro">ابدأ بمادة واحدة، طبّق ملفها العملي، ثم عد إلى المكتبة. لا حاجة لفتح كل شيء اليوم.</p>
<dl className="summary">
<div className="summary-row"><dt>هدفك</dt><dd id="summaryGoal">{state.goal || '—'}</dd></div>
<div className="summary-row"><dt>مستواك</dt><dd id="summaryLevel">{state.level || '—'}</dd></div>
<div className="summary-row"><dt>الرسائل</dt><dd id="summaryNotices">{notices.length ? notices.join('، ') : 'لا رسائل بريدية'}</dd></div>
</dl>
<article className="start-card" data-od-id="recommended-first-guide">
<p className="eyebrow">نقطة البداية المقترحة</p>
<h3 id="recommendationTitle">{rec[0]}</h3>
<p id="recommendationCopy">{rec[1]}</p>
<Link className="btn btn-primary" href={rec[2]} id="startGuide" data-od-id="start-first-guide">ابدأ الدليل الأول</Link>
</article>
<div className="actions">
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 2 }))}>تعديل الاختيارات</button>
<Link className="btn btn-secondary" href="/articles">اذهب إلى المكتبة</Link>
</div>
</section>
</form>
</section>
</>
)
}
@@ -0,0 +1,42 @@
import Topnav from '@/components/topnav'
import SiteMenu from '@/components/site-menu'
import Footer from '@/components/footer'
import OnboardingWizard from './onboarding-wizard'
import { redirect } from 'next/navigation'
import { getViewer } from '@/lib/viewer'
import '../styles/vip-onboarding.css'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'تهيئة العضوية' }
export default async function VipOnboardingPage() {
const viewer = await getViewer()
if (!viewer) redirect('/login?next=%2Fvip-onboarding')
if (viewer.role !== 'vip' && viewer.role !== 'admin') redirect('/subscription')
return (
<>
<Topnav
viewer={{ email: viewer.email }}
accountLabel="حسابي"
menu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/article-agent-guide', label: 'المقالات' },
{ href: '/subscription', label: 'العضوية' },
]}
/>
<SiteMenu
links={[
{ href: '/articles', label: 'المكتبة' },
{ href: '/posts/agent-loop-stop-condition', label: 'دليل الأعضاء' },
{ href: '/account-settings', label: 'إعدادات الحساب' },
]}
/>
<main>
<p className="member-strip" data-od-id="member-status">تم تفعيل وصول VIP — لنستثمر أول خمس دقائق في إعداد مسارك.</p>
<OnboardingWizard viewerId={viewer.id ?? null} />
</main>
<Footer />
</>
)
}
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page
+52
View File
@@ -0,0 +1,52 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
+30
View File
@@ -0,0 +1,30 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './importMap.js'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)
@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
export const dynamic = 'force-dynamic'
/** Agent update endpoint: PUT /api/agent/posts/:slug */
export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
const key = process.env.AGENT_API_KEY
if (!key || req.headers.get('x-agent-key') !== key) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await getPayload({ config: configPromise })
const { slug } = await ctx.params
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
const existing = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
limit: 1,
})
const post = existing.docs[0]
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
// Only writable fields; status is admin-only
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
const data: Record<string, unknown> = {}
for (const field of allowed) {
if (field in body) data[field] = body[field]
}
const doc = await payload.update({ collection: 'posts', id: post.id, data, depth: 0 })
return NextResponse.json({
id: doc.id, title: doc.title, slug: doc.slug,
status: doc.status, access: doc.access, category: doc.category,
})
}
+131
View File
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import type { Payload } from 'payload'
import configPromise from '@payload-config'
export const dynamic = 'force-dynamic'
type Body = Record<string, unknown>
function unauthorized() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
function validKey(req: NextRequest) {
const key = process.env.AGENT_API_KEY
if (!key) return false
return req.headers.get('x-agent-key') === key
}
/** Fields the agent is allowed to write; status is NEVER included. */
function pickFields(body: Body): Body {
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
const out: Body = {}
for (const field of allowed) {
if (field in body) out[field] = body[field]
}
// cover omitted → per-category default assigned below (seeded covers)
return out
}
const CATEGORY_COVER_SLUGS: Record<string, string> = {
'وكلاء': 'cover-agents',
'نماذج': 'cover-models',
'استرجاع': 'cover-retrieval',
'سلامة': 'cover-safety',
'أدوات': 'cover-tools',
'أعمال': 'cover-business',
}
async function defaultCoverId(payload: Payload, category: unknown) {
const filename = CATEGORY_COVER_SLUGS[String(category)] || 'cover-default'
const res = await payload.find({
collection: 'media',
where: { filename: { equals: `${filename}.jpg` } },
limit: 1,
})
return res.docs[0]?.id
}
export async function POST(req: NextRequest) {
if (!validKey(req)) return unauthorized()
const payload = await getPayload({ config: configPromise })
const body = (await req.json().catch(() => null)) as Body | null
if (!body || typeof body.slug !== 'string' || !body.slug) {
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
}
// Slug conflict → 409
const existing = await payload.find({
collection: 'posts',
where: { slug: { equals: body.slug } },
limit: 1,
})
if (existing.docs.length > 0) {
return NextResponse.json({ error: 'slug already exists' }, { status: 409 })
}
const data = pickFields(body)
if (!data.cover) data.cover = await defaultCoverId(payload, data.category)
if (!data.content || typeof data.content !== 'object') {
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later
data.content = {
root: {
type: 'root', format: '', indent: 0, version: 1, direction: 'rtl',
children: [{ type: 'paragraph', version: 1, children: [{ type: 'text', version: 1, text: '…' }] }],
},
}
}
const doc = await payload.create({
collection: 'posts',
data: { ...data, status: 'draft' },
draft: true,
depth: 0,
})
return NextResponse.json({
id: doc.id,
title: doc.title,
slug: doc.slug,
status: doc.status,
access: doc.access,
category: doc.category,
}, { status: 201 })
}
export async function PUT(req: NextRequest) {
if (!validKey(req)) return unauthorized()
const payload = await getPayload({ config: configPromise })
const slug = req.nextUrl.pathname.split('/').filter(Boolean).pop() as string
const body = (await req.json().catch(() => null)) as Body | null
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
const existing = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
limit: 1,
overrideAccess: true,
})
const post = existing.docs[0]
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
const data = pickFields(body)
delete (data as Body).status // agent can never publish/unpublish
const doc = await payload.update({
collection: 'posts',
id: post.id,
data,
depth: 0,
})
return NextResponse.json({
id: doc.id,
title: doc.title,
slug: doc.slug,
status: doc.status,
access: doc.access,
category: doc.category,
})
}
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { headers } from 'next/headers'
import { randomBytes } from 'crypto'
import configPromise from '@payload-config'
import { sendVerificationEmail } from '@/lib/mail'
export const dynamic = 'force-dynamic'
/* POST /api/auth/resend-verification — logged-in user asks for a fresh
verification email. Regenerates the token so old links die. */
export async function POST() {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (user.emailVerified) return NextResponse.json({ already: true })
const token = randomBytes(32).toString('hex')
await payload.update({
collection: 'users',
id: user.id,
data: { verificationToken: token },
overrideAccess: true,
})
const { sent } = await sendVerificationEmail(user.email, token)
if (!sent) {
return NextResponse.json(
{ error: 'خدمة البريد غير مربوطة بعد — ستعمل تلقائياً بعد ضبط SMTP على الاستضافة.' },
{ status: 503 },
)
}
return NextResponse.json({ sent: true })
}
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import configPromise from '@payload-config'
export const dynamic = 'force-dynamic'
/* GET /api/auth/verify-email?token=... — the link sent by email.
Marks the account verified and lands the user on onboarding. */
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get('token')
if (!token) redirect('/verify-email?invalid=1')
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'users',
where: { verificationToken: { equals: token } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const user = docs[0]
if (!user) redirect('/verify-email?invalid=1')
await payload.update({
collection: 'users',
id: user.id,
data: { emailVerified: true, verificationToken: null },
overrideAccess: true,
})
redirect('/onboarding')
}
@@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { headers } from 'next/headers'
import configPromise from '@payload-config'
export const dynamic = 'force-dynamic'
/* POST /api/posts/[slug]/comments — create a moderated comment.
Signed-in only (401); VIP post requires entitlement (403); 1 comment / 60 s
per user, counting pending (429); body 8–2000 chars (400).
NOTE: deliberately NOT /api/comments — Payload mounts its admin REST there
(see docs/adr/0001-comments-route-not-at-api-comments.md). */
export async function POST(_request: Request, ctx: { params: Promise<{ slug: string }> }) {
const { slug } = await ctx.params
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return NextResponse.json({ error: 'سجّل الدخول للتعليق.' }, { status: 401 })
const parsed = await _request.json().catch(() => null)
const text = typeof parsed?.body === 'string' ? parsed.body.trim() : ''
if (text.length < 8) return NextResponse.json({ error: 'اكتب تعليقاً من ٨ أحرف على الأقل.' }, { status: 400 })
if (text.length > 2000) return NextResponse.json({ error: 'التعليق طويل جداً — ٢٠٠٠ حرف كحد أقصى.' }, { status: 400 })
const { docs } = await payload.find({
collection: 'posts',
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
depth: 0,
limit: 1,
})
const post = docs[0] as unknown as { id: number; access: string } | undefined
if (!post) return NextResponse.json({ error: 'المقال غير موجود.' }, { status: 404 })
// Mirror the UI paywall: commenting on a VIP post requires entitlement.
const role = (user as { role?: string }).role
if (post.access === 'vip' && role !== 'vip' && role !== 'admin') {
return NextResponse.json({ error: 'التعليق على هذا الدليل متاح لأعضاء العضوية.' }, { status: 403 })
}
// Naive per-user rate limit: 1 comment / 60 s, any post, counts pending.
const recent = await payload.find({
collection: 'comments',
depth: 0,
limit: 1,
overrideAccess: true,
where: {
and: [
{ author: { equals: user.id } },
{ createdAt: { greater_than: new Date(Date.now() - 60_000) } },
],
},
})
if (recent.docs.length) return NextResponse.json({ error: 'تمهّل قليلاً قبل تعليق جديد.' }, { status: 429 })
const fullName = (user as { fullName?: string }).fullName
await payload.create({
collection: 'comments',
data: { post: post.id, author: user.id, authorName: fullName || 'عضو', body: text, status: 'pending' },
overrideAccess: true,
})
return NextResponse.json({ ok: true, pending: true }, { status: 201 })
}
@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { headers } from 'next/headers'
import configPromise from '@payload-config'
export const dynamic = 'force-dynamic'
/* POST /api/posts/[slug]/like — toggle. Login required (401 otherwise);
one like per user per post (PostLikes uniqueness); likes counter on post. */
export async function POST(_request: Request, ctx: { params: Promise<{ slug: string }> }) {
const { slug } = await ctx.params
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { docs } = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
select: { likes: true },
overrideAccess: true,
})
const post = docs[0]
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const existing = await payload.find({
collection: 'post-likes',
where: { and: [{ user: { equals: user.id } }, { post: { equals: post.id } }] },
depth: 0,
limit: 1,
overrideAccess: true,
})
let liked: boolean
if (existing.docs.length > 0) {
await payload.delete({ collection: 'post-likes', id: existing.docs[0].id, overrideAccess: true })
liked = false
} else {
await payload.create({
collection: 'post-likes',
data: { user: user.id, post: post.id },
overrideAccess: true,
})
liked = true
}
const likes = Math.max(0, (typeof post.likes === 'number' ? post.likes : 0) + (liked ? 1 : -1))
await payload.update({ collection: 'posts', id: post.id, data: { likes }, overrideAccess: true })
return NextResponse.json({ liked, likes })
}
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
export const dynamic = 'force-dynamic'
/* POST /api/posts/[slug]/view — global view counter (client fires this once
per browser session per post, from the post page). */
export async function POST(_request: Request, ctx: { params: Promise<{ slug: string }> }) {
const { slug } = await ctx.params
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
select: { views: true },
overrideAccess: true,
})
const post = docs[0]
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const views = (typeof post.views === 'number' ? post.views : 0) + 1
await payload.update({ collection: 'posts', id: post.id, data: { views }, overrideAccess: true })
return NextResponse.json({ views })
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import Stripe from 'stripe'
import configPromise from '@payload-config'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
const secret = process.env.STRIPE_SECRET_KEY
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
if (!secret || !webhookSecret) {
return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 })
}
const stripe = new Stripe(secret)
const signature = req.headers.get('stripe-signature')
if (!signature) return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
const rawBody = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
const payload = await getPayload({ config: configPromise })
if (event.type === 'checkout.session.completed') {
const session = event.data.object
const userId = session.client_reference_id || session.metadata?.payloadUserId
if (userId) {
await payload.update({
collection: 'users',
id: userId,
data: { role: 'vip' },
overrideAccess: true,
})
}
}
if (event.type === 'customer.subscription.deleted') {
const subscription = event.data.object
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const { docs } = await payload.find({
collection: 'users',
where: { stripeCustomerId: { equals: customerId } },
limit: 1,
overrideAccess: true,
})
for (const u of docs) {
await payload.update({
collection: 'users',
id: u.id,
data: { role: 'free' },
overrideAccess: true,
})
}
}
return NextResponse.json({ received: true })
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import type { Payload } from 'payload'
import { headers } from 'next/headers'
import Stripe from 'stripe'
import configPromise from '@payload-config'
export const dynamic = 'force-dynamic'
export async function POST() {
const secret = process.env.STRIPE_SECRET_KEY
const priceId = process.env.STRIPE_PRICE_ID
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
// Stripe pending mode: no keys yet → simulated gateway. The flow completes
// through /payment-success (which activates VIP on the logged-in account);
// adding STRIPE_SECRET_KEY + STRIPE_PRICE_ID switches to real Checkout with
// the same success/cancel pages.
if (!secret || !priceId) {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
return NextResponse.json({ url: '/payment-success?simulated=1' })
}
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const stripe = new Stripe(secret)
let customerId = user.stripeCustomerId as string | undefined
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { payloadUserId: String(user.id) },
})
customerId = customer.id
await payload.update({
collection: 'users',
id: user.id,
data: { stripeCustomerId: customerId },
})
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: String(user.id),
success_url: `${siteUrl}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${siteUrl}/payment-cancelled`,
})
return NextResponse.json({ url: session.url })
}
+439
View File
@@ -0,0 +1,439 @@
/* AnwarPi — design tokens ported from static assets/anwarpi.css + page styles */
:root {
--bg: oklch(16% 0.03 225);
--surface: oklch(22% 0.04 220);
--fg: oklch(96% 0.015 210);
--muted: oklch(78% 0.03 215);
--border: oklch(36% 0.05 215);
--accent: oklch(78% 0.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;
/* v2 design-system tokens (from arabic-ai-learning-catalog.html) */
--fs-h1: clamp(46px, 6.3vw, 82px);
--fs-h2: clamp(32px, 4vw, 52px);
--fs-h3: 22px;
--fs-lead: 19px;
--fs-body: 16px;
--fs-meta: 12px;
--gap-xs: 8px;
--gap-sm: 12px;
--gap-md: 20px;
--gap-lg: 32px;
--gap-xl: 56px;
--gap-2xl: 96px;
--radius: 2px;
--radius-lg: 2px;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; max-width: 100%; overflow-x: clip; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 16px/1.75 var(--font-body);
text-rendering: optimizeLegibility;
max-width: 100%;
overflow-x: clip;
}
img, svg, video { height: auto; }
main, section, .container, .grid, [class*="grid"], [class*="layout"] { min-width: 0; }
code, pre, bdi { overflow-wrap: anywhere; }
pre, table { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
h1, h2, h3 { font-family: var(--font-display); text-wrap: balance; }
p { text-wrap: pretty; }
:focus-visible { outline: 3px solid color-mix(in oklch, var(--accent) 55%, transparent); outline-offset: 3px; }
::selection { background: color-mix(in oklch, var(--accent) 28%, transparent); }
.container { max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
/* ---- Top navigation ---- */
.topnav {
position: sticky; top: 0; z-index: 20;
background: color-mix(in oklch, var(--bg) 92%, transparent);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
.nav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.logo { display: inline-flex; align-items: center; gap: 10px; font-family: var(--font-display); font-size: 23px; font-weight: 700; }
.logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; flex: none; }
.nav-links { display: flex; align-items: center; gap: 28px; }
.nav-links a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); }
.nav-links a:hover { color: var(--fg); }
.nav-links a[aria-current="page"] { color: var(--fg); font-weight: 700; }
.nav-tools { display: flex; align-items: center; gap: 8px; }
.nav-login {
min-height: 44px; padding-inline: 16px; border: 1px solid var(--border);
background: transparent; color: var(--fg); display: inline-grid; place-items: center;
}
.nav-login:hover { border-color: var(--fg); background: var(--fg-soft); }
/* ---- Buttons ---- */
.btn {
min-height: 46px; display: inline-flex; align-items: center; justify-content: center;
padding: 11px 22px; border: 1px solid var(--border); font-weight: 700;
}
.btn:hover { border-color: var(--fg); background: var(--fg-soft); }
.btn .arrow { margin-inline-start: 10px; }
.btn-primary { background: var(--accent); color: var(--surface); border-color: var(--accent); }
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 86%, black); border-color: var(--accent); }
.btn-secondary { border-color: var(--border); background: transparent; color: var(--fg); }
.text-link {
font-weight: 700; color: var(--fg); text-decoration: underline; text-underline-offset: 4px;
min-height: 44px; display: inline-flex; align-items: center;
}
.text-link:hover { color: var(--accent); }
@media (prefers-reduced-motion: no-preference) {
.btn, .btn-primary, .btn-secondary {
position: relative; overflow: hidden;
transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease;
}
.btn::after, .btn-primary::after, .btn-secondary::after {
content: ""; position: absolute; inset-block: 0; inset-inline-start: -45%; width: 34%;
background: linear-gradient(100deg, transparent 0%, rgba(255,255,255,.45) 50%, transparent 100%);
transform: skewX(-18deg); opacity: 0; pointer-events: none;
}
.btn:hover::after, .btn-primary:hover::after, .btn-secondary:hover::after {
animation: anw-sweep .85s cubic-bezier(.4,0,.2,1);
}
.btn:hover, .btn-primary:hover, .btn-secondary:hover { transform: translateY(-1px); }
.btn-primary:hover { box-shadow: 0 8px 28px color-mix(in oklch, var(--accent) 30%, transparent); }
.btn:active { transform: translateY(0); }
}
@keyframes anw-sweep { 0% { opacity: 1; inset-inline-start: -45%; } 100% { opacity: 1; inset-inline-start: 118%; } }
/* ---- Hero ---- */
.hero { padding-block: clamp(64px, 9vw, 120px); border-bottom: 1px solid var(--border); }
.crumb { font: 12px/1.5 var(--font-mono); color: var(--accent); margin: 0 0 20px; }
.hero h1 { font-size: clamp(44px, 6.5vw, 84px); line-height: 1.08; max-width: 14ch; margin: 0; }
.hero .lead { font-size: clamp(18px, 1.8vw, 22px); line-height: 1.7; color: var(--muted); max-width: 56ch; margin: 26px 0 0; }
/* ---- Cards / grids ---- */
.section { padding-block: 80px; border-bottom: 1px solid var(--border); }
.section h2 { font-size: clamp(34px, 4.5vw, 52px); margin: 0 0 34px; }
/* ---- Chips ---- */
.chip { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); padding: 3px 10px; font: 12px/1.6 var(--font-mono); color: var(--muted); }
.chip-vip { border-color: var(--fg); color: var(--fg); }
.foryou { color: var(--accent); 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); }
/* ---- Article ---- */
.article-head { padding-block: clamp(58px, 9vw, 100px); border-bottom: 1px solid var(--border); }
.lock-chip {
display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--fg);
padding: 6px 14px; font: 12px/1.5 var(--font-mono); color: var(--fg); margin-bottom: 22px;
}
.lock-chip svg { width: 14px; height: 14px; }
.article-head h1 { font-size: clamp(46px, 7vw, 88px); line-height: 1.06; max-width: 12ch; margin: 0; }
.article-head .dek { font-size: clamp(19px, 2vw, 24px); line-height: 1.7; color: var(--muted); max-width: 58ch; margin: 28px 0 0; }
.article-meta {
display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px;
margin-top: 34px; padding-top: 20px; border-top: 1px solid var(--border);
font: 12px/1.5 var(--font-mono); color: var(--muted);
}
.article-layout {
display: grid; grid-template-columns: minmax(0, 740px) 240px;
gap: clamp(48px, 8vw, 100px); align-items: start; padding-block: 80px;
}
.article-body { font-size: 19px; line-height: 2; }
.article-body h2 { font-size: clamp(32px, 4vw, 48px); line-height: 1.2; margin: 64px 0 20px; }
.article-body h3 { font-size: clamp(24px, 3vw, 32px); margin: 48px 0 14px; }
.article-body p { margin: 0 0 26px; }
.article-body ul, .article-body ol { padding-inline-start: 26px; margin: 0 0 26px; }
.article-body li { margin-bottom: 12px; }
.article-body blockquote { border-inline-start: 3px solid var(--accent); margin: 0 0 26px; padding: 8px 24px; color: var(--muted); }
.article-body code { background: var(--fg-soft); padding: 2px 8px; font-family: var(--font-mono); font-size: 15px; }
.article-body pre { background: var(--surface); border: 1px solid var(--border); padding: 20px; }
.article-body img { max-width: 100%; }
.lede { font-family: var(--font-display); font-size: 28px; line-height: 1.6; }
.free-tag { font: 11px/1.5 var(--font-mono); color: var(--muted); margin-bottom: 6px; display: block; }
/* ---- Paywall (from post-locked.html) ---- */
.lock-cut { position: relative; }
.lock-cut::after {
content: ""; position: absolute; inset-inline: 0; bottom: 0; height: 120px;
background: linear-gradient(to bottom, transparent, var(--bg) 88%); pointer-events: none;
}
.lock-cut .cut-p { margin-bottom: 0; }
.paywall {
margin-top: -30px; position: relative; border: 1px solid var(--fg);
background: var(--surface); padding: clamp(28px, 4vw, 44px); z-index: 2;
}
.paywall .crumb { margin-bottom: 12px; }
.paywall h2 { font-size: clamp(30px, 3.6vw, 44px); line-height: 1.18; margin: 0 0 14px; max-width: 16ch; }
.paywall p { color: var(--muted); max-width: 56ch; margin: 0 0 24px; }
.locked-in { border-top: 1px solid var(--fg); margin: 0 0 26px; padding: 0; list-style: none; }
.locked-in li {
display: flex; align-items: center; gap: 12px; min-height: 52px;
border-bottom: 1px solid var(--border); color: var(--muted); font-size: 16px;
}
.locked-in svg { width: 14px; height: 14px; flex: none; }
.locked-in .num { margin-inline-start: auto; font: 12px/1.5 var(--font-mono); }
.paywall-actions { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.price-line { font: 12px/1.6 var(--font-mono); color: var(--muted); margin-top: 18px; }
/* ---- Forms ---- */
.field { margin-bottom: 18px; }
.field label { display: block; font: 12px/1.6 var(--font-mono); color: var(--muted); margin-bottom: 8px; }
.field input, .field textarea, .field select {
width: 100%; min-height: 48px; padding: 10px 14px;
background: var(--bg); border: 1px solid var(--border); color: var(--fg);
}
.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--fg); outline: none; }
.form-error { border: 1px solid oklch(60% .2 25); color: oklch(75% .15 25); padding: 12px 16px; font-size: 14px; margin-bottom: 18px; }
.form-ok { border: 1px solid var(--accent); color: var(--accent); padding: 12px 16px; font-size: 14px; margin-bottom: 18px; }
.form-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 24px; }
.form-actions .btn { width: 100%; }
.form-foot { margin-top: 22px; font-size: 14px; color: var(--muted); }
/* ---- Footer ---- */
.pagefoot { padding-block: 54px; border-top: 1px solid var(--border); color: var(--muted); }
.footer-bottom { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; padding-top: 28px; font-size: 13px; }
/* ---- Note / callout ---- */
.note { border-block: 1px solid var(--fg); padding: 28px 0; margin: 42px 0; }
.note strong { display: block; font-family: var(--font-display); font-size: 22px; margin-bottom: 8px; }
/* ---- Prose for static pages ---- */
.prose { max-width: 720px; margin-inline: auto; padding-block: 80px; font-size: 17px; line-height: 2; }
.prose h1 { font-size: clamp(38px, 5.5vw, 64px); margin: 0 0 18px; }
.prose h2 { font-size: clamp(26px, 3.5vw, 38px); margin: 48px 0 14px; }
.prose p { margin: 0 0 22px; color: var(--muted); }
.prose strong { color: var(--fg); }
@media (pointer: coarse) {
a, button, input, select, textarea, summary, .btn { min-height: 44px; }
}
@media (max-width: 920px) {
:root { --gutter: 22px; }
.container { width: 100%; }
.nav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
.nav-links { display: none; }
.article-layout { grid-template-columns: 1fr; }
}
@media (max-width: 620px) {
:root { --gutter: 18px; }
body { line-height: 1.7; }
h1 { font-size: clamp(38px, 11vw, 50px) !important; }
.paywall-actions { flex-direction: column; align-items: stretch; }
.paywall-actions .btn { width: 100%; }
.form-actions .btn { width: 100%; }
.section { padding-block: 56px; }
.article-layout { padding-block: 56px; }
.pagefoot { padding-block: 40px; }
}
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
/* ============================================================
AnwarPi — shared motion & brand layer (ported verbatim from
/Volumes/ssdout/projects/anwar-pi-website/assets/anwarpi.css)
============================================================ */
/* ---- Brand mark: real logo image replaces the geometric span ---- */
img.logo-mark {
width: 30px;
height: 34px;
object-fit: contain;
transform: none;
background: none;
border-radius: 4px;
flex: none;
}
/* ---- Text selection on dark canvas ---- */
::selection { background: color-mix(in oklch, var(--accent) 28%, transparent); }
/* ---- Button hover: light-sweep + lift (AnwarPi "light" motif) ---- */
@media (prefers-reduced-motion: no-preference) {
.btn, .btn-primary, .btn-secondary, .btn-ghost, .icon-btn {
position: relative;
overflow: hidden;
transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease, color .18s ease;
}
.btn::after, .btn-primary::after, .btn-secondary::after, .btn-ghost::after, .icon-btn::after {
content: "";
position: absolute;
inset-block: 0;
inset-inline-start: -45%;
width: 34%;
background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.45) 50%, transparent 100%);
transform: skewX(-18deg);
opacity: 0;
pointer-events: none;
}
.btn:hover::after, .btn-primary:hover::after, .btn-secondary:hover::after,
.btn-ghost:hover::after, .icon-btn:hover::after {
animation: anw-sweep 0.85s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn:hover, .btn-primary:hover, .btn-secondary:hover, .btn-ghost:hover, .icon-btn:hover {
transform: translateY(-1px);
}
.btn-primary:hover, .btn:hover {
box-shadow: 0 8px 28px color-mix(in oklch, var(--accent) 30%, transparent);
}
.btn:active, .btn-primary:active, .btn-secondary:active, .btn-ghost:active, .icon-btn:active {
transform: translateY(0);
}
}
@keyframes anw-sweep {
0% { opacity: 1; inset-inline-start: -45%; }
100% { opacity: 1; inset-inline-start: 118%; }
}
/* ---- Scroll reveal (driven by anwarpi.js) ---- */
[data-anw] {
transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
.anw-anim [data-anw]:not(.anw-in) {
opacity: 0;
transform: translateY(18px);
}
/* ---- WebGL canvas sits behind everything, above the root background ---- */
#anwarpi-bg {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
pointer-events: none;
transition: opacity 0.9s ease;
}
/* ---- Hero brand mark: the beam logo, black field dissolved into the canvas ---- */
.hero-logo {
display: block;
width: 68px;
height: auto;
border-radius: 6px;
mix-blend-mode: screen; /* drops the jpg's black field — only the beams glow */
margin-block-end: 26px;
}
/* ---- Dark-theme correction: scrims must dim with black, not --fg (now white) ---- */
.dialog-scrim, .menu-scrim, .modal { background: rgba(5, 5, 5, 0.68); }
/* ---- Shared responsive safeguards (verbatim) ---- */
@media (pointer: coarse) {
a, button, input, select, textarea, summary, .btn, .icon-btn { min-height: 44px; }
}
@media (max-width: 920px) {
:root { --gutter: 22px; }
.container { width: 100%; }
/* Keep primary navigation visible on phones and tablets. */
.topnav .nav-inner,
.topnav .topnav-inner {
flex-wrap: wrap;
gap: 0 16px;
padding-top: 10px;
}
.topnav .logo,
.topnav .nav-tools { margin-bottom: 10px; }
.topnav .logo { min-height: 50px; }
input, select, textarea { min-height: 48px; font-size: 16px; }
.footer-col a { min-height: 44px; display: flex; align-items: center; }
.section-head { gap: 20px; }
.table-wrap, .billing-table { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.topnav .nav-links,
.topnav .topnav-inner > nav {
display: flex;
order: 3;
flex: 0 0 calc(100% + (var(--gutter) * 2));
gap: 8px;
margin-inline: calc(var(--gutter) * -1);
padding-inline: var(--gutter);
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: none;
border-top: 1px solid var(--border);
}
.topnav .nav-links::-webkit-scrollbar,
.topnav .topnav-inner > nav::-webkit-scrollbar { display: none; }
.topnav .nav-links a,
.topnav .topnav-inner > nav a {
flex: none;
min-height: 52px;
padding-inline: 12px;
white-space: nowrap;
position: relative;
}
.topnav a[aria-current="page"] {
position: relative;
color: var(--fg);
font-weight: 700;
}
.topnav a[aria-current="page"]::after {
content: "";
position: absolute;
inset-inline: 12px;
bottom: 5px;
height: 3px;
border-radius: 999px;
background: var(--accent);
}
.topnav .nav-links + .nav-tools .menu-trigger,
.topnav .topnav-inner > nav + .nav-tools .menu-trigger { display: none; }
.menu-drawer { width: min(520px, 92vw); }
.dialog-card, .modal-card { max-width: calc(100vw - 36px); max-height: calc(100dvh - 36px); overflow-y: auto; }
}
@media (max-width: 620px) {
:root { --gutter: 18px; }
body { line-height: 1.7; }
h1 { font-size: clamp(38px, 11vw, 50px) !important; overflow-wrap: normal; word-break: normal; }
h2 { overflow-wrap: normal; word-break: normal; }
.topnav .logo { font-size: 20px; }
.topnav .nav-return { display: inline-flex !important; min-height: 44px; align-items: center; font-size: 13px; padding-inline: 4px; }
.topnav .nav-inner,
.topnav .topnav-inner { gap: 0 10px; }
.topnav .nav-links,
.topnav .topnav-inner > nav { gap: 4px; }
.topnav .nav-links a,
.topnav .topnav-inner > nav a { padding-inline: 10px; }
.hero-cta, .dialog-actions, .form-actions { align-items: stretch; gap: 12px; }
.hero-cta .btn, .dialog-actions .btn, .form-actions .btn { width: 100%; }
.pagefoot { padding-block: 40px; }
.footer-bottom { gap: 12px; }
.card, .membership-card, .paywall, .plan, .team-contact { padding-inline: 20px; }
.dialog-card, .modal-card, .dialog-panel, .modal-panel { max-width: calc(100vw - 28px); }
/* QA-R06: fade edge signals horizontal scrollability (RTL: fades at the left edge). */
.topnav nav, .filterbar, .filters, .table-wrap {
-webkit-mask-image: linear-gradient(to left, transparent, #000 32px);
mask-image: linear-gradient(to left, transparent, #000 32px);
}
/* QA-R09: 44px minimum touch targets. */
.topnav nav a, .nav-return { min-height: 44px; display: inline-flex; align-items: center; }
}
/* QA-04: enforce readable minimum text on phones (doubled specificity beats per-page font shorthands). */
@media (max-width: 620px) {
.eyebrow.eyebrow, .post-meta.post-meta, .count.count, .crumb.crumb, .chip.chip,
.access.access, .free-tag.free-tag, .access-chip.access-chip, .filter-label.filter-label {
font-size: 13px;
}
.pagefoot.pagefoot, .footer-bottom.footer-bottom { font-size: 15px; }
}
/* 404 page — centered minimal layout reusing existing utilities. */
main.nf { min-height: 60vh; display: grid; place-content: center; text-align: center; gap: 16px; }
.nf-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from 'next'
import AnwarpiBackground from '@/components/anwarpi-background'
import './globals.css'
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'),
title: { default: 'أنور بي آي', template: '%s — أنور بي آي' },
description: 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.',
alternates: { canonical: './' },
openGraph: {
type: 'website',
siteName: 'أنور بي آي',
locale: 'ar',
images: [{ url: '/og-default.png', width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image', images: ['/og-default.png'] },
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
return (
<html lang="ar" dir="rtl">
<body>
<AnwarpiBackground />
{children}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebSite', name: 'أنور بي آي', url: siteUrl, inLanguage: 'ar' }) }} />
</body>
</html>
)
}
+16
View File
@@ -0,0 +1,16 @@
import Link from 'next/link'
export const metadata = { title: 'الصفحة غير موجودة' }
export default function NotFound() {
return (
<main className="container nf">
<h1>ضاعت الصفحة.</h1>
<p>الرابط الذي تبحث عنه غير موجود أو نُقل.</p>
<div className="nf-actions">
<Link className="btn btn-primary" href="/">العودة للرئيسية</Link>
<Link className="btn btn-secondary" href="/articles">تصفح المكتبة</Link>
</div>
</main>
)
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from 'next'
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
export default function robots(): MetadataRoute.Robots {
return {
rules: [{
userAgent: '*',
allow: '/',
disallow: ['/admin', '/api', '/account-settings', '/onboarding', '/vip-onboarding', '/verify-email', '/payment-success', '/payment-cancelled', '/forgot-password'],
}],
sitemap: `${base}/sitemap.xml`,
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { MetadataRoute } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
export const dynamic = 'force-dynamic'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const payload = await getPayload({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
limit: 500,
depth: 0,
})
const staticPaths = ['', '/articles', '/subscription', '/terms', '/privacy', '/about', '/login', '/signup']
return [
...staticPaths.map(p => ({ url: `${base}${p}` })),
...posts.docs.map(p => ({ url: `${base}/posts/${p.slug}`, lastModified: p.updatedAt ? new Date(String(p.updatedAt)) : undefined })),
]
}
+44
View File
@@ -0,0 +1,44 @@
import type { CollectionConfig } from 'payload'
/* Moderated member comments (QA-D04). Lifecycle: pending → approved | rejected.
Author is forced server-side (hook + custom route) so REST cannot spoof identity. */
const Comments: CollectionConfig = {
slug: 'comments',
admin: { defaultColumns: ['post', 'authorName', 'status', 'createdAt'] },
access: {
read: ({ req }) =>
(req.user as { role?: string } | undefined)?.role === 'admin'
? true
: { status: { equals: 'approved' } },
create: ({ req }) => !!req.user,
update: ({ req }) => (req.user as { role?: string } | undefined)?.role === 'admin',
delete: ({ req }) => (req.user as { role?: string } | undefined)?.role === 'admin',
},
hooks: {
beforeChange: [
({ data, req, operation }) => {
if (operation === 'create' && req.user) data.author = req.user.id
return data
},
],
},
fields: [
{ name: 'post', type: 'relationship', relationTo: 'posts', required: true },
{ name: 'author', type: 'relationship', relationTo: 'users', required: true, admin: { hidden: true } },
{ name: 'authorName', type: 'text', label: 'Author name' },
{ name: 'body', type: 'textarea', required: true, minLength: 8, maxLength: 2000 },
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'pending',
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Approved', value: 'approved' },
{ label: 'Rejected', value: 'rejected' },
],
},
],
}
export default Comments
+31
View File
@@ -0,0 +1,31 @@
import path from 'path'
import { fileURLToPath } from 'url'
import type { AccessArgs, CollectionConfig } from 'payload'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const isAdmin = (args: AccessArgs) =>
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: path.resolve(dirname, '../../media'),
mimeTypes: ['image/jpg', 'image/jpeg', 'image/png', 'image/webp'],
imageSizes: [],
},
admin: {
hidden: ({ user }) => user?.role !== 'admin',
},
access: {
admin: isAdmin,
read: () => true, // covers must render publicly
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [],
}
export default Media

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