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
+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>
)}
</>
)
}