Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6385d60dff | ||
|
|
36f508df75 | ||
|
|
26d662ed7c | ||
|
|
e35d908672 |
+12
-12
@@ -4,18 +4,18 @@
|
||||
|
||||
## 1. Stack (pinned 2026-10-11)
|
||||
|
||||
| Layer | Choice | Version |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Framework | Next.js App Router (server components first) | next@15.5.27 (CVE-driven bump from 15.4.11, owner-approved 2026-10-13) |
|
||||
| CMS / API / admin | Payload 3 (Next-native, Lexical rich text) | ^3.60.0 |
|
||||
| Database | PostgreSQL | postgres:16 (container) |
|
||||
| DB adapter | @payloadcms/db-postgres (Drizzle migrations) | ^3.60.0 |
|
||||
| UI runtime | React | ^19.2.0 |
|
||||
| Styling | Hand-rolled CSS (`src/app/design.css` + per-route CSS). No Tailwind, no component library. | — |
|
||||
| Payments | stripe | ^18.5.0 (scaffolded, gated by `billingEnabled()`) |
|
||||
| Email | Cloudflare Email REST API (ADR-0003) — **never SMTP** | — |
|
||||
| Language | TypeScript strict | ^5.9.0 |
|
||||
| Deploy | Docker Compose on VPS `root@147.79.102.201:/root/anwarpi`, Traefik proxy network, apex anwarpy.ai (www 308) | — |
|
||||
| Layer | Choice | Version |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Framework | Next.js App Router (server components first) | next@15.4.11 — ceiling of `@payloadcms/next` peer support; known CVEs risk-accepted (SECURITY.md §8) |
|
||||
| CMS / API / admin | Payload 3 (Next-native, Lexical rich text) | ^3.60.0 |
|
||||
| Database | PostgreSQL | postgres:16 (container) |
|
||||
| DB adapter | @payloadcms/db-postgres (Drizzle migrations) | ^3.60.0 |
|
||||
| UI runtime | React | ^19.2.0 |
|
||||
| Styling | Hand-rolled CSS (`src/app/design.css` + per-route CSS). No Tailwind, no component library. | — |
|
||||
| Payments | stripe | ^18.5.0 (scaffolded, gated by `billingEnabled()`) |
|
||||
| Email | Cloudflare Email REST API (ADR-0003) — **never SMTP** | — |
|
||||
| Language | TypeScript strict | ^5.9.0 |
|
||||
| Deploy | Docker Compose on VPS `root@147.79.102.201:/root/anwarpi`, Traefik proxy network, apex anwarpy.ai (www 308) | — |
|
||||
|
||||
## 2. Repository layout
|
||||
|
||||
|
||||
+21
-11
@@ -12,18 +12,19 @@
|
||||
|
||||
## 2. Environment variables & secrets
|
||||
|
||||
| Var | Scope | Rule |
|
||||
|---|---|---|
|
||||
| `DATABASE_URI` / `DB_PASSWORD` | server | compose builds the URI; never commit, never echo |
|
||||
| `PAYLOAD_SECRET` | server | signing/encryption; rotate = invalidate sessions |
|
||||
| `AGENT_API_KEY` | server | pipeline auth; header `x-agent-key` |
|
||||
| `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `MAIL_FROM` | server | mail transport |
|
||||
| `LAUNCH_MODE` | server | launch policy (SECURITY-relevant: gates paid flows) |
|
||||
| `NEXT_PUBLIC_SITE_URL` | public | canonical/og origin |
|
||||
| `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | server | Phase 3 |
|
||||
| `SEED_TEST_USERS` | dev only | never set in prod |
|
||||
| Var | Scope | Rule |
|
||||
| --------------------------------------------------------------- | -------- | --------------------------------------------------- |
|
||||
| `DATABASE_URI` / `DB_PASSWORD` | server | compose builds the URI; never commit, never echo |
|
||||
| `PAYLOAD_SECRET` | server | signing/encryption; rotate = invalidate sessions |
|
||||
| `AGENT_API_KEY` | server | pipeline auth; header `x-agent-key` |
|
||||
| `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `MAIL_FROM` | server | mail transport |
|
||||
| `LAUNCH_MODE` | server | launch policy (SECURITY-relevant: gates paid flows) |
|
||||
| `NEXT_PUBLIC_SITE_URL` | public | canonical/og origin |
|
||||
| `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | server | Phase 3 |
|
||||
| `SEED_TEST_USERS` | dev only | never set in prod |
|
||||
|
||||
Rules:
|
||||
|
||||
- `.env*` files are gitignored and **excluded from rsync deploys**; prod secrets live only in `/root/anwarpi/.env.production`.
|
||||
- Never print secret values into transcripts, docs, videos, or evidence. Known incident: 8 chars of prod `DB_PASSWORD` leaked in a session transcript on 2026-10-10 → **owner to rotate**; rotation is the standing remedy for any partial leak.
|
||||
- Read secrets at runtime via `process.env` inside server modules only; never in `'use client'` code (only `NEXT_PUBLIC_*` there).
|
||||
@@ -38,7 +39,7 @@ Rules:
|
||||
## 4. Input handling & pipeline hardening
|
||||
|
||||
- **`pickFields`** on the agent API strips privileged fields (`access`, `readingMinutes`, `status` except explicit `publish: true` opt-in on POST; PUT cannot set `status`).
|
||||
- **`scanForSecrets` / `scanAgentPayload`** (`src/lib/secret-scan.ts`): POST and PUT scan the *real text* (Lexical → `lexicalToText`, or the raw string) for AWS AKIA keys, PEM blocks, `sk-` API keys, GitHub tokens (`ghp_`…), generic credential shapes → **422 with pattern NAMES only**. Never echo the matched text, never log it.
|
||||
- **`scanForSecrets` / `scanAgentPayload`** (`src/lib/secret-scan.ts`): POST and PUT scan the _real text_ (Lexical → `lexicalToText`, or the raw string) for AWS AKIA keys, PEM blocks, `sk-` API keys, GitHub tokens (`ghp_`…), generic credential shapes → **422 with pattern NAMES only**. Never echo the matched text, never log it.
|
||||
- String `content` → 400 (Lexical JSON required); no silent placeholder substitution.
|
||||
- Comment bodies: textarea, moderated (pending until approved), author forced server-side from the session — client-supplied `author`/`authorName` identity is ignored/overwritten (ADR-0002). Display names are a snapshot, not a live join, so renamed users can't retroactively forge old comments.
|
||||
- All user-facing error strings are fixed Arabic sentences from the route handlers — errors never reflect raw input back (no echo = reflected-input hygiene).
|
||||
@@ -62,3 +63,12 @@ Rules:
|
||||
- No WAF/Cloudflare proxy changes without owner approval — the domain's DNS/proxy is Ahmed's console.
|
||||
- No secrets in client bundles; no `NEXT_PUBLIC_` for anything except the site URL.
|
||||
- No new auth provider without routing it through `viewer.ts` (see §1).
|
||||
|
||||
## 8. Accepted risks
|
||||
|
||||
### next@15.4.11 — 28 known CVEs, max 9.5 (risk-accepted by owner, 2026-10-13)
|
||||
|
||||
- **Why pinned**: `@payloadcms/next@3.90.2` peer-supports next `<15.5.0 || >=16.3.3` — no Payload 3.x release supports 15.5.x, so the safe ladder is 15.4.11 → (Payload 4 or a verified Next 16 migration). Forcing 15.5.x would run the CMS on an unsupported combo.
|
||||
- **Exposure analysis**: the 9.5-critical (Image Optimizer DoS, GHSA-9g9p-9gw9-jx7f) requires attacker-reachable `remotePatterns` — this app configures none (local covers only). The cache-poisoning advisory targets SSG/ISR caching; the site is predominantly dynamic SSR via the Payload Local API.
|
||||
- **Revisit trigger**: any of — Payload publishes 15.5/16 peer support (then upgrade next in one verified PR), Payload 4 adoption, or a new next advisory with remote-code-execution class impact.
|
||||
- **Gate note**: `procoder check` re-flags this on any `package-lock.json` change. If you must touch the lockfile before upgrading, either upgrade next in the same PR or skip the gate once with the reason in the commit message. Transitive CVE fixes already applied: undici 8.11.2, sharp 0.35.5, postcss 8.5.30, sass 1.105.1.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -15,6 +15,9 @@ services:
|
||||
retries: 10
|
||||
|
||||
web:
|
||||
# Run migrations explicitly as a separate reviewed deployment step.
|
||||
# Access-control-only release: avoid the dev-schema interactive prompt.
|
||||
command: ["npm", "run", "start"]
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
|
||||
Generated
+902
-3655
File diff suppressed because it is too large
Load Diff
+4
-15
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:launch": "node --test tests/free-launch.test.cjs",
|
||||
"dev": "next dev",
|
||||
"build": "payload generate:types && next build",
|
||||
"start": "next start",
|
||||
@@ -12,36 +13,24 @@
|
||||
"covers": "node scripts/generate-covers.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^7.9.14",
|
||||
"@payloadcms/db-postgres": "^3.60.0",
|
||||
"@payloadcms/next": "^3.60.0",
|
||||
"@payloadcms/richtext-lexical": "^3.60.0",
|
||||
"next": "^15.5.27",
|
||||
"next": "15.4.11",
|
||||
"payload": "^3.60.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sharp": "^0.35.5",
|
||||
"sharp": "^0.34.0",
|
||||
"stripe": "^18.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"eslint": "^10.12.0",
|
||||
"prettier": "^3.9.10",
|
||||
"typescript": "^5.9.0",
|
||||
"typescript-eslint": "^8.71.2",
|
||||
"vitest": "^5.0.4"
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^8.11.2",
|
||||
"sharp": "^0.35.5",
|
||||
"postcss": "^8.5.6",
|
||||
"sass": "^1.79.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
@@ -4,9 +4,9 @@ import { getViewer } from '@/lib/viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = {
|
||||
title: 'عن الفريق',
|
||||
title: 'عن الفريق: تجارب الذكاء الاصطناعي والبنية التحتية',
|
||||
description:
|
||||
'فريق أنور بآي: عرب يكتبون أدلة ذكاء اصطناعي عملية — تجارب موثقة، خطوات قابلة للتطبيق، وملفات عمل حقيقية بلا حشو تسويقي.',
|
||||
'فريق أنور پآي: عرب يكتبون محتوى ذكاء اصطناعي عملي — تجارب موثقة، خطوات قابلة للتطبيق، وملفات عمل حقيقية بلا حشو تسويقي.',
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
@@ -19,7 +19,7 @@ export default async function AboutPage() {
|
||||
<div className="wrap about-hero-grid">
|
||||
<div>
|
||||
<h1 id="about-title">نكتب عن تجاربنا، ونشارك ما جرّبناه عمليًا</h1>
|
||||
<p className="lede">أنور بآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى أدلة عربية يمكن تنفيذها في عملك.</p>
|
||||
<p className="lede">أنور پآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى محتوى عربي يمكن تطبيقه في عملك.</p>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="about-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
@@ -54,7 +54,7 @@ export default async function AboutPage() {
|
||||
|
||||
<section className="about-principles" aria-labelledby="principles-title">
|
||||
<div className="wrap principles-grid">
|
||||
<h2 id="principles-title">كيف نكتب كل دليل</h2>
|
||||
<h2 id="principles-title">كيف نكتب كل عنصر محتوى</h2>
|
||||
<dl className="principles">
|
||||
<div><dt>التطبيق قبل الاستعراض</dt><dd>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</dd></div>
|
||||
<div><dt>نشرح ما تحت الغطاء</dt><dd>نفضّل الأدوات المفتوحة والتشغيل الذي يمنحك فهماً وتحكماً أكبر في بياناتك ومواردك.</dd></div>
|
||||
@@ -65,7 +65,7 @@ export default async function AboutPage() {
|
||||
|
||||
<section className="about-contact">
|
||||
<div className="wrap contact-strip">
|
||||
<p>عندك سؤال عن دليل أو فكرة تريد تطبيقها؟</p>
|
||||
<p>عندك سؤال عن محتوى أو فكرة تريد تطبيقها؟</p>
|
||||
<a className="btn btn-quiet" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -151,14 +151,14 @@ export default function AccountSettingsClient({ email, userId, fullName, role, b
|
||||
|
||||
<section className={`panel${tab === 'prefs' ? ' active' : ''}`} id="panel-prefs" role="tabpanel" aria-labelledby="tab-prefs" data-od-id="panel-prefs">
|
||||
<h2>التفضيلات</h2>
|
||||
<p className="panel-sub">رسائل أنور بآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p>
|
||||
<p className="panel-sub">رسائل أنور پآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p>
|
||||
<div className="prefs">
|
||||
<div className="pref-row"><div><strong>الرسالة الأسبوعية</strong><p>خلاصة ما نُشر من أدلة ومقالات، كل يوم أحد.</p></div>
|
||||
<div className="pref-row"><div><strong>الرسالة الأسبوعية</strong><p>خلاصة ما نُشر من محتوى، كل يوم أحد.</p></div>
|
||||
<label className="switch"><input type="checkbox" id="prefWeekly" aria-label="تفعيل الرسالة الأسبوعية" checked={prefs.weekly} onChange={e => setPrefs(p => ({ ...p, weekly: e.target.checked }))} /><span className="track"></span></label></div>
|
||||
<div className="pref-row"><div><strong>إشعارات التعليقات</strong><p>رسالة عند ردّ أحد الأعضاء على تعليقك.</p></div>
|
||||
<label className="switch"><input type="checkbox" id="prefReplies" aria-label="تفعيل إشعارات التعليقات" checked={prefs.replies} onChange={e => setPrefs(p => ({ ...p, replies: e.target.checked }))} /><span className="track"></span></label></div>
|
||||
<div className="pref-row"><div><strong>تحديثات الأدلة المحفوظة</strong><p>إشعار عندما يُحدَّث دليل حفظته للعودة إليه.</p></div>
|
||||
<label className="switch"><input type="checkbox" id="prefUpdates" aria-label="تفعيل تحديثات الأدلة المحفوظة" checked={prefs.updates} onChange={e => setPrefs(p => ({ ...p, updates: e.target.checked }))} /><span className="track"></span></label></div>
|
||||
<div className="pref-row"><div><strong>تحديثات المحتوى المحفوظ</strong><p>إشعار عندما يُحدَّث محتوى حفظته للعودة إليه.</p></div>
|
||||
<label className="switch"><input type="checkbox" id="prefUpdates" aria-label="تفعيل تحديثات المحتوى المحفوظ" checked={prefs.updates} onChange={e => setPrefs(p => ({ ...p, updates: e.target.checked }))} /><span className="track"></span></label></div>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="button" id="savePrefs" style={{ marginTop: 28 }} data-od-id="save-prefs" onClick={savePrefs}>حفظ التفضيلات</button>
|
||||
<p className={`status ${prefsStatus?.ok ? 'ok' : prefsStatus ? 'err' : ''}`} id="prefsStatus" role="status">{prefsStatus?.text}</p>
|
||||
@@ -176,7 +176,7 @@ export default function AccountSettingsClient({ email, userId, fullName, role, b
|
||||
{billingEnabled
|
||||
? <div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
|
||||
: <div><span className="price-tag price-tag-early">وصول مبكر مجاني</span></div>}
|
||||
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : billingEnabled ? 'الأدلة المجانية ومعاينة الأدلة' : 'كل المحتوى المنشور مؤقتاً — الوصول المبكر المجاني'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل المحتوى الحصري وملفات العمل' : billingEnabled ? 'المحتوى المجاني ومعاينة المحتوى' : 'كل المحتوى المنشور مؤقتاً — الوصول المبكر المجاني'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">{billingEnabled ? 'ستُربط عبر Stripe' : 'لا يوجد — لن يُحصَّل أي مبلغ خلال الوصول المبكر'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,11 @@ import configPromise from '@payload-config'
|
||||
import '../styles/account-settings.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'إعدادات الحساب' }
|
||||
export const metadata = {
|
||||
title: 'إعدادات الحساب',
|
||||
description: 'إدارة حسابك القائم في أنور پآي: بيانات الحساب وتفضيلات الرسائل والميزات المتاحة. يلزم تسجيل الدخول، ولا تحتاج إلى حساب لقراءة المحتوى العام.',
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
@@ -21,7 +25,7 @@ export default async function AccountSettingsPage() {
|
||||
{user ? (
|
||||
<main id="content">
|
||||
<section className="page-head" data-od-id="settings-header"><div className="container">
|
||||
<p className="crumb">حسابك في أنور بآي</p>
|
||||
<p className="crumb">حسابك في أنور پآي</p>
|
||||
<h1 data-od-id="settings-title">إعدادات الحساب</h1>
|
||||
<p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? formatDate(String(user.createdAt)) : 'اليوم'}</p>
|
||||
</div></section>
|
||||
@@ -42,7 +46,7 @@ export default async function AccountSettingsPage() {
|
||||
<p>من هنا تعدّل اسمك وكلمة المرور وتفضيلات الرسائل، وتدير عضويتك.</p>
|
||||
<div className="actions">
|
||||
<a className="btn btn-primary" href="/login?next=%2Faccount-settings">تسجيل الدخول</a>
|
||||
<a className="btn btn-quiet" href="/signup">إنشاء حساب مجاني</a>
|
||||
<a className="btn btn-quiet" href="/articles">اقرأ مجاناً دون حساب</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import { accessLabel } from '@/lib/launch'
|
||||
import Footer from '@/components/footer'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
@@ -10,9 +11,9 @@ import { formatNumber, guidesLabel, minutesLabel, contentTypeLabel } from '@/lib
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = {
|
||||
title: 'المكتبة',
|
||||
title: 'مكتبة الذكاء الاصطناعي: نماذج ووكلاء وأدوات',
|
||||
description:
|
||||
'أدلة الذكاء الاصطناعي العملية بالعربية: وكلاء، نماذج، استرجاع، سلامة، أدوات، وأعمال — بخطوات قابلة للتطبيق وتجارب موثقة وملفات عمل.',
|
||||
'محتوى الذكاء الاصطناعي العملي بالعربية: وكلاء، نماذج، استرجاع، سلامة، أدوات، وأعمال — بخطوات قابلة للتطبيق وتجارب موثقة وملفات عمل.',
|
||||
}
|
||||
|
||||
type PostDoc = {
|
||||
@@ -93,7 +94,7 @@ export default async function ArticlesPage() {
|
||||
<section className="lib-head" aria-labelledby="library-title">
|
||||
<div className="wrap">
|
||||
<h1 id="library-title">المكتبة</h1>
|
||||
<p className="lede">تضم المكتبة {guidesLabel(posts.length)}، منها {guidesLabel(freeCount)} متاحة مجاناً بالكامل. ابحث بالموضوع أو صفِّ النتائج حسب المسار.</p>
|
||||
<p className="lede">تضم المكتبة {guidesLabel(posts.length)}، كلها مفتوحة للقراءة خلال الوصول المبكر؛ {guidesLabel(freeCount)} مصنفة مجاناً بشكل دائم. ابحث بالموضوع أو صفِّ النتائج حسب المسار.</p>
|
||||
<div className="lib-tools">
|
||||
<label className="lib-search">
|
||||
<span className="sr-only">ابحث في العناوين والوصف</span>
|
||||
@@ -115,13 +116,13 @@ export default async function ArticlesPage() {
|
||||
<div className="segmented" role="group" aria-label="نوع الوصول">
|
||||
<button type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
|
||||
<button type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
|
||||
<button type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">للأعضاء</button>
|
||||
<button type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">للعضوية مستقبلاً · مجانية الآن</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="lib-results" aria-label="الأدلة">
|
||||
<section className="lib-results" aria-label="المحتوى">
|
||||
<div className="wrap">
|
||||
<nav className="topic-strip" aria-label="تصفّح المسارات">
|
||||
{TOPICS.map(topic => <Link key={topic.key} href={`/topics/${topic.key}`}>{topic.label}</Link>)}
|
||||
@@ -156,7 +157,7 @@ export default async function ArticlesPage() {
|
||||
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
|
||||
<p className="guide-card-dek">{post.dek}</p>
|
||||
<div className="guide-card-foot">
|
||||
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span>
|
||||
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{accessLabel(post.access)}</span>
|
||||
<span className="engagement">
|
||||
<span data-views>{formatNumber(post.views ?? 0)} قراءة</span>
|
||||
<button className="like" type="button" data-like={post.slug} aria-pressed={likedSlugs.has(post.slug)} aria-label="أعجبني">
|
||||
@@ -171,7 +172,7 @@ export default async function ArticlesPage() {
|
||||
})}
|
||||
</div>
|
||||
<div className="empty" id="emptyState">
|
||||
<p>لا يوجد دليل يطابق هذا البحث.</p>
|
||||
<p>لا يوجد محتوى يطابق هذا البحث.</p>
|
||||
<p>جرّب كلمة أقصر أو اختر «كل المسارات».</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export type DeckSlide = {
|
||||
kicker: string
|
||||
index: string
|
||||
title: React.ReactNode
|
||||
body: string
|
||||
format: React.ReactNode
|
||||
link?: { href: string; label: string }
|
||||
meta?: React.ReactNode
|
||||
}
|
||||
|
||||
const V1_SLIDES: DeckSlide[] = [
|
||||
{
|
||||
kicker: 'محتوى حصري · المستوى المتوسط',
|
||||
index: '01',
|
||||
title: 'ابنِ وكيلك الأول دون فوضى',
|
||||
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
|
||||
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
|
||||
link: { href: '/posts/agent-loop-stop-condition', label: 'شاهد المعاينة ←' },
|
||||
},
|
||||
{
|
||||
kicker: 'مقال تطبيقي · للمبتدئين',
|
||||
index: '02',
|
||||
title: 'كيف تختار نموذجاً لمهمتك؟',
|
||||
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
|
||||
format: <>قراءة · 12 دقيقة</>,
|
||||
link: { href: '/posts/article-agent-guide', label: 'ابدأ القراءة ←' },
|
||||
},
|
||||
{
|
||||
kicker: 'مسار متقدم · للأعضاء',
|
||||
index: '03',
|
||||
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
|
||||
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
|
||||
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
|
||||
link: { href: '/subscription', label: 'شاهد للأعضاء ←' },
|
||||
},
|
||||
]
|
||||
|
||||
const DOT_LABELS = ['الشريحة الأولى', 'الشريحة الثانية', 'الشريحة الثالثة']
|
||||
|
||||
/* Featured content carousel. Manual selection pauses rotation until resumed. */
|
||||
export default function CatalogDeck({ slides = V1_SLIDES }: { slides?: DeckSlide[] }) {
|
||||
const [activeSlide, setActiveSlide] = useState(0)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [playing, setPlaying] = useState(true)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const current = slides.length ? activeSlide % slides.length : 0
|
||||
const advancing = ready && playing && !hovered && !focused && slides.length > 1
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem('midad-slide'))
|
||||
if (Number.isInteger(stored) && stored >= 0) setActiveSlide(stored)
|
||||
} catch { /* Persistence is optional. */ }
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
if (motion.matches) setPlaying(false)
|
||||
const onMotion = () => { if (motion.matches) setPlaying(false) }
|
||||
motion.addEventListener('change', onMotion)
|
||||
setReady(true)
|
||||
return () => motion.removeEventListener('change', onMotion)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
try { localStorage.setItem('midad-slide', String(current)) } catch { /* optional persistence */ }
|
||||
}, [current, ready])
|
||||
|
||||
useEffect(() => {
|
||||
if (!advancing) return
|
||||
const timer = setTimeout(() => setActiveSlide(index => (index + 1) % slides.length), 6500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [advancing, current, slides.length])
|
||||
|
||||
function selectSlide(index: number) {
|
||||
setActiveSlide((index + slides.length) % slides.length)
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
if (!slides.length) return null
|
||||
|
||||
return (
|
||||
<div className="deck" data-od-id="featured-deck" role="region" aria-roledescription="عرض شرائح" aria-label="مختارات مميزة"
|
||||
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
|
||||
onFocusCapture={() => setFocused(true)}
|
||||
onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'ArrowLeft') { event.preventDefault(); selectSlide(current + 1) }
|
||||
if (event.key === 'ArrowRight') { event.preventDefault(); selectSlide(current - 1) }
|
||||
}}>
|
||||
|
||||
<div className="deck-frame" aria-live={advancing ? 'off' : 'polite'}>
|
||||
{slides.map((s, i) => (
|
||||
<article className={`deck-slide${i === current ? ' active' : ''}`} hidden={i !== current} aria-label={`${i + 1} من ${slides.length}`} data-slide={i} key={i}>
|
||||
<div className="deck-slide-top"><span className="deck-slide-kicker">{s.kicker}</span><span className="deck-slide-index num">{s.index}</span></div>
|
||||
<div className="deck-slide-body"><h2>{s.title}</h2><p>{s.body}</p></div>
|
||||
<div className="deck-slide-foot">
|
||||
<span className="deck-slide-format">{s.format}</span>
|
||||
{s.link
|
||||
? <Link className="deck-slide-link" href={s.link.href}>{s.link.label}</Link>
|
||||
: <span className="meta">{s.meta}</span>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="deck-controls">
|
||||
<div className="deck-dots" aria-label="اختيار الشريحة">
|
||||
{slides.map((_, i) => (
|
||||
<button type="button" className={`deck-dot${i === current ? ' active' : ''}`} data-dot={i} aria-label={DOT_LABELS[i] ?? `الشريحة ${i + 1}`} aria-current={i === current ? 'true' : undefined} onClick={() => selectSlide(i)} key={i}></button>
|
||||
))}
|
||||
</div>
|
||||
<div className="deck-arrows">
|
||||
<button type="button" className="icon-btn" id="prevSlide" aria-label="الشريحة السابقة" disabled={slides.length < 2} onClick={() => selectSlide(current - 1)}>→</button>
|
||||
<button type="button" className="icon-btn" id="nextSlide" aria-label="الشريحة التالية" disabled={slides.length < 2} onClick={() => selectSlide(current + 1)}>←</button>
|
||||
{slides.length > 1 && <button type="button" className="icon-btn deck-play" aria-label={playing ? 'إيقاف العرض التلقائي' : 'تشغيل العرض التلقائي'} onClick={() => setPlaying(value => !value)}>{playing ? 'Ⅱ' : '▶'}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/* Filter bar for the editors' picks. Cards carry space-separated topics. */
|
||||
export default function CatalogFilters() {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const filters = [...document.querySelectorAll('.filter')] as HTMLElement[]
|
||||
const cards = [...document.querySelectorAll('.catalog-card')] as HTMLElement[]
|
||||
const empty = document.getElementById('emptyState')
|
||||
|
||||
function filterCatalog(topic: string) {
|
||||
let visible = 0
|
||||
filters.forEach(button => {
|
||||
const selected = (button as HTMLElement).dataset.filter === topic
|
||||
button.classList.toggle('active', selected)
|
||||
button.setAttribute('aria-pressed', String(selected))
|
||||
})
|
||||
cards.forEach(card => {
|
||||
const show = topic === 'all' || (card.dataset.topic || '').split(' ').includes(topic)
|
||||
card.hidden = !show
|
||||
if (show) visible += 1
|
||||
})
|
||||
empty?.classList.toggle('show', visible === 0)
|
||||
}
|
||||
|
||||
filters.forEach(button => button.addEventListener('click', () => {
|
||||
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
filterCatalog((button as HTMLElement).dataset.filter!)
|
||||
}, { signal: controller.signal }))
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="filterbar" role="group" aria-label="تصفية المحتوى" data-od-id="catalog-filters">
|
||||
<button type="button" className="filter active" data-filter="all" aria-pressed="true">الكل</button>
|
||||
<button type="button" className="filter" data-filter="agents" aria-pressed="false">الوكلاء</button>
|
||||
<button type="button" className="filter" data-filter="models" aria-pressed="false">النماذج</button>
|
||||
<button type="button" className="filter" data-filter="automation" aria-pressed="false">الأتمتة</button>
|
||||
<button type="button" className="filter" data-filter="foundations" aria-pressed="false">الأساسيات</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export default function LoginPage() {
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
<h1>أكمل من حيث توقفت.</h1>
|
||||
<p>سجّل الدخول لمتابعة قراءة الأدلة والمشاركة في النقاشات.</p>
|
||||
<p>القراءة مجانية دون حساب. تسجيل الدخول لأصحاب الحسابات القائمة والميزات المتاحة.</p>
|
||||
</section>
|
||||
<section className="auth-form-wrap" data-od-id="login-form-section">
|
||||
<form className="auth-form" id="loginForm" method="post" action="/api/users/login" noValidate onSubmit={onSubmit} data-od-id="login-form">
|
||||
@@ -100,7 +100,7 @@ export default function LoginPage() {
|
||||
<div className="status" id="loginStatus" role="status">{status}</div>
|
||||
<div className="divider">أو</div>
|
||||
<Link className="magic" href="/forgot-password">استعد الوصول إلى حسابك</Link>
|
||||
<p className="signup">ليس لديك حساب؟ <Link href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : '/signup'}>أنشئ حساباً جديداً</Link></p>
|
||||
<p className="signup">التسجيل العام متوقف. <Link href="/articles">اقرأ دون حساب</Link></p>
|
||||
<p className="fine">بالمتابعة، أنت توافق على <Link href="/terms">شروط الاستخدام</Link> و<Link href="/privacy">سياسة الخصوصية</Link>.</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -123,20 +123,20 @@ export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
|
||||
<h2>ابدأ مجاناً أو افتح محتوى العضوية الكامل.</h2>
|
||||
<p className="intro" id="summaryCopy">{summary}</p>
|
||||
<div className="benefits" data-od-id="vip-benefits">
|
||||
<article className="benefit"><span className="benefit-num">01</span><div><h3>الأدلة المتقدمة كاملة</h3><p>اقرأ ما بعد المعاينة في أدلة الوكلاء وRAG والأتمتة والنماذج.</p></div></article>
|
||||
<article className="benefit"><span className="benefit-num">02</span><div><h3>ملفات العمل</h3><p>نزّل القوالب وقوائم الفحص المرتبطة بكل دليل.</p></div></article>
|
||||
<article className="benefit"><span className="benefit-num">03</span><div><h3>نقطة بداية مخصصة</h3><p>إعداد العضوية ترتّب دليلك الأول ورسائلك بناءً على هدفك.</p></div></article>
|
||||
<article className="benefit"><span className="benefit-num">01</span><div><h3>المحتوى المتقدم كاملًا</h3><p>اقرأ ما بعد المعاينة في محتوى الوكلاء وRAG والأتمتة والنماذج.</p></div></article>
|
||||
<article className="benefit"><span className="benefit-num">02</span><div><h3>ملفات العمل</h3><p>نزّل القوالب وقوائم الفحص المرتبطة بكل عنصر محتوى.</p></div></article>
|
||||
<article className="benefit"><span className="benefit-num">03</span><div><h3>نقطة بداية مخصصة</h3><p>إعداد العضوية يرتّب محتواك الأول ورسائلك بناءً على هدفك.</p></div></article>
|
||||
</div>
|
||||
<div className="upgrade-note">
|
||||
<strong>الحساب المجاني لا يختفي.</strong>
|
||||
<p>يمكنك قراءة الأدلة المجانية وحفظها، والترقية عندما تحتاج الأدلة الحصرية.</p>
|
||||
<p>يمكنك قراءة المحتوى المجاني وحفظه، والترقية عندما تحتاج المحتوى الحصري.</p>
|
||||
</div>
|
||||
<div className="actions" data-od-id="final-actions">
|
||||
{nextPath ? (
|
||||
<Link className="btn btn-primary" href={nextPath} data-od-id="continue-next-cta">تابع إلى وجهتك</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link className="btn btn-secondary" href="/articles?access=free" data-od-id="continue-free-cta">ابدأ بالأدلة المجانية</Link>
|
||||
<Link className="btn btn-secondary" href="/articles?access=free" data-od-id="continue-free-cta">ابدأ بالمحتوى المجاني</Link>
|
||||
<Link className="btn btn-primary" href="/subscription" data-od-id="view-membership-cta">شاهد العضوية والأسعار</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
|
||||
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import { freeEarlyAccess, accessLabel, LAUNCH_POLICY } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* Editorial picks for «ابدأ من هنا». */
|
||||
const START_HERE = ['first-arabic-agent', 'choosing-your-model', 'prototype-to-production', 'simple-arabic-rag', 'safe-agent-guardrails', 'agent-loop-stop-condition']
|
||||
/* Demo seed articles remain in the library, but never in the popular section. */
|
||||
const DEMO_POST_SLUGS = ['first-arabic-agent', 'agent-loop-stop-condition', 'choosing-your-model', 'systematic-model-eval', 'simple-arabic-rag', 'vector-databases-in-practice', 'safe-agent-guardrails', 'agent-output-review-gates', 'indispensable-ai-tools', 'tool-pipelines-and-permissions', 'ai-in-your-business', 'prototype-to-production', 'article-agent-guide']
|
||||
|
||||
type Doc = { id: number; title: string; slug: string; dek?: string | null; access: 'free' | 'vip'; category: string; level?: string | null; readingMinutes: number; cover?: { url?: string | null } | number | null }
|
||||
type Doc = { id: number; title: string; slug: string; dek?: string | null; access: 'free' | 'vip'; category: string; level?: string | null; readingMinutes: number; views?: number | null; cover?: { url?: string | null } | number | null }
|
||||
|
||||
export default async function HomePage() {
|
||||
const viewer = await getViewer()
|
||||
@@ -43,7 +43,17 @@ export default async function HomePage() {
|
||||
.slice(0, 4)
|
||||
.map(({ d }) => d)
|
||||
const counts = Object.fromEntries(TOPICS.map(t => [t.key, docs.filter(d => topicsFor(d).includes(t.key)).length]))
|
||||
const startHere = START_HERE.map(slug => docs.find(d => d.slug === slug)).filter((d): d is Doc => Boolean(d))
|
||||
// Query the complete published collection, not just the 200 newest posts.
|
||||
// Read-count ties prefer the newest publication, then a stable slug order.
|
||||
const { docs: popular } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { and: [{ status: { equals: 'published' } }, { slug: { not_in: DEMO_POST_SLUGS } }] },
|
||||
depth: 1,
|
||||
limit: 6,
|
||||
sort: ['-views', '-publishedAt', 'slug'],
|
||||
select: { title: true, slug: true, dek: true, access: true, category: true, readingMinutes: true, cover: true, views: true },
|
||||
})
|
||||
const startHere = popular as unknown as Doc[]
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-home">
|
||||
@@ -54,7 +64,7 @@ export default async function HomePage() {
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'أنور بآي',
|
||||
name: 'أنور پآي',
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai',
|
||||
logo: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'}/brand/icon-256.png`,
|
||||
}),
|
||||
@@ -67,15 +77,15 @@ export default async function HomePage() {
|
||||
<div className="home-hero-copy">
|
||||
<p className="kicker">مكتبة عربية للذكاء الاصطناعي العملي</p>
|
||||
<h1>افهم الذكاء الاصطناعي، ثم ابنِ به.</h1>
|
||||
<p className="lede">أدلة عربية عملية عن الوكلاء والنماذج والأتمتة، يكتبها مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p>
|
||||
<p className="lede">محتوى عربي عملي عن الوكلاء والنماذج والأتمتة، يكتبه مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p>
|
||||
<div className="actions">
|
||||
<a className="btn btn-primary" href="/articles?access=free">ابدأ بدليل مجاني</a>
|
||||
<a className="btn btn-primary" href="/articles">ابدأ القراءة المجانية</a>
|
||||
<Link className="btn btn-quiet" href="/articles">تصفح المكتبة</Link>
|
||||
</div>
|
||||
</div>
|
||||
{hero.length > 0 && (
|
||||
<aside className="hero-panel" aria-labelledby="new-title">
|
||||
<h2 id="new-title">أحدث الأدلة</h2>
|
||||
<h2 id="new-title">أحدث المحتوى</h2>
|
||||
<ul role="list">
|
||||
{hero.map(d => {
|
||||
const coverUrl = typeof d.cover === 'object' ? d.cover?.url ?? null : null
|
||||
@@ -84,13 +94,13 @@ export default async function HomePage() {
|
||||
<Link href={`/posts/${d.slug}`}>
|
||||
{coverUrl && <img className="hero-panel-thumb" src={coverUrl} alt="" width={72} height={48} loading="lazy" />}
|
||||
<span className="hero-panel-title">{d.title}</span>
|
||||
<span className="meta-row"><span>{d.category}</span><span>{minutesLabel(d.readingMinutes)}</span><span className={d.access === 'vip' ? 'is-vip' : undefined}>{d.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></span>
|
||||
<span className="meta-row"><span>{d.category}</span><span>{minutesLabel(d.readingMinutes)}</span><span className={d.access === 'vip' ? 'is-vip' : undefined}>{accessLabel(d.access)}</span></span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<Link className="text-link" href="/articles">كل الأدلة</Link>
|
||||
<Link className="text-link" href="/articles">كل المحتوى</Link>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
@@ -100,7 +110,7 @@ export default async function HomePage() {
|
||||
<div className="wrap">
|
||||
<header className="section-head">
|
||||
<h2 id="paths-title">اختر مساراً حسب ما تريد بناءه</h2>
|
||||
<p>كل دليل ينتمي إلى مسار، وأدلة المبتدئين تظهر أيضاً في الأساسيات.</p>
|
||||
<p>ينتمي كل عنصر محتوى إلى مسار، ويظهر محتوى المبتدئين أيضاً في الأساسيات.</p>
|
||||
</header>
|
||||
<ol className="paths" role="list">
|
||||
{TOPICS.map(topic => (
|
||||
@@ -120,8 +130,8 @@ export default async function HomePage() {
|
||||
<section className="home-start" aria-labelledby="start-title">
|
||||
<div className="wrap">
|
||||
<header className="section-head">
|
||||
<h2 id="start-title">ابدأ من هنا</h2>
|
||||
<Link className="text-link" href="/articles">كل الأدلة ({formatNumber(docs.length)})</Link>
|
||||
<h2 id="start-title">الأكثر قراءة</h2>
|
||||
<Link className="text-link" href="/articles">كل المحتوى ({formatNumber(docs.length)})</Link>
|
||||
</header>
|
||||
<ul className="card-grid" role="list">
|
||||
{startHere.map(post => {
|
||||
@@ -130,10 +140,10 @@ export default async function HomePage() {
|
||||
<li key={post.id}>
|
||||
<article className="guide">
|
||||
{coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />}
|
||||
<p className="meta-row"><span className="cat">{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span><span className={post.access === 'vip' ? 'is-vip' : undefined}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></p>
|
||||
<p className="meta-row"><span className="cat">{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span><span>{formatNumber(post.views ?? 0)} قراءة</span><span className={post.access === 'vip' ? 'is-vip' : undefined}>{accessLabel(post.access)}</span></p>
|
||||
<h3><Link href={`/posts/${post.slug}`}>{post.title}</Link></h3>
|
||||
<p className="guide-dek">{post.dek}</p>
|
||||
<span className="guide-link" aria-hidden="true">اقرأ الدليل</span>
|
||||
<span className="guide-link" aria-hidden="true">اقرأ المحتوى</span>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
@@ -146,8 +156,8 @@ export default async function HomePage() {
|
||||
<section className="home-membership" id="membership" aria-labelledby="membership-title">
|
||||
<div className="wrap membership-panel">
|
||||
<div>
|
||||
<h2 id="membership-title">العضوية تفتح الأدلة المتقدمة وملفات العمل.</h2>
|
||||
<p>الأدلة المجانية كاملة دائماً. العضوية تضيف الأدلة المعمّقة، القوالب الجاهزة للتنزيل، والنقاش مع الفريق.</p>
|
||||
<h2 id="membership-title">اقرأ مجاناً الآن؛ العضوية خطة مستقبلية.</h2>
|
||||
<p>{LAUNCH_POLICY.summary}</p>
|
||||
</div>
|
||||
<div className="membership-offer">
|
||||
{freeEarlyAccess() ? (
|
||||
@@ -159,11 +169,11 @@ export default async function HomePage() {
|
||||
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
|
||||
)}
|
||||
<ul role="list">
|
||||
<li>{guidesLabel(docs.length - freeCount)} للأعضاء، وتُضاف أدلة جديدة باستمرار</li>
|
||||
<li>{guidesLabel(docs.length - freeCount)} مصنفة للعضوية مستقبلاً ومفتوحة الآن</li>
|
||||
<li>قوالب وقوائم فحص قابلة للتنزيل</li>
|
||||
<li>التعليق والنقاش على كل دليل</li>
|
||||
<li>القراءة دون حساب؛ التسجيل العام متوقف</li>
|
||||
</ul>
|
||||
<Link className="btn btn-primary" href="/subscription">اطّلع على العضوية</Link>
|
||||
<Link className="btn btn-primary" href="/subscription">تفاصيل الوصول المبكر</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,11 @@ import Footer from '@/components/footer'
|
||||
import Link from 'next/link'
|
||||
import '../styles/payment-cancelled.css'
|
||||
|
||||
export const metadata = { title: 'لم يكتمل الدفع' }
|
||||
export const metadata = {
|
||||
title: 'لم يكتمل الدفع',
|
||||
description: 'صفحة حالة عملية دفع غير مكتملة في أنور پآي. ارجع إلى المكتبة أو تواصل مع الفريق إذا احتجت إلى مساعدة؛ إتاحة الدفع تخضع لسياسة الإطلاق الحالية.',
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
/* Payment cancelled — design from payment-cancelled.html. */
|
||||
export default function PaymentCancelledPage() {
|
||||
@@ -24,7 +28,7 @@ export default function PaymentCancelledPage() {
|
||||
</div>
|
||||
<div className="actions">
|
||||
<Link className="btn btn-primary" href="/subscription" data-od-id="retry-payment-cta">العودة إلى العضوية</Link>
|
||||
<Link className="btn btn-secondary" href="/articles" data-od-id="browse-free-articles-cta">تصفح الأدلة المجانية</Link>
|
||||
<Link className="btn btn-secondary" href="/articles" data-od-id="browse-free-articles-cta">تصفح المحتوى المجاني</Link>
|
||||
</div>
|
||||
<p className="help">واجهت مشكلة أثناء الدفع؟ <a href="mailto:hello@anwarpy.ai?subject=%D9%85%D8%B4%D9%83%D9%84%D8%A9%20%D9%81%D9%8A%20%D8%A7%D9%84%D8%AF%D9%81%D8%B9" data-od-id="payment-help-link">تواصل مع الفريق</a>.</p>
|
||||
</article>
|
||||
|
||||
@@ -63,8 +63,8 @@ export default async function PaymentSuccessPage({ searchParams }: Props) {
|
||||
{vipNow ? (
|
||||
<>
|
||||
<p className="eyebrow">تم الدفع · العضوية فعّالة</p>
|
||||
<h1 data-od-id="success-title">مرحباً بك في عضوية أنور بآي.</h1>
|
||||
<p className="lead">تم تفعيل وصولك إلى الأدلة الحصرية وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p>
|
||||
<h1 data-od-id="success-title">مرحباً بك في عضوية أنور پآي.</h1>
|
||||
<p className="lead">تم تفعيل وصولك إلى المحتوى الحصري وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p>
|
||||
<dl className="summary" data-od-id="payment-summary">
|
||||
<div className="summary-row"><dt>الحالة</dt><dd>عضوية فعّالة</dd></div>
|
||||
<div className="summary-row"><dt>الخطة</dt><dd>عضوية فردية · شهرياً · <bdi dir="ltr">$10</bdi></dd></div>
|
||||
@@ -72,10 +72,10 @@ export default async function PaymentSuccessPage({ searchParams }: Props) {
|
||||
</dl>
|
||||
<div className="next-step" data-od-id="next-step">
|
||||
<h2 data-od-id="next-step-title">جهّز مسارك الأول</h2>
|
||||
<p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بدليل واحد يناسبك.</p>
|
||||
<p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بعنصر محتوى واحد يناسبك.</p>
|
||||
<div className="actions">
|
||||
<Link className="btn btn-primary" href="/vip-onboarding" data-od-id="start-onboarding-cta">ابدأ إعداد العضوية</Link>
|
||||
<Link className="btn btn-secondary" href="/posts/agent-loop-stop-condition" data-od-id="open-member-guide-cta">افتح دليل الأعضاء</Link>
|
||||
<Link className="btn btn-secondary" href="/posts/agent-loop-stop-condition" data-od-id="open-member-guide-cta">افتح محتوى الأعضاء</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import configPromise from '@payload-config'
|
||||
import Topnav from '@/components/topnav'
|
||||
import { accessLabel } from '@/lib/launch'
|
||||
import Footer from '@/components/footer'
|
||||
import PostExtras from './post-extras'
|
||||
import { LikeButton, ViewPing } from './engagement'
|
||||
@@ -11,6 +12,9 @@ import { TOPICS, topicsFor } from '@/lib/topics'
|
||||
import { formatDate, formatNumber, minutesLabel, contentTypeLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import { extractH2s, innerText, type Node } from '@/lib/lexical'
|
||||
import { youtubeVideoId } from '@/lib/youtube'
|
||||
import { articleImageUrl } from '@/lib/article-image'
|
||||
import YouTubePlayer from '@/components/youtube-player'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -29,7 +33,7 @@ export async function generateMetadata({ params }: Args) {
|
||||
const post = res.docs[0] as unknown as
|
||||
| { title: string; dek: string; publishedAt?: string | null; updatedAt?: string | null; cover?: { url?: string | null } | number }
|
||||
| undefined
|
||||
if (!post) return { title: 'أنور بآي' }
|
||||
if (!post) return { title: 'أنور پآي' }
|
||||
|
||||
const ogImage =
|
||||
post.cover && typeof post.cover === 'object' && post.cover.url ? post.cover.url : '/og-default.png'
|
||||
@@ -39,7 +43,7 @@ export async function generateMetadata({ params }: Args) {
|
||||
openGraph: {
|
||||
// Next replaces (not merges) the layout-level openGraph — redeclare fully.
|
||||
type: 'article',
|
||||
siteName: 'أنور بآي',
|
||||
siteName: 'أنور پآي',
|
||||
locale: 'ar',
|
||||
title: post.title,
|
||||
description: post.dek,
|
||||
@@ -207,15 +211,15 @@ export default async function PostPage({ params }: Args) {
|
||||
'@type': 'Article',
|
||||
headline: post.title,
|
||||
description: post.dek,
|
||||
image: typeof post.cover === 'object' && post.cover?.url ? post.cover.url : undefined,
|
||||
image: typeof post.cover === 'object' && post.cover?.url ? new URL(post.cover.url, siteUrl).toString() : undefined,
|
||||
datePublished: post.publishedAt ?? undefined,
|
||||
dateModified: post.updatedAt ?? post.publishedAt ?? undefined,
|
||||
author: post.authorName
|
||||
? { '@type': 'Person', name: post.authorName }
|
||||
: { '@type': 'Organization', name: 'أنور بآي' },
|
||||
: { '@type': 'Organization', name: 'أنور پآي' },
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'أنور بآي',
|
||||
name: 'أنور پآي',
|
||||
logo: { '@type': 'ImageObject', url: `${siteUrl}/brand/icon-256.png` },
|
||||
},
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': `${siteUrl}/posts/${post.slug}` },
|
||||
@@ -249,7 +253,7 @@ export default async function PostPage({ params }: Args) {
|
||||
<div className="art-meta">
|
||||
<span className={`badge${post.access === 'vip' ? ' badge-vip' : ''}`}>
|
||||
{post.access === 'vip' && <LockIcon />}
|
||||
{post.access === 'free' ? 'مجاني بالكامل' : unlocked ? 'للأعضاء' : 'معاينة مجانية'}
|
||||
{unlocked ? accessLabel(post.access) : 'معاينة مجانية'}
|
||||
</span>
|
||||
<span>{post.category}</span>
|
||||
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||
@@ -294,11 +298,11 @@ export default async function PostPage({ params }: Args) {
|
||||
: <p key={i} className={i === 0 ? 'intro' : undefined}>{para}</p>
|
||||
))}
|
||||
<section className="paywall" aria-labelledby="paywall-title">
|
||||
<h2 id="paywall-title">بقية هذا الدليل للأعضاء</h2>
|
||||
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل الأدلة الحصرية الأخرى.</p>
|
||||
<h2 id="paywall-title">بقية هذا المحتوى للأعضاء</h2>
|
||||
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل المحتوى الحصري الآخر.</p>
|
||||
{toc.length > 0 && (
|
||||
<div className="paywall-preview">
|
||||
<p className="aside-title">ماذا ينتظرك داخل هذا الدليل:</p>
|
||||
<p className="aside-title">ماذا ينتظرك داخل هذا المحتوى:</p>
|
||||
<ul role="list">{toc.map((t, i) => <li key={i}><LockIcon />{t}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -306,15 +310,15 @@ export default async function PostPage({ params }: Args) {
|
||||
<a className="btn btn-primary" href="/subscription">اشترك بـ <bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> شهرياً</a>
|
||||
<a className="btn btn-quiet" href={`/login?next=/posts/${post.slug}`}>لدي عضوية، تسجيل الدخول</a>
|
||||
</div>
|
||||
<p className="fine">تلغي في أي وقت من صفحة حسابك. <Link href="/subscription#faq">أسئلة قبل الاشتراك</Link> · <Link href="/articles?access=free">أكمل بالأدلة المجانية</Link></p>
|
||||
<p className="fine">تلغي في أي وقت من صفحة حسابك. <Link href="/subscription#faq">أسئلة قبل الاشتراك</Link> · <Link href="/articles?access=free">أكمل بالمحتوى المجاني</Link></p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
<aside className="art-aside" aria-label="داخل هذا الدليل">
|
||||
<aside className="art-aside" aria-label="داخل هذا المحتوى">
|
||||
{unlocked && toc.length > 0 && (
|
||||
<nav className="toc" aria-label="فهرس الدليل">
|
||||
<p className="aside-title">في هذا الدليل</p>
|
||||
<nav className="toc" aria-label="فهرس المحتوى">
|
||||
<p className="aside-title">في هذا المحتوى</p>
|
||||
<ol>{toc.map((t, i) => <li key={i}><a href={`#sec-${i}`}>{t}</a></li>)}</ol>
|
||||
</nav>
|
||||
)}
|
||||
@@ -340,17 +344,17 @@ export default async function PostPage({ params }: Args) {
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<div className="files">
|
||||
<p className="aside-title">ملفات هذا الدليل</p>
|
||||
<p className="aside-title">ملفات هذا المحتوى</p>
|
||||
<ul>
|
||||
{attachments.map(item => {
|
||||
// Per-file gate mirrors GET /api/files/[id]: free streams to
|
||||
// anyone; vip needs a vip/admin viewer (post access is
|
||||
// irrelevant — a vip file on a free post stays locked).
|
||||
const canDownload = item.access === 'free' || role === 'vip' || role === 'admin'
|
||||
const accessLabel = item.access === 'free' ? 'مجاني' : 'للأعضاء'
|
||||
const fileAccessLabel = item.access === 'free' ? 'مجاني' : 'ملف مقيد للأعضاء'
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<span>{item.label || item.filename} <span className="file-access">· {accessLabel}</span></span>
|
||||
<span>{item.label || item.filename} <span className="file-access">· {fileAccessLabel}</span></span>
|
||||
{canDownload
|
||||
? <a className="dl-btn" href={`/api/files/${item.id}`} download>تنزيل</a>
|
||||
: <span className="dl-locked"><LockIcon />للأعضاء</span>}
|
||||
@@ -381,7 +385,7 @@ export default async function PostPage({ params }: Args) {
|
||||
</article>
|
||||
))}
|
||||
{comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && (
|
||||
<p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا الدليل.</p>
|
||||
<p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا المحتوى.</p>
|
||||
)}
|
||||
</div>
|
||||
<PostExtras slug={post.slug} loggedIn={role !== null} />
|
||||
@@ -397,7 +401,7 @@ export default async function PostPage({ params }: Args) {
|
||||
{related.map(item => (
|
||||
<li key={item.id}>
|
||||
<Link href={`/posts/${item.slug}`} className="related-item">
|
||||
<span className="meta-row"><span>{item.category}</span><span>{minutesLabel(item.readingMinutes)}</span><span>{item.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></span>
|
||||
<span className="meta-row"><span>{item.category}</span><span>{minutesLabel(item.readingMinutes)}</span><span>{accessLabel(item.access)}</span></span>
|
||||
<span className="related-title">{item.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -434,6 +438,26 @@ function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): Rea
|
||||
}
|
||||
if (n.type === 'linebreak') return <br key={key} />
|
||||
if (n.type === 'link') return <a key={key} href={n.url || '#'} className="text-link">{renderChildren(n.children, counter)}</a>
|
||||
// Only a paragraph made of exactly one recognized link is media.
|
||||
// Keep inline/multiple links ordinary; never interpret authored HTML.
|
||||
if (n.type === 'paragraph' && n.children?.length === 1) {
|
||||
const link = n.children[0]
|
||||
if (link.type === 'link' && link.url && youtubeVideoId(link.url)) {
|
||||
return <YouTubePlayer key={key} url={link.url} />
|
||||
}
|
||||
if (link.type === 'link' && link.url && link.children?.every(child => child.type === 'text')) {
|
||||
const src = articleImageUrl(link.url)
|
||||
const caption = innerText(link.children)
|
||||
if (src && caption.trim()) {
|
||||
return (
|
||||
<figure key={key} style={{ maxWidth: '100%', marginInline: 0 }}>
|
||||
<img src={src} alt={caption} loading="lazy" decoding="async" style={{ display: 'block', width: '100%', maxWidth: '100%', height: 'auto' }} />
|
||||
<figcaption>{caption}</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const inner = renderChildren(n.children, counter)
|
||||
switch (n.type) {
|
||||
case 'heading': {
|
||||
|
||||
@@ -77,6 +77,10 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
|
||||
const dlHandler = (e: Event) => {
|
||||
const b = e.currentTarget as HTMLButtonElement
|
||||
const file = DL_FILES[b.dataset.dl ?? '']
|
||||
if (!file) {
|
||||
b.textContent = 'الملف غير متاح'
|
||||
return
|
||||
}
|
||||
if (file) {
|
||||
const blob = new Blob([file.body], { type: file.type })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -138,9 +142,9 @@ export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn:
|
||||
if (!loggedIn) {
|
||||
return (
|
||||
<div className="comment-gate">
|
||||
<p>سجّل الدخول لتشارك سؤالك أو تجربتك مع هذا الدليل.</p>
|
||||
<p>سجّل الدخول لتشارك سؤالك أو تجربتك مع هذا المحتوى.</p>
|
||||
<a className="btn btn-quiet" href={`/login?next=/posts/${slug}`}>سجّل الدخول للتعليق</a>
|
||||
<p>لا تملك حساباً؟ <a href={`/signup?next=/posts/${slug}`}>أنشئ حساباً مجاناً</a></p>
|
||||
<p>التسجيل العام متوقف؛ القراءة لا تتطلب حساباً. للملاحظات: <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { LAUNCH_POLICY } from '@/lib/launch'
|
||||
import '../styles/privacy.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'الخصوصية' }
|
||||
export const metadata = {
|
||||
title: 'سياسة خصوصية Anwarpy',
|
||||
description: 'سياسة خصوصية أنور پآي: تعرّف على بيانات الزيارة والحسابات والتواصل، وكيف تُستخدم وتُحفظ، ومدة الاحتفاظ بها، وطريقة طلب الوصول والتصحيح والحذف.',
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div id="site" className="site-page page-privacy">
|
||||
<Topnav />
|
||||
<main id="content">
|
||||
<section className="hero" data-od-id="privacy-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">الخصوصية · مسودة ما قبل الإطلاق</p>
|
||||
<h1 data-od-id="privacy-title">بياناتك ليست مادة للمحتوى.</h1>
|
||||
<p className="lead">توضح هذه المسودة الحد الأدنى المقترح لجمع البيانات واستخدامها داخل أنور بآي.</p>
|
||||
<p className="notice" role="note">هذه سياسة تجريبية للواجهة وليست نصاً قانونياً نهائياً. يجب مراجعتها واعتمادها قبل نشر الموقع.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container policy" data-od-id="privacy-content">
|
||||
<section data-od-id="privacy-data"><h2>ما الذي نجمعه؟</h2><p>عند تشغيل الحسابات فعلياً قد نحتاج إلى الاسم والبريد الإلكتروني، وبيانات العضوية والفوترة التي يوفرها مزوّد الدفع. لا ينبغي تخزين بيانات البطاقة كاملة داخل المنصة.</p></section>
|
||||
<section data-od-id="privacy-use"><h2>كيف تُستخدم البيانات؟</h2><ul><li>تسجيل الدخول وحفظ التقدّم والتفضيلات.</li><li>إدارة العضوية وإرسال الرسائل التي اختارها المستخدم.</li><li>حماية المنصة وتشخيص الأعطال وتحسين تجربة القراءة.</li></ul></section>
|
||||
<section data-od-id="privacy-sharing"><h2>المشاركة والاحتفاظ</h2><p>لا تُباع البيانات الشخصية. قبل الإطلاق يجب تسمية مزوّدي الاستضافة والبريد والدفع والتحليلات، وتحديد مدة الاحتفاظ بكل نوع من البيانات بوضوح.</p></section>
|
||||
<section data-od-id="privacy-rights"><h2>حقوقك</h2><p>يمكنك طلب نسخة من بياناتك أو تصحيحها أو حذفها بعد تفعيل الحسابات. أرسل طلبك إلى <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>.</p></section>
|
||||
<section data-od-id="privacy-updates"><h2>التحديثات</h2><p>يجب إضافة تاريخ السريان وملخص أي تغيير جوهري عند اعتماد النسخة القانونية النهائية.</p></section>
|
||||
<section className="hero"><div className="container">
|
||||
<p className="eyebrow">سياسات الوصول المبكر المجاني</p>
|
||||
<h1>سياسة خصوصية Anwarpy</h1>
|
||||
<p className="lead">تاريخ السريان: <time dateTime={LAUNCH_POLICY.effectiveDate}>{LAUNCH_POLICY.effectiveDate}</time></p>
|
||||
<p>{LAUNCH_POLICY.summary}</p>
|
||||
</div></section>
|
||||
<div className="container policy">
|
||||
<section><h2>المشغّل والنطاق</h2><p>يدير Anwarpy هذا الموقع. القراءة مجانية ولا تتطلب حساباً، وإنشاء الحسابات العامة متوقف. تتناول هذه السياسة بيانات زيارة الموقع والحسابات القائمة والتواصل، لا الملاحظات الخاصة التي لم تنشر.</p></section>
|
||||
<section><h2>البيانات التي تعالجها الخدمة</h2><p>قد تتضمن الحسابات القائمة الاسم والبريد وكلمة مرور محفوظة بصيغة تجزئة، الدور وحالة التحقق وتفضيلات المستخدم واهتماماته، وسجلات قبول الشروط. ترتبط التعليقات والإعجابات بالحساب عند استخدامها. تسجل الخدمة عدادات قراءة المنشورات، وقد تعالج الاستضافة وCloudflare عنوان IP ومعلومات الطلب والمتصفح وسجلات الأخطاء لأغراض التشغيل والأمان. ما ترسله إلى بريد الدعم، بما فيه عنوانك ومضمون الرسالة، يعالج للرد على طلبك.</p></section>
|
||||
<section><h2>الاستخدام والتخزين في المتصفح</h2><p>نستخدم البيانات لتسجيل الدخول وتشغيل الميزات المتاحة ومراجعة التعليقات وحماية الخدمة وتشخيص الأعطال والرد على الطلبات. قد يستخدم تسجيل الدخول ملفات ارتباط للجلسة، وتستخدم بعض الواجهات التخزين المحلي للتفضيلات والعرض؛ يمكنك مسحه من إعدادات المتصفح وقد تفقد معه تلك التفضيلات. لا نعلن عن أداة تحليلات تسويقية أو تتبع إعلاني غير متحقق منها.</p></section>
|
||||
<section><h2>الاستضافة والبريد والأطراف الأخرى</h2><p>يعمل الموقع على خادم خاص افتراضي (VPS). تمر الطلبات عبر Cloudflare بوصفه وسيطاً للشبكة والحماية. يستخدم تكامل البريد في الشيفرة Cloudflare Email Sending API للرسائل التشغيلية حين يكون مهيأً؛ لا نفترض أن كل رسالة سلمت. لا نجمع بيانات البطاقة ولا ننشئ اشتراكات Stripe في هذا الإطلاق. لا نبيع البيانات الشخصية. يقتصر وصول المزوّدين والمسؤولين إلى ما يلزم للتشغيل والحماية ومعالجة طلبك، وقد يجري النقل خارج بلدك وفق مواقع البنية التحتية؛ لا ندعي إقامة بيانات في بلد محدد.</p></section>
|
||||
<section><h2>البريد التشغيلي والتسويق</h2><p>الرسائل المتعلقة بحساب قائم أو طلب دعم رسائل تشغيلية وليست موافقة على التسويق. التسجيل في إشعارات العضوية متوقف إلى حين التحقق من التسليم والموافقة الاختيارية؛ لا نطلب بريدك للقراءة ولا نسجلك تلقائياً في قائمة. يمكنك التواصل مع <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> دون الاشتراك في أي خدمة مدفوعة.</p></section>
|
||||
<section><h2>الاحتفاظ والحذف</h2><p>السياسة المعتمدة هي الاحتفاظ بالبيانات الشخصية لمدة أقصاها سنة واحدة من جمعها، ثم حذفها أو إزالة ارتباطها بالهوية. يمكن حذفها قبل ذلك بطلب منك أو عند انتفاء الحاجة إليها. الاستثناء هو ما يلزم للاحتفاظ به لالتزام قانوني واجب أو نزاع قائم أو تحقيق أمني محدد؛ يقتصر على البيانات اللازمة ويزال عند انتهاء السبب. قد تبقى نسخ احتياطية معزولة حتى دورة استبدالها، ضمن الحد السنوي ما لم ينطبق استثناء. هذه سياسة يتولى المشغّل تنفيذها؛ لا ندعي وجود حذف تلقائي مجدول أو نظام تطهير مختبر. النسخ التي نشرها أو نزلها طرف آخر لا يمكن استرجاعها عن بُعد.</p></section>
|
||||
<section><h2>طلبات الوصول والتصحيح والحذف</h2><p>لطلب نسخة أو تصحيح أو حذف بياناتك أرسل إلى <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>. قد نطلب تحققاً مناسباً من ملكية الحساب لحماية بياناتك؛ لا ترسل كلمة مرور أو بيانات بطاقة. يشمل الطلب الحساب والبيانات المرتبطة به التي يمكن تحديدها، مع مراعاة الاستثناءات المذكورة. لا نعد بمدة تنفيذ لم يتم التحقق منها.</p></section>
|
||||
<section><h2>تغييرات السياسة</h2><p>ننشر أي تغيير جوهري وتاريخ سريانه هنا. قبل تشغيل الدفع أو جمع بيانات جديدة أو إتاحة قائمة تسويق، ستحدث هذه السياسة لتصف السلوك الفعلي وخيارات المستخدم.</p></section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -1,145 +1,23 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { safeNext } from '@/lib/next'
|
||||
import '../styles/signup.css'
|
||||
|
||||
/* Signup — markup from signup.html, wired to real Payload registration
|
||||
(create + auto-login) instead of the simulated client-only flow. */
|
||||
// Account creation is administrator-only until protected signup is approved.
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState<string | null>(null)
|
||||
const [suggestion, setSuggestion] = useState<string | null>(null)
|
||||
const [pwOk, setPwOk] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [nextPath, setNextPath] = useState<string | null>(null)
|
||||
|
||||
/* Common typos → canonical domain (step 16; on blur of the email input). */
|
||||
const EMAIL_TYPOS: Record<string, string> = {
|
||||
'gmial.com': 'gmail.com',
|
||||
'gmai.com': 'gmail.com',
|
||||
'gmail.co': 'gmail.com',
|
||||
'hotmial.com': 'hotmail.com',
|
||||
'outlook.co': 'outlook.com',
|
||||
'yahoo.co': 'yahoo.com',
|
||||
}
|
||||
function domainSuggestion(raw: string): string | null {
|
||||
const at = raw.lastIndexOf('@')
|
||||
if (at < 0) return null
|
||||
return EMAIL_TYPOS[raw.slice(at + 1).toLowerCase().trim()] ?? null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setNextPath(safeNext(new URLSearchParams(window.location.search).get('next')))
|
||||
}, [])
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const data = new FormData(form)
|
||||
setEmailError(null)
|
||||
if (!form.checkValidity()) {
|
||||
setStatus('تحقق من جميع الحقول المطلوبة.')
|
||||
form.reportValidity()
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
const res = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: data.get('email'), password: data.get('password'), fullName: data.get('name'), termsVersion: '2026-10-03' }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null) as { error?: string; errors?: { message?: string; path?: string; field?: string }[] } | null
|
||||
// Middleware 429 shape: { error: 'محاولات كثيرة جدًا. حاول بعد قليل.' }
|
||||
// + Retry-After seconds → show verbatim, append «حاول بعد X دقيقة.»
|
||||
const retryAfterHeader = res.headers.get('Retry-After')
|
||||
if (typeof err?.error === 'string') {
|
||||
const retryAfterSec = Number(retryAfterHeader)
|
||||
setStatus(
|
||||
retryAfterHeader && Number.isFinite(retryAfterSec)
|
||||
? `${err.error} حاول بعد ${Math.max(1, Math.ceil(retryAfterSec / 60))} دقيقة.`
|
||||
: err.error,
|
||||
)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const first = err?.errors?.[0]
|
||||
const failedField = first?.path ?? first?.field
|
||||
if (failedField === 'email' && first?.message) {
|
||||
// Duplicate email (unique-constraint ValidationError) → inline under
|
||||
// the email input, not the global status line.
|
||||
setEmailError('هذا البريد مسجَّل لدينا.')
|
||||
} else {
|
||||
setStatus(first?.message || 'تعذّر إنشاء الحساب — قد يكون البريد مستخدماً بالفعل.')
|
||||
}
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const login = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
|
||||
})
|
||||
if (login.ok) {
|
||||
try { sessionStorage.setItem('anwarpi:signup-email', String(data.get('email'))) } catch { /* private mode */ }
|
||||
try { nextPath ? sessionStorage.setItem('anwarpi:signup-next', nextPath) : sessionStorage.removeItem('anwarpi:signup-next') } catch { /* private mode */ }
|
||||
router.refresh()
|
||||
router.push('/verify-email')
|
||||
return
|
||||
}
|
||||
setStatus('تم إنشاء الحساب — سجّل الدخول للمتابعة.')
|
||||
router.push(nextPath ? '/login?next=' + encodeURIComponent(nextPath) : '/login')
|
||||
} catch {
|
||||
setStatus('تعذّر الاتصال. حاول مرة أخرى بعد التحقق من اتصالك.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page page-signup">
|
||||
<Topnav />
|
||||
<main id="content" className="auth">
|
||||
<section className="auth-story">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
<h1>ابدأ بحساب مجاني.</h1>
|
||||
<p>الحساب المجاني يفتح كل الأدلة المجانية كاملة والتعليق عليها. يمكنك الاشتراك في العضوية لاحقاً إن احتجتها.</p>
|
||||
<h1>التسجيل الجديد متوقف مؤقتاً.</h1>
|
||||
<p>يمكنك قراءة المحتوى المتاح خلال الوصول المبكر دون إنشاء حساب.</p>
|
||||
</section>
|
||||
<section className="auth-form-wrap" data-od-id="signup-form-section">
|
||||
<form className="auth-form" id="signupForm" method="post" action="/api/users" noValidate onSubmit={onSubmit} data-od-id="signup-form">
|
||||
<p className="eyebrow">حساب جديد</p>
|
||||
<h2 data-od-id="signup-title">إنشاء حساب</h2>
|
||||
<p className="lead">أدخل بياناتك الأساسية للبدء.</p>
|
||||
<div className="field"><label htmlFor="name">الاسم</label><input className="input" id="name" name="name" type="text" autoComplete="name" minLength={2} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل اسمك.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
|
||||
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required value={email} onChange={e => setEmail(e.target.value)} onBlur={e => setSuggestion(domainSuggestion(e.target.value))} onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} />
|
||||
{emailError && (
|
||||
<p className="field-error" role="alert">هذا البريد مسجَّل لدينا. <Link href="/login?next=/verify-email">تسجيل الدخول</Link></p>
|
||||
)}
|
||||
{!emailError && suggestion && (
|
||||
<button type="button" className="typo-suggest" onClick={() => { const at = email.lastIndexOf('@'); setEmail(`${email.slice(0, at + 1)}${suggestion}`); setSuggestion(null) }}>
|
||||
هل تقصد <bdi dir="ltr">{suggestion}</bdi>؟
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="field"><label htmlFor="password">كلمة المرور</label><div className="input-wrap"><input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="new-password" minLength={8} aria-describedby="passwordHint" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة مرور من ثمانية أحرف على الأقل.')} onInput={e => { e.currentTarget.setCustomValidity(''); setPwOk(e.currentTarget.value.length >= 8) }} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className={`fine${pwOk ? ' ok' : ''}`} id="passwordHint">ثمانية أحرف على الأقل.</span></div>
|
||||
<label className="check"><input id="terms" name="terms" type="checkbox" required /><span>أوافق على <Link href="/privacy">سياسة الخصوصية</Link> و<Link href="/terms">شروط الاستخدام</Link>.</span></label>
|
||||
<button className="btn btn-primary" type="submit" disabled={busy} data-od-id="create-account-cta">{busy ? 'جارٍ إنشاء الحساب…' : 'أنشئ حسابي'}</button>
|
||||
<p className="fine signup-reassure">مجاناً — بلا بطاقة ائتمانية. لن نشارك بريدك أبداً.</p>
|
||||
<div className="status" id="signupStatus" role="status" aria-live="polite">{status}</div>
|
||||
<p className="signin">لديك حساب بالفعل؟ <Link href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : '/login'}>سجّل الدخول</Link></p>
|
||||
</form>
|
||||
<section className="auth-form-wrap">
|
||||
<h2>لديك حساب بالفعل؟</h2>
|
||||
<p>تسجيل الدخول للحسابات الحالية متاح. أوقفنا إنشاء الحسابات الجديدة مؤقتاً إلى حين استكمال حماية التسجيل.</p>
|
||||
<p><Link href="/login" className="btn btn-primary">تسجيل الدخول</Link></p>
|
||||
<p><Link href="/articles">تصفح المكتبة دون حساب</Link></p>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.launch-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem 1rem;
|
||||
padding: .75rem max(1rem, env(safe-area-inset-right));
|
||||
background: #123a35;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: .9rem;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.launch-banner strong { font-weight: 700; }
|
||||
.launch-banner a { color: #fff; text-decoration: underline; text-underline-offset: .2em; }
|
||||
.launch-banner a:focus-visible { outline: 2px solid #fff; outline-offset: 4px; }
|
||||
@media (max-width: 600px) {
|
||||
.launch-banner { flex-direction: column; align-items: stretch; gap: .25rem; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
|
||||
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
|
||||
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
|
||||
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}.page-posts .nav-login,.page-posts .icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .nav-login{padding-inline:16px}.page-posts .nav-login:hover,.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .icon-btn{width:46px}
|
||||
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
|
||||
.page-posts .demo-strip b{color:var(--surface);font-weight:700}
|
||||
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
|
||||
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
|
||||
.page-posts .lock-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--fg);padding:6px 14px;font:12px/1.5 var(--font-mono);color:var(--fg);margin-bottom:22px}
|
||||
.page-posts .lock-chip svg{width:14px;height:14px}
|
||||
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
|
||||
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
|
||||
.page-posts .article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
|
||||
.page-posts .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
|
||||
.page-posts .article-body{font-size:19px;line-height:2}
|
||||
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
|
||||
.page-posts .article-body p{margin:0 0 26px}
|
||||
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
|
||||
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
|
||||
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
|
||||
.page-posts .free-tag{font:11px/1.5 var(--font-mono);color:var(--muted);margin-bottom:6px;display:block}
|
||||
.page-posts .lock-cut{position:relative}
|
||||
.page-posts .lock-cut::after{content:"";position:absolute;inset-inline:0;bottom:0;height:120px;background:linear-gradient(to bottom,transparent,var(--bg) 88%);pointer-events:none}
|
||||
.page-posts .lock-cut .cut-p{margin-bottom:0}
|
||||
.page-posts .paywall{margin-top:-30px;position:relative;border:1px solid var(--fg);background:var(--surface);padding:clamp(28px,4vw,44px);z-index:2}
|
||||
.page-posts .paywall .crumb{margin-bottom:12px}
|
||||
.page-posts .paywall h2{font-size:clamp(30px,3.6vw,44px);line-height:1.18;margin:0 0 14px;max-width:16ch}
|
||||
.page-posts .paywall p{color:var(--muted);max-width:56ch;margin:0 0 24px}
|
||||
.page-posts .locked-in{border-top:1px solid var(--fg);margin:0 0 26px}
|
||||
.page-posts .locked-in li{display:flex;align-items:center;gap:12px;min-height:52px;border-bottom:1px solid var(--border);color:var(--muted);font-size:16px}
|
||||
.page-posts .locked-in svg{width:14px;height:14px;flex:none}
|
||||
.page-posts .locked-in .num{margin-inline-start:auto;font:12px/1.5 var(--font-mono)}
|
||||
.page-posts .paywall-actions{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||
.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-posts .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||
.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
|
||||
.page-posts .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}
|
||||
.page-posts .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-posts .text-link{font-weight:700;color:var(--fg);text-decoration:underline;text-underline-offset:4px;min-height:44px;display:inline-flex;align-items:center}
|
||||
.page-posts .text-link:hover{color:var(--accent)}
|
||||
.page-posts .price-line{font:12px/1.6 var(--font-mono);color:var(--muted);margin-top:18px}
|
||||
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
|
||||
.page-posts .toc strong{font-family:var(--font-display);font-size:20px}
|
||||
.page-posts .toc a,.page-posts .toc .toc-locked{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .toc a:hover{color:var(--fg)}
|
||||
.page-posts .toc .toc-locked{color:color-mix(in oklch,var(--muted) 60%,transparent)}
|
||||
.page-posts .toc .toc-locked svg{width:12px;height:12px;flex:none}
|
||||
.page-posts .member-note{margin-top:28px;border:1px solid var(--fg);padding:22px}
|
||||
.page-posts .member-note strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:8px}
|
||||
.page-posts .member-note p{color:var(--muted);font-size:14px;margin:0 0 16px}
|
||||
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
|
||||
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
|
||||
.page-posts .footer-col a{display:block;min-height:36px}
|
||||
.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}
|
||||
.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
|
||||
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display:flex;flex-direction:column}.page-posts .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
|
||||
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .article-body{font-size:18px}}
|
||||
@media(max-width:620px){.page-posts .nav-login{display:none}.page-posts .article-head h1{font-size:46px}.page-posts .article-layout{padding-block:56px}.page-posts .footer-grid{grid-template-columns:1fr;gap:28px}.page-posts .paywall-actions{flex-direction:column;align-items:stretch}.page-posts .paywall-actions .btn{width:100%}}
|
||||
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
|
||||
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
|
||||
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
|
||||
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}
|
||||
.page-posts .nav-account{min-height:44px;display:inline-flex;align-items:center;gap:10px;padding-inline:16px;border:1px solid var(--fg);background:var(--fg);color:var(--surface);font-weight:700}.page-posts .nav-account:hover{background:var(--accent);border-color:var(--accent)}
|
||||
.page-posts .nav-account .avatar{width:22px;height:22px;background:var(--surface);color:var(--fg);display:grid;place-items:center;font:12px/1 var(--font-mono);font-weight:700}
|
||||
.page-posts .icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
|
||||
.page-posts .demo-strip b{color:var(--surface);font-weight:700}
|
||||
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
|
||||
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
|
||||
.page-posts .open-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--accent);color:var(--accent);padding:6px 14px;font:12px/1.5 var(--font-mono);margin-bottom:22px}
|
||||
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
|
||||
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
|
||||
.page-posts .article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
|
||||
.page-posts .save-btn{min-height:40px;padding:6px 14px;border:1px solid var(--border);background:transparent;color:var(--fg);font:12px/1.5 var(--font-mono)}.page-posts .save-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .save-btn[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
|
||||
.page-posts .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
|
||||
.page-posts .article-body{font-size:19px;line-height:2}
|
||||
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
|
||||
.page-posts .article-body h3{font-size:27px;margin:42px 0 14px}
|
||||
.page-posts .article-body p{margin:0 0 26px}
|
||||
.page-posts .article-body ul{padding-inline-start:24px}.page-posts .article-body li{margin-block:10px}
|
||||
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
|
||||
.page-posts .code{direction:ltr;text-align:left;background:var(--fg);color:var(--surface);padding:26px;overflow:auto;font:14px/1.8 var(--font-mono);margin:34px 0}
|
||||
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
|
||||
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
|
||||
.page-posts .steps{counter-reset:step;list-style:none;padding:0}.page-posts .steps li{counter-increment:step;display:grid;grid-template-columns:56px 1fr;gap:18px;padding:22px 0;border-bottom:1px solid var(--border)}.page-posts .steps li::before{content:"٠" counter(step,arabic-indic);font-family:var(--font-display);font-size:34px;color:var(--accent);line-height:1}
|
||||
.page-posts .steps strong{display:block;font-size:20px;margin-bottom:6px}.page-posts .steps p{margin:0;color:var(--muted)}
|
||||
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
|
||||
.page-posts .toc strong{font-family:var(--font-display);font-size:20px}
|
||||
.page-posts .toc a{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.page-posts .toc a:hover{color:var(--fg)}
|
||||
.page-posts .progress-card{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}
|
||||
.page-posts .progress-card strong{font-size:18px;display:block;margin-bottom:12px}
|
||||
.page-posts .progress-track{height:6px;background:color-mix(in oklch,var(--surface) 25%,transparent);margin-bottom:8px}
|
||||
.page-posts .progress-bar{height:100%;background:var(--accent);width:40%}
|
||||
.page-posts .progress-card .p-label{font:11px/1.6 var(--font-mono);color:color-mix(in oklch,var(--surface) 72%,transparent)}
|
||||
.page-posts .files-card{margin-top:18px;border:1px solid var(--fg);padding:22px}
|
||||
.page-posts .files-card strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:12px}
|
||||
.page-posts .file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)}
|
||||
.page-posts .file-row:last-of-type{border-bottom:0}
|
||||
.page-posts .file-row span{font-size:14px}
|
||||
.page-posts .dl-btn{min-height:38px;padding:4px 12px;border:1px solid var(--border);background:transparent;color:var(--accent);font:12px/1.5 var(--font-mono);font-weight:700}.page-posts .dl-btn:hover{border-color:var(--accent);background:var(--accent-soft)}
|
||||
.page-posts .comments{padding-block:80px;border-top:1px solid var(--border)}.page-posts .comments-wrap{max-width:740px}.page-posts .comments h2{font-size:clamp(36px,5vw,54px);margin:0}
|
||||
.page-posts .comment-list{display:grid}.page-posts .comment{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .comment-head{display:flex;justify-content:space-between;gap:16px}.page-posts .comment p{margin:8px 0 0}
|
||||
.page-posts .comment-form{display:grid;gap:18px;margin-top:36px}.page-posts .field{display:grid;gap:7px}.page-posts .field label{font-size:13px;color:var(--muted)}
|
||||
.page-posts .input,.page-posts .textarea{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px}.page-posts .textarea{min-height:130px;resize:vertical}
|
||||
.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-posts .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
|
||||
.page-posts .status{font-size:14px;color:var(--muted);min-height:22px}
|
||||
.page-posts .related{padding-block:80px;border-top:1px solid var(--border)}.page-posts .related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.page-posts .related-card{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .related-card+.related-card{margin-inline-start:28px}.page-posts .related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.page-posts .related-card h3{font-size:24px;margin:12px 0}.page-posts .related-card a{font-weight:700}.page-posts .related-card a:hover{color:var(--accent)}
|
||||
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
|
||||
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
|
||||
.page-posts .footer-col a{display:block;min-height:36px}.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}
|
||||
.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
|
||||
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display:flex;flex-direction:column}.page-posts .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
|
||||
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .related-grid{grid-template-columns:1fr}.page-posts .related-card+.related-card{margin-inline-start:0}.page-posts .article-body{font-size:18px}}
|
||||
@media(max-width:620px){.page-posts .nav-account .name-label{display:none}.page-posts .article-head h1{font-size:46px}.page-posts .article-layout,.page-posts .comments,.page-posts .related{padding-block:56px}.page-posts .footer-grid{grid-template-columns:1fr;gap:28px}.page-posts .article-meta{align-items:flex-start;flex-direction:column}.page-posts .steps li{grid-template-columns:38px 1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -1,60 +1,4 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
/* Early-access notification signup (L-01). 200 → success copy;
|
||||
503/4xx → the server's Arabic error verbatim; 5xx/network → generic fallback. */
|
||||
// Disabled until delivery and explicit opt-in are verified. No email collection.
|
||||
export default function NotifyForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [done, setDone] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (pending) return
|
||||
setPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
const data = (await res.json().catch(() => null)) as { error?: string; alreadySubscribed?: boolean } | null
|
||||
if (res.ok) {
|
||||
setDone(data?.alreadySubscribed ? 'بريدك مسجَّل لدينا بالفعل — سنراسلك عند تفعيل العضوية.' : 'تم! سنراسلك عند تفعيل العضوية.')
|
||||
} else {
|
||||
setError(data?.error ?? 'تعذر الحفظ، حاول مرة أخرى.')
|
||||
}
|
||||
} catch {
|
||||
setError('تعذر الحفظ، حاول مرة أخرى.')
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return <p className="notify-done" role="status">{done}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="notify-form" onSubmit={submit}>
|
||||
<label className="sr-only" htmlFor="notify-email">البريد الإلكتروني</label>
|
||||
<input
|
||||
id="notify-email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
dir="ltr"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" type="submit" disabled={pending || email.trim() === ''}>
|
||||
{pending ? 'جارٍ الحفظ…' : 'أعلمني عند تفعيل العضوية'}
|
||||
</button>
|
||||
{error && <p className="notify-error" role="alert">{error}</p>}
|
||||
</form>
|
||||
)
|
||||
return <p>التسجيل في إشعارات البريد متوقف حالياً. للتواصل: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p>
|
||||
}
|
||||
|
||||
@@ -1,166 +1,47 @@
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import SubscribeButton from './subscribe-button'
|
||||
import Faq from './faq'
|
||||
import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import type { Viewer } from '@/lib/viewer'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import NotifyForm from './notify-form'
|
||||
import { LAUNCH_POLICY } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = {
|
||||
title: 'العضوية',
|
||||
description:
|
||||
'انضم لقائمة الإشعار: وصول مبكر مجاني لكل الأدلة اليوم، وخطة عضوية مستقبلية بـ 10$ شهرياً تضيف ملفات عمل وملخصات للأعضاء.',
|
||||
title: 'الوصول المبكر المجاني والعضوية المستقبلية',
|
||||
description: 'اقرأ المحتوى المنشور دون حساب أو بطاقة دفع. العضوية خطة مستقبلية غير متاحة للشراء، ولا يتحول الوصول أو الدفع تلقائياً.',
|
||||
}
|
||||
|
||||
/* Member-library samples — real VIP guides, read from the CMS at request time. */
|
||||
const SAMPLES = [
|
||||
{ slug: 'agent-loop-stop-condition', cover: 'تشريح وكيل ناجح', variant: '', output: 'قالب حالة الوكيل + قائمة فحص قبل التشغيل' },
|
||||
{ slug: 'systematic-model-eval', cover: 'قِس قبل أن تبدّل', variant: 'guide-cover-light', output: 'مجموعة صغيرة من اختبارات التقييم قابلة لإعادة التشغيل' },
|
||||
{ slug: 'prototype-to-production', cover: 'من التجربة إلى الإنتاج', variant: '', output: 'قائمة قرارات ما قبل الإطلاق' },
|
||||
]
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{ q: 'هل أستطيع الإلغاء في أي وقت؟', a: 'نعم. يتوقف التجديد التالي، ويستمر وصولك حتى نهاية الفترة المدفوعة.' },
|
||||
{ q: 'هل المحتوى مناسب للمبتدئ؟', a: 'كل دليل يوضح مستواه. ابدأ بالأدلة المجانية في مسار الأساسيات، ثم انتقل إلى الوكلاء والأتمتة.' },
|
||||
{ q: 'هل توجد شهادة إكمال؟', a: 'لا حالياً. تركيز أنور بآي على التطبيق والملفات العملية، لا على شهادات الحضور.' },
|
||||
{ q: 'هل يمكنني استخدام الملفات في عملي؟', a: 'نعم للاستخدام الشخصي والمهني داخل مشاريعك. إعادة بيع الملفات أو نشرها كما هي غير مشمول.' },
|
||||
]
|
||||
|
||||
export default async function SubscriptionPage() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
const role = (user?.role as string) || null
|
||||
const entitled = role === 'vip' || role === 'admin'
|
||||
const early = freeEarlyAccess()
|
||||
const viewer: Viewer = user ? { email: user.email, role: role ?? 'free' } : null
|
||||
const { docs: published } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
depth: 0,
|
||||
limit: 200,
|
||||
select: { title: true, slug: true, dek: true, category: true, readingMinutes: true, access: true },
|
||||
})
|
||||
const freeCount = published.filter(doc => doc.access === 'free').length
|
||||
const memberCount = published.length - freeCount
|
||||
const samples = SAMPLES.flatMap(sample => {
|
||||
const post = published.find(doc => doc.slug === sample.slug)
|
||||
return post ? [{ ...sample, post }] : []
|
||||
})
|
||||
|
||||
const viewer = await getViewer()
|
||||
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>
|
||||
<section className="mem-hero"><div className="wrap narrow">
|
||||
<p className="kicker">الوصول الحالي</p>
|
||||
<h1>وصول مبكر مجاني، دون حساب أو بطاقة دفع.</h1>
|
||||
<p className="lede">{LAUNCH_POLICY.summary}</p>
|
||||
<Link className="btn btn-primary" href="/articles">ابدأ القراءة</Link>
|
||||
</div></section>
|
||||
<section className="mem-compare"><div className="wrap narrow">
|
||||
<h2>ما المتاح الآن وما المخطط مستقبلاً؟</h2>
|
||||
<div className="table-wrap"><table>
|
||||
<thead><tr><th scope="col">الميزة</th><th scope="col">الوصول المجاني الآن</th><th scope="col">العضوية مستقبلاً · غير متاحة للشراء</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th scope="row">المحتوى المنشور للقراءة العامة</th><td>كامل دون حساب</td><td>المحتوى المصنف مجانياً يبقى مجانياً</td></tr>
|
||||
<tr><th scope="row">المواد المصنفة للعضوية مستقبلاً</th><td>مفتوحة خلال الوصول المبكر</td><td>قد يتغير الوصول بعد الإعلان والموافقة والاختبارات</td></tr>
|
||||
<tr><th scope="row">الملفات</th><td>التنزيلات المتاحة والمعتمدة فقط؛ بعض الملفات المقيدة لا تزال محمية</td><td>الملفات المشمولة وشروطها تعلن قبل التفعيل</td></tr>
|
||||
<tr><th scope="row">الحسابات والتفاعل</th><td>التسجيل العام متوقف؛ الميزات القائمة لأصحاب الحسابات حسب صلاحياتهم</td><td>لا نعد بميزات قبل اختبارها</td></tr>
|
||||
<tr><th scope="row">الدفع</th><td>لا بطاقة، لا اشتراك، لا رسوم</td><td>السعر والفوترة والإلغاء يعلن عنها قبل إتاحة الشراء</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div></section>
|
||||
<section className="mem-faq"><div className="wrap narrow">
|
||||
<h2>هل تنتهي الفترة تلقائياً بعد أسبوعين؟</h2>
|
||||
<p>لا. الأسبوعان نقطة مراجعة فقط. لا يغيّر التاريخ الوصول ولا ينشئ اشتراكاً، وقد يستمر الإطلاق المجاني.</p>
|
||||
<h2>كيف أتواصل؟</h2>
|
||||
<p>إشعارات البريد غير متاحة للتسجيل حالياً. للدعم والملاحظات وطلبات حذف البيانات: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p>
|
||||
<p><Link href="/terms">شروط الاستخدام</Link> · <Link href="/privacy">الخصوصية</Link></p>
|
||||
</div></section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -1,54 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
/* comingSoon comes from the server page (!billingEnabled()) so the disabled
|
||||
«قريباً» state + mailto render from first paint — no POST is attempted while
|
||||
checkout is disabled (L-01). A 503 from the API (e.g. mode flipped after
|
||||
hydration) lands in the same honest state. */
|
||||
export default function SubscribeButton({ loggedIn, comingSoon = false }: { loggedIn: boolean; comingSoon?: boolean }) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
async function subscribe() {
|
||||
setPending(true)
|
||||
const res = await fetch('/api/subscribe', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data?.url) {
|
||||
window.location.href = data.url as string
|
||||
return
|
||||
}
|
||||
}
|
||||
// 503 (billing disabled) or any failure → honest coming-soon state.
|
||||
setFailed(true)
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
if (comingSoon || failed) {
|
||||
return (
|
||||
<div className="plan-soon-wrap">
|
||||
<button className="btn btn-primary" type="button" disabled>قريباً</button>
|
||||
<p className="plan-soon" role="status">الدفع الإلكتروني غير مفعّل خلال فترة الوصول المبكر. راسلنا على <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> لتفعيل عضويتك يدوياً.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
/* QA-R01/R07: anonymous visitors get explicit signup + login CTAs instead
|
||||
of a silent redirect. Both preserve /subscription as the destination. */
|
||||
return (
|
||||
<span className="cta-pair">
|
||||
<Link className="btn btn-primary" href="/signup?next=%2Fsubscription">أنشئ حساباً واشترك</Link>
|
||||
<Link className="btn btn-quiet" href="/login?next=%2Fsubscription">لدي حساب، تسجيل الدخول</Link>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="btn btn-primary" onClick={subscribe} disabled={pending}>
|
||||
{pending ? 'جارٍ التحويل…' : 'اشترك الآن'}
|
||||
</button>
|
||||
)
|
||||
// Fail closed even if an older template reuses this control without comingSoon.
|
||||
export default function SubscribeButton(_props: { loggedIn: boolean; comingSoon?: boolean }) {
|
||||
return <Link className="btn btn-primary" href="/articles">اقرأ مجاناً دون حساب</Link>
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { LAUNCH_POLICY } from '@/lib/launch'
|
||||
import '../styles/privacy.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'شروط الاستخدام' }
|
||||
export const metadata = {
|
||||
title: 'شروط استخدام Anwarpy',
|
||||
description: 'شروط استخدام أنور پآي خلال الإطلاق التجريبي: القراءة المجانية، وحقوق المحتوى والملفات، والاستخدام المقبول، والحسابات والتواصل، وشروط أي تغيير مستقبلي في الوصول.',
|
||||
}
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div id="site" className="site-page page-terms">
|
||||
<Topnav />
|
||||
<main id="content">
|
||||
<section className="hero" data-od-id="terms-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">شروط الاستخدام · مسودة ما قبل الإطلاق</p>
|
||||
<h1 data-od-id="terms-title">شروط استخدام أنور بآي.</h1>
|
||||
<p className="lead">توضح هذه المسودة القواعد الأساسية لاستخدام مكتبة أنور بآي والاشتراك فيها.</p>
|
||||
<p className="notice" role="note">هذه مسودة للمراجعة القانونية وليست نصاً قانونياً نهائياً. يجب اعتماد النسخة النهائية قبل نشر الموقع.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container policy" data-od-id="terms-content">
|
||||
<section data-od-id="terms-service"><h2>ما هي الخدمة؟</h2><p>أنور بآي مكتبة محتوى عربية تقدم أدلة ومسارات تعليمية، بعضها مجاني وبعضها متاح لمشتركي العضوية. يمنحك الاشتراك وصولاً شخصياً إلى المحتوى المدفوع طوال فترة الاشتراك الفعّالة.</p></section>
|
||||
<section data-od-id="terms-account"><h2>حسابك</h2><p>أنت مسؤول عن دقة بيانات التسجيل والاحتفاظ بكلمة المرور في سرية، وعن أي نشاط يجري عبر حسابك. يمكنك حذف حسابك في أي وقت بمراسلة <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>، وسنحذف بياناتك وفق سياسة الخصوصية.</p></section>
|
||||
<section data-od-id="terms-billing"><h2>الاشتراك والدفع</h2><p>العضوية بـ <bdi dir="ltr">$10</bdi> شهرياً وتُدفع عبر مزوّد الدفع (Stripe). يمكنك الإلغاء في أي وقت ويتوقف التجديد التالي فوراً مع بقاء وصولك حتى نهاية الفترة المدفوعة. السعر الحالي سعر تجريبي للمعاينة وقد يتغير قبل الإطلاق مع إشعار مسبق.</p></section>
|
||||
<section data-od-id="terms-content"><h2>المحتوى والملكية</h2><p>حقوق المحتوى والملفات محفوظة لأصحابها. حصولك على الاشتراك يمنحك حق الاستخدام الشخصي والمهني داخل مشاريعك، ولا يشمل إعادة نشر المحتوى أو بيعه أو توزيعه كما هو.</p></section>
|
||||
<section data-od-id="terms-conduct"><h2>الاستخدام المقبول</h2><p>لا يجوز استخدام الخدمة لاستخراج البيانات آلياً أو إعادة نشر المحتوى على نطاق واسع أو أي استخدام يضر بالمنصة أو بمستخدميها. قد نوقف الحسابات المخالفة بعد تنبيه صاحبها.</p></section>
|
||||
<section data-od-id="terms-disclaimer"><h2>إخلاء المسؤولية</h2><p>المحتوى تعليمي ويُقدَّم «كما هو» دون ضمانات لنتائج محددة. أنت مسؤول عن قراراتك ومشاريعك، وينبغي اختبار أي قالب أو ملف قبل استخدامه في بيئة إنتاج.</p></section>
|
||||
<section data-od-id="terms-changes"><h2>تعديل الشروط</h2><p>قد نحدّث هذه الشروط مع نمو الخدمة. عند أي تغيير جوهري سنبلّغ المستخدمين المسجلين عبر البريد الإلكتروني قبل سريان التعديل، ونحدّث تاريخ السريان أعلى الصفحة.</p></section>
|
||||
<section data-od-id="terms-law"><h2>التواصل</h2><p>لأي سؤال عن هذه الشروط راسلنا على <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>. القانون الحاكم واختصاص المحاكم يحدَّدان في النسخة القانونية النهائية بعد المراجعة.</p></section>
|
||||
<section className="hero"><div className="container">
|
||||
<p className="eyebrow">سياسات الوصول المبكر المجاني</p>
|
||||
<h1>شروط استخدام Anwarpy</h1>
|
||||
<p className="lead">تاريخ السريان: <time dateTime={LAUNCH_POLICY.effectiveDate}>{LAUNCH_POLICY.effectiveDate}</time></p>
|
||||
<p>{LAUNCH_POLICY.summary}</p>
|
||||
</div></section>
|
||||
<div className="container policy">
|
||||
<section><h2>الخدمة والمشغّل</h2><p>يدير Anwarpy مكتبة عربية للمحتوى التعليمي. القراءة العامة للمحتوى المنشور مجانية خلال الوصول المبكر، دون حساب أو بطاقة دفع. المسودات والملاحظات الخاصة والملفات غير المعتمدة ليست جزءاً من هذا الوصول.</p></section>
|
||||
<section><h2>الوصول المبكر والعضوية المستقبلية</h2><p>بعض المواد مصنفة للعضوية مستقبلاً لكنها مفتوحة للقراءة الآن. نراجع الإطلاق المدفوع بعد نحو أسبوعين من الإطلاق الفعلي؛ المراجعة ليست موعد انتهاء تلقائياً وقد يستمر الوصول المجاني. أي تغيير في الوصول يحتاج موافقة المشغّل واختبارات الدفع والصلاحيات وإعلاناً على الموقع قبل تطبيقه. لا يوجد اشتراك مدفوع متاح الآن ولا تجديد أو رسوم تلقائية. الأسعار وشروط الفوترة والإلغاء المستقبلية ستعلن قبل إتاحة الشراء؛ القراءة أو التواصل لا يمثلان موافقة على الدفع.</p></section>
|
||||
<section><h2>الحسابات والتواصل</h2><p>إنشاء الحسابات العامة متوقف، والحسابات ينشئها المسؤول فقط. يستطيع أصحاب الحسابات القائمة تسجيل الدخول للميزات المتاحة؛ لا يلزم ذلك للقراءة. حافظ على سرية بيانات الدخول ولا تنتحل هوية غيرك. للاستفسار أو طلب حذف بيانات حساب قائم: <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>.</p></section>
|
||||
<section><h2>المحتوى والملفات</h2><p>حقوق المحتوى لأصحابها. يمكنك قراءة المحتوى واستخدام الملفات المتاحة في مشاريعك الشخصية والمهنية، ما لم يذكر الملف ترخيصاً مختلفاً أو حقوقاً لطرف آخر. لا يشمل ذلك إعادة بيع المواد أو إعادة نشرها كما هي. الملفات التي نزلت أثناء الوصول المجاني لا يمكن سحب نسخها منك عن بُعد؛ وقد يتغير إتاحة تنزيلها لاحقاً بعد إعلان.</p></section>
|
||||
<section><h2>الاستخدام المقبول</h2><p>لا تحاول الوصول إلى بيانات خاصة أو تجاوز الصلاحيات أو نشر برمجيات ضارة أو محتوى مسيء أو إغراق الخدمة. يجوز تقييد النشاط الضار وإيقاف الحساب المخالف لحماية الخدمة، دون المساس بحقوق يقررها القانون.</p></section>
|
||||
<section><h2>طبيعة المحتوى</h2><p>المحتوى تعليمي وليس ضماناً للنتائج أو بديلاً عن استشارة متخصصة. اختبر التعليمات والقوالب في بيئة آمنة قبل الإنتاج، وراجع التكاليف والصلاحيات وحقوق الأطراف الأخرى. لا نضمن استمرار كل مادة أو خلوها من الخطأ، ولا يستبعد هذا حقوقاً إلزامية للمستخدم.</p></section>
|
||||
<section><h2>التحديثات وطلبات المساعدة</h2><p>ننشر التغييرات الجوهرية وتاريخ سريانها على الموقع. لا نعتمد على إشعارات بريدية غير مختبرة لتغيير الوصول. قناة الدعم وطلبات البيانات هي <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>؛ لا يمثل مراسلتها اشتراكاً في التسويق.</p></section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TOPICS, topicsFor, isTopic } from '@/lib/topics'
|
||||
import { guidesLabel, minutesLabel, formatNumber, contentTypeLabel } from '@/lib/format'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import Topnav from '@/components/topnav'
|
||||
import { accessLabel } from '@/lib/launch'
|
||||
import Footer from '@/components/footer'
|
||||
|
||||
type Args = { params: Promise<{ topic: string }> }
|
||||
@@ -30,11 +31,11 @@ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
const { topic } = await params
|
||||
if (!isTopic(topic)) return { title: 'أنور بآي' }
|
||||
if (!isTopic(topic)) return { title: 'أنور پآي' }
|
||||
const meta = TOPICS.find(t => t.key === topic)
|
||||
return {
|
||||
title: `${meta?.label} — مسار`,
|
||||
description: `كل أدلة مسار ${meta?.label} في أنور بآي: ${meta?.hint}. أدلة عملية بالعربية بخطوات قابلة للتطبيق.`,
|
||||
description: `كل محتوى مسار ${meta?.label} في أنور پآي: ${meta?.hint}. محتوى عملي بالعربية بخطوات قابلة للتطبيق.`,
|
||||
alternates: { canonical: `/topics/${topic}` },
|
||||
}
|
||||
}
|
||||
@@ -90,10 +91,10 @@ export default async function TopicPage({ params }: Args) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="topic-list" aria-label={`أدلة ${meta.label}`}>
|
||||
<section className="topic-list" aria-label={`محتوى ${meta.label}`}>
|
||||
<div className="wrap narrow">
|
||||
{posts.length === 0 && (
|
||||
<p className="muted">لا أدلة منشورة في هذا المسار بعد. تابعنا للقادم قريباً.</p>
|
||||
<p className="muted">لا يوجد محتوى منشور في هذا المسار بعد. تابعنا للقادم قريباً.</p>
|
||||
)}
|
||||
<div className="lib-grid">
|
||||
{posts.map(post => {
|
||||
@@ -112,7 +113,7 @@ export default async function TopicPage({ params }: Args) {
|
||||
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
|
||||
<p className="guide-card-dek">{post.dek}</p>
|
||||
<div className="guide-card-foot">
|
||||
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span>
|
||||
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{accessLabel(post.access)}</span>
|
||||
{post.views != null && <span className="engagement">{formatNumber(post.views)} قراءة</span>}
|
||||
{locked && <span className="badge badge-vip">يتطلب عضوية</span>}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,11 @@ import VerifyActions from './verify-actions'
|
||||
import '../styles/email-verification.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'تأكيد البريد الإلكتروني' }
|
||||
export const metadata = {
|
||||
title: 'تأكيد البريد الإلكتروني',
|
||||
description: 'صفحة تأكيد البريد الإلكتروني للحسابات القائمة في أنور پآي: مراجعة حالة التأكيد والخطوات التالية. قراءة المحتوى العام لا تتطلب حسابًا.',
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
type Props = { searchParams: Promise<{ invalid?: string }> }
|
||||
|
||||
@@ -48,7 +52,7 @@ export default async function VerifyEmailPage({ searchParams }: Props) {
|
||||
<p className="status" role="status">بريدك الإلكتروني مؤكَّد بالفعل ✓ — تابع إلى الإعداد.</p>
|
||||
)}
|
||||
<ol className="steps" aria-label="خطوات تأكيد البريد">
|
||||
<li><span className="step-num">01</span><span>افتح رسالة أنور بآي في صندوق الوارد.</span></li>
|
||||
<li><span className="step-num">01</span><span>افتح رسالة أنور پآي في صندوق الوارد.</span></li>
|
||||
<li><span className="step-num">02</span><span>اضغط رابط تأكيد البريد الإلكتروني داخل الرسالة.</span></li>
|
||||
<li><span className="step-num">03</span><span>افحص مجلد الرسائل غير المرغوبة (Spam).</span></li>
|
||||
<li><span className="step-num">04</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function OnboardingWizard({ viewerId }: { viewerId: number | null
|
||||
<p className="eyebrow">نقطة البداية المقترحة</p>
|
||||
<h3 id="recommendationTitle">{rec[0]}</h3>
|
||||
<p id="recommendationCopy">{rec[1]}</p>
|
||||
<Link className="btn btn-primary" href={rec[2]} id="startGuide" data-od-id="start-first-guide">ابدأ الدليل الأول</Link>
|
||||
<Link className="btn btn-primary" href={rec[2]} id="startGuide" data-od-id="start-first-guide">ابدأ المحتوى الأول</Link>
|
||||
</article>
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 2 }))}>تعديل الاختيارات</button>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { FILES_DIR } from '@/collections/Files'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -27,10 +26,9 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
|
||||
}
|
||||
if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
|
||||
|
||||
// L-02: all files open during free early access (owner curates file
|
||||
// access in the admin; ALL vip files download while billing is off).
|
||||
// Viewer checks apply only when billing is enabled.
|
||||
if (doc.access === 'vip' && !freeEarlyAccess()) {
|
||||
// No approved public-file relationship exists here. Free launch must not
|
||||
// expose every private upload by ID; explicitly free files stay public.
|
||||
if (doc.access === 'vip') {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 })
|
||||
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
|
||||
|
||||
@@ -1,63 +1,11 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { sendMail } from '@/lib/mail'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/* POST /api/notify — L-01 launch-notification list.
|
||||
Idempotent: an existing email returns 200 { ok: true, alreadySubscribed: true }
|
||||
(no duplicate row, no re-send). A fresh email is stored and a confirmation
|
||||
is sent; if the mail transport is not wired the response is an honest 503
|
||||
(the row is kept — no fake success). */
|
||||
export async function POST(req: Request) {
|
||||
let body: { email?: string } | null = null
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'صيغة الطلب غير صحيحة.' }, { status: 400 })
|
||||
}
|
||||
const email = (body?.email ?? '').trim().toLowerCase()
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
return NextResponse.json({ error: 'أدخل بريداً إلكترونياً صحيحاً.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: 'launch-notifications',
|
||||
where: { email: { equals: email } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
if (docs.length > 0) {
|
||||
return NextResponse.json({ ok: true, alreadySubscribed: true })
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.create({
|
||||
collection: 'launch-notifications',
|
||||
data: { email, source: 'subscription' },
|
||||
})
|
||||
} catch (err) {
|
||||
// Lost a unique-constraint race against a concurrent signup → idempotent OK.
|
||||
const message = err instanceof Error ? err.message : ''
|
||||
if (/duplicate key|unique constraint/i.test(message)) {
|
||||
return NextResponse.json({ ok: true, alreadySubscribed: true })
|
||||
}
|
||||
console.error('launch notification create failed:', err)
|
||||
return NextResponse.json({ error: 'تعذر حفظ البريد، حاول مرة أخرى.' }, { status: 500 })
|
||||
}
|
||||
|
||||
const { sent } = await sendMail(
|
||||
email,
|
||||
'سنعلمك عند تفعيل العضوية — أنور بي آي',
|
||||
'مرحباً بك في أنور بي آي.\n\nسجّلنا بريدك في قائمة الإشعار، وسنراسلك قبل تفعيل العضوية بأيام.\n\nخلال الوصول المبكر كل الأدلة المنشورة متاحة مجاناً، ولن يُنشأ لك اشتراك أو يُحصَّل أي مبلغ.',
|
||||
// Delivery is not verified. Do not collect addresses or claim subscription.
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{ error: 'التسجيل في إشعارات البريد متوقف حالياً. للتواصل: hello@anwarpy.ai' },
|
||||
{ status: 503 },
|
||||
)
|
||||
if (!sent) {
|
||||
return NextResponse.json({ error: 'خدمة البريد غير مربوطة بعد.' }, { status: 503 })
|
||||
}
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function POST(_request: Request, ctx: { params: Promise<{ slug: str
|
||||
|
||||
// Mirror the UI paywall: commenting on a VIP post requires entitlement.
|
||||
if (post.access === 'vip' && role !== 'vip' && role !== 'admin') {
|
||||
return NextResponse.json({ error: 'التعليق على هذا الدليل متاح لأعضاء العضوية.' }, { status: 403 })
|
||||
return NextResponse.json({ error: 'التعليق على هذا المحتوى متاح لأعضاء العضوية.' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Naive per-user rate limit: 1 comment / 60 s, any post, counts pending.
|
||||
|
||||
@@ -13,9 +13,8 @@ export async function POST() {
|
||||
const priceId = process.env.STRIPE_PRICE_ID
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
|
||||
|
||||
// L-01: checkout is disabled server-side during free early access —
|
||||
// 503, and never a Checkout session in this mode. Paid transition =
|
||||
// LAUNCH_MODE=paid + Stripe keys; this route re-enables with no code change.
|
||||
// Free-launch policy is code-locked: Stripe configuration alone cannot
|
||||
// create a checkout session. Re-enabling requires separate operator review.
|
||||
if (!billingEnabled()) {
|
||||
return NextResponse.json({ error: 'الدفع غير مفعّل خلال فترة الوصول المبكر.' }, { status: 503 })
|
||||
}
|
||||
|
||||
+8
-10
@@ -1,15 +1,13 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Readex_Pro } from 'next/font/google'
|
||||
import localFont from 'next/font/local'
|
||||
import './globals.css'
|
||||
import './polish.css'
|
||||
import './design.css'
|
||||
import './color-trial.css'
|
||||
|
||||
/* Readex Pro: a modern Arabic/Latin face drawn for screens and interfaces.
|
||||
Self-hosted at build time by next/font. */
|
||||
const sans = Readex_Pro({
|
||||
subsets: ['arabic', 'latin'],
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
/* Bundle Readex Pro's Arabic/Latin variable font so builds never fetch Google Fonts. */
|
||||
const sans = localFont({
|
||||
src: [{ path: '../fonts/ReadexPro.ttf', weight: '300 700', style: 'normal' }],
|
||||
display: 'swap',
|
||||
variable: '--font-sans',
|
||||
})
|
||||
@@ -19,12 +17,12 @@ export const viewport: Viewport = { colorScheme: 'only light', themeColor: '#fff
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'),
|
||||
title: { default: 'أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية', template: '%s — أنور بآي' },
|
||||
description: 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.',
|
||||
title: { default: 'أنور پآي — محتوى الذكاء الاصطناعي العملي بالعربية', template: '%s — أنور پآي' },
|
||||
description: 'محتوى عربي عملي عن الذكاء الاصطناعي: تشغيل النماذج محليًا، وبناء الوكلاء، والأتمتة وRAG. استكشف تجارب أنور پآي وشروحات الأدوات بخطوات قابلة للتطبيق.',
|
||||
alternates: { canonical: './' },
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: 'أنور بآي',
|
||||
siteName: 'أنور پآي',
|
||||
locale: 'ar',
|
||||
images: [{ url: '/og-default.png', width: 1200, height: 630 }],
|
||||
},
|
||||
@@ -40,7 +38,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebSite', name: 'أنور بآي', url: siteUrl, inLanguage: 'ar' }) }} />
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebSite', name: 'أنور پآي', url: siteUrl, inLanguage: 'ar' }) }} />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -20,13 +20,13 @@ export async function GET(): Promise<Response> {
|
||||
renderIndexSections(docs, !early),
|
||||
'---',
|
||||
'',
|
||||
'# الأدلة الكاملة',
|
||||
'# المحتوى الكامل',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const doc of docs) {
|
||||
const vipOnly = !early && doc.access === 'vip'
|
||||
const suffix = vipOnly ? ' (دليل للأعضاء)' : ''
|
||||
const suffix = vipOnly ? ' (محتوى للأعضاء)' : ''
|
||||
parts.push(`## ${doc.title}${suffix}`, '')
|
||||
parts.push(`URL: /posts/${doc.slug}`, '')
|
||||
if (doc.dek) parts.push(doc.dek, '')
|
||||
@@ -34,7 +34,7 @@ export async function GET(): Promise<Response> {
|
||||
parts.push(`حُدّث: ${formatDate(doc.updatedAt)}`, '')
|
||||
}
|
||||
if (vipOnly) {
|
||||
parts.push('هذا الدليل متاح للأعضاء فقط. العضوية: /subscription', '')
|
||||
parts.push('هذا المحتوى متاح للأعضاء فقط. العضوية: /subscription', '')
|
||||
} else if (typeof doc.content === 'object' && doc.content !== null) {
|
||||
parts.push(lexicalToPlain(doc.content), '')
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ const Posts: CollectionConfig = {
|
||||
label: 'نوع المحتوى',
|
||||
options: [
|
||||
{ label: 'ملاحظة تقنية', value: 'note' },
|
||||
{ label: 'دليل عملي', value: 'tutorial' },
|
||||
{ label: 'محتوى عملي', value: 'tutorial' },
|
||||
{ label: 'تجربة موثقة', value: 'case_study' },
|
||||
{ label: 'مقارنة', value: 'comparison' },
|
||||
],
|
||||
@@ -143,10 +143,11 @@ const Posts: CollectionConfig = {
|
||||
{ name: 'readingMinutes', type: 'number', required: true, defaultValue: 5 },
|
||||
{ name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
|
||||
{
|
||||
// Private traceability + agent-pipeline dedup key (L-10). Never exposed:
|
||||
// pickFields strips it on ingest and public queries never select it.
|
||||
// Private traceability + agent-pipeline dedup key; internal Local API
|
||||
// callers may explicitly override access, public REST/GraphQL may not.
|
||||
name: 'sourceId',
|
||||
type: 'text',
|
||||
access: { read: () => false },
|
||||
index: true,
|
||||
admin: { description: 'معرّف المصدر (خاص — للتتبّع ومنع التكرار)' },
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { promisify } from 'util'
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
import { sendVerificationEmail } from '@/lib/mail'
|
||||
|
||||
const isAdmin = (args: AccessArgs) =>
|
||||
const isAdmin = (args: Pick<AccessArgs, 'req'>) =>
|
||||
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
|
||||
|
||||
export const ROLES = ['free', 'vip', 'admin'] as const
|
||||
@@ -20,10 +20,10 @@ const Users: CollectionConfig = {
|
||||
// Only self and admins can read full user docs
|
||||
read: ({ req, id }) =>
|
||||
req.user?.role === 'admin' || (id != null && req.user?.id === id),
|
||||
// Public open for self-signup; beforeChange forces role 'free' for non-admins
|
||||
create: () => true,
|
||||
// Public registration is disabled. Only administrators create accounts.
|
||||
create: isAdmin,
|
||||
update: ({ req, id }) =>
|
||||
req.user?.role === 'admin' || req.user?.id === id,
|
||||
req.user?.role === 'admin' || (req.user != null && id != null && String(req.user.id) === String(id)),
|
||||
delete: isAdmin,
|
||||
},
|
||||
hooks: {
|
||||
@@ -161,17 +161,19 @@ const Users: CollectionConfig = {
|
||||
label: 'Notification prefs',
|
||||
admin: { description: 'weekly/trackUpdates/replies switches (VIP onboarding)' },
|
||||
},
|
||||
{ name: 'emailVerified', type: 'checkbox', defaultValue: false, label: 'Email verified' },
|
||||
{ name: 'emailVerified', type: 'checkbox', defaultValue: false, label: 'Email verified', access: { create: isAdmin, update: isAdmin } },
|
||||
{
|
||||
name: 'verificationToken',
|
||||
type: 'text',
|
||||
access: { create: isAdmin, update: isAdmin, read: () => false },
|
||||
hidden: true,
|
||||
admin: { readOnly: true, description: 'Email verification token (hashed at rest not required for this flow)' },
|
||||
},
|
||||
{ name: 'onboarded', type: 'checkbox', defaultValue: false, label: 'Onboarding completed' },
|
||||
{ name: 'onboarded', type: 'checkbox', defaultValue: false, label: 'Onboarding completed', access: { create: isAdmin } },
|
||||
{
|
||||
name: 'role',
|
||||
type: 'select',
|
||||
access: { create: isAdmin, update: isAdmin },
|
||||
required: true,
|
||||
defaultValue: 'free',
|
||||
options: [
|
||||
@@ -183,6 +185,7 @@ const Users: CollectionConfig = {
|
||||
{
|
||||
name: 'stripeCustomerId',
|
||||
type: 'text',
|
||||
access: { create: isAdmin, update: isAdmin },
|
||||
label: 'Stripe Customer ID',
|
||||
admin: { readOnly: true, position: 'sidebar' },
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Footer() {
|
||||
<Link className="logo" href="/">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
|
||||
أنور بآي
|
||||
أنور پآي
|
||||
</Link>
|
||||
<p>محتوى عربي عملي لتعلّم الذكاء الاصطناعي والوكلاء.</p>
|
||||
</div>
|
||||
@@ -18,12 +18,12 @@ export default function Footer() {
|
||||
<strong>استكشف</strong>
|
||||
<Link href="/articles">المكتبة</Link>
|
||||
<Link href="/#topics">المسارات</Link>
|
||||
<a href="/articles?access=free">الأدلة المجانية</a>
|
||||
<a href="/articles?access=free">المحتوى المجاني</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<strong>العضوية</strong>
|
||||
<Link href="/subscription">الأسعار والمزايا</Link>
|
||||
<Link href="/signup">إنشاء حساب</Link>
|
||||
<strong>الوصول المبكر</strong>
|
||||
<Link href="/subscription">الوصول المجاني والخطة المستقبلية</Link>
|
||||
<Link href="/articles">اقرأ دون حساب</Link>
|
||||
<Link href="/login">تسجيل الدخول</Link>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
@@ -35,7 +35,7 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>© {new Date().getFullYear()} أنور بآي</span>
|
||||
<span>© {new Date().getFullYear()} أنور پآي</span>
|
||||
<span>العربية أولاً · تعلّم بوضوح</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,6 @@ export type NavLink = { href: string; label: string }
|
||||
export const SITE_LINKS: NavLink[] = [
|
||||
{ href: '/articles', label: 'المكتبة' },
|
||||
{ href: '/#topics', label: 'المسارات' },
|
||||
{ href: '/subscription', label: 'العضوية' },
|
||||
{ href: '/subscription', label: 'الوصول المبكر' },
|
||||
{ href: '/about', label: 'عن الفريق' },
|
||||
]
|
||||
|
||||
@@ -68,12 +68,12 @@ export default function SiteMenu({
|
||||
return (
|
||||
<aside className="menu-panel" id={menuId} aria-hidden="true" data-od-id="site-menu">
|
||||
<button className="menu-scrim" type="button" aria-label="إغلاق القائمة" data-menu-close></button>
|
||||
<div className="menu-drawer" role="dialog" aria-modal="true" aria-label="قائمة أنور بآي">
|
||||
<div className="menu-drawer" role="dialog" aria-modal="true" aria-label="قائمة أنور پآي">
|
||||
<div className="menu-head">
|
||||
<Link className="logo" href="/">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
|
||||
أنور بآي
|
||||
أنور پآي
|
||||
</Link>
|
||||
<button className="icon-btn" type="button" aria-label="إغلاق القائمة" data-menu-close>×</button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function Topnav({ viewer }: { viewer?: { email: string } | null }
|
||||
<Link className="logo" href="/" data-od-id="brand">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="logo-mark" src="/brand/mark-light.svg" alt="" width={36} height={32} />
|
||||
أنور بآي
|
||||
أنور پآي
|
||||
</Link>
|
||||
<NavLinks links={SITE_LINKS} />
|
||||
<div className="nav-tools">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ export const formatNumber = (value: number | null | undefined) => numberFormat.f
|
||||
/* Single source for the displayed membership price. */
|
||||
export const MEMBERSHIP_PRICE = '$10'
|
||||
|
||||
/* Arabic count agreement for «دليل»: 1 دليل، 2 دليلان، 3–10 أدلة، 11+ دليلاً. */
|
||||
/* Count UI content items while preserving the existing formatter API. */
|
||||
export function guidesLabel(n: number): string {
|
||||
if (n === 0) return 'لا أدلة'
|
||||
if (n === 1) return 'دليل واحد'
|
||||
if (n === 2) return 'دليلان'
|
||||
return `${formatNumber(n)} ${n <= 10 ? 'أدلة' : 'دليلاً'}`
|
||||
if (n === 0) return 'لا محتوى'
|
||||
if (n === 1) return 'عنصر محتوى واحد'
|
||||
if (n === 2) return 'عنصران من المحتوى'
|
||||
return `${formatNumber(n)} ${n <= 10 ? 'عناصر محتوى' : 'عنصر محتوى'}`
|
||||
}
|
||||
|
||||
export function minutesLabel(n: number | null | undefined): string {
|
||||
@@ -32,7 +32,7 @@ export function minutesLabel(n: number | null | undefined): string {
|
||||
export function contentTypeLabel(value: string | null | undefined): string | null {
|
||||
switch (value) {
|
||||
case 'note': return 'ملاحظة تقنية'
|
||||
case 'tutorial': return 'دليل عملي'
|
||||
case 'tutorial': return 'محتوى عملي'
|
||||
case 'case_study': return 'تجربة موثقة'
|
||||
case 'comparison': return 'مقارنة'
|
||||
default: return null
|
||||
|
||||
+18
-13
@@ -1,17 +1,22 @@
|
||||
/* Single source of launch policy (L-01/L-02).
|
||||
|
||||
Free early access is a server-side policy, never a data rewrite: the
|
||||
intended VIP tier stays in the DB (`access: 'vip'`) and only becomes
|
||||
enforced again when billing turns on. Rollback / paid transition =
|
||||
set LAUNCH_MODE=paid + the Stripe env vars and redeploy. No timers,
|
||||
no date logic — idempotent by construction. */
|
||||
export const billingEnabled = (): boolean =>
|
||||
process.env.LAUNCH_MODE === 'paid' &&
|
||||
Boolean(process.env.STRIPE_SECRET_KEY) &&
|
||||
Boolean(process.env.STRIPE_WEBHOOK_SECRET)
|
||||
/* Adopted free-launch policy. Changing environment variables or passing a
|
||||
review date cannot activate billing or a paywall. A paid transition requires
|
||||
a separately reviewed code change, operator approval and payment/access QA.
|
||||
Intended tiers remain in the database; publication/private checks still apply. */
|
||||
export const LAUNCH_POLICY = {
|
||||
mode: 'free_early_access',
|
||||
effectiveDate: '2026-10-10',
|
||||
operator: 'Anwarpy',
|
||||
supportEmail: 'hello@anwarpy.ai',
|
||||
billingEnabled: false,
|
||||
notificationSignupEnabled: false,
|
||||
summary: 'يمكنك قراءة المحتوى المنشور خلال الوصول المبكر مجاناً دون حساب أو بطاقة دفع. نراجع العضوية بعد نحو أسبوعين من الإطلاق؛ هذه نقطة مراجعة وليست موعد حجب. قد تمتد الفترة، ولن يُفعّل الدفع أو الحجب إلا بقرار يدوي بعد الاختبارات وإعلان التغيير. لن يُنشأ اشتراك أو يُحصّل مبلغ تلقائياً.',
|
||||
} as const
|
||||
|
||||
export const billingEnabled = (): boolean => LAUNCH_POLICY.billingEnabled
|
||||
export const freeEarlyAccess = (): boolean => !billingEnabled()
|
||||
export const accessLabel = (tier: string): string => tier === 'vip'
|
||||
? 'مجاني الآن · للعضوية مستقبلاً'
|
||||
: 'مجاني'
|
||||
|
||||
/* Configurable Arabic reading rate (words/minute); revisit after reader
|
||||
feedback. Used by the Posts beforeChange hook and the backfill script. */
|
||||
// Configurable Arabic reading rate, used by the existing publication hook.
|
||||
export const READING_WORDS_PER_MINUTE = 160
|
||||
|
||||
+3
-3
@@ -41,8 +41,8 @@ export async function fetchIndexDocs(withContent = false): Promise<IndexDoc[]> {
|
||||
return docs as unknown as IndexDoc[]
|
||||
}
|
||||
|
||||
export const SITE_TITLE = 'أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية'
|
||||
export const SITE_INTRO = 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.'
|
||||
export const SITE_TITLE = 'أنور پآي — محتوى الذكاء الاصطناعي العملي بالعربية'
|
||||
export const SITE_INTRO = 'محتواك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.'
|
||||
|
||||
/** Topic-grouped markdown index lines; a post can appear under several topics. */
|
||||
export function renderIndexSections(docs: IndexDoc[], markVip: boolean): string {
|
||||
@@ -52,7 +52,7 @@ export function renderIndexSections(docs: IndexDoc[], markVip: boolean): string
|
||||
if (items.length === 0) continue
|
||||
lines.push(`## ${topic.label}`, '')
|
||||
for (const doc of items) {
|
||||
const suffix = markVip && doc.access === 'vip' ? ' (دليل للأعضاء)' : ''
|
||||
const suffix = markVip && doc.access === 'vip' ? ' (محتوى للأعضاء)' : ''
|
||||
lines.push(`- [${doc.title}${suffix}](/posts/${doc.slug}): ${doc.dek}`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
@@ -7,7 +7,7 @@ export const TOPICS: { key: TopicKey; label: string; hint: string }[] = [
|
||||
{ key: 'agents', label: 'الوكلاء', hint: 'تخطيط · حلقات · سلامة' },
|
||||
{ key: 'models', label: 'النماذج', hint: 'اختيار · تقييم · استرجاع' },
|
||||
{ key: 'automation', label: 'الأتمتة', hint: 'أدوات · أعمال · إنتاج' },
|
||||
{ key: 'foundations', label: 'الأساسيات', hint: 'أدلة للمبتدئين' },
|
||||
{ key: 'foundations', label: 'الأساسيات', hint: 'محتوى للمبتدئين' },
|
||||
]
|
||||
|
||||
const CATEGORY_TOPIC: Record<string, TopicKey> = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user