fix: reconcile approved live VPS source and operations

This commit is contained in:
2026-10-13 18:04:15 +03:00
parent 36f508df75
commit 6385d60dff
57 changed files with 2474 additions and 4630 deletions
+45
View File
@@ -0,0 +1,45 @@
# Live VPS source reconciliation
## Source of truth
This reconciliation imports the already deployed application source from `/root/anwarpi` into `web/`. The owner explicitly confirmed that the VPS copy, including its package manifest and lockfile, is authoritative. It does not deploy or restart the website.
Included features:
- Admin-only user creation and protected administrative user fields.
- Free early-access reading with registration, checkout and notification signup disabled.
- The exact trial banner, current policy pages and support contact.
- Arabic branding «أنور پآي» and site-wide «محتوى» labels.
- SEO titles/descriptions, utility-page noindex and absolute Article image URLs.
- Most-read homepage cards based on the published collection's cumulative views.
- Click-to-load privacy-enhanced YouTube embeds and same-origin inline raster images.
- Bundled Readex Pro font with its OFL license.
- The deployed daily backup script and cron definition under `ops/`.
## Deliberate exclusions
Do not import `.env` files, secrets, database dumps, backups, uploads/media, private files, build output, dependency directories or TypeScript build caches into Git. Published article records remain in the production CMS database; they are not application-source files.
Generated `payload-types.ts` remains generated/ignored. The repository's existing removal of a hardcoded publishing key from `web/docs/agent-publish-api.md` is retained rather than copying that key back from an older live document.
## Dependencies and security
The deployed `web/package.json` and `web/package-lock.json` are imported as requested. This replaces repository-only dependency/tooling edits and CVE overrides that were not on the live VPS. This release is a source reconciliation, **not a security upgrade**, and does not claim to resolve dependency advisories. Keep the existing recorded Next.js risk acceptance visible and evaluate upgrades separately.
## Verification
Before publication, the reconciled application passed:
- `npm ci --ignore-scripts --no-audit --no-fund`.
- `npm run test:launch`: 10 tests passed, 0 failed.
- `npm run generate:types`.
- `npx tsc --noEmit`.
- `npm run build`: Next.js production build completed successfully.
Added-line credential/injection screening found no literal credentials. Linting is skipped by the deployed build configuration; these results do not claim a separate lint pass or a remote CI run. The independent pre-publication review is recorded in the reconciliation's private evidence, not treated as a substitute for the commands above.
## Operational files
`ops/daily_backup.py` is the deployed `/root/anwarpy-daily-backup.py`; `ops/anwarpy-backup.cron` is the deployed cron definition. Their paths and container names reflect the current installation. Merely merging these files does not install cron or run a backup.
Backup archives include runtime configuration and potentially sensitive data. Keep their destination root-only and never publish archive contents. This workflow did not read or change production data, invoke a backup, apply migrations, or deploy a replacement image.
+3
View File
@@ -0,0 +1,3 @@
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
15 2 * * * root /usr/bin/python3 /root/anwarpy-daily-backup.py >> /root/anwarpy-backups/daily.log 2>&1
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/python3
"""Bounded daily backup. Never modifies application data or schema."""
from pathlib import Path
import subprocess, json, datetime, shutil, os
os.umask(0o077)
root=Path('/root/anwarpy-backups');root.mkdir(mode=0o700,exist_ok=True)
now=datetime.datetime.now(datetime.timezone.utc);folder=root/('daily-'+now.strftime('%Y%m%dT%H%M%SZ'));folder.mkdir(mode=0o700)
try:
with (folder/'database.dump').open('wb') as out:
subprocess.run(['docker','exec','anwarpi-db-1','pg_dump','-U','anwarpi','-d','anwarpi','-Fc'],stdout=out,check=True)
subprocess.run(['tar','-czf',str(folder/'source-config.tar.gz'),'-C','/root','anwarpi'],check=True)
info=json.loads(subprocess.check_output(['docker','inspect','anwarpi-web-1'],text=True))[0]
for mount in info['Mounts']:
if mount['Destination'] in ['/app/media','/app/files-private']:
subprocess.run(['tar','-czf',str(folder/(mount['Destination'].split('/')[-1]+'.tar.gz')),'-C',mount['Source'],'.'],check=True)
subprocess.run(['docker','cp',str(folder/'database.dump'),'anwarpi-db-1:/var/lib/postgresql/daily-archive-check.dump'],check=True)
archive=subprocess.check_output(['docker','exec','anwarpi-db-1','pg_restore','--list','/var/lib/postgresql/daily-archive-check.dump'],text=True)
assert 'TABLE DATA' in archive
record={'status':'ok','finished_at':now.isoformat(),'backup':str(folder),'image':info['Image'],'archive_list_valid':True}
(folder/'verification.json').write_text(json.dumps(record))
for old in root.glob('daily-*'):
if old.is_dir() and old!=folder and now.timestamp()-old.stat().st_mtime>30*86400:shutil.rmtree(old)
except Exception as error:
record={'status':'failed','finished_at':now.isoformat(),'error_type':type(error).__name__}
(root/'health.json').write_text(json.dumps(record));print(json.dumps(record));raise
(root/'health.json').write_text(json.dumps(record));print(json.dumps(record))
+3
View File
@@ -15,6 +15,9 @@ services:
retries: 10 retries: 10
web: web:
# Run migrations explicitly as a separate reviewed deployment step.
# Access-control-only release: avoid the dev-schema interactive prompt.
command: ["npm", "run", "start"]
build: build:
context: . context: .
args: args:
+868 -3393
View File
File diff suppressed because it is too large Load Diff
+3 -14
View File
@@ -4,6 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:launch": "node --test tests/free-launch.test.cjs",
"dev": "next dev", "dev": "next dev",
"build": "payload generate:types && next build", "build": "payload generate:types && next build",
"start": "next start", "start": "next start",
@@ -12,7 +13,6 @@
"covers": "node scripts/generate-covers.mjs" "covers": "node scripts/generate-covers.mjs"
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.9.14",
"@payloadcms/db-postgres": "^3.60.0", "@payloadcms/db-postgres": "^3.60.0",
"@payloadcms/next": "^3.60.0", "@payloadcms/next": "^3.60.0",
"@payloadcms/richtext-lexical": "^3.60.0", "@payloadcms/richtext-lexical": "^3.60.0",
@@ -20,28 +20,17 @@
"payload": "^3.60.0", "payload": "^3.60.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"sharp": "^0.35.5", "sharp": "^0.34.0",
"stripe": "^18.5.0" "stripe": "^18.5.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.2", "@types/nodemailer": "^8.0.2",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"eslint": "^10.12.0", "typescript": "^5.9.0"
"prettier": "^3.9.10",
"typescript": "^5.9.0",
"typescript-eslint": "^8.71.2",
"vitest": "^5.0.4"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
},
"overrides": {
"undici": "^8.11.2",
"sharp": "^0.35.5",
"postcss": "^8.5.6",
"sass": "^1.79.0"
} }
} }
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

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