Free early-access launch: LAUNCH_MODE policy, CRO, pipeline hardening, AI-SEO, ops

- launch.ts policy: LAUNCH_MODE=free_early_access default; effective access =
  published && (free || early); intended tier (access) never rewritten
- Billing off server-side: /api/subscribe 503 honest copy; subscribe CTAs
  render disabled 'قريباً' from first paint; /subscription early-access panel
  + /api/notify (LaunchNotifications) with honest 503 when mail transport off
- Signup trust: verification email at signup (Users afterChange), no
  confirm-password, 429 message fidelity, typo suggestion, live password hint
- Agent pipeline: pickFields strips access/readingMinutes, sourceId dedup,
  secret-scan 422 (pattern names only), string-content rejected (was silently
  replaced), publish stays explicit opt-in
- lexical.ts: root unwrap fix — lexicalToText/countLexicalWords returned 0
  for real serialized trees (secret scan + readingMinutes were inert)
- llms-full.txt: fetchIndexDocs(true) — bodies were never selected
- Content honesty: contentType/authorName/sourceId fields + migration,
  readingMinutes computed at write (160 wpm) + backfill script, byline,
  'حُدّث' freshness
- SEO/AI-SEO: metadata rewrite (published-only, og:image 1200x630), Article +
  BreadcrumbList + FAQPage + Organization JSON-LD, isAccessibleForFree honest,
  table renderer, robots AI-crawler policy (10 named bots), llms.txt +
  llms-full.txt, /topics/* hubs + sitemap, noindex auth pages, www 301
- Ops: backup-db.sh (restore rehearsed on VPS), docs copies (launch
  readiness + marketing audit + owner-inputs for L-03), GLOSSARY.md entries

Verified: tsc --noEmit 0, npm run build 0, local smoke (ghost VIP read,
subscribe/notify/agent APIs, signup token, 429 on 6th, llms/topics/sitemap,
og:image/JSON-LD)
This commit is contained in:
AHMED
2026-10-10 18:03:45 +03:00
parent 82414e8a93
commit 0161924088
57 changed files with 4042 additions and 402 deletions
+14 -2
View File
@@ -30,17 +30,29 @@ services:
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
AGENT_API_KEY: ${AGENT_API_KEY}
NEXT_PUBLIC_SITE_URL: https://anwarpy.ai
LAUNCH_MODE: ${LAUNCH_MODE:-free_early_access}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID}
MAIL_FROM: noreply@anwarpy.ai
labels:
- traefik.enable=true
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`)
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`)
- traefik.http.routers.anwarpi.entrypoints=websecure
- traefik.http.routers.anwarpi.tls.certresolver=mytlschallenge
- traefik.http.routers.anwarpi.middlewares=pagelimit@docker,connlimit@docker
- traefik.http.services.anwarpi.loadbalancer.server.port=3000
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && (Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`))
# 39: canonical host is the apex. www hits this router first (priority
# above anwarpi-api's 100) and is 301-redirected before any rate limit.
- traefik.http.routers.anwarpi-www.rule=Host(`www.anwarpy.ai`)
- traefik.http.routers.anwarpi-www.entrypoints=websecure
- traefik.http.routers.anwarpi-www.tls.certresolver=mytlschallenge
- traefik.http.routers.anwarpi-www.priority=110
- traefik.http.routers.anwarpi-www.service=anwarpi
- traefik.http.routers.anwarpi-www.middlewares=www-redirect
- traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\.anwarpy\.ai/(.*)
- traefik.http.middlewares.www-redirect.redirectregex.replacement=https://anwarpy.ai/$$1
- traefik.http.middlewares.www-redirect.redirectregex.permanent=true
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && Host(`anwarpy.ai`)
- traefik.http.routers.anwarpi-api.entrypoints=websecure
- traefik.http.routers.anwarpi-api.tls=true
- traefik.http.routers.anwarpi-api.tls.certresolver=mytlschallenge
+40
View File
@@ -0,0 +1,40 @@
/**
* Backfill `readingMinutes` on every post from its body (L-07).
* No schema change — safe to re-run any time.
*
* Usage: cd web && npx tsx scripts/backfill-reading-minutes.ts
*/
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { countLexicalWords } from '../src/lib/lexical'
import { READING_WORDS_PER_MINUTE } from '../src/lib/launch'
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'posts',
limit: 500,
depth: 0,
overrideAccess: true,
})
let updated = 0
for (const post of docs) {
const words = countLexicalWords(post.content)
const readingMinutes = Math.max(1, Math.round(words / READING_WORDS_PER_MINUTE))
if (readingMinutes !== post.readingMinutes) {
await payload.update({
collection: 'posts',
id: post.id,
data: { readingMinutes },
overrideAccess: true,
context: { systemBackfill: true },
})
payload.logger.info(`backfill: ${post.slug} ${post.readingMinutes} → ${readingMinutes} (${words} words)`)
updated += 1
}
}
payload.logger.info(`backfill complete: ${docs.length} scanned, ${updated} updated`)
// Postgres pool keeps the loop alive — release it so the script exits.
await payload.db.destroy?.()
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# anwarpy.ai — Postgres backup (L-06)
#
# Run ON THE VPS (needs the `proxy`/compose network up):
# ./scripts/backup-db.sh
#
# Cron (nightly 03:15, keep dir writable):
# 15 3 * * * cd /root/anwarpi && ./scripts/backup-db.sh >> backups/backup.log 2>&1
#
# Restore a dump into the running db container:
# gunzip -c backups/anwarpi-YYYYMMDD-HHMMSS.sql.gz | \
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi anwarpi
#
# Restore-rehearsal (verify a backup is loadable BEFORE you need it):
# gunzip -c backups/<file>.sql.gz | \
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -c "CREATE DATABASE anwarpi_restore_test;"
# gunzip -c backups/<file>.sql.gz | \
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -d anwarpi_restore_test -q
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -d anwarpi_restore_test -tAc \
# "SELECT (SELECT count(*) FROM posts) AS posts, (SELECT count(*) FROM users) AS users;"
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -c "DROP DATABASE anwarpi_restore_test;"
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p backups
STAMP=$(date +%Y%m%d-%H%M%S)
OUT="backups/anwarpi-${STAMP}.sql.gz"
docker compose -f docker-compose.vps.yml exec -T db pg_dump -U anwarpi anwarpi | gzip > "$OUT"
echo "wrote $OUT ($(du -h "$OUT" | cut -f1))"
+5 -1
View File
@@ -3,7 +3,11 @@ import Footer from '@/components/footer'
import { getViewer } from '@/lib/viewer'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'عن الفريق' }
export const metadata = {
title: 'عن الفريق',
description:
'فريق أنور بآي: عرب يكتبون أدلة ذكاء اصطناعي عملية — تجارب موثقة، خطوات قابلة للتطبيق، وملفات عمل حقيقية بلا حشو تسويقي.',
}
export default async function AboutPage() {
const viewer = await getViewer()
@@ -14,6 +14,7 @@ type Props = {
userId: number
fullName: string
role: string
billingEnabled: boolean
}
type Status = { ok: boolean; text: string } | null
@@ -25,7 +26,7 @@ const TABS = [
{ id: 'membership', label: 'العضوية والفوترة' },
]
export default function AccountSettingsClient({ email, userId, fullName, role }: Props) {
export default function AccountSettingsClient({ email, userId, fullName, role, billingEnabled }: Props) {
const router = useRouter()
const [tab, setTab] = useState('profile')
const [name, setName] = useState(fullName)
@@ -172,13 +173,15 @@ export default function AccountSettingsClient({ email, userId, fullName, role }:
<span className={`state-chip${entitled ? '' : ' cancelled'}`} id="planState">{entitled ? 'نشطة' : role === 'admin' ? 'إداري' : 'مجاني'}</span>
</div>
<div className="plan-details">
<div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : 'الأدلة المجانية ومعاينة الأدلة'}</dd></dl>
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">ستُربط عبر Stripe</dd></dl>
{billingEnabled
? <div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
: <div><span className="price-tag price-tag-early">وصول مبكر مجاني</span></div>}
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : billingEnabled ? 'الأدلة المجانية ومعاينة الأدلة' : 'كل المحتوى المنشور مؤقتاً — الوصول المبكر المجاني'}</dd></dl>
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">{billingEnabled ? 'ستُربط عبر Stripe' : 'لا يوجد — لن يُحصَّل أي مبلغ خلال الوصول المبكر'}</dd></dl>
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
</div>
<div className="membership-actions" id="membershipActions">
{!entitled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
{!entitled && billingEnabled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
</div>
</article>
<div className="cancel-zone" id="cancelZone" data-od-id="cancel-zone">
@@ -4,6 +4,7 @@ import Topnav from '@/components/topnav'
import Footer from '@/components/footer'
import AccountSettingsClient from './client'
import { formatDate } from '@/lib/format'
import { billingEnabled } from '@/lib/launch'
import configPromise from '@payload-config'
import '../styles/account-settings.css'
@@ -30,6 +31,7 @@ export default async function AccountSettingsPage() {
userId={Number(user.id)}
fullName={(user.fullName as string) || ''}
role={((user.role as string) || 'free')}
billingEnabled={billingEnabled()}
/>
</section>
</main>
+12 -2
View File
@@ -6,10 +6,14 @@ import configPromise from '@payload-config'
import Link from 'next/link'
import ArticlesClient from './articles-client'
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
import { formatNumber, guidesLabel, minutesLabel } from '@/lib/format'
import { formatNumber, guidesLabel, minutesLabel, contentTypeLabel } from '@/lib/format'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'المكتبة' }
export const metadata = {
title: 'المكتبة',
description:
'أدلة الذكاء الاصطناعي العملية بالعربية: وكلاء، نماذج، استرجاع، سلامة، أدوات، وأعمال — بخطوات قابلة للتطبيق وتجارب موثقة وملفات عمل.',
}
type PostDoc = {
id: number
@@ -24,6 +28,7 @@ type PostDoc = {
likes: number
publishedAt: string
cover?: { url?: string | null } | number | null
contentType?: string | null
}
type Viewer = { id: number; email: string; role: string; interest: string | null; level: string | null } | null
@@ -40,6 +45,7 @@ export default async function ArticlesPage() {
select: {
title: true, slug: true, dek: true, access: true, category: true, level: true,
readingMinutes: true, views: true, likes: true, publishedAt: true, cover: true,
contentType: true,
},
}),
payload.auth({ headers: await headers() }),
@@ -117,6 +123,9 @@ export default async function ArticlesPage() {
<section className="lib-results" aria-label="الأدلة">
<div className="wrap">
<nav className="topic-strip" aria-label="تصفّح المسارات">
{TOPICS.map(topic => <Link key={topic.key} href={`/topics/${topic.key}`}>{topic.label}</Link>)}
</nav>
<p className="results-count" id="resultCount" aria-live="polite">{guidesLabel(posts.length)}</p>
<div className="lib-grid" id="postList">
{posts.map(post => {
@@ -139,6 +148,7 @@ export default async function ArticlesPage() {
<div className="guide-card-body">
<p className="meta-row">
<span>{post.category}</span>
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
<span>{minutesLabel(post.readingMinutes)}</span>
<span>{post.level}</span>
{meta._match && <span className="for-you">يناسب مستواك</span>}
@@ -1,124 +0,0 @@
'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>
)
}
@@ -1,44 +0,0 @@
'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>
)
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { redirect } from 'next/navigation'
/* QA-07: /catalog was a stale duplicate library — /articles is canonical.
Deck/filter components in this folder are still used by the homepage. */
/* QA-07: /catalog was a stale duplicate library — /articles is canonical. */
export const dynamic = 'force-dynamic'
export default function CatalogPage() {
@@ -1,7 +1,11 @@
import type { Metadata } from 'next'
/* QA-06: page-specific title (the page itself is a client component). */
export const metadata: Metadata = { title: 'استعادة كلمة المرور' }
/* QA-06: page-specific title (the page itself is a client component).
Utility pages stay out of search indexes (step 38). */
export const metadata: Metadata = {
title: 'استعادة كلمة المرور',
robots: { index: false, follow: true },
}
export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) {
return children
+6 -2
View File
@@ -1,7 +1,11 @@
import type { Metadata } from 'next'
/* QA-06: page-specific title (the page itself is a client component). */
export const metadata: Metadata = { title: 'تسجيل الدخول' }
/* QA-06: page-specific title (the page itself is a client component).
Utility pages stay out of search indexes (step 38). */
export const metadata: Metadata = {
title: 'تسجيل الدخول',
robots: { index: false, follow: true },
}
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children
+17
View File
@@ -41,6 +41,23 @@ export default function LoginPage() {
router.push(safeNext(new URLSearchParams(window.location.search).get('next')) ?? '/account-settings')
return
}
const err = await res.json().catch(() => null) as { error?: string; errors?: { message?: string }[] } | null
// Rate-limit / middleware shape first: { error: string } + optional Retry-After.
if (typeof err?.error === 'string') {
const retryAfterSec = Number(res.headers.get('Retry-After'))
setStatus(
res.headers.get('Retry-After') && Number.isFinite(retryAfterSec)
? `${err.error} حاول بعد ${Math.max(1, Math.ceil(retryAfterSec / 60))} دقيقة.`
: err.error,
)
return
}
const message = err?.errors?.[0]?.message
if (message && /locked/i.test(message)) {
// Payload lockout: maxLoginAttempts 5 / lockTime 15min (Users.ts).
setStatus('تم تجاوز عدد المحاولات المسموح. حاول بعد ١٥ دقيقة.')
return
}
setStatus('تعذّر تسجيل الدخول — تحقّق من البريد وكلمة المرور.')
} catch {
setStatus('تعذّر الاتصال. حاول تسجيل الدخول مرة أخرى.')
+23 -2
View File
@@ -6,6 +6,7 @@ import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
import { freeEarlyAccess } from '@/lib/launch'
export const dynamic = 'force-dynamic'
@@ -46,6 +47,19 @@ export default async function HomePage() {
return (
<div id="site" className="site-page v2 v2-home">
{/* Organization structured data (home page only; WebSite LD lives in layout). */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'أنور بآي',
url: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai',
logo: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'}/brand/icon-256.png`,
}),
}}
/>
<Topnav viewer={viewer} />
<main id="content">
<section className="home-hero">
@@ -91,7 +105,7 @@ export default async function HomePage() {
<ol className="paths" role="list">
{TOPICS.map(topic => (
<li key={topic.key}>
<Link href={`/articles?topic=${topic.key}`} className="path">
<Link href={`/topics/${topic.key}`} className="path">
<span className="path-name">{topic.label}</span>
<span className="path-hint">{topic.hint}</span>
<span className="path-count">{guidesLabel(counts[topic.key])}</span>
@@ -136,7 +150,14 @@ export default async function HomePage() {
<p>الأدلة المجانية كاملة دائماً. العضوية تضيف الأدلة المعمّقة، القوالب الجاهزة للتنزيل، والنقاش مع الفريق.</p>
</div>
<div className="membership-offer">
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
{freeEarlyAccess() ? (
<>
<p className="price price-early">وصول مبكر مجاني</p>
<p className="early-line">يمكنك قراءة المحتوى المنشور خلال الوصول المبكر دون بطاقة دفع.</p>
</>
) : (
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
)}
<ul role="list">
<li>{guidesLabel(docs.length - freeCount)} للأعضاء، وتُضاف أدلة جديدة باستمرار</li>
<li>قوالب وقوائم فحص قابلة للتنزيل</li>
+108 -36
View File
@@ -8,7 +8,9 @@ import PostExtras from './post-extras'
import { LikeButton, ViewPing } from './engagement'
import { getViewer } from '@/lib/viewer'
import { TOPICS, topicsFor } from '@/lib/topics'
import { formatDate, formatNumber, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
import { formatDate, formatNumber, minutesLabel, contentTypeLabel, MEMBERSHIP_PRICE } from '@/lib/format'
import { freeEarlyAccess } from '@/lib/launch'
import { extractH2s, innerText, type Node } from '@/lib/lexical'
export const dynamic = 'force-dynamic'
@@ -16,21 +18,36 @@ export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: Args) {
const { slug } = await params
const payload = await getPayload({ config: configPromise })
// Drafts must never leak title/description into metadata.
const res = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
depth: 0,
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
depth: 1,
limit: 1,
select: { title: true, dek: true, publishedAt: true },
select: { title: true, dek: true, publishedAt: true, updatedAt: true, cover: true },
})
const post = res.docs[0] as unknown as { title: string; dek: string; publishedAt?: string | null } | undefined
return post
? {
title: post.title,
description: post.dek,
openGraph: { type: 'article', title: post.title, description: post.dek, publishedTime: post.publishedAt ?? undefined },
}
: { title: 'أنور بآي' }
const post = res.docs[0] as unknown as
| { title: string; dek: string; publishedAt?: string | null; updatedAt?: string | null; cover?: { url?: string | null } | number }
| undefined
if (!post) return { title: 'أنور بآي' }
const ogImage =
post.cover && typeof post.cover === 'object' && post.cover.url ? post.cover.url : '/og-default.png'
return {
title: post.title,
description: post.dek,
openGraph: {
// Next replaces (not merges) the layout-level openGraph — redeclare fully.
type: 'article',
siteName: 'أنور بآي',
locale: 'ar',
title: post.title,
description: post.dek,
publishedTime: post.publishedAt ?? undefined,
images: [{ url: ogImage, width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image', title: post.title, description: post.dek, images: [ogImage] },
}
}
type Args = { params: Promise<{ slug: string }> }
@@ -47,10 +64,13 @@ type PostDoc = {
likes?: number | null
readingMinutes: number
publishedAt?: string | null
updatedAt?: string | null
teaser?: string | null
content?: unknown
cover?: { url?: string | null }
attachments?: (number | { id: number })[]
contentType?: string | null
authorName?: string | null
}
async function isLikedByViewer(postId: number, viewerId?: number): Promise<boolean> {
@@ -83,8 +103,9 @@ export default async function PostPage({ params }: Args) {
select: {
title: true, slug: true, dek: true, access: true, category: true, level: true,
views: true, likes: true,
readingMinutes: true, publishedAt: true, teaser: true, cover: true,
readingMinutes: true, publishedAt: true, updatedAt: true, teaser: true, cover: true,
content: true, attachments: true,
contentType: true, authorName: true,
},
})
const post = res.docs[0] as unknown as PostDoc | undefined
@@ -95,7 +116,10 @@ export default async function PostPage({ params }: Args) {
const role = viewer?.role ?? null
const viewerId = viewer?.id
const viewerLevel = viewer?.level ?? null
const unlocked = post.access === 'free' || role === 'vip' || role === 'admin'
// L-02: free early access unlocks every published post (the query above
// already filters status: 'published'); paid mode keeps the tier gate.
const earlyAccessNotice = freeEarlyAccess() && post.access === 'vip'
const unlocked = freeEarlyAccess() || post.access === 'free' || role === 'vip' || role === 'admin'
const postTopics = topicsFor(post)
// The downloadable templates are agent-workflow files; only offer them on agent guides.
const hasAgentFiles = postTopics.includes('agents')
@@ -162,16 +186,54 @@ export default async function PostPage({ params }: Args) {
})
: null
// TOC from content h2s (server-extracted from the Lexical tree)
const toc = unlocked ? extractH2s(post.content) : []
// TOC from content h2s (server-extracted from the Lexical tree) — computed
// unconditionally; the paywall preview (paid mode) also uses it.
const toc = extractH2s(post.content)
const topic = postTopics[0]
const topicLabel = TOPICS.find(t => t.key === topic)?.label ?? post.category
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
return (
<div id="site" className="site-page v2 v2-article page-posts">
<ViewPing slug={post.slug} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: post.title, datePublished: post.publishedAt ?? undefined, author: { '@type': 'Organization', name: 'أنور بآي' }, inLanguage: 'ar' }) }} />
{/* L-09: honest Article + Breadcrumb structured data. JSON-LD array = multiple objects in one script. */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([
{
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.dek,
image: typeof post.cover === 'object' && post.cover?.url ? post.cover.url : undefined,
datePublished: post.publishedAt ?? undefined,
dateModified: post.updatedAt ?? post.publishedAt ?? undefined,
author: post.authorName
? { '@type': 'Person', name: post.authorName }
: { '@type': 'Organization', name: 'أنور بآي' },
publisher: {
'@type': 'Organization',
name: 'أنور بآي',
logo: { '@type': 'ImageObject', url: `${siteUrl}/brand/icon-256.png` },
},
mainEntityOfPage: { '@type': 'WebPage', '@id': `${siteUrl}/posts/${post.slug}` },
isAccessibleForFree: post.access === 'free' || freeEarlyAccess(),
inLanguage: 'ar',
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'الرئيسية', item: siteUrl },
{ '@type': 'ListItem', position: 2, name: topicLabel, item: `${siteUrl}/topics/${topic}` },
{ '@type': 'ListItem', position: 3, name: post.title, item: `${siteUrl}/posts/${post.slug}` },
],
},
]),
}}
/>
<Topnav viewer={viewer} />
<main id="content">
<header className="art-head">
@@ -190,9 +252,14 @@ export default async function PostPage({ params }: Args) {
{post.access === 'free' ? 'مجاني بالكامل' : unlocked ? 'للأعضاء' : 'معاينة مجانية'}
</span>
<span>{post.category}</span>
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
{post.authorName && <span>بقلم {post.authorName}</span>}
<span>{minutesLabel(post.readingMinutes)} للقراءة</span>
<span>{post.level ?? 'مبتدئ'}</span>
{post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>}
{post.updatedAt && post.publishedAt && new Date(post.updatedAt) > new Date(post.publishedAt) && (
<span>· حُدّث {formatDate(post.updatedAt)}</span>
)}
{(post.level ?? 'مبتدئ') === viewerLevel && <span className="for-you">يناسب مستواك</span>}
</div>
<div className="art-actions">
@@ -211,7 +278,14 @@ export default async function PostPage({ params }: Args) {
<div className="wrap art-layout">
<article className="art-body">
{unlocked ? (
<RichText data={post.content} />
<>
{earlyAccessNotice && (
<section className="early-notice" aria-label="إشعار الوصول المبكر">
<p>متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.</p>
</section>
)}
<RichText data={post.content} />
</>
) : (
<>
{(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => (
@@ -222,11 +296,17 @@ export default async function PostPage({ params }: Args) {
<section className="paywall" aria-labelledby="paywall-title">
<h2 id="paywall-title">بقية هذا الدليل للأعضاء</h2>
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل الأدلة الحصرية الأخرى.</p>
{toc.length > 0 && (
<div className="paywall-preview">
<p className="aside-title">ماذا ينتظرك داخل هذا الدليل:</p>
<ul role="list">{toc.map((t, i) => <li key={i}><LockIcon />{t}</li>)}</ul>
</div>
)}
<div className="paywall-actions">
<a className="btn btn-primary" href="/subscription">اشترك بـ <bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> شهرياً</a>
<a className="btn btn-quiet" href={`/login?next=/posts/${post.slug}`}>لدي عضوية، تسجيل الدخول</a>
</div>
<p className="fine">تلغي في أي وقت من صفحة حسابك.</p>
<p className="fine">تلغي في أي وقت من صفحة حسابك. <Link href="/subscription#faq">أسئلة قبل الاشتراك</Link> · <Link href="/articles?access=free">أكمل بالأدلة المجانية</Link></p>
</section>
</>
)}
@@ -343,7 +423,7 @@ function SaveButton() {
}
// Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules.
type Node = { type?: string; text?: string; children?: Node[]; tag?: string; url?: string; listType?: string; code?: string }
// `Node`, `innerText` and `extractH2s` live in lib/lexical (shared with llms routes).
function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] {
return (nodes || []).map((n, i) => {
@@ -370,27 +450,19 @@ function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): Rea
case 'paragraph': return <p key={key}>{inner}</p>
case 'code': return <pre key={key} aria-label="مثال برمجي"><code>{n.code ?? innerText(n.children)}</code></pre>
case 'horizontalrule': return <hr key={key} />
case 'table':
return (
<div key={key} className="post-table" role="region" aria-label="جدول" tabIndex={0}>
<table>{inner}</table>
</div>
)
case 'tablerow': return <tr key={key}>{inner}</tr>
case 'tablecell': return n.headerState ? <th key={key}>{inner}</th> : <td key={key}>{inner}</td>
default: return <p key={key}>{inner}</p>
}
})
}
function innerText(nodes?: Node[]): string {
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
}
function extractH2s(data: unknown): string[] {
try {
const root = (data as { root?: { children?: Node[] } })?.root
if (!root?.children) return []
return root.children
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
.map(n => innerText(n.children))
} catch {
return []
}
}
function RichText({ data }: { data: unknown }) {
if (!data || typeof data !== 'object' || !('root' in data)) return null
const rootUnknown = data.root
+4 -1
View File
@@ -1,6 +1,9 @@
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'إنشاء حساب' }
export const metadata: Metadata = {
title: 'إنشاء حساب',
robots: { index: false, follow: true },
}
export default function SignupLayout({ children }: { children: React.ReactNode }) {
return children
+56 -9
View File
@@ -14,11 +14,29 @@ import '../styles/signup.css'
export default function SignupPage() {
const router = useRouter()
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = 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')))
}, [])
@@ -27,10 +45,9 @@ export default function SignupPage() {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
const confirm = form.elements.namedItem('confirmPassword') as HTMLInputElement
confirm.setCustomValidity(data.get('password') === data.get('confirmPassword') ? '' : 'كلمتا المرور غير متطابقتان.')
setEmailError(null)
if (!form.checkValidity()) {
setStatus(data.get('password') !== data.get('confirmPassword') ? 'كلمتا المرور غير متطابقتان.' : 'تحقق من جميع الحقول المطلوبة.')
setStatus('تحقق من جميع الحقول المطلوبة.')
form.reportValidity()
return
}
@@ -43,8 +60,29 @@ export default function SignupPage() {
body: JSON.stringify({ email: data.get('email'), password: data.get('password'), fullName: data.get('name'), termsVersion: '2026-10-03' }),
})
if (!res.ok) {
const err = await res.json().catch(() => null)
setStatus(err?.errors?.[0]?.message || 'تعذّر إنشاء الحساب — قد يكون البريد مستخدماً بالفعل.')
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
}
@@ -85,11 +123,20 @@ export default function SignupPage() {
<h2 data-od-id="signup-title">إنشاء حساب</h2>
<p className="lead">أدخل بياناتك الأساسية للبدء.</p>
<div className="field"><label htmlFor="name">الاسم</label><input className="input" id="name" name="name" type="text" autoComplete="name" minLength={2} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل اسمك.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
<div className="field"><label htmlFor="password">كلمة المرور</label><div className="input-wrap"><input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="new-password" minLength={8} aria-describedby="passwordHint" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة مرور من ثمانية أحرف على الأقل.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className="fine" id="passwordHint">ثمانية أحرف على الأقل.</span></div>
<div className="field"><label htmlFor="confirmPassword">تأكيد كلمة المرور</label><div className="input-wrap"><input className="input" id="confirmPassword" name="confirmPassword" type={showConfirm ? 'text' : 'password'} autoComplete="new-password" minLength={8} required onInvalid={e => e.currentTarget.setCustomValidity('أعد إدخال كلمة المرور.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="confirmPassword" onClick={() => setShowConfirm(v => !v)}>{showConfirm ? 'إخفاء' : 'إظهار'}</button></div></div>
<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>
@@ -64,6 +64,7 @@
.page-account-settings .plan-details dt{font:12px/1.6 var(--font-mono);color:var(--muted)}
.page-account-settings .plan-details dd{margin:4px 0 0;font-size:16px}
.page-account-settings .price-tag{font-family:var(--font-display);font-size:44px;line-height:1}
.page-account-settings .price-tag-early{font-size:24px;color:var(--accent,var(--fg))}
.page-account-settings .price-tag small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:6px}
.page-account-settings .membership-actions{display:flex;gap:14px;flex-wrap:wrap;padding-top:22px;border-top:1px solid var(--border)}
.page-account-settings .cancel-zone{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
@@ -5,4 +5,9 @@
.page-verify-email .verification{min-height:calc(100vh - 70px);display:grid;place-items:center;padding-block:clamp(54px,9vw,110px)}.page-verify-email .card{width:min(700px,100%);background:color-mix(in oklch,var(--surface) 92%,transparent);border:1px solid var(--border);padding:clamp(28px,6vw,64px)}.page-verify-email .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-verify-email .card h1{font-size:clamp(42px,7vw,72px);line-height:1.08;margin:0;max-width:11ch}.page-verify-email .lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:54ch;margin:22px 0 0}.page-verify-email .email{display:block;color:var(--fg);font:700 16px/1.6 var(--font-mono);direction:ltr;text-align:right;overflow-wrap:anywhere;margin-top:8px}.page-verify-email .steps{list-style:none;margin:36px 0;padding:0;border-top:1px solid var(--fg)}.page-verify-email .steps li{display:grid;grid-template-columns:34px 1fr;gap:14px;padding:17px 0;border-bottom:1px solid var(--border)}.page-verify-email .step-num{font:12px/1.8 var(--font-mono);color:var(--accent)}.page-verify-email .actions{display:flex;gap:12px;flex-wrap:wrap}.page-verify-email .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.page-verify-email .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-verify-email .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-verify-email .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-verify-email .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.page-verify-email .status{min-height:27px;color:var(--muted);font-size:14px;margin:16px 0 0}.page-verify-email .fine{color:var(--muted);font-size:12px;margin:24px 0 0;padding-top:20px;border-top:1px solid var(--border)}
@media(max-width:620px){.page-verify-email .nav-inner{gap:10px}.page-verify-email .logo{font-size:20px}.page-verify-email .nav-return{font-size:13px}.page-verify-email .verification{align-items:start;padding-block:34px}.page-verify-email .card{padding:28px 20px}.page-verify-email .actions{align-items:stretch}.page-verify-email .actions .btn{width:100%}}
@media(prefers-reduced-motion:reduce){.page-verify-email *{transition-duration:.01ms!important}}
.page-verify-email .change-email{margin-top:18px}
.page-verify-email .change-email summary{cursor:pointer;font-size:13px;color:var(--muted)}
.page-verify-email .change-email summary:hover{color:var(--fg)}
.page-verify-email .change-email-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:center}
.page-verify-email .change-email-row .input{min-height:48px;border:1px solid color-mix(in oklch,var(--border) 72%,var(--fg));background:var(--surface);color:var(--fg);padding:0 14px;min-width:min(300px,100%)}
@@ -1,63 +0,0 @@
/* 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}}
@@ -1,62 +0,0 @@
/* 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}}
+5
View File
@@ -6,3 +6,8 @@
@media(max-width:920px){.page-signup .auth{grid-template-columns:1fr}.page-signup .auth-story{min-height:390px;padding:58px var(--gutter)}.page-signup .auth-form-wrap{padding:58px var(--gutter);justify-content:center}.page-signup .story-foot{margin-top:48px}}
@media(max-width:620px){.page-signup .nav-inner{gap:12px}.page-signup .nav-return{font-size:13px}.page-signup .auth-story{min-height:auto}.page-signup .auth-story h1{font-size:46px}.page-signup .auth-form-wrap{padding-block:46px}.page-signup .story-list{margin-top:38px}}
@media(prefers-reduced-motion:reduce){.page-signup *{transition-duration:.01ms!important}}
.page-signup .fine.ok{color:var(--success,#2e9e6b)}
.page-signup .field-error{margin:2px 0 0;font-size:13px;color:var(--error,#b4443c)}
.page-signup .field-error a{color:inherit;text-decoration:underline}
.page-signup .typo-suggest{justify-self:start;margin-top:4px;padding:0;border:0;background:none;color:var(--accent);font:inherit;font-size:13px;cursor:pointer;text-decoration:underline}
.page-signup .signup-reassure{margin-top:12px}
@@ -0,0 +1,60 @@
'use client'
import { useState } from 'react'
/* Early-access notification signup (L-01). 200 → success copy;
503/4xx → the server's Arabic error verbatim; 5xx/network → generic fallback. */
export default function NotifyForm() {
const [email, setEmail] = useState('')
const [pending, setPending] = useState(false)
const [done, setDone] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function submit(e: React.FormEvent) {
e.preventDefault()
if (pending) return
setPending(true)
setError(null)
try {
const res = await fetch('/api/notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
const data = (await res.json().catch(() => null)) as { error?: string; alreadySubscribed?: boolean } | null
if (res.ok) {
setDone(data?.alreadySubscribed ? 'بريدك مسجَّل لدينا بالفعل — سنراسلك عند تفعيل العضوية.' : 'تم! سنراسلك عند تفعيل العضوية.')
} else {
setError(data?.error ?? 'تعذر الحفظ، حاول مرة أخرى.')
}
} catch {
setError('تعذر الحفظ، حاول مرة أخرى.')
} finally {
setPending(false)
}
}
if (done) {
return <p className="notify-done" role="status">{done}</p>
}
return (
<form className="notify-form" onSubmit={submit}>
<label className="sr-only" htmlFor="notify-email">البريد الإلكتروني</label>
<input
id="notify-email"
type="email"
name="email"
required
dir="ltr"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button className="btn btn-primary" type="submit" disabled={pending || email.trim() === ''}>
{pending ? 'جارٍ الحفظ…' : 'أعلمني عند تفعيل العضوية'}
</button>
{error && <p className="notify-error" role="alert">{error}</p>}
</form>
)
}
+39 -5
View File
@@ -8,9 +8,15 @@ import configPromise from '@payload-config'
import Link from 'next/link'
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
import type { Viewer } from '@/lib/viewer'
import { freeEarlyAccess } from '@/lib/launch'
import NotifyForm from './notify-form'
export const dynamic = 'force-dynamic'
export const metadata = { title: 'العضوية' }
export const metadata = {
title: 'العضوية',
description:
'انضم لقائمة الإشعار: وصول مبكر مجاني لكل الأدلة اليوم، وخطة عضوية مستقبلية بـ 10$ شهرياً تضيف ملفات عمل وملخصات للأعضاء.',
}
/* Member-library samples — real VIP guides, read from the CMS at request time. */
const SAMPLES = [
@@ -31,6 +37,7 @@ export default async function SubscriptionPage() {
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',
@@ -50,6 +57,16 @@ export default async function SubscriptionPage() {
<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">
@@ -64,15 +81,17 @@ export default async function SubscriptionPage() {
<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>
<p className="plan-note">تلغي متى شئت، ويبقى حسابك المجاني ومحفوظاتك.</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)} />
<SubscribeButton loggedIn={Boolean(user)} comingSoon={early} />
)}
</div>
<p className="fine">الدفع عبر Stripe. لا نحفظ بيانات بطاقتك.</p>
<p className="fine">{early ? 'لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.' : 'الدفع عبر Stripe. لا نحفظ بيانات بطاقتك.'}</p>
</article>
</div>
</section>
@@ -118,7 +137,22 @@ export default async function SubscriptionPage() {
</div>
</section>
<section className="mem-faq" aria-labelledby="faq-title">
<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>
@@ -3,9 +3,13 @@
import { useState } from 'react'
import Link from 'next/link'
export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
/* 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 [comingSoon, setComingSoon] = useState(false)
const [failed, setFailed] = useState(false)
async function subscribe() {
setPending(true)
@@ -17,13 +21,18 @@ export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
return
}
}
// Stripe pending mode (no keys configured) or failure
setComingSoon(true)
// 503 (billing disabled) or any failure → honest coming-soon state.
setFailed(true)
setPending(false)
}
if (comingSoon) {
return <p className="plan-soon" role="status">الدفع الإلكتروني متاح قريباً. راسلنا على <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> لتفعيل عضويتك يدوياً.</p>
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) {
@@ -0,0 +1,136 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
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 Footer from '@/components/footer'
type Args = { params: Promise<{ topic: string }> }
type HubPost = {
id: number
title: string
slug: string
dek: string
access: 'free' | 'vip'
category: string
level: string
readingMinutes: number
views: number
cover?: { url?: string | null } | number | null
contentType?: string | null
}
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: 'أنور بآي' }
const meta = TOPICS.find(t => t.key === topic)
return {
title: `${meta?.label} — مسار`,
description: `كل أدلة مسار ${meta?.label} في أنور بآي: ${meta?.hint}. أدلة عملية بالعربية بخطوات قابلة للتطبيق.`,
alternates: { canonical: `/topics/${topic}` },
}
}
export default async function TopicPage({ params }: Args) {
const { topic } = await params
if (!isTopic(topic)) notFound()
const meta = TOPICS.find(t => t.key === topic)!
const payload = await getPayload({ config: configPromise })
const [res, auth] = await Promise.all([
payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
depth: 1,
limit: 200,
sort: '-publishedAt',
select: {
title: true, slug: true, dek: true, access: true, category: true, level: true,
readingMinutes: true, views: true, cover: true, contentType: true,
},
}),
payload.auth({ headers: await headers() }),
])
const role = (auth.user?.role as string) || null
// Topic membership is derived (category + level), same rule as the library/home.
const posts = (res.docs as unknown as (HubPost & { likes?: number })[]).filter(post => topicsFor(post).includes(topic))
const totalMinutes = posts.reduce((sum, post) => sum + (post.readingMinutes ?? 0), 0)
return (
<div id="site" className="site-page v2 page-topic">
{/* L-09: Breadcrumb structured data mirrors the visible trail. */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'الرئيسية', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: meta.label, item: `${SITE_URL}/topics/${topic}` },
],
}),
}}
/>
<Topnav viewer={auth.user ? { email: String(auth.user.email) } : null} />
<main id="content">
<section className="topic-hero">
<div className="wrap narrow">
<p className="crumbs" dir="rtl"><Link href="/">الرئيسية</Link> <span aria-hidden="true">/</span> <span>{meta.label}</span></p>
<h1>{meta.label}</h1>
<p className="topic-hint">{meta.hint} · {guidesLabel(posts.length)} · {minutesLabel(totalMinutes)} قراءة</p>
</div>
</section>
<section className="topic-list" aria-label={`أدلة ${meta.label}`}>
<div className="wrap narrow">
{posts.length === 0 && (
<p className="muted">لا أدلة منشورة في هذا المسار بعد. تابعنا للقادم قريباً.</p>
)}
<div className="lib-grid">
{posts.map(post => {
const coverUrl = typeof post.cover === 'object' ? post.cover?.url ?? null : null
const locked = post.access === 'vip' && role !== 'vip' && role !== 'admin' && !freeEarlyAccess()
return (
<article className="post guide-card" key={post.id}>
{coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />}
<div className="guide-card-body">
<p className="meta-row">
<span>{post.category}</span>
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
<span>{minutesLabel(post.readingMinutes)}</span>
<span>{post.level}</span>
</p>
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
<p className="guide-card-dek">{post.dek}</p>
<div className="guide-card-foot">
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span>
{post.views != null && <span className="engagement">{formatNumber(post.views)} قراءة</span>}
{locked && <span className="badge badge-vip">يتطلب عضوية</span>}
</div>
</div>
</article>
)
})}
</div>
<p className="topic-other">
مسارات أخرى:{' '}
{TOPICS.filter(t => t.key !== topic).map((t, i) => (
<span key={t.key}>{i > 0 && ' · '}<Link href={`/topics/${t.key}`}>{t.label}</Link></span>
))}
</p>
</div>
</section>
</main>
<Footer />
</div>
)
}
+5 -2
View File
@@ -19,12 +19,14 @@ export default async function VerifyEmailPage({ searchParams }: Props) {
const { invalid } = await searchParams
let email: string | null = null
let verified = false
let userId: number | null = null
try {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (user) {
email = user.email
verified = !!user.emailVerified
userId = Number(user.id)
}
} catch {
/* no db / no session — page still renders */
@@ -48,9 +50,10 @@ export default async function VerifyEmailPage({ searchParams }: Props) {
<ol className="steps" aria-label="خطوات تأكيد البريد">
<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>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
<li><span className="step-num">03</span><span>افحص مجلد الرسائل غير المرغوبة (Spam).</span></li>
<li><span className="step-num">04</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
</ol>
<VerifyActions verified={verified} />
<VerifyActions verified={verified} email={email} userId={userId} />
</div>
</section>
</main>
@@ -1,12 +1,52 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useEffect, useState } from 'react'
/* Resend button + status — real /api/auth/resend-verification endpoint.
Without SMTP configured the API answers 503 with a clear message. */
export default function VerifyActions({ verified }: { verified: boolean }) {
Without SMTP configured the API answers 503 with a clear message.
«البريد غير صحيح؟» — collapsed change-email affordance: PATCH /api/users/:id
(self-update allowed in Users.ts access) then resend; 400 → duplicate-email
copy. Hidden without a session (no id → nothing to patch). */
export default function VerifyActions({ verified, email, userId }: { verified: boolean; email: string | null; userId: number | null }) {
const [status, setStatus] = useState('')
const [newEmail, setNewEmail] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (email) {
setNewEmail(email)
return
}
try {
const saved = sessionStorage.getItem('anwarpi:signup-email')
if (saved) setNewEmail(saved)
} catch { /* private mode */ }
}, [email])
async function changeEmail() {
if (busy || !userId) return
setBusy(true)
setStatus('جارٍ التحديث…')
try {
const patchRes = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: newEmail.trim() }),
})
if (!patchRes.ok) {
setStatus(patchRes.status === 400 ? 'هذا البريد مسجَّل لدينا.' : 'تعذّر تحديث البريد، حاول مرة أخرى.')
return
}
const resendRes = await fetch('/api/auth/resend-verification', { method: 'POST' })
const data = await resendRes.json().catch(() => null) as { error?: string; already?: boolean } | null
setStatus(resendRes.ok || data?.already ? 'أرسلنا رسالة تأكيد جديدة إلى بريدك المحدَّث.' : data?.error || 'تم تحديث البريد لكن تعذّر الإرسال الآن.')
} catch {
setStatus('تعذّر الاتصال، حاول مرة أخرى.')
} finally {
setBusy(false)
}
}
return (
<>
@@ -19,7 +59,7 @@ export default function VerifyActions({ verified }: { verified: boolean }) {
button.disabled = true
setStatus('جارٍ الإرسال…')
const res = await fetch('/api/auth/resend-verification', { method: 'POST' })
const data = await res.json().catch(() => null)
const data = await res.json().catch(() => null) as { error?: string; already?: boolean } | null
if (res.ok || data?.already) {
setStatus('أرسلنا الرسالة من جديد — افحص صندوق الوارد.')
} else {
@@ -33,6 +73,17 @@ export default function VerifyActions({ verified }: { verified: boolean }) {
)}
</div>
<p className="status" id="resendStatus" role="status" aria-live="polite">{status}</p>
{userId && !verified && (
<details className="change-email" data-od-id="change-email">
<summary>البريد غير صحيح؟</summary>
<div className="change-email-row">
<input className="input" type="email" inputMode="email" dir="ltr" value={newEmail} onChange={e => setNewEmail(e.target.value)} aria-label="البريد الإلكتروني الصحيح" />
<button className="btn btn-secondary" type="button" onClick={changeEmail} disabled={busy || newEmail.trim() === ''}>
{busy ? 'جارٍ التحديث…' : 'حدّث البريد وأعد الإرسال'}
</button>
</div>
</details>
)}
</>
)
}
+53 -3
View File
@@ -5,6 +5,8 @@ import type { Media } from '@payload-types'
import configPromise from '@payload-config'
import { resolveCoverImage } from '@/lib/agent-media'
import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files'
import { scanAgentPayload } from '@/lib/secret-scan'
import { lexicalToText } from '@/lib/lexical'
export const dynamic = 'force-dynamic'
@@ -20,9 +22,11 @@ function validKey(req: NextRequest) {
return req.headers.get('x-agent-key') === key
}
/** Fields the agent is allowed to write; status is NEVER included. */
/** Fields the agent is allowed to write; status is NEVER included.
* `access`/`readingMinutes` are admin-only (tier changes and reading time
* are admin/agent-policy decisions — the hook computes honest minutes). */
function pickFields(body: Body): Body {
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'cover'] as const
const out: Body = {}
for (const field of allowed) {
if (field in body) out[field] = body[field]
@@ -58,6 +62,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
}
// L-10 dedup first: a retried ingest with the same sourceId surfaces the
// existing post (200) instead of a misleading slug 409.
if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') {
const dupe = await payload.find({
collection: 'posts',
where: { sourceId: { equals: body.sourceId } },
limit: 1,
overrideAccess: true,
})
const dup = dupe.docs[0] as { id: number; slug: string; status: string } | undefined
if (dup) {
return NextResponse.json({ id: dup.id, slug: dup.slug, status: dup.status, deduplicated: true }, { status: 200 })
}
}
// Slug conflict → 409
const existing = await payload.find({
collection: 'posts',
@@ -69,6 +88,20 @@ export async function POST(req: NextRequest) {
}
const data = pickFields(body)
if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') data.sourceId = body.sourceId
// L-05: pattern names only — matched values are never stored or echoed.
// A string content (wrong shape) is scanned raw too — lexicalToText of a
// string returns '' and would silently skip the scan.
const secretPatterns = scanAgentPayload([
data.title as string,
data.dek as string,
data.teaser as string,
typeof data.content === 'string' ? data.content : lexicalToText(data.content),
])
if (secretPatterns.length > 0) {
return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 })
}
// Per-post work files (inline base64) → stored as gated `files` documents.
// Like coverImage, `attachments` is an input transform, not a raw field.
@@ -99,7 +132,14 @@ export async function POST(req: NextRequest) {
// directly (unpublished content requires a deliberate admin action otherwise).
const publish = body.publish === true
if (publish && !data.publishedAt) data.publishedAt = new Date().toISOString()
if (!data.content || typeof data.content !== 'object') {
if (typeof data.content === 'string') {
// Wrong shape — never store it and never silently drop the agent's text.
return NextResponse.json(
{ error: 'content must be Lexical JSON ({ root: { children: [...] } }), not a markdown/plain string' },
{ status: 400 },
)
}
if (!data.content) {
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later
data.content = {
root: {
@@ -159,6 +199,16 @@ export async function PUT(req: NextRequest) {
const data = pickFields(body)
delete (data as Body).status // agent can never publish/unpublish
const secretPatterns = scanAgentPayload([
data.title as string,
data.dek as string,
data.teaser as string,
typeof data.content === 'string' ? data.content : lexicalToText(data.content),
])
if (secretPatterns.length > 0) {
return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 })
}
const doc = await payload.update({
collection: 'posts',
id: post.id,
+5 -1
View File
@@ -5,6 +5,7 @@ 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'
@@ -26,7 +27,10 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
}
if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
if (doc.access === 'vip') {
// 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()) {
const viewer = await getViewer()
if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 })
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
+63
View File
@@ -0,0 +1,63 @@
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خلال الوصول المبكر كل الأدلة المنشورة متاحة مجاناً، ولن يُنشأ لك اشتراك أو يُحصَّل أي مبلغ.',
)
if (!sent) {
return NextResponse.json({ error: 'خدمة البريد غير مربوطة بعد.' }, { status: 503 })
}
return NextResponse.json({ ok: true })
}
+5 -1
View File
@@ -3,6 +3,7 @@ import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { REST_DELETE, REST_GET, REST_PATCH, REST_PUT } from '@payloadcms/next/routes'
import { getViewer } from '@/lib/viewer'
import { freeEarlyAccess } from '@/lib/launch'
export const dynamic = 'force-dynamic'
@@ -35,7 +36,10 @@ export async function GET(req: NextRequest, ctx: Ctx) {
})
const post = docs[0]
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (post.access === 'vip') {
// L-02: in free early access, published VIP posts serve the full body
// (mirrors the page + field-ACL policy); the tier gate applies only when
// billing is enabled.
if (post.access === 'vip' && !freeEarlyAccess()) {
const viewer = await getViewer()
if (!viewer) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
+11 -8
View File
@@ -4,6 +4,7 @@ import type { Payload } from 'payload'
import { headers } from 'next/headers'
import Stripe from 'stripe'
import configPromise from '@payload-config'
import { billingEnabled } from '@/lib/launch'
export const dynamic = 'force-dynamic'
@@ -12,15 +13,17 @@ export async function POST() {
const priceId = process.env.STRIPE_PRICE_ID
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
// Stripe pending mode: no keys yet → subscription is «قريباً» (coming soon).
// No VIP is granted without a Stripe-verified session; adding
// STRIPE_SECRET_KEY + STRIPE_PRICE_ID switches to real Checkout with the
// same success/cancel pages.
// 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.
if (!billingEnabled()) {
return NextResponse.json({ error: 'الدفع غير مفعّل خلال فترة الوصول المبكر.' }, { status: 503 })
}
// Config-error guard for paid mode (TS narrowing; billingEnabled() already
// guarantees the secret) — never create a session with a missing price.
if (!secret || !priceId) {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
return NextResponse.json({ comingSoon: true })
return NextResponse.json({ error: 'الدفع غير مكتمل الإعداد حالياً.' }, { status: 503 })
}
const payload = await getPayload({ config: configPromise })
+31
View File
@@ -207,6 +207,8 @@ body { background: var(--ink); color: var(--mist); }
#site .membership-offer { border: 1px solid var(--seam); border-top: 2px solid var(--champagne); border-radius: var(--r-md); padding: 28px; background: var(--basalt); display: grid; gap: 18px; }
#site .membership-offer .price { font: 600 40px/1 var(--font-display); color: var(--mist); display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
#site .membership-offer .price span { font: 400 15px/1.5 var(--font-body); color: var(--stone); }
#site .membership-offer .price-early { color: var(--champagne); }
#site .membership-offer .early-line { color: var(--stone); font-size: 15.5px; margin: -8px 0 0; }
#site .membership-offer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
#site .membership-offer li { display: flex; gap: 12px; align-items: baseline; color: var(--mist); font-size: 15.5px; }
#site .membership-offer li::before { content: ""; flex: none; width: 9px; height: 8px; background: var(--champagne); clip-path: polygon(50% 0, 100% 100%, 0 100%); transform: translateY(-1px); }
@@ -277,7 +279,22 @@ body { background: var(--ink); color: var(--mist); }
#site .art-body li + li { margin-top: .4em; }
#site .art-body li::marker { color: var(--champagne); }
#site .art-body blockquote { margin-inline: 0; padding: 4px 20px; border-inline-start: 2px solid var(--champagne); color: var(--mist); font-size: 19px; }
#site .topic-hero { padding-block: clamp(40px, 6vw, 72px) 8px; }
#site .topic-hero h1 { font-size: clamp(28px, 3vw, 40px); margin: 0 0 6px; }
#site .topic-hero .crumbs { display: flex; gap: 8px; color: var(--stone); font-size: 14px; margin: 0 0 18px; }
#site .topic-hero .crumbs a { color: var(--champagne); }
#site .topic-hint { color: var(--mist); margin: 0; }
#site .topic-list { padding-block: 32px 72px; }
#site .topic-other { margin-top: 40px; color: var(--stone); font-size: 15px; }
#site .topic-other a { color: var(--champagne); }
#site .topic-strip { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 22px; }
#site .topic-strip a { display: inline-block; padding: 6px 16px; border: 1px solid var(--seam); border-radius: 999px; color: var(--mist); font-size: 14.5px; }
#site .topic-strip a:hover { border-color: var(--champagne); color: var(--champagne); }
#site .art-body pre { direction: ltr; text-align: left; background: var(--ink-2); border: 1px solid var(--seam); border-radius: var(--r-md); padding: 20px 22px; overflow-x: auto; font: 14.5px/1.75 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
#site .art-body .post-table { overflow-x: auto; -webkit-overflow-scrolling: touch; }
#site .art-body .post-table table { width: 100%; border-collapse: collapse; font-size: 16px; line-height: 1.8; }
#site .art-body .post-table th, #site .art-body .post-table td { border: 1px solid var(--seam); padding: 10px 14px; text-align: start; vertical-align: top; }
#site .art-body .post-table th { background: var(--ink-2); font-weight: 600; color: var(--mist); }
#site .art-body pre code { background: none; padding: 0; white-space: pre; }
#site .art-body hr { border: 0; height: 1px; background: var(--seam); margin-block: 2.4em; }
#site .art-body .intro { font-size: 21px; line-height: 1.9; color: var(--mist); }
@@ -285,6 +302,11 @@ body { background: var(--ink); color: var(--mist); }
#site .paywall h2 { font-size: clamp(24px, 2.4vw, 30px); margin: 0 0 12px; }
#site .paywall p { color: var(--stone); font-size: 16.5px; }
#site .paywall-actions { display: flex; flex-wrap: wrap; gap: 12px; margin: 24px 0 14px; }
#site .paywall-preview ul { list-style: none; margin: 0 0 6px; padding: 0; display: grid; gap: 8px; }
#site .paywall-preview li { display: flex; gap: 10px; align-items: baseline; color: var(--mist); font-size: 15.5px; }
#site .paywall-preview li svg { flex: none; width: 14px; height: 14px; transform: translateY(2px); }
#site .early-notice { margin: 0 0 28px; padding: 14px 18px; border: 1px solid var(--seam); border-inline-start: 2px solid var(--champagne); border-radius: var(--r-md); background: var(--basalt); }
#site .early-notice p { color: var(--stone); font-size: 16.5px; margin: 0; }
#site .art-aside { position: sticky; top: 104px; display: grid; gap: 28px; }
#site .aside-title { font: 600 15px/1.6 var(--font-body); color: var(--mist); margin-bottom: 10px; }
#site .toc ol { list-style: none; margin: 0; padding: 0; border-inline-start: 1px solid var(--seam); }
@@ -324,6 +346,13 @@ body { background: var(--ink); color: var(--mist); }
MEMBERSHIP
================================================================== */
#site.v2-membership .mem-hero { padding-block: clamp(56px, 8vw, 104px); border-bottom: 1px solid var(--seam); }
#site.v2-membership .early-access { padding-top: clamp(32px, 5vw, 56px); }
#site.v2-membership .early-access .wrap > p { color: var(--stone); max-width: 70ch; }
#site.v2-membership .early-access h2 { font-size: clamp(22px, 2.4vw, 30px); margin-bottom: 10px; }
#site .notify-form { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; align-items: center; }
#site .notify-form input { min-height: 48px; padding: 0 14px; border: 1px solid var(--seam); border-radius: var(--r-sm, 10px); background: var(--bg); color: var(--fg); font: inherit; min-width: min(320px, 100%); }
#site .notify-form .notify-error { color: var(--error, #b4443c); width: 100%; margin: 0; }
#site .notify-done { color: var(--success, var(--champagne)); font-weight: 600; margin-top: 18px; }
#site.v2-membership .mem-hero-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 1fr); gap: clamp(32px, 6vw, 88px); align-items: start; }
#site.v2-membership .mem-hero h1 { font-size: clamp(36px, 4.6vw, 60px); max-width: 15ch; }
#site.v2-membership .mem-hero .lede { margin-top: 22px; }
@@ -341,6 +370,7 @@ body { background: var(--ink); color: var(--mist); }
#site .plan-cta .btn { width: 100%; }
#site .plan-soon { padding: 14px 16px; border: 1px dashed var(--seam-2); border-radius: var(--r-sm); color: var(--stone); font-size: 14.5px; }
#site .plan-soon a { color: var(--mist); text-decoration: underline; text-underline-offset: 4px; }
#site .plan-soon-wrap { display: grid; gap: 10px; justify-items: start; }
#site.v2-membership .mem-samples { padding-block: clamp(64px, 8vw, 104px); background: var(--ink-2); border-bottom: 1px solid var(--seam); }
#site .sample-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px 24px; }
#site .sample { display: grid; gap: 8px; align-content: start; height: 100%; }
@@ -605,6 +635,7 @@ body, #site { background: var(--ink); color: var(--mist); }
#site.v2-article .art-head { background: var(--ink-2); }
#site .art-body pre { background: #0f172a; color: #e2e8f0; border-color: #0f172a; }
#site .paywall { background: #f2faf7; border-color: #cfe9e1; border-top-color: var(--champagne); }
#site .early-notice { background: #f2faf7; border-color: #cfe9e1; border-inline-start-color: var(--champagne); }
#site .comment, #site .comment-gate { background: #fff; }
#site .art-related { background: var(--ink-2); }
#site :is(.related-item, .sample) { background: #fff; }
+1 -1
View File
@@ -19,7 +19,7 @@ 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 — أنور بآي' },
title: { default: 'أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية', template: '%s — أنور بآي' },
description: 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.',
alternates: { canonical: './' },
openGraph: {
+45
View File
@@ -0,0 +1,45 @@
import { SITE_INTRO, SITE_TITLE, fetchIndexDocs, renderIndexSections, type IndexDoc } from '@/lib/llms'
import { freeEarlyAccess } from '@/lib/launch'
import { formatDate } from '@/lib/format'
import { lexicalToPlain } from '@/lib/lexical'
export const dynamic = 'force-dynamic'
export async function GET(): Promise<Response> {
const early = freeEarlyAccess()
// Public read path: the content field's read ACL already decides body
// availability — with early access every published body passes; with billing
// on, VIP bodies come back undefined and are listed members-only.
const docs = await fetchIndexDocs(true) as (IndexDoc & { content?: unknown })[]
const parts: string[] = [
`# ${SITE_TITLE}`,
'',
`> ${SITE_INTRO}`,
'',
renderIndexSections(docs, !early),
'---',
'',
'# الأدلة الكاملة',
'',
]
for (const doc of docs) {
const vipOnly = !early && doc.access === 'vip'
const suffix = vipOnly ? ' (دليل للأعضاء)' : ''
parts.push(`## ${doc.title}${suffix}`, '')
parts.push(`URL: /posts/${doc.slug}`, '')
if (doc.dek) parts.push(doc.dek, '')
if (doc.updatedAt && doc.publishedAt && doc.updatedAt !== doc.publishedAt) {
parts.push(`حُدّث: ${formatDate(doc.updatedAt)}`, '')
}
if (vipOnly) {
parts.push('هذا الدليل متاح للأعضاء فقط. العضوية: /subscription', '')
} else if (typeof doc.content === 'object' && doc.content !== null) {
parts.push(lexicalToPlain(doc.content), '')
}
parts.push('---', '')
}
return new Response(parts.join('\n'), { headers: { 'content-type': 'text/plain; charset=utf-8' } })
}
+17
View File
@@ -0,0 +1,17 @@
import { SITE_INTRO, SITE_TITLE, fetchIndexDocs, renderIndexSections } from '@/lib/llms'
export const dynamic = 'force-dynamic'
export async function GET(): Promise<Response> {
const docs = await fetchIndexDocs()
const body = [
`# ${SITE_TITLE}`,
'',
`> ${SITE_INTRO}`,
'',
`النسخة الكاملة: /llms-full.txt`,
'',
renderIndexSections(docs, false),
].join('\n')
return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } })
}
+28 -5
View File
@@ -1,12 +1,35 @@
import type { MetadataRoute } from 'next'
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
/* Owner decision: allow all named search/AI crawlers explicitly. The `*`
group keeps its `/api` disallow; the named groups opt back into the
public content APIs and the llms.txt feeds. */
const AI_BOTS = [
'GPTBot',
'OAI-SearchBot',
'ChatGPT-User',
'ClaudeBot',
'Claude-User',
'Claude-SearchBot',
'PerplexityBot',
'Google-Extended',
'Applebot',
'Bingbot',
]
export default function robots(): MetadataRoute.Robots {
return {
rules: [{
userAgent: '*',
allow: '/',
disallow: ['/admin', '/api', '/account-settings', '/onboarding', '/vip-onboarding', '/verify-email', '/payment-success', '/payment-cancelled', '/forgot-password'],
}],
rules: [
...AI_BOTS.map(userAgent => ({
userAgent,
allow: ['/', '/api/posts', '/llms.txt', '/llms-full.txt'],
})),
{
userAgent: '*',
allow: '/',
disallow: ['/admin', '/api', '/account-settings', '/onboarding', '/vip-onboarding', '/verify-email', '/payment-success', '/payment-cancelled', '/forgot-password'],
},
],
sitemap: `${base}/sitemap.xml`,
}
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { MetadataRoute } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { TOPICS } from '@/lib/topics'
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
@@ -14,7 +15,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
limit: 500,
depth: 0,
})
const staticPaths = ['', '/articles', '/subscription', '/terms', '/privacy', '/about', '/login', '/signup']
const staticPaths = ['', '/articles', '/subscription', '/terms', '/privacy', '/about',
// Topic hubs are indexable; /login and /signup are noindex (step 38).
...TOPICS.map(topic => `/topics/${topic.key}`)]
return [
...staticPaths.map(p => ({ url: `${base}${p}` })),
...posts.docs.map(p => ({ url: `${base}/posts/${p.slug}`, lastModified: p.updatedAt ? new Date(String(p.updatedAt)) : undefined })),
@@ -0,0 +1,34 @@
import type { AccessArgs, CollectionConfig } from 'payload'
const isAdmin = (args: AccessArgs) =>
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
/* L-01 early-access notification list — real, minimal. The /api/notify route
is the primary writer (duplicate check + confirmation email); direct REST
create stays open (`create: () => true`) so the list never silently drops
a signup, but reads/updates/deletes are admin-only. */
const LaunchNotifications: CollectionConfig = {
slug: 'launch-notifications',
admin: {
useAsTitle: 'email',
defaultColumns: ['email', 'source', 'createdAt'],
},
access: {
create: () => true,
read: isAdmin,
update: isAdmin,
delete: isAdmin,
admin: isAdmin,
},
fields: [
{ name: 'email', type: 'text', required: true, unique: true, index: true },
{
name: 'source',
type: 'select',
defaultValue: 'subscription',
options: [{ label: 'صفحة العضوية', value: 'subscription' }],
},
],
}
export default LaunchNotifications
+52 -2
View File
@@ -1,4 +1,6 @@
import type { AccessArgs, CollectionConfig } from 'payload'
import { freeEarlyAccess, READING_WORDS_PER_MINUTE } from '@/lib/launch'
import { countLexicalWords } from '@/lib/lexical'
const isAdmin = (args: AccessArgs) =>
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
@@ -7,6 +9,19 @@ export const CATEGORIES = ['وكلاء', 'نماذج', 'استرجاع', 'سلا
const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
// L-07: honest reading estimates. Recomputed from the body on every
// write — agent/admin-supplied `readingMinutes` is always overridden.
beforeChange: [
({ data, originalDoc }) => {
const words =
data.content !== undefined
? countLexicalWords(data.content)
: countLexicalWords(originalDoc?.content)
return { ...data, readingMinutes: Math.max(1, Math.round(words / READING_WORDS_PER_MINUTE)) }
},
],
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'category', 'access', 'status', 'publishedAt'],
@@ -61,8 +76,13 @@ const Posts: CollectionConfig = {
access: {
// Never serve VIP body to non-VIP clients — defense in depth
// (the paywall page also never queries it for them).
read: ((args: { doc?: { access?: string }; req: { user?: { role?: string } | null } }) => {
read: ((args: { doc?: { access?: string; status?: string }; req: { user?: { role?: string } | null } }) => {
if (!args.doc) return true
// L-02: during free early access, published posts serve their body
// to everyone — Payload REST must not contradict the page. The
// field-permission `doc` arg is the full document incl. `status`
// (verified: payload/dist/fields/hooks/afterRead/promise.js:226).
if (freeEarlyAccess() && args.doc.status === 'published') return true
if (args.doc.access !== 'vip') return true
return args.req.user?.role === 'vip' || args.req.user?.role === 'admin'
}) as never, // field-permission signature is structurally compatible; TS can't infer it here
@@ -95,11 +115,41 @@ const Posts: CollectionConfig = {
{ label: 'متقدم', value: 'متقدم' },
],
},
{ name: 'views', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
{
name: 'contentType',
type: 'select',
label: 'نوع المحتوى',
options: [
{ label: 'ملاحظة تقنية', value: 'note' },
{ label: 'دليل عملي', value: 'tutorial' },
{ label: 'تجربة موثقة', value: 'case_study' },
{ label: 'مقارنة', value: 'comparison' },
],
},
{
// L-09: real author display — never invent values; empty = no byline.
name: 'authorName',
type: 'text',
label: 'اسم الكاتب',
},
{
name: 'views',
type: 'number',
defaultValue: 0,
admin: { readOnly: true, position: 'sidebar' },
},
{ name: 'likes', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
{ name: 'cover', type: 'upload', relationTo: 'media', required: true },
{ 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.
name: 'sourceId',
type: 'text',
index: true,
admin: { description: 'معرّف المصدر (خاص — للتتبّع ومنع التكرار)' },
},
{
name: 'status',
type: 'select',
+27 -1
View File
@@ -1,6 +1,7 @@
import { pbkdf2 } from 'crypto'
import { pbkdf2, randomBytes } from 'crypto'
import { promisify } from 'util'
import type { AccessArgs, CollectionConfig } from 'payload'
import { sendVerificationEmail } from '@/lib/mail'
const isAdmin = (args: AccessArgs) =>
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
@@ -88,6 +89,31 @@ const Users: CollectionConfig = {
return data
},
],
// L-06 signup trust: the verify-email page claims «أرسلنا رابط التأكيد» —
// make that true at signup instead of only after clicking resend. Mirror
// api/auth/resend-verification exactly: fresh token → internal update
// (systemActivation bypasses the beforeChange token strip) → email.
// Mail env absent → sendVerificationEmail returns { sent: false } and the
// hook skips silently; the resend route keeps the honest-503 path.
afterChange: [
async ({ doc, operation, req }) => {
if (operation !== 'create' || !doc?.email) return
try {
const token = randomBytes(32).toString('hex')
await req.payload.update({
collection: 'users',
id: doc.id,
data: { verificationToken: token },
overrideAccess: true,
context: { systemActivation: true },
req,
})
await sendVerificationEmail(doc.email, token)
} catch (err) {
console.error('signup verification email failed:', err)
}
},
],
},
fields: [
{
+11
View File
@@ -27,3 +27,14 @@ export function minutesLabel(n: number | null | undefined): string {
if (value === 2) return 'دقيقتان'
return `${formatNumber(value)} ${value % 100 >= 3 && value % 100 <= 10 ? 'دقائق' : 'دقيقة'}`
}
/* Content-type badge labels (L-09). Null for unset/unknown — no byline invented. */
export function contentTypeLabel(value: string | null | undefined): string | null {
switch (value) {
case 'note': return 'ملاحظة تقنية'
case 'tutorial': return 'دليل عملي'
case 'case_study': return 'تجربة موثقة'
case 'comparison': return 'مقارنة'
default: return null
}
}
+17
View File
@@ -0,0 +1,17 @@
/* 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)
export const freeEarlyAccess = (): boolean => !billingEnabled()
/* Configurable Arabic reading rate (words/minute); revisit after reader
feedback. Used by the Posts beforeChange hook and the backfill script. */
export const READING_WORDS_PER_MINUTE = 160
+133
View File
@@ -0,0 +1,133 @@
/**
* Helpers over serialized Lexical richText JSON (Payload's editor state).
* A serialized tree is `{ root: { children: [...] } }`; text lives on
* `{ text }` nodes, nesting happens via `{ children }`.
*/
export type Node = {
type?: string
text?: string
children?: Node[]
tag?: string
url?: string
listType?: string
code?: string
headerState?: number
}
/** Root `children` of a serialized tree, narrowed at runtime. */
function rootChildren(data: unknown): Node[] | null {
if (data === null || typeof data !== 'object' || !('root' in data)) return null
const root: unknown = data.root // unknown via `in` narrowing — no shape assumed
if (root === null || typeof root !== 'object' || !('children' in root)) return null
const children: unknown = root.children
if (!Array.isArray(children)) return null
// Boundary: the array is the editor's serialized node list; every access
// below goes through Node's all-optional contract, so nothing unchecked.
const nodes: Node[] = children
return nodes
}
/** Concatenated text of every text node (secret scanning, llms extraction). */
export function lexicalToText(content: unknown): string {
let out = ''
const walk = (node: unknown): void => {
if (node === null || typeof node !== 'object') return
if ('text' in node && typeof node.text === 'string') out += node.text + '\n'
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) walk(child)
}
}
// Serialized trees wrap nodes in `root` — walk it, not the wrapper.
if (content !== null && typeof content === 'object' && 'root' in content) {
walk((content as { root: unknown }).root)
} else {
walk(content)
}
return out
}
/** Count whitespace-delimited words across all text nodes of a Lexical tree. */
export function countLexicalWords(content: unknown): number {
let words = 0
const walk = (node: unknown): void => {
if (node === null || typeof node !== 'object') return
if ('text' in node && typeof node.text === 'string') {
words += node.text.trim().split(/\s+/).filter(Boolean).length
}
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) walk(child)
}
}
// Same root unwrap as lexicalToText.
if (content !== null && typeof content === 'object' && 'root' in content) {
walk((content as { root: unknown }).root)
} else {
walk(content)
}
return words
}
export function innerText(nodes?: Node[]): string {
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
}
/** H2 headings — TOC and the paid-mode paywall preview. */
export function extractH2s(data: unknown): string[] {
const children = rootChildren(data)
if (!children) return []
return children
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
.map(n => innerText(n.children))
}
function tagLevel(tag?: string): number {
const n = Number.parseInt((tag ?? 'h2').replace('h', ''), 10)
return Number.isFinite(n) && n >= 1 && n <= 6 ? n : 2
}
/**
* Plain-text rendering for llms-full.txt: headings → `## `, paragraphs and
* list items → lines, code → fenced blocks, table rows → `| a | b |`.
*/
export function lexicalToPlain(data: unknown): string {
const children = rootChildren(data)
if (!children) return ''
const lines: string[] = []
for (const n of children) {
switch (n.type) {
case 'heading':
lines.push(`${'#'.repeat(tagLevel(n.tag))} ${innerText(n.children)}`.trim())
break
case 'paragraph':
lines.push(innerText(n.children).trim())
break
case 'list':
for (const item of n.children || []) lines.push(`- ${innerText(item.children).trim()}`)
break
case 'listitem':
lines.push(`- ${innerText(n.children).trim()}`)
break
case 'code':
lines.push('```\n' + (n.code ?? innerText(n.children)) + '\n```')
break
case 'table':
for (const row of n.children || []) {
const cells = (row.children || []).map(cell => innerText(cell.children).trim().replace(/\|/g, '\\|'))
lines.push(`| ${cells.join(' | ')} |`)
}
break
case 'quote':
lines.push(`> ${innerText(n.children).trim()}`)
break
case 'horizontalrule':
lines.push('---')
break
default: {
const text = innerText(n.children).trim()
if (text) lines.push(text)
}
}
}
return lines.filter(line => line !== '').join('\n\n')
}
+61
View File
@@ -0,0 +1,61 @@
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { TOPICS, topicsFor, type TopicKey } from '@/lib/topics'
export type IndexDoc = {
id: number | string
title: string
slug: string
dek: string
access: 'free' | 'vip'
category: string
level: string
publishedAt: string | null
updatedAt: string | null
/** Present only when requested via fetchIndexDocs(true). */
content?: unknown
}
/** Published posts, public read path — no overrideAccess (drafts stay hidden,
* and the content field's own read ACL decides body availability). */
export async function fetchIndexDocs(withContent = false): Promise<IndexDoc[]> {
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
depth: 0,
limit: 500,
sort: '-publishedAt',
select: {
title: true,
slug: true,
dek: true,
access: true,
category: true,
level: true,
publishedAt: true,
updatedAt: true,
...(withContent ? { content: true } : {}),
},
})
return docs as unknown as IndexDoc[]
}
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 {
const lines: string[] = []
for (const topic of TOPICS) {
const items = docs.filter(doc => topicsFor(doc).includes(topic.key))
if (items.length === 0) continue
lines.push(`## ${topic.label}`, '')
for (const doc of items) {
const suffix = markVip && doc.access === 'vip' ? ' (دليل للأعضاء)' : ''
lines.push(`- [${doc.title}${suffix}](/posts/${doc.slug}): ${doc.dek}`)
}
lines.push('')
}
return lines.join('\n')
}
+39
View File
@@ -1,3 +1,42 @@
/* Generic text-only send over the same Cloudflare Email Sending REST call —
used by /api/notify for the launch-notification confirmation. Single-shot,
honest { sent: false } on any failure; no retry. */
export async function sendMail(to: string, subject: string, text: string): Promise<{ sent: boolean }> {
const apiToken = process.env.CLOUDFLARE_API_TOKEN
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
if (!apiToken || !accountId) return { sent: false }
try {
const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/email/sending/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to,
from: { address: process.env.MAIL_FROM ?? 'noreply@anwarpy.ai', name: 'أنور بي آي' },
subject,
text,
}),
})
if (!res.ok) {
console.error('cloudflare email sending failed:', res.status, await res.text())
return { sent: false }
}
const json = (await res.json()) as { success?: boolean }
if (!json.success) {
console.error('cloudflare email sending rejected:', JSON.stringify(json))
return { sent: false }
}
return { sent: true }
} catch (err) {
console.error('cloudflare email sending threw:', err)
return { sent: false }
}
}
/* Cloudflare Email Sending (REST) — activates only when CLOUDFLARE_API_TOKEN
and CLOUDFLARE_ACCOUNT_ID are set. Without them, sendVerificationEmail
returns { sent: false } and the UI degrades to a clear status message; the
+31
View File
@@ -0,0 +1,31 @@
/**
* L-05: reject agent content that looks like a leaked credential before it
* is stored or published. `scanForSecrets` returns matched PATTERN NAMES
* only — never the matched values (those must not be echoed anywhere).
*/
const PATTERNS: readonly (readonly [string, RegExp])[] = [
['aws_access_key', /AKIA[0-9A-Z]{16}/],
['pem_private_key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
['openai_api_key', /sk-[A-Za-z0-9]{20,}/],
['github_token', /ghp_[A-Za-z0-9]{36}/],
['generic_credential', /(?:password|passwd|api[_-]?key|secret)\s*[:=]\s*\S+/i],
] as const
export function scanForSecrets(text: string): string[] {
const names: string[] = []
for (const [name, re] of PATTERNS) {
if (re.test(text)) names.push(name)
}
return names
}
/** Scan the strings an agent can set; returns pattern names across all targets. */
export function scanAgentPayload(targets: (string | undefined | null)[]): string[] {
const names = new Set<string>()
for (const target of targets) {
if (typeof target !== 'string') continue
for (const name of scanForSecrets(target)) names.add(name)
}
return [...names]
}
-2
View File
@@ -14,7 +14,6 @@ export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "users" ADD COLUMN "full_name" varchar;
ALTER TABLE "users" ADD COLUMN "interest" "enum_users_interest";
ALTER TABLE "users" ADD COLUMN "level" "enum_users_level";
ALTER TABLE "users" ADD COLUMN "goal" "enum_users_goal";
@@ -43,7 +42,6 @@ export async function down({ db, payload, req }: MigrateDownArgs): Promise<void>
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_post_likes_fk";
DROP INDEX "payload_locked_documents_rels_post_likes_id_idx";
ALTER TABLE "users" DROP COLUMN "full_name";
ALTER TABLE "users" DROP COLUMN "interest";
ALTER TABLE "users" DROP COLUMN "level";
ALTER TABLE "users" DROP COLUMN "goal";
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_posts_content_type" AS ENUM('note', 'tutorial', 'case_study', 'comparison');
CREATE TYPE "public"."enum_launch_notifications_source" AS ENUM('subscription');
CREATE TABLE "launch_notifications" (
"id" serial PRIMARY KEY NOT NULL,
"email" varchar NOT NULL,
"source" "enum_launch_notifications_source" DEFAULT 'subscription',
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "posts" ADD COLUMN "content_type" "enum_posts_content_type";
ALTER TABLE "posts" ADD COLUMN "author_name" varchar;
ALTER TABLE "posts" ADD COLUMN "source_id" varchar;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "launch_notifications_id" integer;
CREATE UNIQUE INDEX "launch_notifications_email_idx" ON "launch_notifications" USING btree ("email");
CREATE INDEX "launch_notifications_updated_at_idx" ON "launch_notifications" USING btree ("updated_at");
CREATE INDEX "launch_notifications_created_at_idx" ON "launch_notifications" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_launch_notifications_fk" FOREIGN KEY ("launch_notifications_id") REFERENCES "public"."launch_notifications"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "posts_source_id_idx" ON "posts" USING btree ("source_id");
CREATE INDEX "payload_locked_documents_rels_launch_notifications_id_idx" ON "payload_locked_documents_rels" USING btree ("launch_notifications_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "launch_notifications" DISABLE ROW LEVEL SECURITY;
DROP TABLE "launch_notifications" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_launch_notifications_fk";
DROP INDEX "posts_source_id_idx";
DROP INDEX "payload_locked_documents_rels_launch_notifications_id_idx";
ALTER TABLE "posts" DROP COLUMN "content_type";
ALTER TABLE "posts" DROP COLUMN "author_name";
ALTER TABLE "posts" DROP COLUMN "source_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "launch_notifications_id";
DROP TYPE "public"."enum_posts_content_type";
DROP TYPE "public"."enum_launch_notifications_source";`)
}
+7 -1
View File
@@ -4,6 +4,7 @@ import * as migration_20261003_111451 from './20261003_111451';
import * as migration_20261003_133758 from './20261003_133758';
import * as migration_20261003_201454 from './20261003_201454';
import * as migration_20261009_130817 from './20261009_130817';
import * as migration_20261010_142214 from './20261010_142214';
export const migrations = [
{
@@ -34,6 +35,11 @@ export const migrations = [
{
up: migration_20261009_130817.up,
down: migration_20261009_130817.down,
name: '20261009_130817'
name: '20261009_130817',
},
{
up: migration_20261010_142214.up,
down: migration_20261010_142214.down,
name: '20261010_142214'
},
];
+2 -1
View File
@@ -10,6 +10,7 @@ import Media from './collections/Media'
import Files from './collections/Files'
import PostLikes from './collections/PostLikes'
import Comments from './collections/Comments'
import LaunchNotifications from './collections/LaunchNotifications'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -20,7 +21,7 @@ export default buildConfig({
meta: { titleSuffix: '— أنور بي آي' },
},
secret: process.env.PAYLOAD_SECRET || 'dev-secret-change-me',
collections: [Users, Posts, Media, Files, PostLikes, Comments],
collections: [Users, Posts, Media, Files, PostLikes, Comments, LaunchNotifications],
editor: lexicalEditor(),
db: postgresAdapter({
pool: {