Audit fixes: VIP self-grant, draft leak, onboarding flag, hero personalization, gated downloads
Deploy GitHub Pages / deploy (push) Canceled after 0s
Deploy GitHub Pages / deploy (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'عن الفريق' }
|
||||
|
||||
export default async function AboutPage() {
|
||||
const viewer = await getViewer()
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-about">
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<section className="about-hero" aria-labelledby="about-title">
|
||||
<div className="wrap about-hero-grid">
|
||||
<div>
|
||||
<h1 id="about-title">نكتب عن تجاربنا، ونشارك ما جرّبناه عمليًا</h1>
|
||||
<p className="lede">أنور بآي منصة يقودها أنور عنبتاوي مع أحمد محمد. نحوّل خبرة سنوات في البنية التحتية والذكاء الاصطناعي التطبيقي إلى أدلة عربية يمكن تنفيذها في عملك.</p>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="about-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-team" aria-labelledby="team-title">
|
||||
<div className="wrap">
|
||||
<h2 id="team-title">الفريق</h2>
|
||||
<div className="people">
|
||||
<article className="person">
|
||||
<p className="person-role">المؤسس، 20 عاماً في البنية التحتية</p>
|
||||
<h3>أنور عنبتاوي</h3>
|
||||
<p>يقود رؤية المنصة بخبرة عقدين في البنية التحتية لتقنية المعلومات. يهتم بتشغيل النماذج الكبيرة محلياً، واستكشاف النماذج المفتوحة، وبناء بيئات تجعل التجربة التقنية قابلة للتشغيل الحقيقي.</p>
|
||||
<ul className="tags" aria-label="مجالات اهتمام أنور" role="list">
|
||||
<li>البنية التحتية</li><li>النماذج المفتوحة</li><li>التشغيل المحلي</li><li>الأنظمة الموثوقة</li>
|
||||
</ul>
|
||||
<a className="text-link" href="https://www.linkedin.com/in/anwaranabtawi/" target="_blank" rel="noopener noreferrer">أنور على LinkedIn</a>
|
||||
</article>
|
||||
<article className="person">
|
||||
<p className="person-role">الذكاء الاصطناعي التطبيقي وتكامل الأعمال</p>
|
||||
<h3>أحمد محمد</h3>
|
||||
<p>يركز على ضبط النماذج، وأطر الوكلاء وتنسيقها، وأنظمة <bdi dir="ltr">RAG</bdi>. يهمه أن يصبح الذكاء الاصطناعي جزءاً فعّالاً من طريقة إنجاز العمل، لا تجربة منفصلة عنه.</p>
|
||||
<ul className="tags" aria-label="مجالات اهتمام أحمد" role="list">
|
||||
<li>ضبط النماذج</li><li>أطر الوكلاء</li><li>تنسيق الوكلاء</li><li><bdi dir="ltr">RAG</bdi></li>
|
||||
</ul>
|
||||
<a className="text-link" href="https://www.linkedin.com/in/ahmed-mohamed-423583151/" target="_blank" rel="noopener noreferrer">أحمد على LinkedIn</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-principles" aria-labelledby="principles-title">
|
||||
<div className="wrap principles-grid">
|
||||
<h2 id="principles-title">كيف نكتب كل دليل</h2>
|
||||
<dl className="principles">
|
||||
<div><dt>التطبيق قبل الاستعراض</dt><dd>نقيس قيمة التقنية بقدرتها على حل مشكلة واضحة أو تحسين خطوة حقيقية في العمل.</dd></div>
|
||||
<div><dt>نشرح ما تحت الغطاء</dt><dd>نفضّل الأدوات المفتوحة والتشغيل الذي يمنحك فهماً وتحكماً أكبر في بياناتك ومواردك.</dd></div>
|
||||
<div><dt>حدود قبل الإطلاق</dt><dd>الوكيل أو النموذج لا يكتمل دون مراقبة وحدود واضحة تمنع الفشل الصامت.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-contact">
|
||||
<div className="wrap contact-strip">
|
||||
<p>عندك سؤال عن دليل أو فكرة تريد تطبيقها؟</p>
|
||||
<a className="btn btn-quiet" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
/* 4-tab settings — markup from account-settings.html with the real Payload
|
||||
endpoints wired (PATCH /api/users/me for profile + password). Prefs stay
|
||||
client-side (localStorage) faithful to the original. Stripe is pending:
|
||||
the cancel button renders disabled with a note. */
|
||||
|
||||
type Props = {
|
||||
email: string
|
||||
userId: number
|
||||
fullName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
type Status = { ok: boolean; text: string } | null
|
||||
|
||||
const TABS = [
|
||||
{ id: 'profile', label: 'الملف الشخصي' },
|
||||
{ id: 'password', label: 'كلمة المرور' },
|
||||
{ id: 'prefs', label: 'التفضيلات' },
|
||||
{ id: 'membership', label: 'العضوية والفوترة' },
|
||||
]
|
||||
|
||||
export default function AccountSettingsClient({ email, userId, fullName, role }: Props) {
|
||||
const router = useRouter()
|
||||
const [tab, setTab] = useState('profile')
|
||||
const [name, setName] = useState(fullName)
|
||||
const [profileStatus, setProfileStatus] = useState<Status>(null)
|
||||
const [profileBusy, setProfileBusy] = useState(false)
|
||||
|
||||
const [currentPw, setCurrentPw] = useState('')
|
||||
const [newPw, setNewPw] = useState('')
|
||||
const [confirmPw, setConfirmPw] = useState('')
|
||||
const [pwStatus, setPwStatus] = useState<Status>(null)
|
||||
const [pwBusy, setPwBusy] = useState(false)
|
||||
|
||||
const [prefs, setPrefs] = useState({ weekly: true, replies: true, updates: false })
|
||||
const [prefsStatus, setPrefsStatus] = useState<Status>(null)
|
||||
const [logoutBusy, setLogoutBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('anw-prefs')
|
||||
if (raw) setPrefs(p => ({ ...p, ...JSON.parse(raw) }))
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
const rulesOk = {
|
||||
len: newPw.length >= 8,
|
||||
num: /\d/.test(newPw),
|
||||
diff: newPw.length > 0 && newPw !== currentPw,
|
||||
}
|
||||
|
||||
async function saveProfile(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (name.trim().length < 3) {
|
||||
setProfileStatus({ ok: false, text: 'أدخل اسماً من 3 أحرف على الأقل.' })
|
||||
return
|
||||
}
|
||||
setProfileBusy(true)
|
||||
const res = await fetch(`/api/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fullName: name.trim() }),
|
||||
})
|
||||
setProfileStatus(res.ok
|
||||
? { ok: true, text: 'تم حفظ الاسم ✓' }
|
||||
: { ok: false, text: 'تعذّر الحفظ — أعد المحاولة.' })
|
||||
setProfileBusy(false)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
async function changePassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!currentPw) { setPwStatus({ ok: false, text: 'أدخل كلمة المرور الحالية.' }); return }
|
||||
if (!(rulesOk.len && rulesOk.num)) { setPwStatus({ ok: false, text: 'كلمة المرور الجديدة لا تحقق الشروط أعلاه.' }); return }
|
||||
if (newPw === currentPw) { setPwStatus({ ok: false, text: 'كلمة المرور الجديدة تطابق الحالية.' }); return }
|
||||
if (newPw !== confirmPw) { setPwStatus({ ok: false, text: 'التأكيد لا يطابق كلمة المرور الجديدة.' }); return }
|
||||
setPwBusy(true)
|
||||
const res = await fetch(`/api/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: newPw }),
|
||||
})
|
||||
setPwStatus(res.ok
|
||||
? { ok: true, text: 'تم تحديث كلمة المرور ✓' }
|
||||
: { ok: false, text: 'تعذّر التحديث — تحقق من كلمة المرور الحالية أو أعد المحاولة.' })
|
||||
if (res.ok) {
|
||||
setCurrentPw(''); setNewPw(''); setConfirmPw('')
|
||||
}
|
||||
setPwBusy(false)
|
||||
}
|
||||
|
||||
function savePrefs() {
|
||||
try { localStorage.setItem('anw-prefs', JSON.stringify(prefs)) } catch { /* ignore */ }
|
||||
setPrefsStatus({ ok: true, text: 'تم حفظ التفضيلات ✓' })
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setLogoutBusy(true)
|
||||
await fetch('/api/users/logout', { method: 'POST' })
|
||||
router.refresh()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const entitled = role === 'vip' || role === 'admin'
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="settings-nav" role="tablist" aria-label="أقسام الإعدادات" data-od-id="settings-nav">
|
||||
{TABS.map(t => (
|
||||
<button role="tab" key={t.id} id={`tab-${t.id}`} aria-selected={tab === t.id} aria-controls={`panel-${t.id}`} data-tab={t.id} onClick={() => setTab(t.id)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="panels">
|
||||
<section className={`panel${tab === 'profile' ? ' active' : ''}`} id="panel-profile" role="tabpanel" aria-labelledby="tab-profile" data-od-id="panel-profile">
|
||||
<h2>الملف الشخصي</h2>
|
||||
<p className="panel-sub">الاسم كما يظهر في تعليقاتك وقوائم الحفظ. البريد الإلكتروني هو معرّف الدخول ولا يتغير من هنا.</p>
|
||||
<form className="form-grid" id="profileForm" noValidate onSubmit={saveProfile}>
|
||||
<div className="field"><label htmlFor="fullName">الاسم الكامل</label><input className="input" id="fullName" name="fullName" value={name} onChange={e => setName(e.target.value)} autoComplete="name" required /></div>
|
||||
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" value={email} disabled /><span className="hint">لتغيير البريد تواصل معنا على <bdi>hello@anwarpy.ai</bdi></span></div>
|
||||
<button className="btn btn-primary" type="submit" data-od-id="save-profile" disabled={profileBusy}>{profileBusy ? 'جارٍ الحفظ…' : 'حفظ التغييرات'}</button>
|
||||
<p className={`status ${profileStatus?.ok ? 'ok' : profileStatus ? 'err' : ''}`} id="profileStatus" role="status">{profileStatus?.text}</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={`panel${tab === 'password' ? ' active' : ''}`} id="panel-password" role="tabpanel" aria-labelledby="tab-password" data-od-id="panel-password">
|
||||
<h2>تغيير كلمة المرور</h2>
|
||||
<p className="panel-sub">بعد التغيير تبقى جلستك الحالية مفتوحة، وتُطلب كلمة المرور الجديدة عند الدخول من جهاز آخر.</p>
|
||||
<form className="form-grid" id="passwordForm" noValidate onSubmit={changePassword}>
|
||||
<div className="field"><label htmlFor="currentPw">كلمة المرور الحالية</label><input className="input" id="currentPw" type="password" name="currentPw" autoComplete="current-password" required value={currentPw} onChange={e => setCurrentPw(e.target.value)} /></div>
|
||||
<div className="field"><label htmlFor="newPw">كلمة المرور الجديدة</label><input className="input" id="newPw" type="password" name="newPw" autoComplete="new-password" required minLength={8} aria-describedby="pwRules" value={newPw} onChange={e => setNewPw(e.target.value)} /></div>
|
||||
<ul className="pw-rules" id="pwRules">
|
||||
<li data-rule="len" className={rulesOk.len ? 'ok' : ''}>8 أحرف على الأقل</li>
|
||||
<li data-rule="num" className={rulesOk.num ? 'ok' : ''}>تحتوي رقماً واحداً على الأقل</li>
|
||||
<li data-rule="diff" className={rulesOk.diff ? 'ok' : ''}>تختلف عن كلمة المرور الحالية</li>
|
||||
</ul>
|
||||
<div className="field"><label htmlFor="confirmPw">تأكيد كلمة المرور</label><input className="input" id="confirmPw" type="password" name="confirmPw" autoComplete="new-password" required value={confirmPw} onChange={e => setConfirmPw(e.target.value)} /></div>
|
||||
<button className="btn btn-primary" type="submit" data-od-id="save-password" disabled={pwBusy}>{pwBusy ? 'جارٍ التحديث…' : 'تحديث كلمة المرور'}</button>
|
||||
<p className={`status ${pwStatus?.ok ? 'ok' : pwStatus ? 'err' : ''}`} id="passwordStatus" role="status" aria-live="polite">{pwStatus?.text}</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={`panel${tab === 'prefs' ? ' active' : ''}`} id="panel-prefs" role="tabpanel" aria-labelledby="tab-prefs" data-od-id="panel-prefs">
|
||||
<h2>التفضيلات</h2>
|
||||
<p className="panel-sub">رسائل أنور بآي قليلة ومقصودة. يمكنك إيقاف أي نوع منها من دون التأثير على عضويتك.</p>
|
||||
<div className="prefs">
|
||||
<div className="pref-row"><div><strong>الرسالة الأسبوعية</strong><p>خلاصة ما نُشر من أدلة ومقالات، كل يوم أحد.</p></div>
|
||||
<label className="switch"><input type="checkbox" id="prefWeekly" aria-label="تفعيل الرسالة الأسبوعية" checked={prefs.weekly} onChange={e => setPrefs(p => ({ ...p, weekly: e.target.checked }))} /><span className="track"></span></label></div>
|
||||
<div className="pref-row"><div><strong>إشعارات التعليقات</strong><p>رسالة عند ردّ أحد الأعضاء على تعليقك.</p></div>
|
||||
<label className="switch"><input type="checkbox" id="prefReplies" aria-label="تفعيل إشعارات التعليقات" checked={prefs.replies} onChange={e => setPrefs(p => ({ ...p, replies: e.target.checked }))} /><span className="track"></span></label></div>
|
||||
<div className="pref-row"><div><strong>تحديثات الأدلة المحفوظة</strong><p>إشعار عندما يُحدَّث دليل حفظته للعودة إليه.</p></div>
|
||||
<label className="switch"><input type="checkbox" id="prefUpdates" aria-label="تفعيل تحديثات الأدلة المحفوظة" checked={prefs.updates} onChange={e => setPrefs(p => ({ ...p, updates: e.target.checked }))} /><span className="track"></span></label></div>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="button" id="savePrefs" style={{ marginTop: 28 }} data-od-id="save-prefs" onClick={savePrefs}>حفظ التفضيلات</button>
|
||||
<p className={`status ${prefsStatus?.ok ? 'ok' : prefsStatus ? 'err' : ''}`} id="prefsStatus" role="status">{prefsStatus?.text}</p>
|
||||
</section>
|
||||
|
||||
<section className={`panel${tab === 'membership' ? ' active' : ''}`} id="panel-membership" role="tabpanel" aria-labelledby="tab-membership" data-od-id="panel-membership">
|
||||
<h2>العضوية والفوترة</h2>
|
||||
<p className="panel-sub">خطتك وحالة وصولك. الإلغاء يوقف التجديد التالي ويحفظ وصولك حتى نهاية الفترة المدفوعة.</p>
|
||||
<article className="membership-card" data-od-id="membership-card">
|
||||
<div className="membership-head">
|
||||
<div><h3 id="planName">{entitled ? 'عضوية فردية' : 'حساب مجاني'}</h3><span className="hint">الحالة الحقيقية لحسابك</span></div>
|
||||
<span className={`state-chip${entitled ? '' : ' cancelled'}`} id="planState">{entitled ? 'نشطة' : role === 'admin' ? 'إداري' : 'مجاني'}</span>
|
||||
</div>
|
||||
<div className="plan-details">
|
||||
<div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
|
||||
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : 'الأدلة المجانية ومعاينة الأدلة'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">ستُربط عبر Stripe</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
|
||||
</div>
|
||||
<div className="membership-actions" id="membershipActions">
|
||||
{!entitled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
|
||||
</div>
|
||||
</article>
|
||||
<div className="cancel-zone" id="cancelZone" data-od-id="cancel-zone">
|
||||
<h3>إلغاء الاشتراك</h3>
|
||||
{entitled ? (
|
||||
<>
|
||||
<p>ربط الدفع (Stripe) غير مكتمل بعد — الإلغاء الذاتي سيتاح بعد ربط الدفع. حتى ذلك الحين تواصل معنا على <bdi>hello@anwarpy.ai</bdi> لأي تغيير.</p>
|
||||
<button className="text-danger" type="button" id="cancelOpen" data-od-id="cancel-open" disabled>إلغاء الاشتراك</button>
|
||||
<p className="hint">سيتاح بعد ربط الدفع</p>
|
||||
</>
|
||||
) : (
|
||||
<p>لا يوجد اشتراك مدفوع لإلغائه. يمكنك الترقية في أي وقت من صفحة <Link className="text-link" href="/subscription">العضوية</Link>.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="billing" data-od-id="billing-history">
|
||||
<h3>سجل الفواتير</h3>
|
||||
<p className="billing-sub">كل دفعة تُصدر فاتورة ضريبية فور اكتمالها وترسل إلى بريدك المسجّل. الفواتير متاحة للتنزيل في أي وقت.</p>
|
||||
<div className="billing-table" role="table" aria-label="سجل الفواتير">
|
||||
<div className="billing-row head" role="row">
|
||||
<span role="columnheader">التاريخ</span><span role="columnheader">الوصف</span><span role="columnheader">المبلغ</span><span role="columnheader">الحالة</span><span role="columnheader">الفاتورة</span>
|
||||
</div>
|
||||
<div className="billing-row" role="row">
|
||||
<span className="bdate" role="cell">—</span>
|
||||
<span className="desc" role="cell">لا فواتير بعد — ربط الدفع قادم</span>
|
||||
<span className="amt" role="cell">—</span>
|
||||
<span role="cell"><span className="hint">—</span></span>
|
||||
<span role="cell"><span className="hint">—</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="form-actions" style={{ marginTop: 32 }}>
|
||||
<button className="btn btn-secondary" type="button" onClick={logout} disabled={logoutBusy}>تسجيل الخروج</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import AccountSettingsClient from './client'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import configPromise from '@payload-config'
|
||||
import '../styles/account-settings.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'إعدادات الحساب' }
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page page-account-settings">
|
||||
<Topnav viewer={user} />
|
||||
{user ? (
|
||||
<main id="content">
|
||||
<section className="page-head" data-od-id="settings-header"><div className="container">
|
||||
<p className="crumb">حسابك في أنور بآي</p>
|
||||
<h1 data-od-id="settings-title">إعدادات الحساب</h1>
|
||||
<p className="sub"><bdi>{String(user.email)}</bdi> · عضو منذ {user.createdAt ? formatDate(String(user.createdAt)) : 'اليوم'}</p>
|
||||
</div></section>
|
||||
<section className="container settings-layout" data-od-id="settings-layout">
|
||||
<AccountSettingsClient
|
||||
email={String(user.email)}
|
||||
userId={Number(user.id)}
|
||||
fullName={(user.fullName as string) || ''}
|
||||
role={((user.role as string) || 'free')}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
) : (
|
||||
<main id="content" className="gate">
|
||||
<section className="gate-card">
|
||||
<h1>سجّل الدخول لإدارة حسابك</h1>
|
||||
<p>من هنا تعدّل اسمك وكلمة المرور وتفضيلات الرسائل، وتدير عضويتك.</p>
|
||||
<div className="actions">
|
||||
<a className="btn btn-primary" href="/login?next=%2Faccount-settings">تسجيل الدخول</a>
|
||||
<a className="btn btn-quiet" href="/signup">إنشاء حساب مجاني</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)}
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { guidesLabel } from '@/lib/format'
|
||||
|
||||
/* Library controller — search/sort/filters/likes from articles.html, wired to
|
||||
the real backend: likes toggle via /api/posts/[slug]/like (401 → login),
|
||||
views come from the post-page counter, sort reads data-* attributes. */
|
||||
export default function ArticlesClient({ loggedIn }: { loggedIn: boolean }) {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const params = new URLSearchParams(location.search)
|
||||
const allowedTopics = ['all', 'agents', 'models', 'automation', 'foundations']
|
||||
const allowedAccess = ['all', 'free', 'vip']
|
||||
const state: Record<string, string> = {
|
||||
topic: allowedTopics.includes(params.get('topic') || '') ? params.get('topic')! : 'all',
|
||||
access: allowedAccess.includes(params.get('access') || '') ? params.get('access')! : 'all',
|
||||
query: '',
|
||||
sort: 'latest',
|
||||
}
|
||||
const buttons = [...document.querySelectorAll('[data-filter-kind]')] as HTMLElement[]
|
||||
const posts = [...document.querySelectorAll('.post')] as HTMLElement[]
|
||||
const list = document.getElementById('postList')!
|
||||
const count = document.getElementById('resultCount')
|
||||
const empty = document.getElementById('emptyState')
|
||||
const search = document.getElementById('postSearch') as HTMLInputElement | null
|
||||
const sort = document.getElementById('postSort') as HTMLSelectElement | null
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat('en-US').format(value)
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.toLocaleLowerCase('ar').normalize('NFKD').replace(/[\u064B-\u065F\u0670]/g, '').replace(/[إأآ]/g, 'ا').replace(/ى/g, 'ي')
|
||||
}
|
||||
|
||||
function paintMetrics(post: HTMLElement) {
|
||||
const views = Number(post.dataset.viewsCount || 0)
|
||||
const likes = Number(post.dataset.likesCount || 0)
|
||||
const viewsEl = post.querySelector('[data-views]')
|
||||
if (viewsEl) viewsEl.textContent = `${formatNumber(views)} قراءة`
|
||||
const like = post.querySelector('[data-like]') as HTMLElement | null
|
||||
if (like) {
|
||||
like.setAttribute('aria-pressed', post.dataset.liked === '1' ? 'true' : 'false')
|
||||
const span = like.querySelector('span')
|
||||
if (span) span.textContent = formatNumber(likes)
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const ordered = [...posts].sort((a, b) => {
|
||||
if (state.sort === 'views') return Number(b.dataset.viewsCount || 0) - Number(a.dataset.viewsCount || 0) || Number(b.dataset.order) - Number(a.dataset.order)
|
||||
if (state.sort === 'likes') return Number(b.dataset.likesCount || 0) - Number(a.dataset.likesCount || 0) || Number(b.dataset.order) - Number(a.dataset.order)
|
||||
return Number(b.dataset.order) - Number(a.dataset.order)
|
||||
})
|
||||
ordered.forEach(post => list.appendChild(post))
|
||||
let visible = 0
|
||||
posts.forEach(post => {
|
||||
const topicMatch = state.topic === 'all' || (post.dataset.topic || '').split(' ').includes(state.topic)
|
||||
const accessMatch = state.access === 'all' || post.dataset.access === state.access
|
||||
const queryMatch = !state.query || normalize(post.textContent || '').includes(normalize(state.query))
|
||||
post.hidden = !(topicMatch && accessMatch && queryMatch)
|
||||
if (!post.hidden) visible += 1
|
||||
})
|
||||
if (count) count.textContent = visible === 0 ? 'لا نتائج' : guidesLabel(visible)
|
||||
empty?.classList.toggle('show', visible === 0)
|
||||
}
|
||||
|
||||
buttons.forEach(button => button.addEventListener('click', () => {
|
||||
const kind = button.dataset.filterKind!
|
||||
state[kind] = button.dataset.filterValue!
|
||||
buttons.filter(item => item.dataset.filterKind === kind).forEach(item => {
|
||||
item.setAttribute('aria-pressed', String(item === button))
|
||||
})
|
||||
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
applyFilters()
|
||||
}, { signal: controller.signal }))
|
||||
|
||||
search?.addEventListener('input', () => { state.query = search.value.trim(); applyFilters() }, { signal: controller.signal })
|
||||
sort?.addEventListener('change', () => { state.sort = sort.value; applyFilters() }, { signal: controller.signal })
|
||||
|
||||
posts.forEach(post => {
|
||||
paintMetrics(post)
|
||||
const like = post.querySelector('[data-like]') as HTMLElement | null
|
||||
like?.addEventListener('click', async () => {
|
||||
if (!loggedIn) {
|
||||
location.href = '/login?next=/articles'
|
||||
return
|
||||
}
|
||||
const slug = like.dataset.like!
|
||||
const res = await fetch(`/api/posts/${slug}/like`, { method: 'POST' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
post.dataset.likesCount = String(data.likes)
|
||||
post.dataset.liked = data.liked ? '1' : '0'
|
||||
paintMetrics(post)
|
||||
applyFilters()
|
||||
}, { signal: controller.signal })
|
||||
})
|
||||
|
||||
buttons.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.filterValue === state[button.dataset.filterKind!])))
|
||||
applyFilters()
|
||||
return () => controller.abort()
|
||||
}, [loggedIn])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import ArticlesClient from './articles-client'
|
||||
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
|
||||
import { formatNumber, guidesLabel, minutesLabel } from '@/lib/format'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'المكتبة' }
|
||||
|
||||
type PostDoc = {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
dek: string
|
||||
access: 'free' | 'vip'
|
||||
category: string
|
||||
level: string
|
||||
readingMinutes: number
|
||||
views: number
|
||||
likes: number
|
||||
publishedAt: string
|
||||
cover?: { url?: string | null } | number | null
|
||||
}
|
||||
|
||||
type Viewer = { id: number; email: string; role: string; interest: string | null; level: string | null } | null
|
||||
|
||||
export default async function ArticlesPage() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const [res, auth] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
depth: 1,
|
||||
limit: 100,
|
||||
sort: '-publishedAt',
|
||||
select: {
|
||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||
readingMinutes: true, views: true, likes: true, publishedAt: true, cover: true,
|
||||
},
|
||||
}),
|
||||
payload.auth({ headers: await headers() }),
|
||||
])
|
||||
const posts = res.docs as unknown as PostDoc[]
|
||||
const user = auth.user as { id: number; email: string; role: string; interest: string | null; level: string | null } | undefined
|
||||
const viewer: Viewer = user
|
||||
? { id: user.id, email: user.email, role: (user.role as string) ?? 'free', interest: (user.interest as string | null) ?? null, level: (user.level as string | null) ?? null }
|
||||
: null
|
||||
|
||||
// Which posts the viewer liked
|
||||
const likedSlugs = new Set<string>()
|
||||
if (viewer) {
|
||||
const { docs } = await payload.find({
|
||||
collection: 'post-likes',
|
||||
where: { user: { equals: viewer.id } },
|
||||
depth: 0,
|
||||
limit: 500,
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const like of docs) {
|
||||
const postId = typeof like.post === 'object' ? (like.post as { id: number }).id : like.post
|
||||
const post = posts.find(p => p.id === postId)
|
||||
if (post) likedSlugs.add(post.slug)
|
||||
}
|
||||
}
|
||||
|
||||
// Personalization: boost rows matching the viewer's interest + level (order
|
||||
// is a sort weight — matching posts float to the top under «الأحدث أولاً»).
|
||||
const interestTopic = viewer?.interest ? INTEREST_MAP[viewer.interest] : undefined
|
||||
posts.forEach((post, i) => {
|
||||
const el = post as unknown as { _order?: number; _match?: boolean }
|
||||
el._order = posts.length - i
|
||||
el._match = !!interestTopic && topicsFor(post).some(topic => topic === interestTopic) && post.level === viewer!.level
|
||||
if (el._match) el._order += 1000
|
||||
})
|
||||
|
||||
const freeCount = posts.filter(p => p.access === 'free').length
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-library">
|
||||
<Topnav viewer={viewer} />
|
||||
<ArticlesClient loggedIn={!!viewer} />
|
||||
<main id="content">
|
||||
<section className="lib-head" aria-labelledby="library-title">
|
||||
<div className="wrap">
|
||||
<h1 id="library-title">المكتبة</h1>
|
||||
<p className="lede">تضم المكتبة {guidesLabel(posts.length)}، منها {guidesLabel(freeCount)} متاحة مجاناً بالكامل. ابحث بالموضوع أو صفِّ النتائج حسب المسار.</p>
|
||||
<div className="lib-tools">
|
||||
<label className="lib-search">
|
||||
<span className="sr-only">ابحث في العناوين والوصف</span>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M13 13l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
|
||||
<input id="postSearch" type="search" autoComplete="off" placeholder="ابحث: وكيل، RAG، اختيار النموذج…" />
|
||||
</label>
|
||||
<label className="lib-sort">
|
||||
<span>الترتيب</span>
|
||||
<select id="postSort"><option value="latest">الأحدث</option><option value="views">الأكثر قراءة</option><option value="likes">الأكثر إعجاباً</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="lib-filters">
|
||||
<div className="segmented" role="group" aria-label="المسار">
|
||||
<button type="button" data-filter-kind="topic" data-filter-value="all" aria-pressed="true">كل المسارات</button>
|
||||
{TOPICS.map(topic => (
|
||||
<button type="button" data-filter-kind="topic" data-filter-value={topic.key} aria-pressed="false" key={topic.key}>{topic.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="segmented" role="group" aria-label="نوع الوصول">
|
||||
<button type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
|
||||
<button type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
|
||||
<button type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">للأعضاء</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="lib-results" aria-label="الأدلة">
|
||||
<div className="wrap">
|
||||
<p className="results-count" id="resultCount" aria-live="polite">{guidesLabel(posts.length)}</p>
|
||||
<div className="lib-grid" id="postList">
|
||||
{posts.map(post => {
|
||||
const meta = post as unknown as { _order?: number; _match?: boolean }
|
||||
const topics = topicsFor(post)
|
||||
const coverUrl = typeof post.cover === 'object' ? post.cover?.url ?? null : null
|
||||
return (
|
||||
<article
|
||||
className="post guide-card"
|
||||
key={post.id}
|
||||
data-topic={topics.join(' ')}
|
||||
data-access={post.access}
|
||||
data-level={post.level}
|
||||
data-views-count={post.views ?? 0}
|
||||
data-likes-count={post.likes ?? 0}
|
||||
data-liked={likedSlugs.has(post.slug) ? '1' : '0'}
|
||||
data-order={meta._order}
|
||||
>
|
||||
{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>
|
||||
<span>{minutesLabel(post.readingMinutes)}</span>
|
||||
<span>{post.level}</span>
|
||||
{meta._match && <span className="for-you">يناسب مستواك</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>
|
||||
<span className="engagement">
|
||||
<span data-views>{formatNumber(post.views ?? 0)} قراءة</span>
|
||||
<button className="like" type="button" data-like={post.slug} aria-pressed={likedSlugs.has(post.slug)} aria-label="أعجبني">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 13.5 2.6 8.3a3.2 3.2 0 0 1 4.5-4.6L8 4.6l.9-.9a3.2 3.2 0 0 1 4.5 4.6Z" fill="none" stroke="currentColor" strokeWidth="1.4" /></svg>
|
||||
<span>{formatNumber(post.likes ?? 0)}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="empty" id="emptyState">
|
||||
<p>لا يوجد دليل يطابق هذا البحث.</p>
|
||||
<p>جرّب كلمة أقصر أو اختر «كل المسارات».</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export type DeckSlide = {
|
||||
kicker: string
|
||||
index: string
|
||||
title: React.ReactNode
|
||||
body: string
|
||||
format: React.ReactNode
|
||||
link?: { href: string; label: string }
|
||||
meta?: React.ReactNode
|
||||
}
|
||||
|
||||
const V1_SLIDES: DeckSlide[] = [
|
||||
{
|
||||
kicker: 'دليل حصري · المستوى المتوسط',
|
||||
index: '01',
|
||||
title: 'ابنِ وكيلك الأول دون فوضى',
|
||||
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
|
||||
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
|
||||
link: { href: '/posts/agent-loop-stop-condition', label: 'شاهد المعاينة ←' },
|
||||
},
|
||||
{
|
||||
kicker: 'مقال تطبيقي · للمبتدئين',
|
||||
index: '02',
|
||||
title: 'كيف تختار نموذجاً لمهمتك؟',
|
||||
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
|
||||
format: <>قراءة · 12 دقيقة</>,
|
||||
link: { href: '/posts/article-agent-guide', label: 'ابدأ القراءة ←' },
|
||||
},
|
||||
{
|
||||
kicker: 'مسار متقدم · للأعضاء',
|
||||
index: '03',
|
||||
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
|
||||
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
|
||||
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
|
||||
link: { href: '/subscription', label: 'شاهد للأعضاء ←' },
|
||||
},
|
||||
]
|
||||
|
||||
const DOT_LABELS = ['الشريحة الأولى', 'الشريحة الثانية', 'الشريحة الثالثة']
|
||||
|
||||
/* Featured content carousel. Manual selection pauses rotation until resumed. */
|
||||
export default function CatalogDeck({ slides = V1_SLIDES }: { slides?: DeckSlide[] }) {
|
||||
const [activeSlide, setActiveSlide] = useState(0)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [playing, setPlaying] = useState(true)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const current = slides.length ? activeSlide % slides.length : 0
|
||||
const advancing = ready && playing && !hovered && !focused && slides.length > 1
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem('midad-slide'))
|
||||
if (Number.isInteger(stored) && stored >= 0) setActiveSlide(stored)
|
||||
} catch { /* Persistence is optional. */ }
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
if (motion.matches) setPlaying(false)
|
||||
const onMotion = () => { if (motion.matches) setPlaying(false) }
|
||||
motion.addEventListener('change', onMotion)
|
||||
setReady(true)
|
||||
return () => motion.removeEventListener('change', onMotion)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
try { localStorage.setItem('midad-slide', String(current)) } catch { /* optional persistence */ }
|
||||
}, [current, ready])
|
||||
|
||||
useEffect(() => {
|
||||
if (!advancing) return
|
||||
const timer = setTimeout(() => setActiveSlide(index => (index + 1) % slides.length), 6500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [advancing, current, slides.length])
|
||||
|
||||
function selectSlide(index: number) {
|
||||
setActiveSlide((index + slides.length) % slides.length)
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
if (!slides.length) return null
|
||||
|
||||
return (
|
||||
<div className="deck" data-od-id="featured-deck" role="region" aria-roledescription="عرض شرائح" aria-label="مختارات مميزة"
|
||||
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
|
||||
onFocusCapture={() => setFocused(true)}
|
||||
onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'ArrowLeft') { event.preventDefault(); selectSlide(current + 1) }
|
||||
if (event.key === 'ArrowRight') { event.preventDefault(); selectSlide(current - 1) }
|
||||
}}>
|
||||
|
||||
<div className="deck-frame" aria-live={advancing ? 'off' : 'polite'}>
|
||||
{slides.map((s, i) => (
|
||||
<article className={`deck-slide${i === current ? ' active' : ''}`} hidden={i !== current} aria-label={`${i + 1} من ${slides.length}`} data-slide={i} key={i}>
|
||||
<div className="deck-slide-top"><span className="deck-slide-kicker">{s.kicker}</span><span className="deck-slide-index num">{s.index}</span></div>
|
||||
<div className="deck-slide-body"><h2>{s.title}</h2><p>{s.body}</p></div>
|
||||
<div className="deck-slide-foot">
|
||||
<span className="deck-slide-format">{s.format}</span>
|
||||
{s.link
|
||||
? <Link className="deck-slide-link" href={s.link.href}>{s.link.label}</Link>
|
||||
: <span className="meta">{s.meta}</span>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="deck-controls">
|
||||
<div className="deck-dots" aria-label="اختيار الشريحة">
|
||||
{slides.map((_, i) => (
|
||||
<button type="button" className={`deck-dot${i === current ? ' active' : ''}`} data-dot={i} aria-label={DOT_LABELS[i] ?? `الشريحة ${i + 1}`} aria-current={i === current ? 'true' : undefined} onClick={() => selectSlide(i)} key={i}></button>
|
||||
))}
|
||||
</div>
|
||||
<div className="deck-arrows">
|
||||
<button type="button" className="icon-btn" id="prevSlide" aria-label="الشريحة السابقة" disabled={slides.length < 2} onClick={() => selectSlide(current - 1)}>→</button>
|
||||
<button type="button" className="icon-btn" id="nextSlide" aria-label="الشريحة التالية" disabled={slides.length < 2} onClick={() => selectSlide(current + 1)}>←</button>
|
||||
{slides.length > 1 && <button type="button" className="icon-btn deck-play" aria-label={playing ? 'إيقاف العرض التلقائي' : 'تشغيل العرض التلقائي'} onClick={() => setPlaying(value => !value)}>{playing ? 'Ⅱ' : '▶'}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/* Filter bar for the editors' picks. Cards carry space-separated topics. */
|
||||
export default function CatalogFilters() {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const filters = [...document.querySelectorAll('.filter')] as HTMLElement[]
|
||||
const cards = [...document.querySelectorAll('.catalog-card')] as HTMLElement[]
|
||||
const empty = document.getElementById('emptyState')
|
||||
|
||||
function filterCatalog(topic: string) {
|
||||
let visible = 0
|
||||
filters.forEach(button => {
|
||||
const selected = (button as HTMLElement).dataset.filter === topic
|
||||
button.classList.toggle('active', selected)
|
||||
button.setAttribute('aria-pressed', String(selected))
|
||||
})
|
||||
cards.forEach(card => {
|
||||
const show = topic === 'all' || (card.dataset.topic || '').split(' ').includes(topic)
|
||||
card.hidden = !show
|
||||
if (show) visible += 1
|
||||
})
|
||||
empty?.classList.toggle('show', visible === 0)
|
||||
}
|
||||
|
||||
filters.forEach(button => button.addEventListener('click', () => {
|
||||
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
filterCatalog((button as HTMLElement).dataset.filter!)
|
||||
}, { signal: controller.signal }))
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="filterbar" role="group" aria-label="تصفية المحتوى" data-od-id="catalog-filters">
|
||||
<button type="button" className="filter active" data-filter="all" aria-pressed="true">الكل</button>
|
||||
<button type="button" className="filter" data-filter="agents" aria-pressed="false">الوكلاء</button>
|
||||
<button type="button" className="filter" data-filter="models" aria-pressed="false">النماذج</button>
|
||||
<button type="button" className="filter" data-filter="automation" aria-pressed="false">الأتمتة</button>
|
||||
<button type="button" className="filter" data-filter="foundations" aria-pressed="false">الأساسيات</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
/* QA-07: /catalog was a stale duplicate library — /articles is canonical.
|
||||
Deck/filter components in this folder are still used by the homepage. */
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function CatalogPage() {
|
||||
redirect('/articles')
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
/* QA-06: page-specific title (the page itself is a client component). */
|
||||
export const metadata: Metadata = { title: 'استعادة كلمة المرور' }
|
||||
|
||||
export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import '../styles/forgot-password.css'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
if (!form.checkValidity()) {
|
||||
setStatus('أدخل بريداً إلكترونياً صحيحاً.')
|
||||
form.reportValidity()
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
const response = await fetch('/api/users/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Request failed')
|
||||
setStatus('إذا كان البريد مسجلاً، فسيصلك رابط الاستعادة.')
|
||||
setEmail('')
|
||||
} catch {
|
||||
setStatus('تعذّر إرسال الطلب. حاول مرة أخرى بعد قليل.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page page-forgot-password">
|
||||
<Topnav />
|
||||
<main id="content" className="reset" data-od-id="password-reset">
|
||||
<section className="reset-card" data-od-id="reset-card">
|
||||
<p className="eyebrow">استعادة الحساب</p>
|
||||
<h1 data-od-id="reset-title">استعادة كلمة المرور</h1>
|
||||
<p className="lead">أدخل البريد الإلكتروني المرتبط بحسابك لتصلك رسالة إعادة تعيين كلمة المرور.</p>
|
||||
<form id="resetForm" method="post" action="/api/users/forgot-password" noValidate onSubmit={onSubmit} data-od-id="reset-form">
|
||||
<div className="field">
|
||||
<label htmlFor="resetEmail">البريد الإلكتروني</label>
|
||||
<input className="input" id="resetEmail" name="email" type="email" inputMode="email" autoComplete="email" dir="ltr" required value={email} onChange={e => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" data-od-id="reset-submit" disabled={busy}>{busy ? 'جارٍ الإرسال…' : 'إرسال رابط الاستعادة'}</button>
|
||||
<p className="status" id="resetStatus" role="status" aria-live="polite">{status}</p>
|
||||
</form>
|
||||
<Link className="back" href="/login">تذكرت كلمة المرور؟ سجّل الدخول</Link>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
/* QA-06: page-specific title (the page itself is a client component). */
|
||||
export const metadata: Metadata = { title: 'تسجيل الدخول' }
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { safeNext } from '@/lib/next'
|
||||
import '../styles/login.css'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [nextPath, setNextPath] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setNextPath(safeNext(new URLSearchParams(window.location.search).get('next')))
|
||||
}, [])
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
if (!form.checkValidity()) {
|
||||
setStatus('تحقق من البريد الإلكتروني وكلمة المرور.')
|
||||
form.reportValidity()
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
const data = new FormData(form)
|
||||
try {
|
||||
const res = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
|
||||
})
|
||||
if (res.ok) {
|
||||
router.refresh()
|
||||
router.push(safeNext(new URLSearchParams(window.location.search).get('next')) ?? '/account-settings')
|
||||
return
|
||||
}
|
||||
setStatus('تعذّر تسجيل الدخول — تحقّق من البريد وكلمة المرور.')
|
||||
} catch {
|
||||
setStatus('تعذّر الاتصال. حاول تسجيل الدخول مرة أخرى.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page page-login">
|
||||
<Topnav />
|
||||
<main id="content" className="auth">
|
||||
<section className="auth-story">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
<h1>أكمل من حيث توقفت.</h1>
|
||||
<p>سجّل الدخول لمتابعة قراءة الأدلة والمشاركة في النقاشات.</p>
|
||||
</section>
|
||||
<section className="auth-form-wrap" data-od-id="login-form-section">
|
||||
<form className="auth-form" id="loginForm" method="post" action="/api/users/login" noValidate onSubmit={onSubmit} data-od-id="login-form">
|
||||
<p className="eyebrow">أهلاً بعودتك</p>
|
||||
<h2 data-od-id="login-title">تسجيل الدخول</h2>
|
||||
<p className="lead">استخدم بريدك وكلمة المرور للوصول إلى حسابك.</p>
|
||||
<div className="field">
|
||||
<label htmlFor="email">البريد الإلكتروني</label>
|
||||
<input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="password">كلمة المرور</label>
|
||||
<div className="input-wrap">
|
||||
<input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="current-password" minLength={8} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة المرور.')} onInput={e => e.currentTarget.setCustomValidity('')} />
|
||||
<button className="password-toggle" id="passwordToggle" type="button" aria-controls="password" aria-label={showPassword ? 'إخفاء كلمة المرور' : 'إظهار كلمة المرور'} aria-pressed={showPassword} onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="check"><input type="checkbox" name="remember" /> تذكّرني</label>
|
||||
<Link href="/forgot-password">نسيت كلمة المرور؟</Link>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" disabled={busy}>{busy ? 'جارٍ تسجيل الدخول…' : 'ادخل إلى حسابك'}</button>
|
||||
<div className="status" id="loginStatus" role="status">{status}</div>
|
||||
<div className="divider">أو</div>
|
||||
<Link className="magic" href="/forgot-password">استعد الوصول إلى حسابك</Link>
|
||||
<p className="signup">ليس لديك حساب؟ <Link href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : '/signup'}>أنشئ حساباً جديداً</Link></p>
|
||||
<p className="fine">بالمتابعة، أنت توافق على <Link href="/terms">شروط الاستخدام</Link> و<Link href="/privacy">سياسة الخصوصية</Link>.</p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { safeNext } from '@/lib/next'
|
||||
|
||||
type Viewer = { id: number; email: string; interest: string | null; level: string | null; onboarded: boolean } | null
|
||||
|
||||
const COPY = ['الخطوة 1 من 3', 'الخطوة 2 من 3', 'الخطوة 3 من 3']
|
||||
const PERCENTS = ['33٪', '67٪', '100٪']
|
||||
|
||||
/* Wizard markup/steps from member-onboarding.html. Interest + level PATCH the
|
||||
user record when leaving each step (falls back to localStorage if no session). */
|
||||
export default function OnboardingWizard({ viewer }: { viewer: Viewer }) {
|
||||
const [step, setStep] = useState(0)
|
||||
const [interest, setInterest] = useState(viewer?.interest ?? '')
|
||||
const [level, setLevel] = useState(viewer?.level ?? '')
|
||||
const [status, setStatus] = useState('')
|
||||
const [nextPath, setNextPath] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try { setNextPath(safeNext(sessionStorage.getItem('anwarpi:signup-next'))) } catch { /* private mode */ }
|
||||
}, [])
|
||||
|
||||
function show(next: number) {
|
||||
setStep(Math.max(0, Math.min(2, next)))
|
||||
}
|
||||
|
||||
async function persist(fields: Record<string, unknown>) {
|
||||
try {
|
||||
localStorage.setItem('anwarpi:member-onboarding', JSON.stringify({ interest, experience: level }))
|
||||
} catch { /* private mode */ }
|
||||
if (!viewer) return
|
||||
await fetch(`/api/users/${viewer.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (step === 0) {
|
||||
if (!interest) { setStatus('اختر مساراً واحداً للمتابعة.'); return }
|
||||
setStatus('')
|
||||
await persist({ interest })
|
||||
show(1)
|
||||
return
|
||||
}
|
||||
if (step === 1) {
|
||||
if (!level) { setStatus('اختر مستوى واحداً للمتابعة.'); return }
|
||||
setStatus('')
|
||||
await persist({ level, onboarded: true })
|
||||
show(2)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = interest && level ? `اخترت مسار ${interest} بمستوى ${level}. حسابك المجاني جاهز، ويمكنك الترقية الآن أو لاحقاً.` : 'حسابك المجاني جاهز. يمكنك الترقية الآن أو لاحقاً من صفحة العضوية.'
|
||||
|
||||
useEffect(() => {
|
||||
const bar = document.getElementById('progressBar')
|
||||
if (bar) bar.style.width = `${((step + 1) / 3) * 100}%`
|
||||
const label = document.getElementById('progressLabel')
|
||||
if (label) label.textContent = COPY[step]
|
||||
const percent = document.getElementById('progressPercent')
|
||||
if (percent) percent.textContent = PERCENTS[step]
|
||||
}, [step])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="container flow" data-od-id="member-onboarding-flow">
|
||||
<nav className="step-nav" aria-label="خطوات إعداد الحساب" data-od-id="onboarding-steps">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className={i === step ? 'active' : i < step ? 'done' : ''} data-step-label={i}>
|
||||
<span>{['01', '02', '03'][i]}</span><span>{['اهتمامك', 'مستواك', 'خيارات العضوية'][i]}</span>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<form id="memberOnboarding" noValidate onSubmit={e => e.preventDefault()}>
|
||||
<section className={`panel${step === 0 ? ' active' : ''}`} data-step="0" data-od-id="step-interest">
|
||||
<h2>أي مسار تريد أن يظهر أولاً؟</h2>
|
||||
<p className="intro">اختر مساراً واحداً الآن. ستظل بقية المكتبة متاحة لك.</p>
|
||||
<div className="choices">
|
||||
{[
|
||||
['الوكلاء', 'الوكلاء وتنسيقهم', 'التخطيط والتنفيذ والتحقق وتنسيق عدة وكلاء.'],
|
||||
['RAG', 'التوليد المعزّز بالاسترجاع (RAG)', 'اختيار المصادر وقياس جودة الاسترجاع والإجابة.'],
|
||||
['الأتمتة', 'الأتمتة للأعمال', 'دمج الذكاء الاصطناعي في تدفقات عمل قابلة للمراقبة.'],
|
||||
['النماذج المفتوحة', 'النماذج المفتوحة', 'التشغيل المحلي والضبط الدقيق واختيار النموذج المناسب.'],
|
||||
].map(([value, label, desc]) => (
|
||||
<label className="choice" key={value}>
|
||||
<input type="radio" name="interest" value={value} checked={interest === value} onChange={() => { setInterest(value); setStatus('') }} />
|
||||
<span className="choice-body"><strong>{label}</strong><small>{desc}</small><span className="choice-mark" aria-hidden="true"></span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="status" id="interestStatus" role="status" aria-live="polite">{step === 0 ? status : ''}</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" data-next onClick={next}>تابع إلى مستواك</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className={`panel${step === 1 ? ' active' : ''}`} data-step="1" data-od-id="step-experience">
|
||||
<h2>ما مستوى خبرتك العملية؟</h2>
|
||||
<p className="intro">سنستخدمه فقط لترتيب نقطة البداية، لا لإخفاء أي محتوى مجاني.</p>
|
||||
<div className="choices">
|
||||
{[
|
||||
['مبتدئ', 'أبدأ من الأساسيات', 'أستخدم أدوات الذكاء الاصطناعي ولم أبنِ مشروعاً بعد.'],
|
||||
['متوسط', 'بنيت تجارب بسيطة', 'جرّبت واجهات برمجة التطبيقات (API) أو أتمتة أو تطبيقاً صغيراً.'],
|
||||
['متقدم', 'أعمل على أنظمة فعلية', 'أبحث عن أنماط أعمق للقياس والموثوقية والتشغيل.'],
|
||||
].map(([value, label, desc]) => (
|
||||
<label className="choice" key={value}>
|
||||
<input type="radio" name="experience" value={value} checked={level === value} onChange={() => { setLevel(value); setStatus('') }} />
|
||||
<span className="choice-body"><strong>{label}</strong><small>{desc}</small><span className="choice-mark" aria-hidden="true"></span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="status" id="experienceStatus" role="status" aria-live="polite">{step === 1 ? status : ''}</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary" type="button" data-back onClick={() => show(0)}>رجوع</button>
|
||||
<button className="btn btn-primary" type="button" data-next onClick={next}>شاهد خيارات الوصول</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className={`panel${step === 2 ? ' active' : ''}`} data-step="2" data-od-id="step-access">
|
||||
<p className="eyebrow">اختيارك، بلا ضغط</p>
|
||||
<h2>ابدأ مجاناً أو افتح محتوى العضوية الكامل.</h2>
|
||||
<p className="intro" id="summaryCopy">{summary}</p>
|
||||
<div className="benefits" data-od-id="vip-benefits">
|
||||
<article className="benefit"><span className="benefit-num">01</span><div><h3>الأدلة المتقدمة كاملة</h3><p>اقرأ ما بعد المعاينة في أدلة الوكلاء وRAG والأتمتة والنماذج.</p></div></article>
|
||||
<article className="benefit"><span className="benefit-num">02</span><div><h3>ملفات العمل</h3><p>نزّل القوالب وقوائم الفحص المرتبطة بكل دليل.</p></div></article>
|
||||
<article className="benefit"><span className="benefit-num">03</span><div><h3>نقطة بداية مخصصة</h3><p>إعداد العضوية ترتّب دليلك الأول ورسائلك بناءً على هدفك.</p></div></article>
|
||||
</div>
|
||||
<div className="upgrade-note">
|
||||
<strong>الحساب المجاني لا يختفي.</strong>
|
||||
<p>يمكنك قراءة الأدلة المجانية وحفظها، والترقية عندما تحتاج الأدلة الحصرية.</p>
|
||||
</div>
|
||||
<div className="actions" data-od-id="final-actions">
|
||||
{nextPath ? (
|
||||
<Link className="btn btn-primary" href={nextPath} data-od-id="continue-next-cta">تابع إلى وجهتك</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link className="btn btn-secondary" href="/articles?access=free" data-od-id="continue-free-cta">ابدأ بالأدلة المجانية</Link>
|
||||
<Link className="btn btn-primary" href="/subscription" data-od-id="view-membership-cta">شاهد العضوية والأسعار</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import OnboardingWizard from './client'
|
||||
import '../styles/member-onboarding.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'إعداد حسابك' }
|
||||
|
||||
/* Signup onboarding — design from member-onboarding.html. Interest + level
|
||||
persist to the user record (backend-aware, per locked decision). */
|
||||
export default async function OnboardingPage() {
|
||||
let viewer: { id: number; email: string; interest: string | null; level: string | null; onboarded: boolean } | null = null
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (user) {
|
||||
viewer = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
interest: (user.interest as string | null) ?? null,
|
||||
level: (user.level as string | null) ?? null,
|
||||
onboarded: !!user.onboarded,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* render wizard without persistence */
|
||||
}
|
||||
|
||||
if (!viewer) redirect('/login?next=%2Fonboarding')
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page page-onboarding">
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<section className="head" data-od-id="onboarding-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">حسابك جاهز · إعداد سريع</p>
|
||||
<h1 data-od-id="onboarding-title">اختر ما تريد تعلّمه أولاً.</h1>
|
||||
<p className="lead">سؤالان فقط لترتيب المكتبة حول هدفك، ثم نوضح الفرق بين الحساب المجاني والعضوية المدفوعة.</p>
|
||||
<div className="progress" aria-label="تقدم الإعداد">
|
||||
<div className="progress-copy"><span id="progressLabel">الخطوة 1 من 3</span><span id="progressPercent">33٪</span></div>
|
||||
<div className="progress-track"><div className="progress-bar" id="progressBar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<OnboardingWizard viewer={viewer} />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import Link from 'next/link'
|
||||
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'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* Editorial picks for «ابدأ من هنا». */
|
||||
const START_HERE = ['first-arabic-agent', 'choosing-your-model', 'prototype-to-production', 'simple-arabic-rag', 'safe-agent-guardrails', 'agent-loop-stop-condition']
|
||||
|
||||
type Doc = { id: number; title: string; slug: string; dek?: string | null; access: 'free' | 'vip'; category: string; level?: string | null; readingMinutes: number; cover?: { url?: string | null } | number | null }
|
||||
|
||||
export default async function HomePage() {
|
||||
const viewer = await getViewer()
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { docs: raw } = await 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, cover: true },
|
||||
})
|
||||
const docs = raw as unknown as Doc[]
|
||||
const freeCount = docs.filter(d => d.access === 'free').length
|
||||
|
||||
// Hero personalization: matching the viewer's interest floats to the top,
|
||||
// level match ranks higher, ties keep the -publishedAt order. Anonymous
|
||||
// viewers get the plain newest-first list.
|
||||
const interestTopic = viewer?.interest ? INTEREST_MAP[viewer.interest] : undefined
|
||||
const score = (d: Doc) => {
|
||||
if (!interestTopic || !topicsFor(d).includes(interestTopic)) return 0
|
||||
return d.level === viewer?.level ? 2 : 1
|
||||
}
|
||||
const hero = docs
|
||||
.map((d, i) => ({ d, i, s: score(d) }))
|
||||
.sort((a, b) => b.s - a.s || a.i - b.i)
|
||||
.slice(0, 4)
|
||||
.map(({ d }) => d)
|
||||
const counts = Object.fromEntries(TOPICS.map(t => [t.key, docs.filter(d => topicsFor(d).includes(t.key)).length]))
|
||||
const startHere = START_HERE.map(slug => docs.find(d => d.slug === slug)).filter((d): d is Doc => Boolean(d))
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-home">
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<section className="home-hero">
|
||||
<div className="wrap home-hero-grid">
|
||||
<div className="home-hero-copy">
|
||||
<p className="kicker">مكتبة عربية للذكاء الاصطناعي العملي</p>
|
||||
<h1>افهم الذكاء الاصطناعي، ثم ابنِ به.</h1>
|
||||
<p className="lede">أدلة عربية عملية عن الوكلاء والنماذج والأتمتة، يكتبها مهندسو بنية تحتية لمن يريد أن يشغّل شيئاً حقيقياً.</p>
|
||||
<div className="actions">
|
||||
<a className="btn btn-primary" href="/articles?access=free">ابدأ بدليل مجاني</a>
|
||||
<Link className="btn btn-quiet" href="/articles">تصفح المكتبة</Link>
|
||||
</div>
|
||||
</div>
|
||||
{hero.length > 0 && (
|
||||
<aside className="hero-panel" aria-labelledby="new-title">
|
||||
<h2 id="new-title">أحدث الأدلة</h2>
|
||||
<ul role="list">
|
||||
{hero.map(d => {
|
||||
const coverUrl = typeof d.cover === 'object' ? d.cover?.url ?? null : null
|
||||
return (
|
||||
<li key={d.id}>
|
||||
<Link href={`/posts/${d.slug}`}>
|
||||
{coverUrl && <img className="hero-panel-thumb" src={coverUrl} alt="" width={72} height={48} loading="lazy" />}
|
||||
<span className="hero-panel-title">{d.title}</span>
|
||||
<span className="meta-row"><span>{d.category}</span><span>{minutesLabel(d.readingMinutes)}</span><span className={d.access === 'vip' ? 'is-vip' : undefined}>{d.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<Link className="text-link" href="/articles">كل الأدلة</Link>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="home-paths" id="topics" aria-labelledby="paths-title">
|
||||
<div className="wrap">
|
||||
<header className="section-head">
|
||||
<h2 id="paths-title">اختر مساراً حسب ما تريد بناءه</h2>
|
||||
<p>كل دليل ينتمي إلى مسار، وأدلة المبتدئين تظهر أيضاً في الأساسيات.</p>
|
||||
</header>
|
||||
<ol className="paths" role="list">
|
||||
{TOPICS.map(topic => (
|
||||
<li key={topic.key}>
|
||||
<Link href={`/articles?topic=${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>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{startHere.length > 0 && (
|
||||
<section className="home-start" aria-labelledby="start-title">
|
||||
<div className="wrap">
|
||||
<header className="section-head">
|
||||
<h2 id="start-title">ابدأ من هنا</h2>
|
||||
<Link className="text-link" href="/articles">كل الأدلة ({formatNumber(docs.length)})</Link>
|
||||
</header>
|
||||
<ul className="card-grid" role="list">
|
||||
{startHere.map(post => {
|
||||
const coverUrl = typeof post.cover === 'object' ? post.cover?.url ?? null : null
|
||||
return (
|
||||
<li key={post.id}>
|
||||
<article className="guide">
|
||||
{coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />}
|
||||
<p className="meta-row"><span className="cat">{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span><span className={post.access === 'vip' ? 'is-vip' : undefined}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></p>
|
||||
<h3><Link href={`/posts/${post.slug}`}>{post.title}</Link></h3>
|
||||
<p className="guide-dek">{post.dek}</p>
|
||||
<span className="guide-link" aria-hidden="true">اقرأ الدليل</span>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="home-membership" id="membership" aria-labelledby="membership-title">
|
||||
<div className="wrap membership-panel">
|
||||
<div>
|
||||
<h2 id="membership-title">العضوية تفتح الأدلة المتقدمة وملفات العمل.</h2>
|
||||
<p>الأدلة المجانية كاملة دائماً. العضوية تضيف الأدلة المعمّقة، القوالب الجاهزة للتنزيل، والنقاش مع الفريق.</p>
|
||||
</div>
|
||||
<div className="membership-offer">
|
||||
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
|
||||
<ul role="list">
|
||||
<li>{guidesLabel(docs.length - freeCount)} للأعضاء، وتُضاف أدلة جديدة باستمرار</li>
|
||||
<li>قوالب وقوائم فحص قابلة للتنزيل</li>
|
||||
<li>التعليق والنقاش على كل دليل</li>
|
||||
</ul>
|
||||
<Link className="btn btn-primary" href="/subscription">اطّلع على العضوية</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import Link from 'next/link'
|
||||
import '../styles/payment-cancelled.css'
|
||||
|
||||
export const metadata = { title: 'لم يكتمل الدفع' }
|
||||
|
||||
/* Payment cancelled — design from payment-cancelled.html. */
|
||||
export default function PaymentCancelledPage() {
|
||||
return (
|
||||
<div id="site" className="site-page page-payment-cancelled">
|
||||
<Topnav />
|
||||
<main id="content">
|
||||
<section className="section" data-od-id="payment-cancelled">
|
||||
<div className="container">
|
||||
<article className="state-card">
|
||||
<div className="status-mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={2}><path d="M9 9l14 14M23 9 9 23" /></svg></div>
|
||||
<p className="eyebrow">لم يكتمل الدفع</p>
|
||||
<h1 data-od-id="cancelled-title">لم تُخصم أي دفعة.</h1>
|
||||
<p className="lead">ألغيت عملية الدفع أو عدت قبل إكمالها. حسابك المجاني وأدلتك المحفوظة لم تتغير.</p>
|
||||
<div className="notice" data-od-id="account-safety-note">
|
||||
<h2 data-od-id="account-safety-title">يمكنك المتابعة وقتما يناسبك</h2>
|
||||
<p>ارجع إلى صفحة العضوية لمراجعة المزايا أو أكمل استخدام المكتبة المجانية الآن.</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<Link className="btn btn-primary" href="/subscription" data-od-id="retry-payment-cta">العودة إلى العضوية</Link>
|
||||
<Link className="btn btn-secondary" href="/articles" data-od-id="browse-free-articles-cta">تصفح الأدلة المجانية</Link>
|
||||
</div>
|
||||
<p className="help">واجهت مشكلة أثناء الدفع؟ <a href="mailto:hello@anwarpy.ai?subject=%D9%85%D8%B4%D9%83%D9%84%D8%A9%20%D9%81%D9%8A%20%D8%A7%D9%84%D8%AF%D9%81%D8%B9" data-od-id="payment-help-link">تواصل مع الفريق</a>.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import Stripe from 'stripe'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import '../styles/payment-success.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'تم تفعيل العضوية' }
|
||||
|
||||
type Props = { searchParams: Promise<{ session_id?: string }> }
|
||||
|
||||
/* Payment success — design from payment-success.html.
|
||||
- real Stripe: verifies the checkout session_id with Stripe before
|
||||
activating (paid + the checkout email matches the signed-in account)
|
||||
- no session: nobody is activated here — /api/stripe/webhook is the primary
|
||||
activator; this page only re-verifies a session it can prove */
|
||||
export default async function PaymentSuccessPage({ searchParams }: Props) {
|
||||
const { session_id: sessionId } = await searchParams
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (!user) redirect('/login?next=/payment-success')
|
||||
|
||||
let activate = false
|
||||
if (sessionId && stripeKey) {
|
||||
try {
|
||||
const stripe = new Stripe(stripeKey)
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId)
|
||||
activate =
|
||||
session.payment_status === 'paid' &&
|
||||
(session.customer_details?.email ?? '').toLowerCase() === user.email.toLowerCase()
|
||||
} catch {
|
||||
// Any Stripe failure (bad key, unknown session, network) → no activation.
|
||||
activate = false
|
||||
}
|
||||
}
|
||||
|
||||
if (activate && user.role !== 'vip' && user.role !== 'admin') {
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: user.id,
|
||||
data: { role: 'vip' },
|
||||
overrideAccess: true,
|
||||
context: { systemActivation: true },
|
||||
})
|
||||
}
|
||||
|
||||
const vipNow = user.role === 'vip' || user.role === 'admin' || activate
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page page-payment-success">
|
||||
<Topnav viewer={user} />
|
||||
<main id="content">
|
||||
<section className="section" data-od-id="payment-success">
|
||||
<div className="container">
|
||||
<article className="state-card">
|
||||
<div className="status-mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={2}><path d="m8 17 5 5 11-13" /></svg></div>
|
||||
{vipNow ? (
|
||||
<>
|
||||
<p className="eyebrow">تم الدفع · العضوية فعّالة</p>
|
||||
<h1 data-od-id="success-title">مرحباً بك في عضوية أنور بآي.</h1>
|
||||
<p className="lead">تم تفعيل وصولك إلى الأدلة الحصرية وملفات العمل. بقيت خطوة قصيرة لاختيار نقطة البداية المناسبة لك.</p>
|
||||
<dl className="summary" data-od-id="payment-summary">
|
||||
<div className="summary-row"><dt>الحالة</dt><dd>عضوية فعّالة</dd></div>
|
||||
<div className="summary-row"><dt>الخطة</dt><dd>عضوية فردية · شهرياً · <bdi dir="ltr">$10</bdi></dd></div>
|
||||
<div className="summary-row"><dt>الوصول</dt><dd>كل محتوى العضوية وملفات الأعضاء</dd></div>
|
||||
</dl>
|
||||
<div className="next-step" data-od-id="next-step">
|
||||
<h2 data-od-id="next-step-title">جهّز مسارك الأول</h2>
|
||||
<p>حدد هدفك ومستواك في أقل من خمس دقائق، ثم ابدأ بدليل واحد يناسبك.</p>
|
||||
<div className="actions">
|
||||
<Link className="btn btn-primary" href="/vip-onboarding" data-od-id="start-onboarding-cta">ابدأ إعداد العضوية</Link>
|
||||
<Link className="btn btn-secondary" href="/posts/agent-loop-stop-condition" data-od-id="open-member-guide-cta">افتح دليل الأعضاء</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="eyebrow">لم تكتمل عملية الدفع</p>
|
||||
<h1 data-od-id="payment-incomplete-title">لم يتم تأكيد الدفع بعد</h1>
|
||||
<p className="lead">لم نتمكن من تأكيد عملية دفع مرتبطة بحسابك. إن أتممت الدفع للتو فقد يحتاج التأكيد بضع دقائق، ويمكنك إعادة المحاولة من صفحة الاشتراك.</p>
|
||||
<div className="actions">
|
||||
<Link className="btn btn-primary" href="/subscription" data-od-id="retry-subscription-cta">العودة إلى صفحة الاشتراك</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/* Views ping — counts one view per browser session per post (global counter
|
||||
on the backend). */
|
||||
export function ViewPing({ slug }: { slug: string }) {
|
||||
useEffect(() => {
|
||||
const key = `anw-viewed:${slug}`
|
||||
try {
|
||||
if (sessionStorage.getItem(key)) return
|
||||
sessionStorage.setItem(key, '1')
|
||||
} catch { /* private mode — count anyway */ }
|
||||
fetch(`/api/posts/${slug}/view`, { method: 'POST', keepalive: true }).catch(() => null)
|
||||
}, [slug])
|
||||
return null
|
||||
}
|
||||
|
||||
/* Like toggle — login required; one like per user per post. */
|
||||
export function LikeButton({ slug, loggedIn, initialLiked, initialCount }: { slug: string; loggedIn: boolean; initialLiked: boolean; initialCount: number }) {
|
||||
const [liked, setLiked] = useState(initialLiked)
|
||||
const [count, setCount] = useState(initialCount)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function toggle() {
|
||||
if (!loggedIn) {
|
||||
location.href = `/login?next=/posts/${slug}`
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/posts/${slug}/like`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLiked(data.liked)
|
||||
setCount(data.likes)
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="like"
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
aria-pressed={liked}
|
||||
aria-label={liked ? 'ألغِ الإعجاب' : 'أعجبني'}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 13.5 2.6 8.3a3.2 3.2 0 0 1 4.5-4.6L8 4.6l.9-.9a3.2 3.2 0 0 1 4.5 4.6Z" fill="none" stroke="currentColor" strokeWidth="1.4" /></svg>
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { getPayload } from 'payload'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import configPromise from '@payload-config'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
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'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* QA-06: article pages get their own title/description. */
|
||||
export async function generateMetadata({ params }: Args) {
|
||||
const { slug } = await params
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const res = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
select: { title: true, dek: true, publishedAt: true },
|
||||
})
|
||||
const post = res.docs[0] as unknown as { title: string; dek: string; publishedAt?: string | null } | undefined
|
||||
return post
|
||||
? {
|
||||
title: post.title,
|
||||
description: post.dek,
|
||||
openGraph: { type: 'article', title: post.title, description: post.dek, publishedTime: post.publishedAt ?? undefined },
|
||||
}
|
||||
: { title: 'أنور بآي' }
|
||||
}
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> }
|
||||
|
||||
type PostDoc = {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
dek: string
|
||||
access: 'free' | 'vip'
|
||||
category: string
|
||||
level?: string | null
|
||||
views?: number | null
|
||||
likes?: number | null
|
||||
readingMinutes: number
|
||||
publishedAt?: string | null
|
||||
teaser?: string | null
|
||||
content?: unknown
|
||||
cover?: { url?: string | null }
|
||||
attachments?: (number | { id: number })[]
|
||||
}
|
||||
|
||||
async function isLikedByViewer(postId: number, viewerId?: number): Promise<boolean> {
|
||||
try {
|
||||
if (!viewerId) return false
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { docs } = await payload.find({
|
||||
collection: 'post-likes',
|
||||
where: { and: [{ user: { equals: viewerId } }, { post: { equals: postId } }] },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return docs.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: Args) {
|
||||
const { slug } = await params
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const res = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
// content selected only for entitled viewers — never reaches non-VIP clients
|
||||
select: {
|
||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||
views: true, likes: true,
|
||||
readingMinutes: true, publishedAt: true, teaser: true, cover: true,
|
||||
content: true, attachments: true,
|
||||
},
|
||||
})
|
||||
const post = res.docs[0] as unknown as PostDoc | undefined
|
||||
if (!post) notFound()
|
||||
|
||||
// One auth lookup per request; everything below reads from it.
|
||||
const viewer = await getViewer()
|
||||
const role = viewer?.role ?? null
|
||||
const viewerId = viewer?.id
|
||||
const viewerLevel = viewer?.level ?? null
|
||||
const unlocked = 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')
|
||||
const liked = await isLikedByViewer(post.id, viewerId)
|
||||
|
||||
// Attachment metadata (label/access) is safe to expose — the bytes are
|
||||
// gated by GET /api/files/[id]. Fetched with overrideAccess because the
|
||||
// files collection read rule is vip/admin and depth population would
|
||||
// otherwise collapse to bare ids for anonymous/free viewers.
|
||||
const attachmentIds = (post.attachments ?? []).map(a => (typeof a === 'object' ? a.id : a))
|
||||
type Attachment = { id: number; label: string; filename: string; access: 'free' | 'vip' }
|
||||
let attachments: Attachment[] = []
|
||||
if (attachmentIds.length > 0) {
|
||||
const filesRes = await payload.find({
|
||||
collection: 'files',
|
||||
depth: 0,
|
||||
limit: 20,
|
||||
overrideAccess: true,
|
||||
where: { id: { in: attachmentIds } },
|
||||
select: { label: true, filename: true, access: true },
|
||||
})
|
||||
const fileDocs = filesRes.docs as unknown as Attachment[]
|
||||
attachments = attachmentIds
|
||||
.map(id => fileDocs.find(d => d.id === id))
|
||||
.filter((d): d is Attachment => Boolean(d))
|
||||
}
|
||||
|
||||
// «اقرأ بعد ذلك»: real published guides, same learning path first.
|
||||
const { docs: others } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { and: [{ status: { equals: 'published' } }, { slug: { not_equals: post.slug } }] },
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
sort: '-publishedAt',
|
||||
select: { title: true, slug: true, category: true, access: true, readingMinutes: true, level: true },
|
||||
})
|
||||
const related = [...others]
|
||||
.sort((a, b) => Number(topicsFor(b).includes(postTopics[0])) - Number(topicsFor(a).includes(postTopics[0])))
|
||||
.slice(0, 3)
|
||||
|
||||
// Approved comments (public read access allows the approved-only filter).
|
||||
const comments = await payload.find({
|
||||
collection: 'comments',
|
||||
depth: 0,
|
||||
limit: 50,
|
||||
sort: '-createdAt',
|
||||
where: { and: [{ post: { equals: post.id } }, { status: { equals: 'approved' } }] },
|
||||
})
|
||||
// Authors see their own pending comments tagged «قيد المراجعة» — needs
|
||||
// overrideAccess because non-admin read access filters to approved only.
|
||||
const myPending = viewerId
|
||||
? await payload.find({
|
||||
collection: 'comments',
|
||||
depth: 0,
|
||||
limit: 10,
|
||||
overrideAccess: true,
|
||||
where: {
|
||||
and: [
|
||||
{ post: { equals: post.id } },
|
||||
{ author: { equals: viewerId } },
|
||||
{ status: { equals: 'pending' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
: null
|
||||
|
||||
// TOC from content h2s (server-extracted from the Lexical tree)
|
||||
const toc = unlocked ? extractH2s(post.content) : []
|
||||
|
||||
const topic = postTopics[0]
|
||||
const topicLabel = TOPICS.find(t => t.key === topic)?.label ?? post.category
|
||||
|
||||
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' }) }} />
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<header className="art-head">
|
||||
<div className="wrap art-head-grid">
|
||||
<div className="art-head-copy">
|
||||
<nav className="crumbs" aria-label="مسار التصفح">
|
||||
<Link href="/articles">المكتبة</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link href={`/articles?topic=${topic}`}>{topicLabel}</Link>
|
||||
</nav>
|
||||
<h1>{post.title}</h1>
|
||||
<p className="lede">{post.dek}</p>
|
||||
<div className="art-meta">
|
||||
<span className={`badge${post.access === 'vip' ? ' badge-vip' : ''}`}>
|
||||
{post.access === 'vip' && <LockIcon />}
|
||||
{post.access === 'free' ? 'مجاني بالكامل' : unlocked ? 'للأعضاء' : 'معاينة مجانية'}
|
||||
</span>
|
||||
<span>{post.category}</span>
|
||||
<span>{minutesLabel(post.readingMinutes)} للقراءة</span>
|
||||
<span>{post.level ?? 'مبتدئ'}</span>
|
||||
{post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>}
|
||||
{(post.level ?? 'مبتدئ') === viewerLevel && <span className="for-you">يناسب مستواك</span>}
|
||||
</div>
|
||||
<div className="art-actions">
|
||||
<LikeButton slug={post.slug} loggedIn={role !== null} initialLiked={liked} initialCount={post.likes ?? 0} />
|
||||
<span className="views" data-views>{formatNumber(post.views)} قراءة</span>
|
||||
{unlocked && post.access === 'vip' && <SaveButton />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{typeof post.cover === 'object' && post.cover?.url && (
|
||||
<div className="wrap"><figure className="art-cover"><img src={post.cover.url} alt={post.title} width={1200} height={630} loading="eager" /></figure></div>
|
||||
)}
|
||||
|
||||
<div className="wrap art-layout">
|
||||
<article className="art-body">
|
||||
{unlocked ? (
|
||||
<RichText data={post.content} />
|
||||
) : (
|
||||
<>
|
||||
{(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => (
|
||||
i === arr.length - 1 && arr.length > 1
|
||||
? <p key={i} className="fade-out">{para}</p>
|
||||
: <p key={i} className={i === 0 ? 'intro' : undefined}>{para}</p>
|
||||
))}
|
||||
<section className="paywall" aria-labelledby="paywall-title">
|
||||
<h2 id="paywall-title">بقية هذا الدليل للأعضاء</h2>
|
||||
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل الأدلة الحصرية الأخرى.</p>
|
||||
<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>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
<aside className="art-aside" aria-label="داخل هذا الدليل">
|
||||
{unlocked && toc.length > 0 && (
|
||||
<nav className="toc" aria-label="فهرس الدليل">
|
||||
<p className="aside-title">في هذا الدليل</p>
|
||||
<ol>{toc.map((t, i) => <li key={i}><a href={`#sec-${i}`}>{t}</a></li>)}</ol>
|
||||
</nav>
|
||||
)}
|
||||
{unlocked && hasAgentFiles && (
|
||||
<div className="files">
|
||||
<p className="aside-title">ملفات العمل</p>
|
||||
<ul>
|
||||
<li><span>قالب حالة الوكيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="agent-state.md">تنزيل</button></li>
|
||||
<li><span>قائمة الفحص قبل التشغيل <bdi dir="ltr">.md</bdi></span><button className="dl-btn" type="button" data-dl="pre-run-checklist.md">تنزيل</button></li>
|
||||
<li><span>سجل الأخطاء <bdi dir="ltr">.csv</bdi></span><button className="dl-btn" type="button" data-dl="error-log.csv">تنزيل</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{!unlocked && (
|
||||
<div className="files is-locked">
|
||||
<p className="aside-title">مع العضوية</p>
|
||||
<ul>
|
||||
<li><LockIcon />الأقسام العملية كاملة</li>
|
||||
{hasAgentFiles && <li><LockIcon />ملفات العمل القابلة للتنزيل</li>}
|
||||
<li><LockIcon />النقاش مع الفريق</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<div className="files">
|
||||
<p className="aside-title">ملفات هذا الدليل</p>
|
||||
<ul>
|
||||
{attachments.map(item => {
|
||||
// Per-file gate mirrors GET /api/files/[id]: free streams to
|
||||
// anyone; vip needs a vip/admin viewer (post access is
|
||||
// irrelevant — a vip file on a free post stays locked).
|
||||
const canDownload = item.access === 'free' || role === 'vip' || role === 'admin'
|
||||
const accessLabel = item.access === 'free' ? 'مجاني' : 'للأعضاء'
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<span>{item.label || item.filename} <span className="file-access">· {accessLabel}</span></span>
|
||||
{canDownload
|
||||
? <a className="dl-btn" href={`/api/files/${item.id}`} download>تنزيل</a>
|
||||
: <span className="dl-locked"><LockIcon />للأعضاء</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{unlocked && (
|
||||
<section className="art-comments" aria-labelledby="comments-title">
|
||||
<div className="wrap narrow">
|
||||
<h2 id="comments-title">النقاش</h2>
|
||||
<div className="comment-list" id="commentList">
|
||||
{comments.docs.map((c) => (
|
||||
<article key={c.id} className="comment">
|
||||
<header><strong>{c.authorName || 'عضو'}</strong><time dateTime={String(c.createdAt)}>{formatDate(String(c.createdAt))}</time></header>
|
||||
<p>{c.body}</p>
|
||||
</article>
|
||||
))}
|
||||
{myPending?.docs.map((c) => (
|
||||
<article key={c.id} className="comment is-pending">
|
||||
<header><strong>{c.authorName || 'عضو'}</strong><span>بانتظار المراجعة</span></header>
|
||||
<p>{c.body}</p>
|
||||
</article>
|
||||
))}
|
||||
{comments.docs.length === 0 && !(myPending && myPending.docs.length > 0) && (
|
||||
<p className="muted">لا تعليقات بعد. شارك سؤالك أو تجربتك مع هذا الدليل.</p>
|
||||
)}
|
||||
</div>
|
||||
<PostExtras slug={post.slug} loggedIn={role !== null} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="art-related" aria-labelledby="related-title">
|
||||
<div className="wrap">
|
||||
<h2 id="related-title">اقرأ بعد ذلك</h2>
|
||||
<ul className="related-grid" role="list">
|
||||
{related.map(item => (
|
||||
<li key={item.id}>
|
||||
<Link href={`/posts/${item.slug}`} className="related-item">
|
||||
<span className="meta-row"><span>{item.category}</span><span>{minutesLabel(item.readingMinutes)}</span><span>{item.access === 'free' ? 'مجاني' : 'للأعضاء'}</span></span>
|
||||
<span className="related-title">{item.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><rect x="3" y="7" width="10" height="7" /><path d="M5 7V5a3 3 0 0 1 6 0v2" /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SaveButton() {
|
||||
return <button className="save-btn" id="saveArticle" type="button" aria-pressed="false">احفظ للعودة لاحقاً</button>
|
||||
}
|
||||
|
||||
// Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules.
|
||||
type Node = { type?: string; text?: string; children?: Node[]; tag?: string; url?: string; listType?: string; code?: string }
|
||||
|
||||
function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] {
|
||||
return (nodes || []).map((n, i) => {
|
||||
const key = `${n.type}-${i}`
|
||||
if (n.type === 'text') {
|
||||
const text = n.text || ''
|
||||
return <span key={key}>{text}</span>
|
||||
}
|
||||
if (n.type === 'linebreak') return <br key={key} />
|
||||
if (n.type === 'link') return <a key={key} href={n.url || '#'} className="text-link">{renderChildren(n.children, counter)}</a>
|
||||
const inner = renderChildren(n.children, counter)
|
||||
switch (n.type) {
|
||||
case 'heading': {
|
||||
const Tag = (n.tag || 'h2') as 'h1' | 'h2' | 'h3' | 'h4'
|
||||
const id = Tag === 'h2' ? `sec-${counter.h2++}` : undefined
|
||||
return <Tag key={key} id={id}>{inner}</Tag>
|
||||
}
|
||||
case 'quote': return <blockquote key={key}>{inner}</blockquote>
|
||||
case 'list': {
|
||||
if (n.listType === 'number') return <ol key={key}>{inner}</ol>
|
||||
return <ul key={key}>{inner}</ul>
|
||||
}
|
||||
case 'listitem': return <li key={key}>{inner}</li>
|
||||
case 'paragraph': return <p key={key}>{inner}</p>
|
||||
case 'code': return <pre key={key} aria-label="مثال برمجي"><code>{n.code ?? innerText(n.children)}</code></pre>
|
||||
case 'horizontalrule': return <hr key={key} />
|
||||
default: return <p key={key}>{inner}</p>
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function innerText(nodes?: Node[]): string {
|
||||
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
|
||||
}
|
||||
|
||||
function extractH2s(data: unknown): string[] {
|
||||
try {
|
||||
const root = (data as { root?: { children?: Node[] } })?.root
|
||||
if (!root?.children) return []
|
||||
return root.children
|
||||
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
|
||||
.map(n => innerText(n.children))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function RichText({ data }: { data: unknown }) {
|
||||
if (!data || typeof data !== 'object' || !('root' in data)) return null
|
||||
const rootUnknown = data.root
|
||||
if (!rootUnknown || typeof rootUnknown !== 'object' || !('children' in rootUnknown)) return null
|
||||
const root = rootUnknown as { children?: Node[] } // named const: same JSON tree the editor produced
|
||||
return <>{renderChildren(root.children, { h2: 0 })}</>
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/* Save button + file download buttons + comment form — client behaviors
|
||||
ported from post-unlocked.html (client-side only, faithful to the original). */
|
||||
export default function PostExtras({ slug, loggedIn }: { slug: string; loggedIn: boolean }) {
|
||||
useEffect(() => {
|
||||
const save = document.getElementById('saveArticle')
|
||||
const onSave = () => {
|
||||
if (!save) return
|
||||
const active = save.getAttribute('aria-pressed') !== 'true'
|
||||
save.setAttribute('aria-pressed', String(active))
|
||||
save.textContent = active ? 'تم الحفظ ✓' : 'احفظ للعودة لاحقاً'
|
||||
}
|
||||
save?.addEventListener('click', onSave)
|
||||
|
||||
/* QA-02: download buttons generate real files (client-side, no backend
|
||||
needed) with proper names and content types. */
|
||||
const DL_FILES: Record<string, { name: string; type: string; body: string }> = {
|
||||
'agent-state.md': {
|
||||
name: 'anwarpi-agent-state-template.md',
|
||||
type: 'text/markdown;charset=utf-8',
|
||||
body: [
|
||||
'# قالب حالة الوكيل',
|
||||
'',
|
||||
'> انسخ هذا القالب لكل وكيل تبنيه؛ الحقول الفارغة تعني منطقاً ناقصاً.',
|
||||
'',
|
||||
'## الهدف الواحد',
|
||||
'- ما الذي ينجزه الوكيل بالضبط؟',
|
||||
'',
|
||||
'## حدود الحلقة',
|
||||
'- أقصى عدد خطوات: ',
|
||||
'- شرط التوقف: ',
|
||||
'- ما لا يفعله الوكيل: ',
|
||||
'',
|
||||
'## سجل الحالة',
|
||||
'| الحقل | القيمة | آخر تحديث |',
|
||||
'| --- | --- | --- |',
|
||||
'| المهمة الحالية | | |',
|
||||
'| الخطوة | | |',
|
||||
'| الملاحظات | | |',
|
||||
'',
|
||||
'## الأدوات والصلاحيات',
|
||||
'- الأداة: الصلاحية الممنوحة: التأكيد البشري المطلوب: ',
|
||||
'',
|
||||
'## سجل التدقيق',
|
||||
'- الوقت: الفعل: النتيجة: ',
|
||||
].join('\n'),
|
||||
},
|
||||
'pre-run-checklist.md': {
|
||||
name: 'anwarpi-pre-run-checklist.md',
|
||||
type: 'text/markdown;charset=utf-8',
|
||||
body: [
|
||||
'# قائمة الفحص قبل التشغيل',
|
||||
'',
|
||||
'- [ ] الهدف مكتوب في جملة واحدة قابلة للقياس',
|
||||
'- [ ] شرط توقف واضح يمنع الحلقة اللانهائية',
|
||||
'- [ ] كل أداة لها صلاحية محددة وشخص مسؤول عن الموافقة',
|
||||
'- [ ] بيانات الاختبار جاهزة وليست بيانات إنتاج',
|
||||
'- [ ] سجل تدقيق يكتب كل فعل يقوم به الوكيل',
|
||||
'- [ ] سقف تكلفة وزمن محدد لكل تشغيل',
|
||||
'- [ ] خطة مراجعة بشرية للمخرجات قبل نشرها',
|
||||
].join('\n'),
|
||||
},
|
||||
'error-log.csv': {
|
||||
name: 'anwarpi-error-log.csv',
|
||||
type: 'text/csv;charset=utf-8',
|
||||
body: '\uFEFF' + [
|
||||
'الوقت,المرحلة,نوع الخطأ,الوصف,الإجراء',
|
||||
',استرجاع,,,',
|
||||
',تنفيذ,,,',
|
||||
',تحقق,,,',
|
||||
].join('\n'),
|
||||
},
|
||||
}
|
||||
const dlHandler = (e: Event) => {
|
||||
const b = e.currentTarget as HTMLButtonElement
|
||||
const file = DL_FILES[b.dataset.dl ?? '']
|
||||
if (file) {
|
||||
const blob = new Blob([file.body], { type: file.type })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.name
|
||||
document.body.append(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
b.textContent = 'تم التنزيل ✓'
|
||||
setTimeout(() => { b.textContent = 'تنزيل' }, 1600)
|
||||
}
|
||||
document.querySelectorAll('[data-dl]').forEach(b => b.addEventListener('click', dlHandler))
|
||||
|
||||
return () => {
|
||||
save?.removeEventListener('click', onSave)
|
||||
document.querySelectorAll('[data-dl]').forEach(b => b.removeEventListener('click', dlHandler))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
if (!form.checkValidity()) {
|
||||
setStatus('اكتب تعليقاً من 8 أحرف على الأقل.')
|
||||
form.reportValidity()
|
||||
return
|
||||
}
|
||||
const text = (form.elements.namedItem('comment') as HTMLTextAreaElement).value.trim()
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${slug}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text }),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (res.status === 201) {
|
||||
form.reset()
|
||||
setStatus('شكراً — وصل تعليقك وسيظهر بعد المراجعة.')
|
||||
} else if (res.status === 401) {
|
||||
setStatus('سجّل الدخول كي تعلّق.')
|
||||
} else {
|
||||
setStatus(data?.error || 'تعذّر الإرسال — حاول مجدداً.')
|
||||
}
|
||||
} catch {
|
||||
setStatus('تعذّر الإرسال — حاول مجدداً.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
return (
|
||||
<div className="comment-gate">
|
||||
<p>سجّل الدخول لتشارك سؤالك أو تجربتك مع هذا الدليل.</p>
|
||||
<a className="btn btn-quiet" href={`/login?next=/posts/${slug}`}>سجّل الدخول للتعليق</a>
|
||||
<p>لا تملك حساباً؟ <a href={`/signup?next=/posts/${slug}`}>أنشئ حساباً مجاناً</a></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="comment-form" id="commentForm" method="post" noValidate onSubmit={onSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="commentBody">تعليقك</label>
|
||||
<textarea className="textarea" id="commentBody" name="comment" required minLength={8} maxLength={2000}></textarea>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" disabled={busy}>{busy ? 'جارٍ الإرسال…' : 'أرسل التعليق'}</button>
|
||||
<p className="status" id="commentStatus" role="status">{status}</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import '../styles/privacy.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'الخصوصية' }
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div id="site" className="site-page page-privacy">
|
||||
<Topnav />
|
||||
<main id="content">
|
||||
<section className="hero" data-od-id="privacy-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">الخصوصية · مسودة ما قبل الإطلاق</p>
|
||||
<h1 data-od-id="privacy-title">بياناتك ليست مادة للمحتوى.</h1>
|
||||
<p className="lead">توضح هذه المسودة الحد الأدنى المقترح لجمع البيانات واستخدامها داخل أنور بآي.</p>
|
||||
<p className="notice" role="note">هذه سياسة تجريبية للواجهة وليست نصاً قانونياً نهائياً. يجب مراجعتها واعتمادها قبل نشر الموقع.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container policy" data-od-id="privacy-content">
|
||||
<section data-od-id="privacy-data"><h2>ما الذي نجمعه؟</h2><p>عند تشغيل الحسابات فعلياً قد نحتاج إلى الاسم والبريد الإلكتروني، وبيانات العضوية والفوترة التي يوفرها مزوّد الدفع. لا ينبغي تخزين بيانات البطاقة كاملة داخل المنصة.</p></section>
|
||||
<section data-od-id="privacy-use"><h2>كيف تُستخدم البيانات؟</h2><ul><li>تسجيل الدخول وحفظ التقدّم والتفضيلات.</li><li>إدارة العضوية وإرسال الرسائل التي اختارها المستخدم.</li><li>حماية المنصة وتشخيص الأعطال وتحسين تجربة القراءة.</li></ul></section>
|
||||
<section data-od-id="privacy-sharing"><h2>المشاركة والاحتفاظ</h2><p>لا تُباع البيانات الشخصية. قبل الإطلاق يجب تسمية مزوّدي الاستضافة والبريد والدفع والتحليلات، وتحديد مدة الاحتفاظ بكل نوع من البيانات بوضوح.</p></section>
|
||||
<section data-od-id="privacy-rights"><h2>حقوقك</h2><p>يمكنك طلب نسخة من بياناتك أو تصحيحها أو حذفها بعد تفعيل الحسابات. أرسل طلبك إلى <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>.</p></section>
|
||||
<section data-od-id="privacy-updates"><h2>التحديثات</h2><p>يجب إضافة تاريخ السريان وملخص أي تغيير جوهري عند اعتماد النسخة القانونية النهائية.</p></section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = { title: 'إنشاء حساب' }
|
||||
|
||||
export default function SignupLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { safeNext } from '@/lib/next'
|
||||
import '../styles/signup.css'
|
||||
|
||||
/* Signup — markup from signup.html, wired to real Payload registration
|
||||
(create + auto-login) instead of the simulated client-only flow. */
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [nextPath, setNextPath] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setNextPath(safeNext(new URLSearchParams(window.location.search).get('next')))
|
||||
}, [])
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const data = new FormData(form)
|
||||
const confirm = form.elements.namedItem('confirmPassword') as HTMLInputElement
|
||||
confirm.setCustomValidity(data.get('password') === data.get('confirmPassword') ? '' : 'كلمتا المرور غير متطابقتان.')
|
||||
if (!form.checkValidity()) {
|
||||
setStatus(data.get('password') !== data.get('confirmPassword') ? 'كلمتا المرور غير متطابقتان.' : 'تحقق من جميع الحقول المطلوبة.')
|
||||
form.reportValidity()
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
const res = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: data.get('email'), password: data.get('password'), fullName: data.get('name'), termsVersion: '2026-10-03' }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null)
|
||||
setStatus(err?.errors?.[0]?.message || 'تعذّر إنشاء الحساب — قد يكون البريد مستخدماً بالفعل.')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const login = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
|
||||
})
|
||||
if (login.ok) {
|
||||
try { sessionStorage.setItem('anwarpi:signup-email', String(data.get('email'))) } catch { /* private mode */ }
|
||||
try { nextPath ? sessionStorage.setItem('anwarpi:signup-next', nextPath) : sessionStorage.removeItem('anwarpi:signup-next') } catch { /* private mode */ }
|
||||
router.refresh()
|
||||
router.push('/verify-email')
|
||||
return
|
||||
}
|
||||
setStatus('تم إنشاء الحساب — سجّل الدخول للمتابعة.')
|
||||
router.push(nextPath ? '/login?next=' + encodeURIComponent(nextPath) : '/login')
|
||||
} catch {
|
||||
setStatus('تعذّر الاتصال. حاول مرة أخرى بعد التحقق من اتصالك.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page page-signup">
|
||||
<Topnav />
|
||||
<main id="content" className="auth">
|
||||
<section className="auth-story">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="auth-mark" src="/brand/mark-light.svg" alt="" width={436} height={382} />
|
||||
<h1>ابدأ بحساب مجاني.</h1>
|
||||
<p>الحساب المجاني يفتح كل الأدلة المجانية كاملة والتعليق عليها. يمكنك الاشتراك في العضوية لاحقاً إن احتجتها.</p>
|
||||
</section>
|
||||
<section className="auth-form-wrap" data-od-id="signup-form-section">
|
||||
<form className="auth-form" id="signupForm" method="post" action="/api/users" noValidate onSubmit={onSubmit} data-od-id="signup-form">
|
||||
<p className="eyebrow">حساب جديد</p>
|
||||
<h2 data-od-id="signup-title">إنشاء حساب</h2>
|
||||
<p className="lead">أدخل بياناتك الأساسية للبدء.</p>
|
||||
<div className="field"><label htmlFor="name">الاسم</label><input className="input" id="name" name="name" type="text" autoComplete="name" minLength={2} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل اسمك.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
|
||||
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
|
||||
<div className="field"><label htmlFor="password">كلمة المرور</label><div className="input-wrap"><input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="new-password" minLength={8} aria-describedby="passwordHint" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة مرور من ثمانية أحرف على الأقل.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className="fine" id="passwordHint">ثمانية أحرف على الأقل.</span></div>
|
||||
<div className="field"><label htmlFor="confirmPassword">تأكيد كلمة المرور</label><div className="input-wrap"><input className="input" id="confirmPassword" name="confirmPassword" type={showConfirm ? 'text' : 'password'} autoComplete="new-password" minLength={8} required onInvalid={e => e.currentTarget.setCustomValidity('أعد إدخال كلمة المرور.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="confirmPassword" onClick={() => setShowConfirm(v => !v)}>{showConfirm ? 'إخفاء' : 'إظهار'}</button></div></div>
|
||||
<label className="check"><input id="terms" name="terms" type="checkbox" required /><span>أوافق على <Link href="/privacy">سياسة الخصوصية</Link> و<Link href="/terms">شروط الاستخدام</Link>.</span></label>
|
||||
<button className="btn btn-primary" type="submit" disabled={busy} data-od-id="create-account-cta">{busy ? 'جارٍ إنشاء الحساب…' : 'أنشئ حسابي'}</button>
|
||||
<div className="status" id="signupStatus" role="status" aria-live="polite">{status}</div>
|
||||
<p className="signin">لديك حساب بالفعل؟ <Link href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : '/login'}>سجّل الدخول</Link></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/* Page-scoped styles: .page-about. Shared tokens live in globals.css. */
|
||||
.page-about *,.page-about *::before,.page-about *::after{box-sizing:border-box}
|
||||
.page-about{-webkit-text-size-adjust:100%}
|
||||
.page-about{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
|
||||
.page-about img,.page-about svg{display:block;max-width:100%;height:auto}
|
||||
.page-about a{color:inherit;text-decoration:none}
|
||||
.page-about h1,.page-about h2,.page-about h3{font-family:var(--font-display);text-wrap:balance}
|
||||
.page-about p{text-wrap:pretty}
|
||||
.page-about :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-about .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-about .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-about .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-about .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}
|
||||
.page-about .logo-mark{width:30px;height:34px;object-fit:contain;flex:none;border-radius:4px}
|
||||
.page-about .nav-links{display:flex;align-items:center;gap:28px}
|
||||
.page-about .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
|
||||
.page-about .nav-links a:hover,.page-about .nav-links a[aria-current="page"]{color:var(--fg)}
|
||||
.page-about .hero{padding-block:clamp(70px,10vw,132px);border-bottom:1px solid var(--border)}
|
||||
.page-about .hero-grid{display:grid;grid-template-columns:1.25fr .75fr;gap:clamp(48px,9vw,112px);align-items:flex-end}
|
||||
.page-about .eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 22px}
|
||||
.page-about .hero h1{font-size:clamp(48px,7.5vw,92px);line-height:1.06;max-width:10ch;margin:0}
|
||||
.page-about .lead{font-size:clamp(19px,2vw,23px);line-height:1.75;color:var(--muted);max-width:54ch;margin:28px 0 0}
|
||||
.page-about .hero-note{border-top:1px solid var(--fg);padding-top:24px}
|
||||
.page-about .hero-note strong{display:block;font-size:clamp(24px,3vw,34px);line-height:1.35}
|
||||
.page-about .hero-note p{color:var(--muted);margin:10px 0 0}
|
||||
.page-about .team{padding-block:clamp(64px,9vw,104px);border-bottom:1px solid var(--border)}
|
||||
.page-about .section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:40px;margin-bottom:46px}
|
||||
.page-about .section-head h2{font-size:clamp(38px,5vw,62px);line-height:1.12;max-width:12ch;margin:0}
|
||||
.page-about .section-head p{color:var(--muted);max-width:48ch;margin:0}
|
||||
.page-about .profile-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:42px;border-top:1px solid var(--fg)}
|
||||
.page-about .profile{padding:34px 0 0;display:flex;flex-direction:column;min-width:0}
|
||||
.page-about .profile+.profile{padding-inline-start:42px;border-inline-start:1px solid var(--border)}
|
||||
.page-about .profile-label{font:12px/1.5 var(--font-mono);color:var(--muted)}
|
||||
.page-about .profile-main .profile-label{color:var(--accent)}
|
||||
.page-about .profile h3{font-size:clamp(32px,4vw,48px);line-height:1.15;margin:20px 0 8px}
|
||||
.page-about .role{color:var(--fg);font-weight:700;margin:0 0 22px}
|
||||
.page-about .bio{color:var(--muted);font-size:17px;margin:0;max-width:58ch}
|
||||
.page-about .topics{display:flex;flex-wrap:wrap;gap:8px;margin:28px 0 32px}
|
||||
.page-about .topic{display:inline-flex;align-items:center;min-height:34px;padding:6px 12px;border:1px solid var(--border);color:var(--muted);font-size:13px}
|
||||
.page-about .profile-link{min-height:50px;display:inline-flex;align-items:center;justify-content:space-between;gap:20px;margin-top:auto;padding:12px 16px;border:1px solid var(--fg);font-weight:700}
|
||||
.page-about .profile-link:hover{background:var(--fg);color:var(--surface)}
|
||||
.page-about .principles{padding-block:clamp(64px,9vw,104px)}
|
||||
.page-about .principle-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:clamp(48px,9vw,112px);align-items:flex-start}
|
||||
.page-about .principle-grid h2{font-size:clamp(38px,5vw,60px);line-height:1.14;margin:0;max-width:11ch}
|
||||
.page-about .principle-list{border-top:1px solid var(--fg)}
|
||||
.page-about .principle{display:grid;grid-template-columns:56px 1fr;gap:20px;padding:26px 0;border-bottom:1px solid var(--border)}
|
||||
.page-about .principle-index{font:12px/1.5 var(--font-mono);color:var(--muted);padding-top:7px}
|
||||
.page-about .principle h3{font-size:24px;margin:0 0 8px}
|
||||
.page-about .principle p{color:var(--muted);margin:0}
|
||||
.page-about .pagefoot{padding-block:50px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-about .footer-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;flex-wrap:wrap}
|
||||
.page-about .footer-links{display:flex;gap:20px;flex-wrap:wrap}
|
||||
.page-about .footer-links a{min-height:44px;display:inline-flex;align-items:center}
|
||||
.page-about .footer-links a:hover{color:var(--fg);text-decoration:underline;text-underline-offset:4px}
|
||||
@media(max-width:920px){
|
||||
.page-about .hero-grid,.page-about .profile-grid,.page-about .principle-grid{grid-template-columns:1fr}
|
||||
.page-about .profile-grid{gap:0}
|
||||
.page-about .profile+.profile{padding-inline-start:0;border-inline-start:0;border-top:1px solid var(--border);margin-top:42px;padding-top:42px}
|
||||
.page-about .section-head{align-items:flex-start;flex-direction:column}
|
||||
.page-about .hero-note{max-width:560px}
|
||||
}
|
||||
@media(max-width:620px){
|
||||
.page-about .hero{padding-block:54px}
|
||||
.page-about .team,.page-about .principles{padding-block:58px}
|
||||
.page-about .profile h3{font-size:36px}
|
||||
.page-about .principle{grid-template-columns:42px 1fr}
|
||||
.page-about .footer-bottom,.page-about .footer-links{align-items:stretch;flex-direction:column;gap:8px}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){.page-about *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
|
||||
/* Matched team cards, independent of generic card and heading overrides. */
|
||||
#site.page-about .section-head { display: grid; grid-template-columns: 1fr 1fr; align-items: start; gap: 40px; margin-bottom: 32px; }
|
||||
#site.page-about .section-head h2 { max-width: none; margin: 0; font-size: clamp(28px, 3vw, 40px); }
|
||||
#site.page-about .section-head p { max-width: none; margin: 0; line-height: 1.9; }
|
||||
#site.page-about .profile-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; border: 0; align-items: stretch; }
|
||||
#site.page-about .profile, #site.page-about .profile + .profile { margin: 0; padding: 32px; border: 1px solid var(--border); background: var(--surface); border-radius: 16px; }
|
||||
#site.page-about .profile-label { font: 13px/1.8 var(--font-body); color: var(--accent); min-height: 24px; }
|
||||
#site.page-about .profile h3 { font-size: 32px; line-height: 1.6; margin: 16px 0 4px; }
|
||||
#site.page-about .profile .role { font-size: 16px; line-height: 1.8; margin: 0 0 20px; }
|
||||
#site.page-about .profile .bio { font-size: 16px; line-height: 2; max-width: none; }
|
||||
#site.page-about .topics { margin: 24px 0; gap: 8px; }
|
||||
#site.page-about .topic { border-radius: 24px; background: var(--accent-soft); border-color: transparent; color: var(--muted); }
|
||||
#site.page-about .profile-link { margin-top: auto; border: 1px solid var(--border); border-radius: 8px; color: var(--accent); font-size: 14px; }
|
||||
#site.page-about .profile-link:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
||||
@media (max-width: 760px) {
|
||||
#site.page-about .section-head, #site.page-about .profile-grid { grid-template-columns: minmax(0, 1fr); gap: 24px; }
|
||||
#site.page-about .profile, #site.page-about .profile + .profile { padding: 24px; }
|
||||
#site.page-about .profile h3 { font-size: 28px; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* Page-scoped styles: .page-account-settings. Shared tokens live in globals.css. */
|
||||
.page-account-settings *,.page-account-settings *::before,.page-account-settings *::after{box-sizing:border-box} .page-account-settings{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-account-settings{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-account-settings.locked{overflow:hidden}
|
||||
.page-account-settings a{color:inherit;text-decoration:none} .page-account-settings button,.page-account-settings input{font:inherit} .page-account-settings button{cursor:pointer} .page-account-settings h1,.page-account-settings h2,.page-account-settings h3{font-family:var(--font-display);text-wrap:balance} .page-account-settings p{text-wrap:pretty} .page-account-settings :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-account-settings .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-account-settings .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-account-settings .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-account-settings .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-account-settings .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-account-settings .nav-links{display:flex;align-items:center;gap:28px}.page-account-settings .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-account-settings .nav-links a:hover{color:var(--fg)}
|
||||
.page-account-settings .nav-tools{display:flex;align-items:center;gap:8px}
|
||||
.page-account-settings .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-account-settings .nav-account:hover{background:var(--accent);border-color:var(--accent)}
|
||||
.page-account-settings .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-account-settings .icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-account-settings .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-account-settings .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
|
||||
.page-account-settings .demo-strip b{color:var(--surface);font-weight:700}
|
||||
.page-account-settings .page-head{padding-block:clamp(48px,7vw,84px) 0}
|
||||
.page-account-settings .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}
|
||||
.page-account-settings .page-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0}
|
||||
.page-account-settings .page-head .sub{color:var(--muted);margin:16px 0 0;max-width:52ch}
|
||||
.page-account-settings .settings-layout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:clamp(40px,7vw,90px);align-items:start;padding-block:56px 90px}
|
||||
.page-account-settings .settings-nav{position:sticky;top:100px;border-top:1px solid var(--fg)}
|
||||
.page-account-settings .settings-nav button{width:100%;min-height:52px;display:flex;justify-content:space-between;align-items:center;gap:12px;background:transparent;border:0;border-bottom:1px solid var(--border);color:var(--muted);font-size:16px;text-align:right;padding:0 4px}
|
||||
.page-account-settings .settings-nav button:hover{color:var(--fg)}
|
||||
.page-account-settings .settings-nav button[aria-selected="true"]{color:var(--fg);font-weight:700}
|
||||
.page-account-settings .settings-nav button[aria-selected="true"]::after{content:"";width:8px;height:8px;background:var(--accent);transform:rotate(45deg)}
|
||||
.page-account-settings .panel{display:none}
|
||||
.page-account-settings .panel.active{display:block}
|
||||
.page-account-settings .panel h2{font-size:clamp(30px,3.4vw,42px);line-height:1.2;margin:0 0 10px}
|
||||
.page-account-settings .panel .panel-sub{color:var(--muted);max-width:56ch;margin:0 0 34px}
|
||||
.page-account-settings .form-grid{display:grid;gap:24px;max-width:560px}
|
||||
.page-account-settings .field{display:grid;gap:7px}
|
||||
.page-account-settings .field label{font-size:13px;color:var(--muted)}
|
||||
.page-account-settings .field .hint{font-size:12px;color:var(--muted)}
|
||||
.page-account-settings .input{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;min-height:48px}
|
||||
.page-account-settings .input:hover{border-color:var(--muted)}
|
||||
.page-account-settings .input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.page-account-settings .input[disabled]{background:var(--fg-soft);color:var(--muted);cursor:not-allowed}
|
||||
.page-account-settings .input.invalid{border-color:var(--accent)}
|
||||
.page-account-settings .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-account-settings .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-account-settings .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
|
||||
.page-account-settings .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}.page-account-settings .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-account-settings .status{font-size:14px;min-height:24px;display:flex;align-items:center;gap:8px}
|
||||
.page-account-settings .status.ok{color:var(--accent);font-weight:700}
|
||||
.page-account-settings .status.err{color:var(--accent)}
|
||||
.page-account-settings .pw-rules{list-style:none;padding:0;margin:4px 0 0;display:grid;gap:4px}
|
||||
.page-account-settings .pw-rules li{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px}
|
||||
.page-account-settings .pw-rules li::before{content:"";width:7px;height:7px;border:1px solid var(--muted);transform:rotate(45deg);flex:none}
|
||||
.page-account-settings .pw-rules li.ok{color:var(--fg)}
|
||||
.page-account-settings .pw-rules li.ok::before{background:var(--accent);border-color:var(--accent)}
|
||||
.page-account-settings .prefs{border-top:1px solid var(--fg);max-width:640px}
|
||||
.page-account-settings .pref-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px 0;border-bottom:1px solid var(--border)}
|
||||
.page-account-settings .pref-row strong{display:block;font-size:17px}
|
||||
.page-account-settings .pref-row p{margin:4px 0 0;color:var(--muted);font-size:14px;max-width:44ch}
|
||||
.page-account-settings .switch{position:relative;width:52px;min-height:32px;flex:none}
|
||||
.page-account-settings .switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
|
||||
.page-account-settings .switch .track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none;transition:background .15s,border-color .15s}
|
||||
.page-account-settings .switch input:checked + .track{background:var(--accent);border-color:var(--accent)}
|
||||
.page-account-settings .switch .track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s}
|
||||
.page-account-settings .switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)}
|
||||
.page-account-settings .switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-account-settings .membership-card{border:1px solid var(--fg);padding:clamp(24px,4vw,38px);max-width:640px}
|
||||
.page-account-settings .membership-head{display:flex;justify-content:space-between;align-items:start;gap:18px;padding-bottom:22px;border-bottom:1px solid var(--border)}
|
||||
.page-account-settings .membership-head h3{font-size:28px;margin:0}
|
||||
.page-account-settings .state-chip{font:11px/1.5 var(--font-mono);padding:6px 12px;border:1px solid var(--accent);color:var(--accent);white-space:nowrap}
|
||||
.page-account-settings .state-chip.cancelled{border-color:var(--muted);color:var(--muted)}
|
||||
.page-account-settings .plan-details{display:grid;grid-template-columns:repeat(2,1fr);gap:20px 32px;padding-block:24px}
|
||||
.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 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}
|
||||
.page-account-settings .cancel-zone h3{font-size:22px;margin:0 0 6px}
|
||||
.page-account-settings .cancel-zone p{color:var(--muted);font-size:14px;margin:0 0 16px;max-width:52ch}
|
||||
.page-account-settings .billing{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
|
||||
.page-account-settings .billing h3{font-size:22px;margin:0 0 6px}
|
||||
.page-account-settings .billing-sub{color:var(--muted);font-size:14px;margin:0 0 18px;max-width:52ch}
|
||||
.page-account-settings .billing-table{border:1px solid var(--border)}
|
||||
.page-account-settings .billing-row{display:grid;grid-template-columns:1.1fr 1.5fr .7fr .8fr auto;gap:12px;align-items:center;padding:13px 18px;border-bottom:1px solid var(--border);font-size:14px}
|
||||
.page-account-settings .billing-row:last-child{border-bottom:0}
|
||||
.page-account-settings .billing-row.head{font:11px/1.6 var(--font-mono);color:var(--muted);padding:10px 18px}
|
||||
.page-account-settings .billing-row .amt,.page-account-settings .billing-row .bdate{font-family:var(--font-mono);white-space:nowrap}
|
||||
.page-account-settings .billing-row .desc{color:var(--fg)}
|
||||
.page-account-settings .chip{display:inline-block;padding:2px 10px;border:1px solid var(--border);font:11px/1.9 var(--font-mono);color:var(--muted);white-space:nowrap}
|
||||
.page-account-settings .chip.paid{color:var(--fg);border-color:var(--fg)}
|
||||
.page-account-settings .chip.upcoming{color:var(--accent);border-color:var(--accent)}
|
||||
.page-account-settings .chip.voided{color:var(--muted);border-color:var(--border);text-decoration:line-through}
|
||||
.page-account-settings .inv-link{font:12px/1.6 var(--font-mono);color:var(--fg);text-decoration:underline;text-underline-offset:3px}
|
||||
.page-account-settings .inv-link:hover{color:var(--accent)}
|
||||
.page-account-settings .inv-link:disabled{color:var(--muted);text-decoration:none;cursor:default}
|
||||
.page-account-settings .inv-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.page-account-settings .text-danger{background:transparent;border:0;color:var(--accent);font-weight:700;text-decoration:underline;text-underline-offset:4px;min-height:44px;padding:0 4px}
|
||||
.page-account-settings .text-danger:hover{color:color-mix(in oklch,var(--accent) 80%,black)}
|
||||
.page-account-settings .dialog{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-account-settings .dialog.open{visibility:visible;pointer-events:auto}
|
||||
.page-account-settings .dialog-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 58%,transparent);opacity:0;transition:opacity .2s}
|
||||
.page-account-settings .dialog.open .dialog-scrim{opacity:1}
|
||||
.page-account-settings .dialog-panel{position:absolute;inset-inline:0;top:50%;transform:translateY(calc(-50% + 12px));margin-inline:auto;width:min(560px,92vw);background:var(--bg);border:1px solid var(--fg);padding:clamp(26px,4vw,40px);opacity:0;transition:opacity .2s,transform .2s}
|
||||
.page-account-settings .dialog.open .dialog-panel{opacity:1;transform:translateY(-50%)}
|
||||
.page-account-settings .dialog-panel h2{font-size:30px;margin:0 0 12px}
|
||||
.page-account-settings .dialog-panel p{color:var(--muted);margin:0 0 14px}
|
||||
.page-account-settings .dialog-panel .keep-note{border-inline-start:2px solid var(--border);padding-inline-start:14px}
|
||||
.page-account-settings .dialog-actions{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px}
|
||||
.page-account-settings .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-account-settings .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
|
||||
.page-account-settings .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
|
||||
.page-account-settings .footer-col a{display:block;min-height:36px}.page-account-settings .footer-col a:hover{color:var(--fg);text-decoration:underline}
|
||||
.page-account-settings .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-account-settings .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-account-settings .menu-panel.open{visibility:visible;pointer-events:auto}.page-account-settings .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-account-settings .menu-panel.open .menu-scrim{opacity:1}.page-account-settings .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-account-settings .menu-panel.open .menu-drawer{transform:translateX(0)}.page-account-settings .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-account-settings .menu-links{display:flex;flex-direction:column}.page-account-settings .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-account-settings .nav-links{display:none}.page-account-settings .settings-layout{grid-template-columns:1fr}.page-account-settings .settings-nav{position:static;display:grid;grid-template-columns:1fr 1fr;border-top:0;margin-bottom:10px}.page-account-settings .settings-nav button{border:1px solid var(--border)}.page-account-settings .footer-grid{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:620px){.page-account-settings .nav-account .name-label{display:none}.page-account-settings .page-head h1{font-size:44px}.page-account-settings .settings-layout{padding-block:40px 60px}.page-account-settings .footer-grid{grid-template-columns:1fr;gap:28px}.page-account-settings .plan-details{grid-template-columns:1fr}.page-account-settings .dialog-actions{flex-direction:column}.page-account-settings .dialog-actions .btn{width:100%}.page-account-settings .billing-row{grid-template-columns:1fr 1fr;row-gap:2px}.page-account-settings .billing-row.head{display:none}.page-account-settings .billing-row .bdate{grid-column:1}.page-account-settings .billing-row .desc{grid-column:1 / -1;color:var(--muted)}}
|
||||
@media(prefers-reduced-motion:reduce){.page-account-settings *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Page-scoped styles: .page-home. Shared tokens live in globals.css. */
|
||||
.page-home *, .page-home *::before, .page-home *::after { box-sizing: border-box; }
|
||||
.page-home { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
|
||||
.page-home {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.65;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.page-home.modal-open { overflow: hidden; }
|
||||
.page-home img, .page-home svg { display: block; max-width: 100%; }
|
||||
.page-home a { color: inherit; text-decoration: none; }
|
||||
.page-home button, .page-home input { font: inherit; }
|
||||
.page-home button { cursor: pointer; }
|
||||
.page-home p { text-wrap: pretty; }
|
||||
.page-home h1, .page-home h2, .page-home h3 { text-wrap: balance; }
|
||||
.page-home :focus-visible { outline: 3px solid color-mix(in oklch, var(--accent) 55%, transparent); outline-offset: 3px; }
|
||||
.page-home .container { max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
|
||||
.page-home .section { padding-block: clamp(64px, 9vw, var(--gap-2xl)); }
|
||||
.page-home .section + .section { border-top: 1px solid var(--border); }
|
||||
.page-home .stack { display: flex; flex-direction: column; }
|
||||
.page-home .stack > * + * { margin-top: var(--gap-md); }
|
||||
.page-home .row { display: flex; align-items: center; gap: var(--gap-md); }
|
||||
.page-home .row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--gap-md); }
|
||||
.page-home .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gap-lg); }
|
||||
.page-home .grid-2-1 { display: grid; grid-template-columns: 2fr 1fr; gap: var(--gap-xl); align-items: start; }
|
||||
.page-home .h1, .page-home h1 { font-family: var(--font-display); font-size: var(--fs-h1); line-height: 1.08; margin: 0; }
|
||||
.page-home .h2, .page-home h2 { font-family: var(--font-display); font-size: var(--fs-h2); line-height: 1.15; margin: 0; }
|
||||
.page-home .h3, .page-home h3 { font-family: var(--font-display); font-size: var(--fs-h3); line-height: 1.35; margin: 0; }
|
||||
.page-home .lead { font-size: var(--fs-lead); line-height: 1.7; color: var(--muted); max-width: 54ch; margin: 0; }
|
||||
.page-home .eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--accent); margin: 0 0 var(--gap-md); }
|
||||
.page-home .meta { font-family: var(--font-mono); font-size: var(--fs-meta); color: var(--muted); }
|
||||
.page-home .num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.page-home .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-home .topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: var(--gap-lg); }
|
||||
.page-home .logo { display: inline-flex; align-items: center; gap: 10px; font-family: var(--font-display); font-size: 23px; font-weight: 700; }
|
||||
.page-home .logo-mark { width: 12px; height: 12px; background: var(--fg); transform: rotate(45deg); }
|
||||
.page-home .logo:hover { opacity: .72; }
|
||||
.page-home .topnav nav { display: flex; align-items: center; gap: var(--gap-lg); }
|
||||
.page-home .topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); font-size: 14px; border-bottom: 1px solid transparent; }
|
||||
.page-home .topnav nav a:hover { color: var(--fg); border-color: var(--fg); }
|
||||
.page-home .nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); background: transparent; color: var(--fg); }
|
||||
.page-home .nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||
.page-home .nav-tools { display: flex; align-items: center; gap: 8px; }
|
||||
.page-home .menu-trigger { width: 46px; height: 46px; display: grid; place-items: center; border: 1px solid var(--fg); background: var(--fg); color: var(--surface); }
|
||||
.page-home .menu-trigger:hover { background: var(--accent); border-color: var(--accent); color: var(--surface); }
|
||||
.page-home .menu-trigger svg { width: 20px; height: 20px; }
|
||||
.page-home .menu-panel { position: fixed; inset: 0; z-index: 60; visibility: hidden; pointer-events: none; }
|
||||
.page-home .menu-panel.open { visibility: visible; pointer-events: auto; }
|
||||
.page-home .menu-scrim { position: absolute; inset: 0; border: 0; background: color-mix(in oklch, var(--fg) 55%, transparent); opacity: 0; transition: opacity .2s ease; }
|
||||
.page-home .menu-panel.open .menu-scrim { opacity: 1; }
|
||||
.page-home .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-home .menu-panel.open .menu-drawer { transform: translateX(0); }
|
||||
.page-home .menu-head { display: flex; align-items: center; justify-content: space-between; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
|
||||
.page-home .menu-links { display: grid; margin-top: 34px; }
|
||||
.page-home .menu-links a { min-height: 70px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); font-family: var(--font-display); font-size: clamp(27px, 4vw, 38px); }
|
||||
.page-home .menu-links a:hover { color: var(--accent); }
|
||||
.page-home .menu-foot { margin-top: auto; padding-top: 24px; color: var(--muted); }
|
||||
|
||||
.page-home .btn { min-height: 46px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 11px 20px; border-radius: var(--radius); border: 1px solid transparent; font-weight: 700; transition: transform .12s ease, background .16s ease, border-color .16s ease; }
|
||||
.page-home .btn:active { transform: translateY(1px); }
|
||||
.page-home .btn-primary { background: var(--accent); color: var(--surface); border-color: var(--accent); }
|
||||
.page-home .btn-primary:hover { background: color-mix(in oklch, var(--accent) 86%, black); color: var(--surface); }
|
||||
.page-home .btn-secondary { background: transparent; color: var(--fg); border-color: var(--border); }
|
||||
.page-home .btn-secondary:hover { background: var(--fg); color: var(--surface); border-color: var(--fg); }
|
||||
.page-home .btn-ghost { background: transparent; color: var(--fg); border-color: transparent; padding-inline: 8px; }
|
||||
.page-home .btn-ghost:hover { border-color: var(--fg); }
|
||||
|
||||
.page-home .hero { padding-block: clamp(56px, 8vw, 112px); overflow: hidden; }
|
||||
.page-home .hero-split { display: grid; grid-template-columns: .85fr 1.15fr; gap: clamp(42px, 7vw, 96px); align-items: center; }
|
||||
.page-home .hero-copy h1 { max-width: 9ch; }
|
||||
.page-home .hero-copy .lead { margin-top: 24px; }
|
||||
.page-home .hero-cta { display: flex; gap: var(--gap-sm); flex-wrap: wrap; margin-top: 32px; }
|
||||
.page-home .trust-line { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
|
||||
|
||||
.page-home .deck { position: relative; min-height: 510px; }
|
||||
.page-home .deck-frame { position: relative; min-height: 470px; border: 1px solid var(--fg); background: var(--surface); overflow: hidden; }
|
||||
.page-home .deck-slide { position: absolute; inset: 0; padding: clamp(28px, 4vw, 52px); display: none; grid-template-rows: auto 1fr auto; }
|
||||
.page-home .deck-slide.active { display: grid; }
|
||||
.page-home .deck-slide::after { content: ""; position: absolute; inset-inline-start: 0; bottom: 0; width: 100%; height: 4px; background: var(--fg); }
|
||||
.page-home .deck-slide-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.page-home .deck-slide-kicker { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
|
||||
.page-home .deck-slide-index { font-family: var(--font-display); font-size: 30px; }
|
||||
.page-home .deck-slide-body { align-self: center; }
|
||||
.page-home .deck-slide-body h2 { font-size: clamp(38px, 5vw, 66px); max-width: 9ch; }
|
||||
.page-home .deck-slide-body p { color: var(--muted); max-width: 38ch; margin: 18px 0 0; }
|
||||
.page-home .deck-slide-foot { display: flex; align-items: end; justify-content: space-between; gap: 20px; border-top: 1px solid var(--border); padding-top: 18px; }
|
||||
.page-home .deck-slide-format { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
.page-home .deck-slide-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); }
|
||||
.page-home .deck-slide-link:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.page-home .play { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--fg); border-radius: 50%; }
|
||||
.page-home .deck-controls { min-height: 44px; display: flex; justify-content: space-between; align-items: center; margin-top: 14px; }
|
||||
.page-home .deck-dots { display: flex; gap: 8px; }
|
||||
.page-home .deck-dot { width: 34px; height: 4px; border: 0; padding: 0; background: var(--border); }
|
||||
.page-home .deck-dot.active { background: var(--fg); }
|
||||
.page-home .deck-arrows { display: flex; gap: 8px; direction: ltr; }
|
||||
.page-home .icon-btn { width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid var(--border); background: transparent; color: var(--fg); }
|
||||
.page-home .icon-btn:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||
|
||||
.page-home .topic-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-block: 1px solid var(--border); }
|
||||
.page-home .topic { min-height: 132px; padding: 24px; border-inline-start: 1px solid var(--border); display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.page-home .topic:first-child { border-inline-start: 0; }
|
||||
.page-home .topic strong { font-family: var(--font-display); font-size: 22px; }
|
||||
.page-home .topic span { color: var(--muted); font-size: 13px; }
|
||||
.page-home .topic:hover { background: var(--fg-soft); }
|
||||
|
||||
.page-home .section-head { margin-bottom: 42px; align-items: end; }
|
||||
.page-home .section-head > div { max-width: 54ch; }
|
||||
.page-home .filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 34px; }
|
||||
.page-home .filter { min-height: 44px; padding: 9px 16px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
|
||||
.page-home .filter:hover { color: var(--fg); border-color: var(--fg); }
|
||||
.page-home .filter.active { background: var(--fg); color: var(--surface); border-color: var(--fg); }
|
||||
.page-home .catalog { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 28px; border-top: 1px solid var(--fg); }
|
||||
.page-home .catalog-card { padding-block: 28px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; min-height: 330px; }
|
||||
.page-home .catalog-card[hidden] { display: none; }
|
||||
.page-home .cover { min-height: 142px; padding: 20px; margin-bottom: 22px; background: var(--fg); color: var(--surface); display: flex; flex-direction: column; justify-content: space-between; position: relative; overflow: hidden; }
|
||||
.page-home .cover::before { content: ""; position: absolute; width: 120px; height: 120px; border: 1px solid color-mix(in oklch, var(--surface) 28%, transparent); border-radius: 50%; inset-inline-end: -28px; top: -35px; }
|
||||
.page-home .cover-light { background: var(--border); color: var(--fg); }
|
||||
.page-home .cover-lined { background: repeating-linear-gradient(0deg, var(--surface), var(--surface) 23px, var(--border) 24px); color: var(--fg); border: 1px solid var(--border); }
|
||||
.page-home .cover-label { position: relative; font-family: var(--font-mono); font-size: 10px; }
|
||||
.page-home .cover-title { position: relative; font-family: var(--font-display); font-size: 27px; line-height: 1.2; max-width: 11ch; }
|
||||
.page-home .card-meta { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; }
|
||||
.page-home .catalog-card h3 { font-size: 24px; }
|
||||
.page-home .catalog-card p { color: var(--muted); margin: 9px 0 20px; font-size: 14px; }
|
||||
.page-home .card-link { margin-top: auto; min-height: 44px; display: inline-flex; align-items: center; justify-content: space-between; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--fg); padding: 14px 0 0; font-weight: 700; text-align: start; }
|
||||
.page-home .card-link:hover { border-color: var(--fg); }
|
||||
.page-home .card-link span:last-child { direction: ltr; }
|
||||
.page-home .empty { display: none; padding: 40px 0; color: var(--muted); }
|
||||
.page-home .empty.show { display: block; }
|
||||
|
||||
.page-home .membership { background: var(--fg); color: var(--surface); }
|
||||
.page-home .membership .eyebrow { color: var(--surface); opacity: .7; }
|
||||
.page-home .membership .lead { color: color-mix(in oklch, var(--surface) 70%, transparent); }
|
||||
.page-home .membership-grid { display: grid; grid-template-columns: 1.35fr .65fr; gap: var(--gap-xl); align-items: end; }
|
||||
.page-home .membership-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid color-mix(in oklch, var(--surface) 25%, transparent); }
|
||||
.page-home .membership-list li { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid color-mix(in oklch, var(--surface) 15%, transparent); }
|
||||
.page-home .membership .btn-primary { background: var(--surface); color: var(--fg); border-color: var(--surface); margin-top: 24px; width: 100%; }
|
||||
.page-home .membership .btn-primary:hover { background: var(--border); color: var(--fg); }
|
||||
|
||||
.page-home .pagefoot { padding-block: var(--gap-xl); color: var(--muted); font-size: 13px; border-top: 1px solid var(--border); }
|
||||
.page-home .footer-grid { display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: var(--gap-xl); padding-bottom: var(--gap-xl); }
|
||||
.page-home .footer-brand p { max-width: 34ch; margin: 14px 0 0; }
|
||||
.page-home .footer-col strong { display: block; color: var(--fg); margin-bottom: 12px; }
|
||||
.page-home .footer-col a { display: block; min-height: 36px; }
|
||||
.page-home .footer-col a:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 4px; }
|
||||
.page-home .footer-bottom { padding-top: 22px; border-top: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.page-home .modal { position: fixed; inset: 0; z-index: 50; display: none; place-items: center; padding: 24px; background: color-mix(in oklch, var(--fg) 62%, transparent); }
|
||||
.page-home .modal.open { display: grid; }
|
||||
.page-home .modal-panel { width: min(620px, 100%); max-height: min(680px, 90vh); overflow: auto; background: var(--surface); color: var(--fg); border: 1px solid var(--fg); padding: clamp(26px, 5vw, 52px); }
|
||||
.page-home .modal-close { float: left; margin: -14px -14px 20px 0; }
|
||||
.page-home .modal-panel h2 { clear: both; font-size: clamp(32px, 5vw, 50px); }
|
||||
.page-home .modal-panel p { color: var(--muted); }
|
||||
.page-home .modal-outline { margin: 28px 0; padding: 0; list-style: none; border-top: 1px solid var(--border); }
|
||||
.page-home .modal-outline li { padding: 13px 0; border-bottom: 1px solid var(--border); }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.page-home .hero-split, .page-home .grid-3, .page-home .grid-2-1, .page-home .membership-grid { grid-template-columns: 1fr; }
|
||||
.page-home .topnav nav { display: none; }
|
||||
.page-home .footer-grid { grid-template-columns: 1fr 1fr; }
|
||||
.page-home .hero-copy h1 { max-width: 11ch; }
|
||||
.page-home .deck { min-height: 440px; }
|
||||
.page-home .deck-frame { min-height: 400px; }
|
||||
.page-home .topic-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.page-home .topic:nth-child(3) { border-inline-start: 0; border-top: 1px solid var(--border); }
|
||||
.page-home .topic:nth-child(4) { border-top: 1px solid var(--border); }
|
||||
.page-home .catalog { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.page-home .nav-action { padding-inline: 12px; }
|
||||
.page-home .hero { padding-top: 42px; }
|
||||
.page-home .deck-slide { padding: 26px; }
|
||||
.page-home .deck-slide-body h2 { font-size: 38px; }
|
||||
.page-home .deck-slide-foot { align-items: start; flex-direction: column; }
|
||||
.page-home .topic-grid, .page-home .catalog { grid-template-columns: 1fr; }
|
||||
.page-home .topic { min-height: 104px; border-inline-start: 0; border-top: 1px solid var(--border); }
|
||||
.page-home .topic:first-child { border-top: 0; }
|
||||
.page-home .section-head { align-items: start; flex-direction: column; }
|
||||
.page-home .catalog-card { min-height: auto; }
|
||||
.page-home .nav-action { display: none; }
|
||||
.page-home .footer-grid { grid-template-columns: 1fr; gap: 28px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-home *, .page-home *::before, .page-home *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* 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 .menu-trigger{background:var(--fg);color:var(--surface);border-color:var(--fg)}.page-posts .menu-trigger:hover{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||
.page-posts .article-head{padding-block:clamp(58px,9vw,110px);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 .access-chip{display:inline-flex;align-items:center;min-height:34px;padding:6px 14px;border:1px solid var(--accent);color:var(--accent);font:12px/1.5 var(--font-mono);margin-bottom:22px}.page-posts .article-head h1{font-size:clamp(48px,7.4vw,94px);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:72px 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:24px;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:52px 1fr;gap:18px;padding:22px 0;border-top:1px solid var(--border)}.page-posts .steps li::before{content:counter(step,decimal-leading-zero);font:14px/1.7 var(--font-mono);color:var(--accent)}
|
||||
.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:block;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.page-posts .toc a:hover{color:var(--fg)}.page-posts .member-note{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}.page-posts .member-note p{color:color-mix(in oklch,var(--surface) 72%,transparent);font-size:14px}.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 20px;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);color:var(--surface)}.page-posts .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-posts .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
|
||||
.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-empty{color:var(--muted);padding:28px 0;border-bottom:1px solid var(--border)}.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;border-radius:0}.page-posts .textarea{min-height:130px;resize:vertical}.page-posts .input:focus,.page-posts .textarea:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.page-posts .form-status{min-height:24px;color:var(--accent);font-weight:700}
|
||||
.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:grid;margin-top:34px}.page-posts .menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.page-posts .menu-links a:hover{color:var(--accent)}.page-posts .menu-foot{margin-top:auto;color:var(--muted)}
|
||||
@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-login{display:none}.page-posts .article-head h1{font-size:48px}.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}}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Page-scoped styles: .page-articles. Shared tokens live in globals.css. */
|
||||
|
||||
.page-articles *, .page-articles *::before, .page-articles *::after { box-sizing: border-box; }
|
||||
.page-articles { -webkit-text-size-adjust: 100%; }
|
||||
.page-articles { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.7 var(--font-body); text-rendering: optimizeLegibility; }
|
||||
.page-articles a { color: inherit; text-decoration: none; }
|
||||
.page-articles button { color: inherit; font: inherit; cursor: pointer; }
|
||||
.page-articles h1, .page-articles h2, .page-articles h3 { margin: 0; font-family: var(--font-display); text-wrap: balance; }
|
||||
.page-articles p { text-wrap: pretty; }
|
||||
.page-articles :focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||
.page-articles .container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
|
||||
.page-articles .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-articles .topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
|
||||
.page-articles .logo { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 23px; font-weight: 700; }
|
||||
.page-articles .logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; }
|
||||
.page-articles .topnav nav { display: flex; align-items: center; gap: 28px; }
|
||||
.page-articles .topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); border-bottom: 1px solid transparent; }
|
||||
.page-articles .topnav nav a:hover, .page-articles .topnav nav a[aria-current="page"] { color: var(--fg); border-color: var(--accent); }
|
||||
.page-articles .nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); }
|
||||
.page-articles .nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||
.page-articles .hero { padding-block: clamp(56px, 8vw, 96px); border-bottom: 1px solid var(--border); }
|
||||
.page-articles .hero-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(360px, 1.1fr); gap: clamp(36px, 7vw, 88px); align-items: end; }
|
||||
.page-articles .eyebrow { margin: 0 0 18px; color: var(--accent); font: 11px/1.5 var(--font-mono); }
|
||||
.page-articles h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; }
|
||||
.page-articles .lead { max-width: 56ch; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2vw, 22px); }
|
||||
.page-articles .weekly { display: block; padding: clamp(24px, 4vw, 40px); background: var(--surface); border: 1px solid var(--border); border-top-color: var(--accent); }
|
||||
.page-articles .weekly:hover { border-color: var(--fg); background: color-mix(in oklch, var(--surface) 88%, var(--fg)); }
|
||||
.page-articles .weekly-meta { display: flex; justify-content: space-between; gap: 16px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||
.page-articles .weekly h2 { margin-top: 42px; font-size: clamp(28px, 4vw, 44px); line-height: 1.18; }
|
||||
.page-articles .weekly p { margin: 14px 0 30px; color: var(--muted); }
|
||||
.page-articles .weekly-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); }
|
||||
.page-articles .library { padding-block: clamp(52px, 8vw, 88px); }
|
||||
.page-articles .library-tools { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 16px; margin-bottom: 22px; }
|
||||
.page-articles .control { width: 100%; min-height: 48px; padding: 11px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--fg); font: inherit; }
|
||||
.page-articles .control::placeholder { color: var(--muted); }
|
||||
.page-articles .control:hover { border-color: var(--fg); }
|
||||
.page-articles .control:focus { outline: 3px solid var(--accent-soft); border-color: var(--accent); }
|
||||
.page-articles .filter-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-bottom: 44px; padding-block: 22px; border-block: 1px solid var(--border); }
|
||||
.page-articles .filter-group { min-width: 0; }
|
||||
.page-articles .filter-label { display: block; margin-bottom: 10px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||
.page-articles .filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: none; }
|
||||
.page-articles .filters::-webkit-scrollbar { display: none; }
|
||||
.page-articles .filter { flex: none; min-height: 44px; padding: 9px 15px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
|
||||
.page-articles .filter:hover { color: var(--fg); border-color: var(--fg); }
|
||||
.page-articles .filter[aria-pressed="true"] { background: var(--fg); color: var(--surface); border-color: var(--fg); }
|
||||
.page-articles .results-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
|
||||
.page-articles h2 { font-size: clamp(30px, 4vw, 46px); line-height: 1.15; }
|
||||
.page-articles .foryou { color: var(--accent); font: 11px/1.5 var(--font-mono); }
|
||||
.page-articles .count { color: var(--muted); font: 12px/1.5 var(--font-mono); }
|
||||
.page-articles .posts { border-top: 1px solid var(--fg); }
|
||||
.page-articles .post { display: grid; grid-template-columns: 138px minmax(0, 1fr) 168px; gap: 28px; align-items: start; padding-block: 28px; border-bottom: 1px solid var(--border); }
|
||||
.page-articles .post[hidden] { display: none; }
|
||||
.page-articles .post-meta { color: var(--muted); font: 12px/1.8 var(--font-mono); }
|
||||
.page-articles .post-meta span { display: block; }
|
||||
.page-articles .post h3 { font-size: clamp(22px, 2.5vw, 30px); line-height: 1.3; }
|
||||
.page-articles .post p { max-width: 62ch; margin: 9px 0 0; color: var(--muted); }
|
||||
.page-articles .post-side { justify-self: stretch; display: grid; gap: 12px; }
|
||||
.page-articles .access { justify-self: end; display: inline-flex; align-items: center; min-height: 34px; padding: 5px 11px; border: 1px solid var(--border); color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||
.page-articles .access.free { border-color: var(--fg); color: var(--fg); }
|
||||
.page-articles .engagement { display: flex; justify-content: flex-end; gap: 12px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||
.page-articles .like { min-height: 44px; padding: 0 10px; border: 1px solid transparent; background: transparent; color: var(--muted); }
|
||||
.page-articles .like:hover, .page-articles .like[aria-pressed="true"] { color: var(--fg); border-color: var(--border); }
|
||||
.page-articles .post-link { display: inline-flex; align-items: center; min-height: 44px; margin-top: 14px; font-weight: 700; border-bottom: 1px solid transparent; }
|
||||
.page-articles .post-link:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.page-articles .soon { color: var(--muted); cursor: default; }
|
||||
.page-articles .empty { display: none; padding: 42px 0; color: var(--muted); }
|
||||
.page-articles .empty.show { display: block; }
|
||||
.page-articles .pagefoot { padding-block: 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
|
||||
.page-articles .footer-row { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||
@media (max-width: 920px) {
|
||||
.page-articles .topnav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
|
||||
.page-articles .logo, .page-articles .nav-action { margin-bottom: 10px; }
|
||||
.page-articles .topnav nav { order: 3; flex: 0 0 calc(100% + (var(--gutter) * 2)); gap: 6px; margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter); overflow-x: auto; border-top: 1px solid var(--border); scrollbar-width: none; }
|
||||
.page-articles .topnav nav::-webkit-scrollbar { display: none; }
|
||||
.page-articles .topnav nav a { flex: none; min-height: 52px; padding-inline: 12px; white-space: nowrap; }
|
||||
.page-articles .hero-grid, .page-articles .filter-panel { grid-template-columns: 1fr; gap: 20px; }
|
||||
.page-articles .weekly { margin-top: 8px; }
|
||||
.page-articles .post { grid-template-columns: 112px minmax(0, 1fr); }
|
||||
.page-articles .post-side { justify-self: start; grid-column: 2; }
|
||||
.page-articles .access { justify-self: start; }
|
||||
.page-articles .engagement { justify-content: flex-start; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.page-articles .nav-action { padding-inline: 12px; }
|
||||
.page-articles h1 { font-size: clamp(42px, 13vw, 58px); }
|
||||
.page-articles .library-tools { grid-template-columns: 1fr; }
|
||||
.page-articles .results-head { align-items: start; flex-direction: column; }
|
||||
.page-articles .post { grid-template-columns: 1fr; gap: 12px; }
|
||||
.page-articles .post-side { grid-column: 1; }
|
||||
.page-articles .post-link { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-articles * { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Page-scoped styles: .page-verify-email. Shared tokens live in globals.css. */
|
||||
|
||||
.page-verify-email *,.page-verify-email *::before,.page-verify-email *::after{box-sizing:border-box}.page-verify-email{-webkit-text-size-adjust:100%}.page-verify-email{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);min-height:100vh}.page-verify-email a{color:inherit;text-decoration:none}.page-verify-email button{font:inherit;cursor:pointer}.page-verify-email h1,.page-verify-email h2{font-family:var(--font-display);text-wrap:balance}.page-verify-email p{text-wrap:pretty}.page-verify-email :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-verify-email .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-verify-email .topnav{border-bottom:1px solid var(--border)}.page-verify-email .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:20px}.page-verify-email .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.page-verify-email .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.page-verify-email .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.page-verify-email .nav-return:hover{color:var(--fg);border-color:var(--fg)}
|
||||
.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}}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Page-scoped styles: .page-forgot-password. Shared tokens live in globals.css. */
|
||||
.page-forgot-password *,.page-forgot-password *::before,.page-forgot-password *::after{box-sizing:border-box}.page-forgot-password{-webkit-text-size-adjust:100%}.page-forgot-password{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body)}.page-forgot-password a{color:inherit;text-decoration:none}.page-forgot-password button,.page-forgot-password input{font:inherit}.page-forgot-password button{cursor:pointer}.page-forgot-password h1{font-family:var(--font-display);text-wrap:balance}.page-forgot-password p{text-wrap:pretty}.page-forgot-password .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-forgot-password .topnav{border-bottom:1px solid var(--border)}.page-forgot-password .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.page-forgot-password .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.page-forgot-password .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.page-forgot-password .nav-return:hover{color:var(--fg);border-color:var(--fg)}
|
||||
.page-forgot-password .reset{min-height:calc(100dvh - 142px);display:grid;place-items:center;padding:64px var(--gutter)}.page-forgot-password .reset-card{width:min(520px,100%);border:1px solid var(--border);background:var(--surface);padding:clamp(28px,6vw,54px)}.page-forgot-password .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.page-forgot-password .reset-card h1{font-size:clamp(38px,6vw,58px);line-height:1.12;margin:0}.page-forgot-password .lead{color:var(--muted);font-size:18px;margin:16px 0 30px}.page-forgot-password .field{display:grid;gap:8px}.page-forgot-password .field label{font-size:13px;color:var(--muted)}.page-forgot-password .input{width:100%;min-height:52px;border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:13px 14px}.page-forgot-password .input:focus{outline:2px solid var(--accent);outline-offset:1px}.page-forgot-password .btn{width:100%;min-height:50px;margin-top:18px;border:1px solid var(--accent);background:var(--accent);color:var(--surface);font-weight:700;padding:12px 20px}.page-forgot-password .btn:hover{background:color-mix(in oklch,var(--accent) 86%,black)}.page-forgot-password .status{min-height:26px;margin:16px 0 0;color:var(--fg)}.page-forgot-password .back{min-height:44px;display:inline-flex;align-items:center;margin-top:18px;color:var(--muted);text-decoration:underline;text-underline-offset:4px}.page-forgot-password .back:hover{color:var(--fg)}.page-forgot-password .pagefoot{padding-block:26px;border-top:1px solid var(--border);color:var(--muted);text-align:center;font-size:13px}
|
||||
@media(max-width:620px){.page-forgot-password .nav-return{display:none}.page-forgot-password .reset{padding-block:40px;align-items:start}.page-forgot-password .reset-card{padding:26px 20px}}
|
||||
@@ -0,0 +1,124 @@
|
||||
/* Homepage presentation, scoped to avoid changing article and account layouts. */
|
||||
.home-page {
|
||||
--border: #2a2d32;
|
||||
--surface: #15171a;
|
||||
--muted: #a5a39d;
|
||||
--radius: 10px;
|
||||
--radius-lg: 20px;
|
||||
background: linear-gradient(180deg, #0b0c0eee, #0c0d0f 70%);
|
||||
}
|
||||
.home-page .topnav { background: #0c0d0fed; }
|
||||
.home-page .nav-inner { min-height: 84px; }
|
||||
.home-page .logo { text-decoration: none; }
|
||||
.home-page .nav-action { border-radius: 8px; font-size: 14px; }
|
||||
.home-page .section { border-bottom: 0; padding-block: 76px; }
|
||||
.home-page section[id] { scroll-margin-top: 135px; }
|
||||
.home-page .hero { padding-block: 86px 72px; background: radial-gradient(ellipse at 20% 30%, #3a332640, transparent 60%); }
|
||||
.home-page .hero-split { grid-template-columns: 1.1fr 1fr; gap: 64px; }
|
||||
.home-page .eyebrow { font-family: var(--font-body); font-size: 13px; font-weight: 600; }
|
||||
.home-page .hero-copy h1 { max-width: 15ch; font-size: clamp(42px, 4.7vw, 64px); line-height: 1.4; letter-spacing: -.025em; }
|
||||
.home-page .hero-highlight { color: var(--accent); }
|
||||
.home-page .hero .lead { font-size: 18px; max-width: 44ch; margin-top: 20px; }
|
||||
.home-page .btn { min-height: 52px; padding-inline: 24px; }
|
||||
.home-page .btn-ghost { border-color: var(--border); }
|
||||
.home-page .trust-line { font-size: 13px; line-height: 1.9; }
|
||||
.home-page .deck { min-height: 460px; }
|
||||
.home-page .deck-frame { min-height: 400px; border: 1px solid #33363b; border-radius: 20px; background: linear-gradient(135deg, #1c1e22, #121316); box-shadow: 0 24px 60px #0003; }
|
||||
.home-page .deck-frame::before { content: ''; position: absolute; width: 280px; height: 280px; border: 1px solid #d4b47c26; border-radius: 50%; left: -100px; top: -120px; box-shadow: 0 0 0 35px #d4b47c0a, 0 0 0 70px #d4b47c08; pointer-events: none; }
|
||||
.home-page .deck-slide { padding: 30px; }
|
||||
.home-page .deck-slide::after { background: var(--accent); height: 3px; opacity: .65; }
|
||||
.home-page .deck-slide-index { color: var(--accent); font: 24px var(--font-mono); }
|
||||
.home-page .deck-slide-body h2 { font-size: clamp(30px, 3.2vw, 42px); line-height: 1.5; max-width: 16ch; margin: 0; }
|
||||
.home-page .deck-slide-body p { font-size: 15px; line-height: 1.9; }
|
||||
.home-page .deck-slide-kicker { font: 12px var(--font-body); }
|
||||
.home-page .deck-slide-foot { font-size: 13px; }
|
||||
.home-page .deck-slide-link { color: var(--accent); border: 0; }
|
||||
.home-page .icon-btn { border-radius: 50%; }
|
||||
.home-page .deck-dot { height: 44px; position: relative; background: transparent; }
|
||||
.home-page .deck-dot::before { content: ''; position: absolute; inset: 20px 0; background: var(--border); border-radius: 8px; }
|
||||
.home-page .deck-dot.active::before { background: var(--accent); }
|
||||
.home-page .section-head { margin-bottom: 30px; gap: 32px; }
|
||||
.home-page .section h2 { font-size: clamp(28px, 3vw, 40px); line-height: 1.5; margin: 0; }
|
||||
.home-page .section-head .lead { font-size: 16px; max-width: 35ch; }
|
||||
.home-page .topic-grid { border: 0; gap: 16px; }
|
||||
.home-page .topic { position: relative; min-height: 158px; border: 1px solid var(--border); border-radius: 12px; padding: 24px; background: #141619; }
|
||||
.home-page .topic::before { content: '01'; color: var(--accent); font: 12px var(--font-mono); margin-bottom: 12px; }
|
||||
.home-page .topic:nth-child(2)::before { content: '02'; color: #c9c6bf; }
|
||||
.home-page .topic:nth-child(3)::before { content: '03'; color: #d4b47c; }
|
||||
.home-page .topic:nth-child(4)::before { content: '04'; color: #c9c6bf; }
|
||||
.home-page .topic::after { content: '↖'; position: absolute; top: 20px; left: 22px; color: var(--muted); }
|
||||
.home-page .topic strong { font-size: 21px; margin-bottom: 4px; }
|
||||
.home-page .topic:hover { border-color: var(--accent); background: #1a1c20; }
|
||||
.home-page #catalog { background: #101114; }
|
||||
.home-page .filterbar { gap: 10px; margin-bottom: 28px; }
|
||||
.home-page .filter { border-radius: 30px; font-size: 14px; padding-inline: 22px; }
|
||||
.home-page .filter.active { background: var(--fg); border-color: var(--fg); color: var(--bg); }
|
||||
.home-page .catalog { border: 0; gap: 24px; }
|
||||
.home-page .catalog-card { border: 1px solid var(--border); padding: 14px; border-radius: 16px; background: #141619; }
|
||||
.home-page .cover { min-height: 172px; border-radius: 9px; margin-bottom: 20px; background: linear-gradient(135deg, #3a3d43, #1a1c20); color: #f4f2ee; }
|
||||
.home-page .cover-light { background: linear-gradient(135deg, #43392d, #1f1b16); }
|
||||
.home-page .cover-lined { background: repeating-linear-gradient(135deg, transparent, transparent 22px, #ffffff06 23px, #ffffff06 24px), #2a2826; border: 0; }
|
||||
.home-page .cover-label { font: 12px var(--font-body); color: #e4e1db; }
|
||||
.home-page .cover-title { font-size: 26px; line-height: 1.5; max-width: 14ch; }
|
||||
.home-page .card-meta, .home-page .catalog-card h3, .home-page .catalog-card p, .home-page .card-link { margin-inline: 8px; }
|
||||
.home-page .catalog-card h3 { font-size: 20px; line-height: 1.65; }
|
||||
.home-page .catalog-card p { line-height: 1.9; }
|
||||
.home-page .card-link { color: var(--accent); font-size: 14px; padding-bottom: 8px; }
|
||||
.home-page .membership { margin: 64px auto; max-width: 1116px; border: 1px solid #3a3d43; border-radius: 20px; background: linear-gradient(115deg, #1e1d1b, #141517); color: var(--fg); }
|
||||
.home-page .membership .eyebrow { color: var(--accent); opacity: 1; }
|
||||
.home-page .membership .lead { color: var(--muted); font-size: 17px; }
|
||||
.home-page .membership-list, .home-page .membership-list li { border-color: #ffffff20; }
|
||||
.home-page .membership-list li span:last-child { color: var(--accent); font-size: 14px; }
|
||||
.home-page .membership .btn-primary { background: var(--fg); border-color: var(--fg); color: var(--bg); }
|
||||
.home-page .pagefoot { padding-block: 36px; }
|
||||
.home-page .pagefoot .row-between { flex-wrap: wrap; font-size: 13px; }
|
||||
.home-page .footer-home-brand { font-size: 20px; font-weight: 700; color: var(--fg); }
|
||||
.home-page .footer-home-brand span { display: block; font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||
@media (max-width: 920px) {
|
||||
.home-page .hero-split { grid-template-columns: 1fr; gap: 36px; }
|
||||
.home-page .hero-copy h1 { max-width: 20ch; }
|
||||
.home-page .hero { padding-top: 48px; }
|
||||
.home-page .deck { min-height: 0; }
|
||||
.home-page .membership { margin-inline: 22px; }
|
||||
.home-page .nav-inner { min-height: 64px; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.home-page .section { padding-block: 44px; }
|
||||
.home-page .hero-copy h1 { font-size: 36px !important; line-height: 1.5; }
|
||||
.home-page .hero .lead { font-size: 16px; }
|
||||
.home-page .hero-cta { margin-top: 24px; }
|
||||
.home-page .nav-action { display: inline-flex; font-size: 12px; padding-inline: 10px; }
|
||||
.home-page .topic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.home-page .topic { padding: 16px; min-height: 150px; }
|
||||
.home-page .topic span { font-size: 12px; }
|
||||
.home-page .topic::after { left: 14px; top: 14px; }
|
||||
.home-page .deck-frame { min-height: 390px; }
|
||||
.home-page .deck-slide { padding: 24px; }
|
||||
.home-page .deck-slide-body h2 { font-size: 30px; }
|
||||
.home-page .deck-slide-foot { flex-direction: row; align-items: center; gap: 12px; }
|
||||
.home-page .deck-slide-format { font-size: 12px; }
|
||||
.home-page .play { width: 28px; height: 28px; }
|
||||
.home-page .membership { margin: 32px 18px; }
|
||||
.home-page .membership-grid { gap: 28px; }
|
||||
.home-page .filterbar { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 6px; }
|
||||
.home-page .filter { flex: none; }
|
||||
.home-page .pagefoot .row-between { flex-direction: column; align-items: start; gap: 18px; }
|
||||
}
|
||||
|
||||
/* Carousel controls and content must not inherit legacy active backgrounds. */
|
||||
#site.home-page .deck-dot, #site.home-page .deck-dot.active { width: 34px; height: 44px; border: 0; padding: 0; background: transparent; border-radius: 8px; }
|
||||
#site.home-page .deck-dot::before { inset: 20px 0; background: var(--border); }
|
||||
#site.home-page .deck-dot.active::before { background: var(--accent); }
|
||||
#site.home-page .deck-frame { display: grid; min-height: 400px; }
|
||||
#site.home-page .deck-slide { position: relative; inset: auto; min-height: 400px; gap: 24px; }
|
||||
#site.home-page .deck-slide-body h2 { max-width: none; }
|
||||
#site.home-page .deck-slide-foot { flex-wrap: wrap; }
|
||||
#site.home-page .deck-controls { gap: 16px; align-items: center; }
|
||||
#site.home-page .deck-arrows { display: flex; gap: 8px; }
|
||||
#site.home-page .deck-controls button:disabled { cursor: default; }
|
||||
@media (max-width: 620px) {
|
||||
#site.home-page .deck-slide { min-height: 390px; padding: 24px; }
|
||||
#site.home-page .deck-controls { gap: 8px; }
|
||||
#site.home-page .deck-arrows { gap: 6px; }
|
||||
#site.home-page .deck-dot, #site.home-page .deck-dot.active { width: 28px; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Page-scoped styles: .page-login. Shared tokens live in globals.css. */
|
||||
.page-login *,.page-login *::before,.page-login *::after{box-sizing:border-box}.page-login{-webkit-text-size-adjust:100%}.page-login{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}.page-login.locked{overflow:hidden}.page-login a{color:inherit;text-decoration:none}.page-login button,.page-login input{font:inherit}.page-login button{cursor:pointer}.page-login h1,.page-login h2,.page-login h3{font-family:var(--font-display);text-wrap:balance}.page-login p{text-wrap:pretty}.page-login :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-login .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-login .topnav{border-bottom:1px solid var(--border)}.page-login .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.page-login .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-login .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)}.page-login .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.page-login .nav-return:hover{color:var(--fg);border-color:var(--fg)}.page-login .icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--fg);background:var(--fg);color:var(--surface)}.page-login .icon-btn:hover{background:var(--accent);border-color:var(--accent);color:var(--surface)}.page-login .nav-tools{display:flex;align-items:center;gap:14px}
|
||||
.page-login .auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:1fr 1fr}.page-login .auth-story{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100vw - var(--container))/2 + var(--gutter)));padding-inline-end:clamp(48px,8vw,100px);background:var(--fg);color:var(--surface);display:flex;flex-direction:column;justify-content:space-between}.page-login .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-login .auth-story .eyebrow{color:var(--surface);opacity:.68}.page-login .auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;max-width:9ch;margin:0}.page-login .auth-story>div>p{color:color-mix(in oklch,var(--surface) 68%,transparent);font-size:19px;max-width:40ch}.page-login .story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 24%,transparent)}.page-login .story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 15%,transparent);display:flex;justify-content:space-between;gap:20px}.page-login .story-mark{font:12px/1.5 var(--font-mono);opacity:.58}.page-login .story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 58%,transparent)}
|
||||
.page-login .auth-form-wrap{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100vw - var(--container))/2 + var(--gutter)));padding-inline-start:clamp(48px,8vw,100px);display:flex;align-items:center}.page-login .auth-form{width:min(440px,100%)}.page-login .auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.page-login .lead{color:var(--muted);font-size:18px;margin:12px 0 34px}.page-login .field{display:grid;gap:7px;margin-top:18px}.page-login .field label{font-size:13px;color:var(--muted)}.page-login .input-wrap{position:relative}.page-login .input{width:100%;min-height:52px;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.page-login .input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.page-login .password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.page-login .password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.page-login .form-row{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:18px 0 24px;font-size:13px}.page-login .check{display:flex;align-items:center;gap:8px}.page-login .form-row a{text-decoration:underline;text-underline-offset:3px}.page-login .form-row a:hover{color:var(--accent)}.page-login .btn{min-height:50px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.page-login .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-login .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.page-login .divider{display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px;margin:26px 0}.page-login .divider::before,.page-login .divider::after{content:"";height:1px;background:var(--border);flex:1}.page-login .magic{min-height:50px;width:100%;border:1px solid var(--border);background:transparent;color:var(--fg);font-weight:700}.page-login .magic:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}.page-login .signup{text-align:center;color:var(--muted);font-size:14px;margin-top:24px}.page-login .signup a{color:var(--fg);font-weight:700;text-decoration:underline;text-underline-offset:3px}.page-login .status{min-height:25px;color:var(--accent);font-weight:700;margin-top:14px}.page-login .fine{font-size:12px;color:var(--muted);margin-top:28px}
|
||||
.page-login .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-login .menu-panel.open{visibility:visible;pointer-events:auto}.page-login .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-login .menu-panel.open .menu-scrim{opacity:1}.page-login .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-login .menu-panel.open .menu-drawer{transform:translateX(0)}.page-login .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-login .menu-links{display:grid;margin-top:34px}.page-login .menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.page-login .menu-links a:hover{color:var(--accent)}.page-login .menu-foot{margin-top:auto;color:var(--muted)}
|
||||
@media(max-width:920px){.page-login .auth{grid-template-columns:1fr}.page-login .auth-story{min-height:440px;padding:64px var(--gutter)}.page-login .auth-form-wrap{padding:64px var(--gutter);justify-content:center}.page-login .story-foot{margin-top:54px}}
|
||||
@media(max-width:620px){.page-login .nav-return{display:none}.page-login .auth-story{min-height:auto}.page-login .auth-story h1{font-size:46px}.page-login .auth-form-wrap{padding-block:50px}.page-login .form-row{align-items:flex-start;flex-direction:column}}
|
||||
@media(prefers-reduced-motion:reduce){.page-login *{transition-duration:.01ms!important}}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* Page-scoped styles: .page-onboarding. Shared tokens live in globals.css. */
|
||||
|
||||
.page-onboarding *,.page-onboarding *::before,.page-onboarding *::after{box-sizing:border-box}.page-onboarding{-webkit-text-size-adjust:100%}.page-onboarding{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body)}.page-onboarding a{color:inherit;text-decoration:none}.page-onboarding button,.page-onboarding input{font:inherit}.page-onboarding button{cursor:pointer}.page-onboarding h1,.page-onboarding h2,.page-onboarding h3{font-family:var(--font-display);text-wrap:balance}.page-onboarding p{text-wrap:pretty}.page-onboarding :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-onboarding .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-onboarding .topnav{border-bottom:1px solid var(--border)}.page-onboarding .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:20px}.page-onboarding .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.page-onboarding .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.page-onboarding .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-onboarding .nav-return:hover{color:var(--fg)}
|
||||
.page-onboarding .head{padding-block:clamp(40px,6vw,64px) 34px;border-bottom:1px solid var(--border)}.page-onboarding .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}.page-onboarding .head h1{font-size:clamp(40px,5vw,58px);line-height:1.16;margin:0;max-width:15ch}.page-onboarding .lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:58ch;margin:16px 0 0}.page-onboarding .progress{margin-top:26px;max-width:720px}.page-onboarding .progress-copy{display:flex;justify-content:space-between;color:var(--fg);font:12px/1.5 var(--font-mono);margin-bottom:10px}.page-onboarding .progress-track{height:6px;background:var(--border)}.page-onboarding .progress-bar{height:100%;width:33.333%;background:var(--accent);transition:width .2s ease}
|
||||
.page-onboarding .flow{display:grid;grid-template-columns:220px minmax(0,1fr);gap:clamp(38px,7vw,84px);align-items:start;padding-block:46px 72px}.page-onboarding .step-nav{border-top:1px solid var(--fg)}.page-onboarding .step-nav div{min-height:58px;display:grid;grid-template-columns:34px 1fr;align-items:center;gap:12px;border-bottom:1px solid var(--border);color:var(--muted)}.page-onboarding .step-nav div.active{color:var(--fg);font-weight:700;border-bottom-color:var(--fg)}.page-onboarding .step-nav div.done{color:var(--fg)}.page-onboarding .step-nav span:first-child{font:12px/1 var(--font-mono)}.page-onboarding .step-nav div.active span:first-child{color:var(--accent)}.page-onboarding .panel{display:none;max-width:760px}.page-onboarding .panel.active{display:block}.page-onboarding .panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px}.page-onboarding .intro{color:var(--muted);max-width:58ch;margin:0 0 30px}.page-onboarding .choices{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.page-onboarding .choice{position:relative;display:block}.page-onboarding .choice input{position:absolute;opacity:0}.page-onboarding .choice-body{min-height:145px;display:flex;flex-direction:column;padding:22px;border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 90%,transparent)}.page-onboarding .choice-body:hover{border-color:var(--fg)}.page-onboarding .choice input:checked+.choice-body{border-color:var(--fg);background:var(--fg-soft)}.page-onboarding .choice input:focus-visible+.choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-onboarding .choice strong{font-size:19px;padding-inline-end:34px}.page-onboarding .choice small{color:var(--muted);font-size:14px;margin-top:8px}.page-onboarding .choice-mark{position:absolute;inset-block-start:24px;inset-inline-end:22px;width:18px;height:18px;border:1px solid var(--fg)}.page-onboarding .choice input:checked+.choice-body .choice-mark{background:var(--fg);box-shadow:inset 0 0 0 4px var(--surface)}
|
||||
.page-onboarding .benefits{border-top:1px solid var(--fg)}.page-onboarding .benefit{display:grid;grid-template-columns:42px 1fr;gap:16px;padding:20px 0;border-bottom:1px solid var(--border)}.page-onboarding .benefit-num{font:12px/1.8 var(--font-mono);color:var(--muted)}.page-onboarding .benefit h3{font-size:22px;margin:0 0 5px}.page-onboarding .benefit p{color:var(--muted);margin:0}.page-onboarding .upgrade-note{margin-top:26px;padding:22px;border:1px solid var(--border);background:var(--surface)}.page-onboarding .upgrade-note strong{display:block;font-size:18px}.page-onboarding .upgrade-note p{color:var(--muted);margin:6px 0 0}.page-onboarding .actions{display:flex;gap:12px;align-items:center;margin-top:32px}.page-onboarding .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.page-onboarding .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-onboarding .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-onboarding .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-onboarding .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.page-onboarding .status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0}.page-onboarding .pagefoot{padding-block:42px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}.page-onboarding .footer-row{display:flex;justify-content:space-between;gap:18px;flex-wrap:wrap}
|
||||
@media(max-width:920px){.page-onboarding .flow{grid-template-columns:1fr}.page-onboarding .step-nav{display:grid;grid-template-columns:repeat(3,1fr);border-top:0}.page-onboarding .step-nav div{padding-inline:12px;border:1px solid var(--border)}.page-onboarding .choices{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:620px){.page-onboarding .logo{font-size:20px}.page-onboarding .nav-return{font-size:13px}.page-onboarding .head{padding-block:40px 30px}.page-onboarding .flow{padding-block:30px 60px;gap:30px}.page-onboarding .step-nav{overflow-x:auto;grid-template-columns:repeat(3,minmax(140px,1fr))}.page-onboarding .choices{grid-template-columns:1fr}.page-onboarding .choice-body{min-height:130px}.page-onboarding .actions{align-items:stretch;flex-direction:column-reverse}.page-onboarding .actions .btn{width:100%}.page-onboarding .footer-row{flex-direction:column}}
|
||||
@media(prefers-reduced-motion:reduce){.page-onboarding *{transition-duration:.01ms!important}}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Page-scoped styles: .page-payment-cancelled. Shared tokens live in globals.css. */
|
||||
|
||||
.page-payment-cancelled *,.page-payment-cancelled *::before,.page-payment-cancelled *::after{box-sizing:border-box}
|
||||
.page-payment-cancelled{-webkit-text-size-adjust:100%}
|
||||
.page-payment-cancelled{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
|
||||
.page-payment-cancelled a{color:inherit;text-decoration:none}
|
||||
.page-payment-cancelled h1,.page-payment-cancelled h2{font-family:var(--font-display);text-wrap:balance}
|
||||
.page-payment-cancelled p{text-wrap:pretty}
|
||||
.page-payment-cancelled :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-payment-cancelled .container{width:min(100%,var(--container));margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-payment-cancelled .topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)}
|
||||
.page-payment-cancelled .topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-payment-cancelled .logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700}
|
||||
.page-payment-cancelled .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}
|
||||
.page-payment-cancelled .nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
|
||||
.page-payment-cancelled .nav-link:hover{color:var(--fg)}
|
||||
.page-payment-cancelled .section{padding-block:clamp(56px,9vw,104px)}
|
||||
.page-payment-cancelled .state-card{max-width:760px;margin-inline:auto;padding:clamp(28px,6vw,64px);border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 92%,transparent)}
|
||||
.page-payment-cancelled .status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px}
|
||||
.page-payment-cancelled .status-mark svg{width:28px;height:28px}
|
||||
.page-payment-cancelled .eyebrow{margin:0 0 18px;color:var(--muted);font:12px/1.5 var(--font-mono);}
|
||||
.page-payment-cancelled h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;}
|
||||
.page-payment-cancelled .lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)}
|
||||
.page-payment-cancelled .notice{margin-top:34px;padding-block:22px;border-block:1px solid var(--border)}
|
||||
.page-payment-cancelled .notice h2{margin:0 0 8px;font-size:24px}
|
||||
.page-payment-cancelled .notice p{margin:0;color:var(--muted)}
|
||||
.page-payment-cancelled .actions{display:flex;align-items:center;gap:12px;margin-top:30px}
|
||||
.page-payment-cancelled .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-payment-cancelled .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||
.page-payment-cancelled .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
|
||||
.page-payment-cancelled .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
|
||||
.page-payment-cancelled .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
|
||||
.page-payment-cancelled .help{margin:24px 0 0;color:var(--muted);font-size:14px}
|
||||
.page-payment-cancelled .help a{text-decoration:underline;text-underline-offset:4px;color:var(--fg)}
|
||||
.page-payment-cancelled .help a:hover{color:var(--accent)}
|
||||
.page-payment-cancelled .pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}
|
||||
.page-payment-cancelled .footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||
@media(max-width:620px){.page-payment-cancelled .topnav-inner{min-height:64px}.page-payment-cancelled .state-card{padding:26px 20px}.page-payment-cancelled .actions{align-items:stretch;flex-direction:column}.page-payment-cancelled .actions .btn{width:100%}.page-payment-cancelled .footer-row{flex-direction:column;gap:8px}}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* Page-scoped styles: .page-payment-success. Shared tokens live in globals.css. */
|
||||
|
||||
.page-payment-success *,.page-payment-success *::before,.page-payment-success *::after{box-sizing:border-box}
|
||||
.page-payment-success{-webkit-text-size-adjust:100%}
|
||||
.page-payment-success{min-height:100vh;margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
|
||||
.page-payment-success a{color:inherit;text-decoration:none}
|
||||
.page-payment-success h1,.page-payment-success h2{font-family:var(--font-display);text-wrap:balance}
|
||||
.page-payment-success p{text-wrap:pretty}
|
||||
.page-payment-success :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-payment-success .container{width:min(100%,var(--container));margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-payment-success .topnav{border-bottom:1px solid var(--border);background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px)}
|
||||
.page-payment-success .topnav-inner{min-height:72px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-payment-success .logo{min-height:44px;display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:700}
|
||||
.page-payment-success .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}
|
||||
.page-payment-success .nav-link{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
|
||||
.page-payment-success .nav-link:hover{color:var(--fg)}
|
||||
.page-payment-success .section{padding-block:clamp(56px,9vw,104px)}
|
||||
.page-payment-success .state-card{max-width:760px;margin-inline:auto;padding:clamp(28px,6vw,64px);border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 92%,transparent)}
|
||||
.page-payment-success .status-mark{width:64px;height:64px;display:grid;place-items:center;border:1px solid var(--fg);margin-bottom:30px}
|
||||
.page-payment-success .status-mark svg{width:30px;height:30px}
|
||||
.page-payment-success .eyebrow{margin:0 0 18px;color:var(--accent);font:12px/1.5 var(--font-mono);}
|
||||
.page-payment-success h1{max-width:12ch;margin:0;font-size:clamp(42px,7vw,72px);line-height:1.1;}
|
||||
.page-payment-success .lead{max-width:54ch;margin:20px 0 0;color:var(--muted);font-size:clamp(18px,2.3vw,22px)}
|
||||
.page-payment-success .summary{margin:34px 0 0;border-top:1px solid var(--fg)}
|
||||
.page-payment-success .summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:17px 0;border-bottom:1px solid var(--border)}
|
||||
.page-payment-success .summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)}
|
||||
.page-payment-success .summary-row dd{margin:0;font-weight:700}
|
||||
.page-payment-success .next-step{margin-top:34px;padding-top:30px;border-top:1px solid var(--border)}
|
||||
.page-payment-success .next-step h2{margin:0 0 8px;font-size:26px}
|
||||
.page-payment-success .next-step p{margin:0;color:var(--muted)}
|
||||
.page-payment-success .actions{display:flex;align-items:center;gap:12px;margin-top:26px}
|
||||
.page-payment-success .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-payment-success .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||
.page-payment-success .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
|
||||
.page-payment-success .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
|
||||
.page-payment-success .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
|
||||
.page-payment-success .pagefoot{padding-block:36px;border-top:1px solid var(--border);color:var(--muted);font-size:13px}
|
||||
.page-payment-success .footer-row{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||
@media(max-width:620px){.page-payment-success .topnav-inner{min-height:64px}.page-payment-success .state-card{padding:26px 20px}.page-payment-success .summary-row{grid-template-columns:1fr;gap:4px}.page-payment-success .actions{align-items:stretch;flex-direction:column}.page-payment-success .actions .btn{width:100%}.page-payment-success .footer-row{flex-direction:column;gap:8px}}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
|
||||
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
|
||||
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
|
||||
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}.page-posts .nav-login,.page-posts .icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .nav-login{padding-inline:16px}.page-posts .nav-login:hover,.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .icon-btn{width:46px}
|
||||
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
|
||||
.page-posts .demo-strip b{color:var(--surface);font-weight:700}
|
||||
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
|
||||
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
|
||||
.page-posts .lock-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--fg);padding:6px 14px;font:12px/1.5 var(--font-mono);color:var(--fg);margin-bottom:22px}
|
||||
.page-posts .lock-chip svg{width:14px;height:14px}
|
||||
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
|
||||
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
|
||||
.page-posts .article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
|
||||
.page-posts .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
|
||||
.page-posts .article-body{font-size:19px;line-height:2}
|
||||
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
|
||||
.page-posts .article-body p{margin:0 0 26px}
|
||||
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
|
||||
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
|
||||
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
|
||||
.page-posts .free-tag{font:11px/1.5 var(--font-mono);color:var(--muted);margin-bottom:6px;display:block}
|
||||
.page-posts .lock-cut{position:relative}
|
||||
.page-posts .lock-cut::after{content:"";position:absolute;inset-inline:0;bottom:0;height:120px;background:linear-gradient(to bottom,transparent,var(--bg) 88%);pointer-events:none}
|
||||
.page-posts .lock-cut .cut-p{margin-bottom:0}
|
||||
.page-posts .paywall{margin-top:-30px;position:relative;border:1px solid var(--fg);background:var(--surface);padding:clamp(28px,4vw,44px);z-index:2}
|
||||
.page-posts .paywall .crumb{margin-bottom:12px}
|
||||
.page-posts .paywall h2{font-size:clamp(30px,3.6vw,44px);line-height:1.18;margin:0 0 14px;max-width:16ch}
|
||||
.page-posts .paywall p{color:var(--muted);max-width:56ch;margin:0 0 24px}
|
||||
.page-posts .locked-in{border-top:1px solid var(--fg);margin:0 0 26px}
|
||||
.page-posts .locked-in li{display:flex;align-items:center;gap:12px;min-height:52px;border-bottom:1px solid var(--border);color:var(--muted);font-size:16px}
|
||||
.page-posts .locked-in svg{width:14px;height:14px;flex:none}
|
||||
.page-posts .locked-in .num{margin-inline-start:auto;font:12px/1.5 var(--font-mono)}
|
||||
.page-posts .paywall-actions{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||
.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-posts .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||
.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
|
||||
.page-posts .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}
|
||||
.page-posts .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-posts .text-link{font-weight:700;color:var(--fg);text-decoration:underline;text-underline-offset:4px;min-height:44px;display:inline-flex;align-items:center}
|
||||
.page-posts .text-link:hover{color:var(--accent)}
|
||||
.page-posts .price-line{font:12px/1.6 var(--font-mono);color:var(--muted);margin-top:18px}
|
||||
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
|
||||
.page-posts .toc strong{font-family:var(--font-display);font-size:20px}
|
||||
.page-posts .toc a,.page-posts .toc .toc-locked{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .toc a:hover{color:var(--fg)}
|
||||
.page-posts .toc .toc-locked{color:color-mix(in oklch,var(--muted) 60%,transparent)}
|
||||
.page-posts .toc .toc-locked svg{width:12px;height:12px;flex:none}
|
||||
.page-posts .member-note{margin-top:28px;border:1px solid var(--fg);padding:22px}
|
||||
.page-posts .member-note strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:8px}
|
||||
.page-posts .member-note p{color:var(--muted);font-size:14px;margin:0 0 16px}
|
||||
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
|
||||
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
|
||||
.page-posts .footer-col a{display:block;min-height:36px}
|
||||
.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}
|
||||
.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
|
||||
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display:flex;flex-direction:column}.page-posts .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
|
||||
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .article-body{font-size:18px}}
|
||||
@media(max-width:620px){.page-posts .nav-login{display:none}.page-posts .article-head h1{font-size:46px}.page-posts .article-layout{padding-block:56px}.page-posts .footer-grid{grid-template-columns:1fr;gap:28px}.page-posts .paywall-actions{flex-direction:column;align-items:stretch}.page-posts .paywall-actions .btn{width:100%}}
|
||||
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
|
||||
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
|
||||
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
|
||||
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}
|
||||
.page-posts .nav-account{min-height:44px;display:inline-flex;align-items:center;gap:10px;padding-inline:16px;border:1px solid var(--fg);background:var(--fg);color:var(--surface);font-weight:700}.page-posts .nav-account:hover{background:var(--accent);border-color:var(--accent)}
|
||||
.page-posts .nav-account .avatar{width:22px;height:22px;background:var(--surface);color:var(--fg);display:grid;place-items:center;font:12px/1 var(--font-mono);font-weight:700}
|
||||
.page-posts .icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
|
||||
.page-posts .demo-strip b{color:var(--surface);font-weight:700}
|
||||
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
|
||||
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
|
||||
.page-posts .open-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--accent);color:var(--accent);padding:6px 14px;font:12px/1.5 var(--font-mono);margin-bottom:22px}
|
||||
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
|
||||
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
|
||||
.page-posts .article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
|
||||
.page-posts .save-btn{min-height:40px;padding:6px 14px;border:1px solid var(--border);background:transparent;color:var(--fg);font:12px/1.5 var(--font-mono)}.page-posts .save-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .save-btn[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
|
||||
.page-posts .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
|
||||
.page-posts .article-body{font-size:19px;line-height:2}
|
||||
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
|
||||
.page-posts .article-body h3{font-size:27px;margin:42px 0 14px}
|
||||
.page-posts .article-body p{margin:0 0 26px}
|
||||
.page-posts .article-body ul{padding-inline-start:24px}.page-posts .article-body li{margin-block:10px}
|
||||
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
|
||||
.page-posts .code{direction:ltr;text-align:left;background:var(--fg);color:var(--surface);padding:26px;overflow:auto;font:14px/1.8 var(--font-mono);margin:34px 0}
|
||||
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
|
||||
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
|
||||
.page-posts .steps{counter-reset:step;list-style:none;padding:0}.page-posts .steps li{counter-increment:step;display:grid;grid-template-columns:56px 1fr;gap:18px;padding:22px 0;border-bottom:1px solid var(--border)}.page-posts .steps li::before{content:"٠" counter(step,arabic-indic);font-family:var(--font-display);font-size:34px;color:var(--accent);line-height:1}
|
||||
.page-posts .steps strong{display:block;font-size:20px;margin-bottom:6px}.page-posts .steps p{margin:0;color:var(--muted)}
|
||||
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
|
||||
.page-posts .toc strong{font-family:var(--font-display);font-size:20px}
|
||||
.page-posts .toc a{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.page-posts .toc a:hover{color:var(--fg)}
|
||||
.page-posts .progress-card{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}
|
||||
.page-posts .progress-card strong{font-size:18px;display:block;margin-bottom:12px}
|
||||
.page-posts .progress-track{height:6px;background:color-mix(in oklch,var(--surface) 25%,transparent);margin-bottom:8px}
|
||||
.page-posts .progress-bar{height:100%;background:var(--accent);width:40%}
|
||||
.page-posts .progress-card .p-label{font:11px/1.6 var(--font-mono);color:color-mix(in oklch,var(--surface) 72%,transparent)}
|
||||
.page-posts .files-card{margin-top:18px;border:1px solid var(--fg);padding:22px}
|
||||
.page-posts .files-card strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:12px}
|
||||
.page-posts .file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)}
|
||||
.page-posts .file-row:last-of-type{border-bottom:0}
|
||||
.page-posts .file-row span{font-size:14px}
|
||||
.page-posts .dl-btn{min-height:38px;padding:4px 12px;border:1px solid var(--border);background:transparent;color:var(--accent);font:12px/1.5 var(--font-mono);font-weight:700}.page-posts .dl-btn:hover{border-color:var(--accent);background:var(--accent-soft)}
|
||||
.page-posts .comments{padding-block:80px;border-top:1px solid var(--border)}.page-posts .comments-wrap{max-width:740px}.page-posts .comments h2{font-size:clamp(36px,5vw,54px);margin:0}
|
||||
.page-posts .comment-list{display:grid}.page-posts .comment{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .comment-head{display:flex;justify-content:space-between;gap:16px}.page-posts .comment p{margin:8px 0 0}
|
||||
.page-posts .comment-form{display:grid;gap:18px;margin-top:36px}.page-posts .field{display:grid;gap:7px}.page-posts .field label{font-size:13px;color:var(--muted)}
|
||||
.page-posts .input,.page-posts .textarea{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px}.page-posts .textarea{min-height:130px;resize:vertical}
|
||||
.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-posts .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
|
||||
.page-posts .status{font-size:14px;color:var(--muted);min-height:22px}
|
||||
.page-posts .related{padding-block:80px;border-top:1px solid var(--border)}.page-posts .related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.page-posts .related-card{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .related-card+.related-card{margin-inline-start:28px}.page-posts .related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.page-posts .related-card h3{font-size:24px;margin:12px 0}.page-posts .related-card a{font-weight:700}.page-posts .related-card a:hover{color:var(--accent)}
|
||||
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
|
||||
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
|
||||
.page-posts .footer-col a{display:block;min-height:36px}.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}
|
||||
.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
|
||||
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display:flex;flex-direction:column}.page-posts .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
|
||||
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .related-grid{grid-template-columns:1fr}.page-posts .related-card+.related-card{margin-inline-start:0}.page-posts .article-body{font-size:18px}}
|
||||
@media(max-width:620px){.page-posts .nav-account .name-label{display:none}.page-posts .article-head h1{font-size:46px}.page-posts .article-layout,.page-posts .comments,.page-posts .related{padding-block:56px}.page-posts .footer-grid{grid-template-columns:1fr;gap:28px}.page-posts .article-meta{align-items:flex-start;flex-direction:column}.page-posts .steps li{grid-template-columns:38px 1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Page-scoped styles: :is(.page-privacy, .page-terms). Shared tokens live in globals.css. */
|
||||
:is(.page-privacy, .page-terms) *,:is(.page-privacy, .page-terms) *::before,:is(.page-privacy, .page-terms) *::after{box-sizing:border-box}:is(.page-privacy, .page-terms){-webkit-text-size-adjust:100%}:is(.page-privacy, .page-terms){margin:0;background:var(--bg);color:var(--fg);font:16px/1.85 var(--font-body)}:is(.page-privacy, .page-terms) a{color:inherit;text-decoration:none}:is(.page-privacy, .page-terms) h1,:is(.page-privacy, .page-terms) h2{font-family:var(--font-display);text-wrap:balance}:is(.page-privacy, .page-terms) p{text-wrap:pretty}:is(.page-privacy, .page-terms) .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
:is(.page-privacy, .page-terms) .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)}:is(.page-privacy, .page-terms) .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}:is(.page-privacy, .page-terms) .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}:is(.page-privacy, .page-terms) .nav-links{display:flex;align-items:center;gap:28px}:is(.page-privacy, .page-terms) .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}:is(.page-privacy, .page-terms) .nav-links a:hover{color:var(--fg)}
|
||||
:is(.page-privacy, .page-terms) .hero{padding-block:clamp(58px,9vw,110px);border-bottom:1px solid var(--border)}:is(.page-privacy, .page-terms) .eyebrow{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}:is(.page-privacy, .page-terms) .hero h1{font-size:clamp(44px,7vw,78px);line-height:1.08;margin:0;max-width:12ch}:is(.page-privacy, .page-terms) .lead{font-size:19px;color:var(--muted);max-width:58ch;margin:24px 0 0}:is(.page-privacy, .page-terms) .notice{margin-top:28px;border:1px solid var(--accent);padding:18px 20px;color:var(--fg);max-width:64ch}
|
||||
:is(.page-privacy, .page-terms) .policy{padding-block:72px 90px}:is(.page-privacy, .page-terms) .policy section{padding-block:30px;border-top:1px solid var(--border)}:is(.page-privacy, .page-terms) .policy h2{font-size:clamp(28px,4vw,40px);margin:0 0 12px}:is(.page-privacy, .page-terms) .policy p,:is(.page-privacy, .page-terms) .policy li{color:var(--muted)}:is(.page-privacy, .page-terms) .policy ul{margin:0;padding-inline-start:24px}:is(.page-privacy, .page-terms) .policy a{color:var(--fg);text-decoration:underline;text-underline-offset:4px}:is(.page-privacy, .page-terms) .pagefoot{padding-block:46px;border-top:1px solid var(--border);color:var(--muted)}:is(.page-privacy, .page-terms) .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||
@media(max-width:920px){:is(.page-privacy, .page-terms) .nav-links{display:flex}}
|
||||
@media(max-width:620px){:is(.page-privacy, .page-terms) .hero{padding-block:48px}:is(.page-privacy, .page-terms) .policy{padding-block:48px 64px}:is(.page-privacy, .page-terms) .notice{padding:16px}}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Page-scoped styles: .page-signup. Shared tokens live in globals.css. */
|
||||
.page-signup *,.page-signup *::before,.page-signup *::after{box-sizing:border-box}.page-signup{-webkit-text-size-adjust:100%}.page-signup{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}.page-signup a{color:inherit;text-decoration:none}.page-signup button,.page-signup input{font:inherit}.page-signup button{cursor:pointer}.page-signup h1,.page-signup h2{font-family:var(--font-display);text-wrap:balance}.page-signup p{text-wrap:pretty}.page-signup :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-signup .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-signup .topnav{border-bottom:1px solid var(--border)}.page-signup .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.page-signup .logo{display:inline-flex;align-items:center;gap:10px;font-size:23px;font-weight:700}.page-signup .logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.page-signup .nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.page-signup .nav-return:hover{color:var(--fg);border-color:var(--fg)}
|
||||
.page-signup .auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.page-signup .auth>*{min-width:0}.page-signup .auth-story{padding:clamp(54px,8vw,104px) max(var(--gutter),calc((100% - var(--container))/2 + var(--gutter)));padding-inline-end:clamp(48px,8vw,100px);background:var(--fg);color:var(--surface);display:flex;flex-direction:column;justify-content:space-between}.page-signup .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-signup .auth-story .eyebrow{color:var(--surface);opacity:.78}.page-signup .auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;max-width:10ch;margin:0}.page-signup .auth-story>div>p{color:color-mix(in oklch,var(--surface) 78%,transparent);font-size:19px;max-width:40ch}.page-signup .story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 32%,transparent)}.page-signup .story-list li{padding:15px 0;border-bottom:1px solid color-mix(in oklch,var(--surface) 22%,transparent);display:flex;justify-content:space-between;gap:20px}.page-signup .story-mark{font:12px/1.5 var(--font-mono);opacity:.74}.page-signup .story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 72%,transparent)}
|
||||
.page-signup .auth-form-wrap{padding:clamp(46px,7vw,88px) max(var(--gutter),calc((100% - var(--container))/2 + var(--gutter)));padding-inline-start:clamp(48px,8vw,100px);display:flex;align-items:center}.page-signup .auth-form{width:min(440px,100%)}.page-signup .auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.page-signup .lead{color:var(--muted);font-size:18px;margin:12px 0 28px}.page-signup .field{display:grid;gap:7px;margin-top:16px}.page-signup .field label{font-size:13px;color:var(--muted)}.page-signup .input-wrap{position:relative}.page-signup .input{width:100%;min-height:52px;border:1px solid color-mix(in oklch,var(--border) 72%,var(--fg));background:var(--surface);color:var(--fg);padding:13px 14px;border-radius:0}.page-signup .input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.page-signup .password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.page-signup .password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.page-signup .check{display:flex;align-items:flex-start;gap:10px;margin:20px 0}.page-signup .check input{margin-top:6px;flex:none}.page-signup .check a{text-decoration:underline;text-underline-offset:3px}.page-signup .check a:hover{color:var(--accent)}.page-signup .btn{min-height:52px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.page-signup .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-signup .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-signup .signin{text-align:center;color:var(--muted);font-size:14px;margin-top:22px}.page-signup .signin a{color:var(--fg);text-decoration:underline;text-underline-offset:3px}.page-signup .signin a:hover{color:var(--accent)}.page-signup .fine{font-size:12px;color:var(--muted);margin-top:14px}.page-signup .status{min-height:28px;margin-top:12px;color:var(--accent);font-size:13px}
|
||||
@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}}
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Page-scoped styles: .page-subscription. Shared tokens live in globals.css. */
|
||||
.page-subscription *,.page-subscription *::before,.page-subscription *::after{box-sizing:border-box}.page-subscription{scroll-behavior:smooth;-webkit-text-size-adjust:100%}.page-subscription{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}.page-subscription.locked{overflow:hidden}.page-subscription a{color:inherit;text-decoration:none}.page-subscription button{font:inherit;cursor:pointer}.page-subscription h1,.page-subscription h2,.page-subscription h3{font-family:var(--font-display);text-wrap:balance}.page-subscription p{text-wrap:pretty}.page-subscription :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}.page-subscription .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-subscription .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-subscription .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}.page-subscription .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-subscription .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)}.page-subscription .nav-links{display:flex;align-items:center;gap:28px}.page-subscription .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-subscription .nav-links a:hover{color:var(--fg)}.page-subscription .nav-tools{display:flex;align-items:center;gap:8px}.page-subscription .nav-login{min-height:44px;display:inline-flex;align-items:center;padding-inline:16px;border:1px solid var(--border)}.page-subscription .nav-login:hover{border-color:var(--fg);background:var(--fg-soft)}.page-subscription .icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--fg);background:var(--fg);color:var(--surface)}.page-subscription .icon-btn:hover{background:var(--accent);border-color:var(--accent);color:var(--surface)}
|
||||
.page-subscription .hero{padding-block:clamp(72px,11vw,140px);border-bottom:1px solid var(--border)}.page-subscription .hero-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:clamp(48px,9vw,110px);align-items:end}.page-subscription .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-subscription .hero .eyebrow{display:block;margin-bottom:34px}.page-subscription .hero h1{font-size:clamp(52px,8vw,102px);line-height:1.08;max-width:10ch;margin:0}.page-subscription .lead{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:52ch;margin:26px 0 0}.page-subscription .hero-note{border-top:1px solid var(--fg);padding-top:24px}.page-subscription .hero-note strong{font-family:var(--font-display);font-size:28px;display:block}.page-subscription .hero-note p{color:var(--muted);margin:8px 0 0}
|
||||
.page-subscription .benefits{padding-block:90px;border-bottom:1px solid var(--border)}.page-subscription .section-head{display:flex;justify-content:space-between;align-items:end;gap:40px;margin-bottom:50px}.page-subscription .section-head h2{font-size:clamp(38px,5vw,60px);line-height:1.15;margin:0;max-width:13ch}.page-subscription .section-head p{color:var(--muted);max-width:46ch}.page-subscription .benefit-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.page-subscription .benefit{padding:30px 0;border-bottom:1px solid var(--border)}.page-subscription .benefit+.benefit{margin-inline-start:32px}.page-subscription .benefit-index{font:12px/1.5 var(--font-mono);color:var(--accent)}.page-subscription .benefit h3{font-size:28px;margin:26px 0 10px}.page-subscription .benefit p{color:var(--muted);margin:0}
|
||||
.page-subscription .guide-samples{padding-block:90px;background:var(--surface);border-bottom:1px solid var(--border)}.page-subscription .guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.page-subscription .guide-card{display:flex;flex-direction:column;min-height:440px;border:1px solid var(--border);background:var(--bg)}.page-subscription .guide-cover{min-height:174px;padding:26px;display:flex;flex-direction:column;justify-content:space-between;background:var(--fg);color:var(--surface)}.page-subscription .guide-cover-light{background:var(--surface);color:var(--fg);border-bottom:1px solid var(--fg)}.page-subscription .guide-cover span:first-child{font:11px/1.5 var(--font-mono);}.page-subscription .guide-cover strong{font-family:var(--font-display);font-size:clamp(28px,3vw,40px);line-height:1.1;max-width:9ch}.page-subscription .guide-body{padding:26px;display:flex;flex:1;flex-direction:column}.page-subscription .guide-meta{display:flex;justify-content:space-between;gap:16px;color:var(--muted);font:12px/1.5 var(--font-mono)}.page-subscription .guide-body h3{font-size:25px;margin:24px 0 10px}.page-subscription .guide-body p{color:var(--muted);margin:0}.page-subscription .guide-output{margin:22px 0 0;padding:18px 0;border-block:1px solid var(--border)}.page-subscription .guide-output strong{display:block;font-size:13px;margin-bottom:6px}.page-subscription .guide-output span{color:var(--muted);font-size:14px}.page-subscription .guide-link{min-height:44px;display:flex;align-items:end;justify-content:space-between;margin-top:auto;padding-top:22px;font-weight:700}.page-subscription .guide-link:hover{color:var(--accent)}
|
||||
.page-subscription .outcomes{padding-block:90px;border-bottom:1px solid var(--border)}.page-subscription .outcome-grid{display:grid;grid-template-columns:.9fr 1.1fr;gap:clamp(48px,9vw,110px);align-items:start}.page-subscription .outcome-grid h2{font-size:clamp(40px,5vw,64px);line-height:1.12;margin:0}.page-subscription .outcome-list{border-top:1px solid var(--fg)}.page-subscription .outcome-card{display:grid;grid-template-columns:72px 1fr;gap:22px;padding:28px 0;border-bottom:1px solid var(--border)}.page-subscription .outcome-card .benefit-index{padding-top:7px}.page-subscription .outcome-card h3{font-size:27px;margin:0 0 8px}.page-subscription .outcome-card p{color:var(--muted);margin:0}.page-subscription .team-contact{margin-top:34px;padding:30px;border:1px solid var(--fg);display:flex;align-items:center;justify-content:space-between;gap:28px}.page-subscription .team-contact h3{font-size:26px;margin:0 0 6px}.page-subscription .team-contact p{color:var(--muted);margin:0;max-width:48ch}.page-subscription .team-contact .btn{flex:0 0 auto}
|
||||
.page-subscription .pricing{padding-block:90px;background:var(--fg);color:var(--surface)}.page-subscription .pricing-grid{display:grid;grid-template-columns:1fr 1fr;gap:clamp(48px,9vw,110px);align-items:start}.page-subscription .pricing h2{font-size:clamp(40px,6vw,70px);line-height:1.08;margin:0}.page-subscription .pricing .lead{color:color-mix(in oklch,var(--surface) 70%,transparent)}.page-subscription .plan{background:var(--surface);color:var(--fg);padding:clamp(30px,5vw,54px)}.page-subscription .plan-head{display:flex;justify-content:space-between;align-items:start;gap:20px;padding-bottom:26px;border-bottom:1px solid var(--border)}.page-subscription .plan h3{font-size:32px;margin:0}.page-subscription .price{font-family:var(--font-display);font-size:58px;line-height:1;text-align:left}.page-subscription .price small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:8px}.page-subscription .trial-label{display:inline-block;font:11px/1.5 var(--font-mono);color:var(--accent);margin-top:10px}.page-subscription .plan-list{list-style:none;margin:24px 0;padding:0}.page-subscription .plan-list li{display:flex;justify-content:space-between;gap:16px;padding:13px 0;border-bottom:1px solid var(--border)}.page-subscription .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 20px;border:1px solid transparent;font-weight:700}.page-subscription .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-subscription .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}.page-subscription .plan .btn{width:100%}.page-subscription .plan-fine{font-size:12px;color:var(--muted);text-align:center;margin:12px 0 0}
|
||||
.page-subscription .compare{padding-block:90px;border-bottom:1px solid var(--border)}.page-subscription .compare h2,.page-subscription .faq h2{font-size:clamp(38px,5vw,58px);margin:0 0 40px}.page-subscription .table-wrap{overflow-x:auto}.page-subscription .table{width:100%;border-collapse:collapse;min-width:620px}.page-subscription .table th,.page-subscription .table td{padding:16px 14px;border-bottom:1px solid var(--border);text-align:right}.page-subscription .table th{font:12px/1.5 var(--font-mono);color:var(--muted)}.page-subscription .table td:not(:first-child),.page-subscription .table th:not(:first-child){text-align:center}.page-subscription .member-col{border-inline:1px solid var(--accent)}
|
||||
.page-subscription .faq{padding-block:90px}.page-subscription .faq-wrap{display:grid;grid-template-columns:.7fr 1.3fr;gap:clamp(46px,8vw,100px)}.page-subscription .faq-list{border-top:1px solid var(--fg)}.page-subscription .faq-item{border-bottom:1px solid var(--border)}.page-subscription .faq-button{width:100%;min-height:68px;border:0;background:transparent;color:var(--fg);display:flex;justify-content:space-between;align-items:center;gap:20px;text-align:right;font-weight:700}.page-subscription .faq-button:hover{color:var(--accent)}.page-subscription .faq-answer{display:none;color:var(--muted);padding:0 0 22px;max-width:64ch}.page-subscription .faq-item.open .faq-answer{display:block}.page-subscription .faq-item.open .faq-button span:last-child{transform:rotate(45deg)}
|
||||
.page-subscription .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}.page-subscription .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}.page-subscription .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}.page-subscription .footer-col a{display:block;min-height:36px}.page-subscription .footer-col a:hover{color:var(--fg);text-decoration:underline}.page-subscription .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-subscription .menu-panel,.page-subscription .dialog{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-subscription .menu-panel.open,.page-subscription .dialog.open{visibility:visible;pointer-events:auto}.page-subscription .menu-scrim,.page-subscription .dialog-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 58%,transparent);opacity:0}.page-subscription .menu-panel.open .menu-scrim,.page-subscription .dialog.open .dialog-scrim{opacity:1}.page-subscription .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-subscription .menu-panel.open .menu-drawer{transform:translateX(0)}.page-subscription .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-subscription .menu-links{display:grid;margin-top:34px}.page-subscription .menu-links a{min-height:70px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-family:var(--font-display);font-size:clamp(27px,4vw,38px)}.page-subscription .menu-links a:hover{color:var(--accent)}.page-subscription .menu-foot{margin-top:auto;color:var(--muted)}.page-subscription .dialog-panel{position:relative;width:min(560px,calc(100% - 36px));margin:10vh auto 0;background:var(--surface);padding:clamp(30px,5vw,54px);border:1px solid var(--fg)}.page-subscription .dialog-panel h2{font-size:42px;margin:12px 0}.page-subscription .dialog-panel p{color:var(--muted)}.page-subscription .dialog-close{position:absolute;inset-inline-end:18px;top:18px;background:transparent;color:var(--fg);border-color:var(--border)}.page-subscription .dialog-actions{display:flex;gap:10px;margin-top:28px}.page-subscription .dialog-actions .btn{flex:1}.page-subscription .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-subscription .btn-secondary:hover{background:var(--fg);color:var(--surface);border-color:var(--fg)}
|
||||
@media(max-width:920px){.page-subscription .nav-links{display:none}.page-subscription .hero-grid,.page-subscription .pricing-grid,.page-subscription .faq-wrap,.page-subscription .outcome-grid{grid-template-columns:1fr}.page-subscription .benefit-grid,.page-subscription .guide-grid{grid-template-columns:1fr}.page-subscription .benefit+.benefit{margin-inline-start:0}.page-subscription .guide-card{min-height:0}.page-subscription .footer-grid{grid-template-columns:1fr 1fr}.page-subscription .section-head{align-items:start;flex-direction:column}.page-subscription .hero-note{max-width:520px}}
|
||||
@media(max-width:620px){.page-subscription .nav-login{display:none}.page-subscription .hero h1{font-size:50px}.page-subscription .benefits,.page-subscription .guide-samples,.page-subscription .outcomes,.page-subscription .pricing,.page-subscription .compare,.page-subscription .faq{padding-block:60px}.page-subscription .footer-grid{grid-template-columns:1fr;gap:28px}.page-subscription .plan-head,.page-subscription .team-contact{flex-direction:column;align-items:stretch}.page-subscription .team-contact .btn{width:100%}.page-subscription .outcome-card{grid-template-columns:48px 1fr}.page-subscription .price{text-align:right}.page-subscription .dialog-actions{flex-direction:column}}
|
||||
@media(prefers-reduced-motion:reduce){.page-subscription *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
/* QA-R01/R07: hero join CTA + price on the first fold. */
|
||||
.page-subscription .hero-join{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:24px}
|
||||
.page-subscription .hero-join .price{font-size:34px}
|
||||
.page-subscription .hero-join .price small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:8px}
|
||||
.page-subscription .cta-pair{display:inline-flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
.page-subscription .trust-line{margin:14px 0 0;color:var(--muted);font-size:14px}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* Page-scoped styles: .page-vip-onboarding. Shared tokens live in globals.css. */
|
||||
.page-vip-onboarding *,.page-vip-onboarding *::before,.page-vip-onboarding *::after{box-sizing:border-box}
|
||||
.page-vip-onboarding{-webkit-text-size-adjust:100%}
|
||||
.page-vip-onboarding{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility}
|
||||
.page-vip-onboarding.locked{overflow:hidden}
|
||||
.page-vip-onboarding a{color:inherit;text-decoration:none}
|
||||
.page-vip-onboarding button,.page-vip-onboarding input{font:inherit}
|
||||
.page-vip-onboarding button{cursor:pointer}
|
||||
.page-vip-onboarding h1,.page-vip-onboarding h2,.page-vip-onboarding h3{font-family:var(--font-display);text-wrap:balance}
|
||||
.page-vip-onboarding p{text-wrap:pretty}
|
||||
.page-vip-onboarding :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-vip-onboarding .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-vip-onboarding .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-vip-onboarding .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-vip-onboarding .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}
|
||||
.page-vip-onboarding .nav-links{display:flex;align-items:center;gap:28px}
|
||||
.page-vip-onboarding .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}
|
||||
.page-vip-onboarding .nav-links a:hover{color:var(--fg)}
|
||||
.page-vip-onboarding .nav-tools{display:flex;align-items:center;gap:8px}
|
||||
.page-vip-onboarding .nav-account{min-height:44px;display:inline-flex;align-items:center;padding-inline:16px;border:1px solid var(--border);font-weight:700}
|
||||
.page-vip-onboarding .nav-account:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-vip-onboarding .icon-btn{width:46px;height:46px;display:grid;place-items:center;border:1px solid var(--border);background:transparent;color:var(--fg)}
|
||||
.page-vip-onboarding .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-vip-onboarding .member-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px var(--gutter);text-align:center}
|
||||
.page-vip-onboarding .onboarding-head{padding-block:clamp(48px,7vw,82px) 42px;border-bottom:1px solid var(--border)}
|
||||
.page-vip-onboarding .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 18px}
|
||||
.page-vip-onboarding .onboarding-head h1{font-size:clamp(42px,6vw,72px);line-height:1.1;margin:0;max-width:13ch}
|
||||
.page-vip-onboarding .lead{font-size:clamp(18px,2vw,22px);color:var(--muted);max-width:58ch;margin:18px 0 0}
|
||||
.page-vip-onboarding .progress-wrap{margin-top:34px;max-width:720px}
|
||||
.page-vip-onboarding .progress-copy{display:flex;justify-content:space-between;gap:16px;color:var(--muted);font:12px/1.5 var(--font-mono);margin-bottom:10px}
|
||||
.page-vip-onboarding .progress-track{height:4px;background:var(--border);overflow:hidden}
|
||||
.page-vip-onboarding .progress-bar{height:100%;width:25%;background:var(--accent);transition:width .22s ease}
|
||||
.page-vip-onboarding .onboarding-layout{display:grid;grid-template-columns:250px minmax(0,1fr);gap:clamp(42px,7vw,90px);align-items:start;padding-block:50px 90px}
|
||||
.page-vip-onboarding .step-nav{position:sticky;top:100px;border-top:1px solid var(--fg)}
|
||||
.page-vip-onboarding .step-nav button{width:100%;min-height:58px;display:grid;grid-template-columns:34px 1fr;align-items:center;gap:12px;padding:0 4px;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--muted);text-align:right}
|
||||
.page-vip-onboarding .step-nav button[aria-current="step"]{color:var(--fg);font-weight:700}
|
||||
.page-vip-onboarding .step-nav button:disabled{cursor:default}
|
||||
.page-vip-onboarding .step-num{font:12px/1 var(--font-mono)}
|
||||
.page-vip-onboarding .step-nav button[aria-current="step"] .step-num{color:var(--accent)}
|
||||
.page-vip-onboarding .step-nav button.done{color:var(--fg)}
|
||||
.page-vip-onboarding .step-panel{display:none;max-width:720px}
|
||||
.page-vip-onboarding .step-panel.active{display:block}
|
||||
.page-vip-onboarding .step-panel h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:0 0 12px}
|
||||
.page-vip-onboarding .step-intro{color:var(--muted);max-width:58ch;margin:0 0 32px}
|
||||
.page-vip-onboarding .choice-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||||
.page-vip-onboarding .choice{position:relative;display:block}
|
||||
.page-vip-onboarding .choice input{position:absolute;opacity:0;pointer-events:none}
|
||||
.page-vip-onboarding .choice-body{min-height:150px;display:flex;flex-direction:column;padding:22px;border:1px solid var(--border);background:color-mix(in oklch,var(--surface) 90%,transparent);transition:border-color .15s,background .15s,transform .15s}
|
||||
.page-vip-onboarding .choice-body:hover{border-color:var(--fg);transform:translateY(-1px)}
|
||||
.page-vip-onboarding .choice input:checked + .choice-body{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-vip-onboarding .choice input:focus-visible + .choice-body{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-vip-onboarding .choice-title{font-size:19px;font-weight:700}
|
||||
.page-vip-onboarding .choice-copy{color:var(--muted);font-size:14px;margin-top:8px}
|
||||
.page-vip-onboarding .choice-mark{width:18px;height:18px;border:1px solid var(--muted);margin-top:auto;align-self:flex-end;display:grid;place-items:center}
|
||||
.page-vip-onboarding .choice input:checked + .choice-body .choice-mark{background:var(--fg);border-color:var(--fg)}
|
||||
.page-vip-onboarding .choice input:checked + .choice-body .choice-mark::after{content:"";width:6px;height:10px;border:solid var(--surface);border-width:0 2px 2px 0;transform:rotate(45deg) translate(-1px,-1px)}
|
||||
.page-vip-onboarding .notice-card{border-block:1px solid var(--fg);padding-block:8px}
|
||||
.page-vip-onboarding .notice-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 0;border-bottom:1px solid var(--border)}
|
||||
.page-vip-onboarding .notice-row:last-child{border-bottom:0}
|
||||
.page-vip-onboarding .notice-row strong{display:block;font-size:18px}
|
||||
.page-vip-onboarding .notice-row p{color:var(--muted);font-size:14px;margin:4px 0 0;max-width:48ch}
|
||||
.page-vip-onboarding .switch{position:relative;width:52px;min-height:32px;flex:none}
|
||||
.page-vip-onboarding .switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}
|
||||
.page-vip-onboarding .track{position:absolute;inset:0;border:1px solid var(--muted);background:var(--surface);pointer-events:none}
|
||||
.page-vip-onboarding .track::after{content:"";position:absolute;top:4px;inset-inline-start:4px;width:22px;height:22px;background:var(--fg);transition:transform .15s}
|
||||
.page-vip-onboarding .switch input:checked + .track{background:var(--fg);border-color:var(--fg)}
|
||||
.page-vip-onboarding .switch input:checked + .track::after{transform:translateX(-20px);background:var(--surface)}
|
||||
.page-vip-onboarding .switch input:focus-visible + .track{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-vip-onboarding .summary{border-top:1px solid var(--fg)}
|
||||
.page-vip-onboarding .summary-row{display:grid;grid-template-columns:150px 1fr;gap:20px;padding:18px 0;border-bottom:1px solid var(--border)}
|
||||
.page-vip-onboarding .summary-row dt{color:var(--muted);font:12px/1.7 var(--font-mono)}
|
||||
.page-vip-onboarding .summary-row dd{margin:0;font-weight:700}
|
||||
.page-vip-onboarding .start-card{margin-top:28px;padding:26px;border:1px solid var(--fg);background:var(--surface)}
|
||||
.page-vip-onboarding .start-card h3{font-size:26px;margin:0 0 8px}
|
||||
.page-vip-onboarding .start-card p{color:var(--muted);margin:0 0 22px;max-width:52ch}
|
||||
.page-vip-onboarding .actions{display:flex;align-items:center;gap:12px;margin-top:34px}
|
||||
.page-vip-onboarding .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-vip-onboarding .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||
.page-vip-onboarding .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--surface)}
|
||||
.page-vip-onboarding .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}
|
||||
.page-vip-onboarding .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-vip-onboarding .btn[disabled]{opacity:.45;cursor:not-allowed;transform:none}
|
||||
.page-vip-onboarding .status{min-height:26px;color:var(--muted);font-size:14px;margin:14px 0 0}
|
||||
.page-vip-onboarding .pagefoot{padding-block:48px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-vip-onboarding .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;font-size:13px}
|
||||
.page-vip-onboarding .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}
|
||||
.page-vip-onboarding .menu-panel.open{visibility:visible;pointer-events:auto}
|
||||
.page-vip-onboarding .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--bg) 72%,transparent)}
|
||||
.page-vip-onboarding .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-vip-onboarding .menu-panel.open .menu-drawer{transform:translateX(0)}
|
||||
.page-vip-onboarding .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}
|
||||
.page-vip-onboarding .menu-links{display:flex;flex-direction:column;margin-top:24px}
|
||||
.page-vip-onboarding .menu-links a{min-height:60px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);font-size:18px}
|
||||
.page-vip-onboarding .menu-links a:hover{color:var(--fg);background:var(--fg-soft)}
|
||||
@media(max-width:920px){
|
||||
.page-vip-onboarding .onboarding-layout{grid-template-columns:1fr;padding-block:34px 70px}
|
||||
.page-vip-onboarding .step-nav{position:static;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));overflow-x:auto;border-top:0;padding-bottom:4px}
|
||||
.page-vip-onboarding .step-nav button{min-width:150px;border:1px solid var(--border);padding-inline:14px}
|
||||
.page-vip-onboarding .choice-grid{grid-template-columns:1fr 1fr}
|
||||
}
|
||||
@media(max-width:620px){
|
||||
.page-vip-onboarding .member-strip{text-align:right}
|
||||
.page-vip-onboarding .onboarding-head{padding-block:42px 32px}
|
||||
.page-vip-onboarding .onboarding-head h1{font-size:44px}
|
||||
.page-vip-onboarding .choice-grid{grid-template-columns:1fr}
|
||||
.page-vip-onboarding .choice-body{min-height:132px}
|
||||
.page-vip-onboarding .notice-row{align-items:flex-start}
|
||||
.page-vip-onboarding .summary-row{grid-template-columns:1fr;gap:4px}
|
||||
.page-vip-onboarding .actions{align-items:stretch;flex-direction:column-reverse}
|
||||
.page-vip-onboarding .actions .btn{width:100%}
|
||||
.page-vip-onboarding .start-card{padding:22px}
|
||||
.page-vip-onboarding .footer-bottom{flex-direction:column}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){.page-vip-onboarding *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
/* FAQ accordions — port of the subscription page's faq-button toggle. */
|
||||
export default function Faq({ items }: { items: { q: string; a: string }[] }) {
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<div className="faq-list">
|
||||
{items.map((item, i) => {
|
||||
const open = openIdx === i
|
||||
return (
|
||||
<article className={`faq-item${open ? ' open' : ''}`} key={i}>
|
||||
<button
|
||||
className="faq-button"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpenIdx(open ? null : i)}
|
||||
>
|
||||
<span>{item.q}</span><span aria-hidden="true">{open ? '−' : '+'}</span>
|
||||
</button>
|
||||
<div className="faq-answer" hidden={!open}>{item.a}</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import SubscribeButton from './subscribe-button'
|
||||
import Faq from './faq'
|
||||
import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import type { Viewer } from '@/lib/viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'العضوية' }
|
||||
|
||||
/* Member-library samples — real VIP guides, read from the CMS at request time. */
|
||||
const SAMPLES = [
|
||||
{ slug: 'agent-loop-stop-condition', cover: 'تشريح وكيل ناجح', variant: '', output: 'قالب حالة الوكيل + قائمة فحص قبل التشغيل' },
|
||||
{ slug: 'systematic-model-eval', cover: 'قِس قبل أن تبدّل', variant: 'guide-cover-light', output: 'مجموعة صغيرة من اختبارات التقييم قابلة لإعادة التشغيل' },
|
||||
{ slug: 'prototype-to-production', cover: 'من التجربة إلى الإنتاج', variant: '', output: 'قائمة قرارات ما قبل الإطلاق' },
|
||||
]
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{ q: 'هل أستطيع الإلغاء في أي وقت؟', a: 'نعم. يتوقف التجديد التالي، ويستمر وصولك حتى نهاية الفترة المدفوعة.' },
|
||||
{ q: 'هل المحتوى مناسب للمبتدئ؟', a: 'كل دليل يوضح مستواه. ابدأ بالأدلة المجانية في مسار الأساسيات، ثم انتقل إلى الوكلاء والأتمتة.' },
|
||||
{ q: 'هل توجد شهادة إكمال؟', a: 'لا حالياً. تركيز أنور بآي على التطبيق والملفات العملية، لا على شهادات الحضور.' },
|
||||
{ q: 'هل يمكنني استخدام الملفات في عملي؟', a: 'نعم للاستخدام الشخصي والمهني داخل مشاريعك. إعادة بيع الملفات أو نشرها كما هي غير مشمول.' },
|
||||
]
|
||||
|
||||
export default async function SubscriptionPage() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
const role = (user?.role as string) || null
|
||||
const entitled = role === 'vip' || role === 'admin'
|
||||
const viewer: Viewer = user ? { email: user.email, role: role ?? 'free' } : null
|
||||
const { docs: published } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
depth: 0,
|
||||
limit: 200,
|
||||
select: { title: true, slug: true, dek: true, category: true, readingMinutes: true, access: true },
|
||||
})
|
||||
const freeCount = published.filter(doc => doc.access === 'free').length
|
||||
const memberCount = published.length - freeCount
|
||||
const samples = SAMPLES.flatMap(sample => {
|
||||
const post = published.find(doc => doc.slug === sample.slug)
|
||||
return post ? [{ ...sample, post }] : []
|
||||
})
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-membership">
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<section className="mem-hero" aria-labelledby="mem-title">
|
||||
<div className="wrap mem-hero-grid">
|
||||
<div className="mem-hero-copy">
|
||||
<h1 id="mem-title">كل الأدلة كاملة، مع ملفات العمل.</h1>
|
||||
<p className="lede">الأدلة المجانية تعرّفك بالمفاهيم. العضوية تفتح الأدلة المعمّقة التي تأخذك من التجربة إلى نظام يعمل، مع القوالب وقوائم الفحص التي نستخدمها فعلاً.</p>
|
||||
<ul className="mem-points" role="list">
|
||||
<li><strong>{guidesLabel(memberCount)} للأعضاء</strong><span>تُضاف أدلة جديدة كل شهر</span></li>
|
||||
<li><strong>ملفات قابلة للتنزيل</strong><span>قوالب حالة، قوائم فحص، سجلات أخطاء</span></li>
|
||||
<li><strong>نقاش مع الفريق</strong><span>اسأل في تعليقات أي دليل</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<article className="plan" aria-labelledby="plan-title">
|
||||
<h2 id="plan-title">عضوية فردية</h2>
|
||||
<p className="plan-price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi><span>شهرياً</span></p>
|
||||
<p className="plan-note">تلغي متى شئت، ويبقى حسابك المجاني ومحفوظاتك.</p>
|
||||
<div className="plan-cta">
|
||||
{entitled ? (
|
||||
<a className="btn btn-primary" href="/account-settings">عضويتك فعّالة، أدر حسابك</a>
|
||||
) : (
|
||||
<SubscribeButton loggedIn={Boolean(user)} />
|
||||
)}
|
||||
</div>
|
||||
<p className="fine">الدفع عبر Stripe. لا نحفظ بيانات بطاقتك.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{samples.length > 0 && (
|
||||
<section className="mem-samples" aria-labelledby="samples-title">
|
||||
<div className="wrap">
|
||||
<header className="section-head">
|
||||
<h2 id="samples-title">من أدلة الأعضاء</h2>
|
||||
<p>كل دليل يبدأ بمشكلة حقيقية وينتهي بشيء تشغّله. اقرأ المقدمة مجاناً قبل أن تقرر.</p>
|
||||
</header>
|
||||
<ul className="sample-grid" role="list">
|
||||
{samples.map(({ slug, output, post }) => (
|
||||
<li key={slug}>
|
||||
<Link href={`/posts/${slug}`} className="sample">
|
||||
<span className="meta-row"><span>{post.category}</span><span>{minutesLabel(post.readingMinutes)}</span></span>
|
||||
<span className="sample-title">{post.title}</span>
|
||||
<span className="sample-dek">{post.dek}</span>
|
||||
<span className="sample-output"><span>ما تخرج به</span>{output}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mem-compare" aria-labelledby="compare-title">
|
||||
<div className="wrap narrow">
|
||||
<h2 id="compare-title">الفرق بين الحساب المجاني والعضوية</h2>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th scope="col"><span className="sr-only">الميزة</span></th><th scope="col">حساب مجاني</th><th scope="col" className="is-member">العضوية</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th scope="row">الأدلة المجانية ({formatNumber(freeCount)})</th><td>كاملة</td><td className="is-member">كاملة</td></tr>
|
||||
<tr><th scope="row">أدلة الأعضاء ({formatNumber(memberCount)})</th><td>المقدمة فقط</td><td className="is-member">كاملة</td></tr>
|
||||
<tr><th scope="row">ملفات العمل</th><td>—</td><td className="is-member">تنزيل كامل</td></tr>
|
||||
<tr><th scope="row">التعليق والنقاش</th><td>على الأدلة المجانية</td><td className="is-member">على كل الأدلة</td></tr>
|
||||
<tr><th scope="row">حفظ الأدلة للعودة لاحقاً</th><td>—</td><td className="is-member">متاح</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mem-faq" aria-labelledby="faq-title">
|
||||
<div className="wrap faq-grid">
|
||||
<div>
|
||||
<h2 id="faq-title">أسئلة قبل الاشتراك</h2>
|
||||
<p>لم تجد إجابتك؟ راسلنا على <a className="text-link" href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> ويرد عليك الفريق مباشرة.</p>
|
||||
</div>
|
||||
<Faq items={FAQ_ITEMS} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [comingSoon, setComingSoon] = useState(false)
|
||||
|
||||
async function subscribe() {
|
||||
setPending(true)
|
||||
const res = await fetch('/api/subscribe', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data?.url) {
|
||||
window.location.href = data.url as string
|
||||
return
|
||||
}
|
||||
}
|
||||
// Stripe pending mode (no keys configured) or failure
|
||||
setComingSoon(true)
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
if (comingSoon) {
|
||||
return <p className="plan-soon" role="status">الدفع الإلكتروني متاح قريباً. راسلنا على <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> لتفعيل عضويتك يدوياً.</p>
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
/* QA-R01/R07: anonymous visitors get explicit signup + login CTAs instead
|
||||
of a silent redirect. Both preserve /subscription as the destination. */
|
||||
return (
|
||||
<span className="cta-pair">
|
||||
<Link className="btn btn-primary" href="/signup?next=%2Fsubscription">أنشئ حساباً واشترك</Link>
|
||||
<Link className="btn btn-quiet" href="/login?next=%2Fsubscription">لدي حساب، تسجيل الدخول</Link>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="btn btn-primary" onClick={subscribe} disabled={pending}>
|
||||
{pending ? 'جارٍ التحويل…' : 'اشترك الآن'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import '../styles/privacy.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'شروط الاستخدام' }
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div id="site" className="site-page page-terms">
|
||||
<Topnav />
|
||||
<main id="content">
|
||||
<section className="hero" data-od-id="terms-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">شروط الاستخدام · مسودة ما قبل الإطلاق</p>
|
||||
<h1 data-od-id="terms-title">شروط استخدام أنور بآي.</h1>
|
||||
<p className="lead">توضح هذه المسودة القواعد الأساسية لاستخدام مكتبة أنور بآي والاشتراك فيها.</p>
|
||||
<p className="notice" role="note">هذه مسودة للمراجعة القانونية وليست نصاً قانونياً نهائياً. يجب اعتماد النسخة النهائية قبل نشر الموقع.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container policy" data-od-id="terms-content">
|
||||
<section data-od-id="terms-service"><h2>ما هي الخدمة؟</h2><p>أنور بآي مكتبة محتوى عربية تقدم أدلة ومسارات تعليمية، بعضها مجاني وبعضها متاح لمشتركي العضوية. يمنحك الاشتراك وصولاً شخصياً إلى المحتوى المدفوع طوال فترة الاشتراك الفعّالة.</p></section>
|
||||
<section data-od-id="terms-account"><h2>حسابك</h2><p>أنت مسؤول عن دقة بيانات التسجيل والاحتفاظ بكلمة المرور في سرية، وعن أي نشاط يجري عبر حسابك. يمكنك حذف حسابك في أي وقت بمراسلة <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>، وسنحذف بياناتك وفق سياسة الخصوصية.</p></section>
|
||||
<section data-od-id="terms-billing"><h2>الاشتراك والدفع</h2><p>العضوية بـ <bdi dir="ltr">$10</bdi> شهرياً وتُدفع عبر مزوّد الدفع (Stripe). يمكنك الإلغاء في أي وقت ويتوقف التجديد التالي فوراً مع بقاء وصولك حتى نهاية الفترة المدفوعة. السعر الحالي سعر تجريبي للمعاينة وقد يتغير قبل الإطلاق مع إشعار مسبق.</p></section>
|
||||
<section data-od-id="terms-content"><h2>المحتوى والملكية</h2><p>حقوق المحتوى والملفات محفوظة لأصحابها. حصولك على الاشتراك يمنحك حق الاستخدام الشخصي والمهني داخل مشاريعك، ولا يشمل إعادة نشر المحتوى أو بيعه أو توزيعه كما هو.</p></section>
|
||||
<section data-od-id="terms-conduct"><h2>الاستخدام المقبول</h2><p>لا يجوز استخدام الخدمة لاستخراج البيانات آلياً أو إعادة نشر المحتوى على نطاق واسع أو أي استخدام يضر بالمنصة أو بمستخدميها. قد نوقف الحسابات المخالفة بعد تنبيه صاحبها.</p></section>
|
||||
<section data-od-id="terms-disclaimer"><h2>إخلاء المسؤولية</h2><p>المحتوى تعليمي ويُقدَّم «كما هو» دون ضمانات لنتائج محددة. أنت مسؤول عن قراراتك ومشاريعك، وينبغي اختبار أي قالب أو ملف قبل استخدامه في بيئة إنتاج.</p></section>
|
||||
<section data-od-id="terms-changes"><h2>تعديل الشروط</h2><p>قد نحدّث هذه الشروط مع نمو الخدمة. عند أي تغيير جوهري سنبلّغ المستخدمين المسجلين عبر البريد الإلكتروني قبل سريان التعديل، ونحدّث تاريخ السريان أعلى الصفحة.</p></section>
|
||||
<section data-od-id="terms-law"><h2>التواصل</h2><p>لأي سؤال عن هذه الشروط راسلنا على <a href="mailto:hello@anwarpy.ai">hello@anwarpy.ai</a>. القانون الحاكم واختصاص المحاكم يحدَّدان في النسخة القانونية النهائية بعد المراجعة.</p></section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import VerifyActions from './verify-actions'
|
||||
import '../styles/email-verification.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'تأكيد البريد الإلكتروني' }
|
||||
|
||||
type Props = { searchParams: Promise<{ invalid?: string }> }
|
||||
|
||||
/* Email verification step — design from email-verification.html. The account
|
||||
was created; the user either clicks the emailed link (real SMTP at deploy)
|
||||
or proceeds to onboarding via the CTA. */
|
||||
export default async function VerifyEmailPage({ searchParams }: Props) {
|
||||
const { invalid } = await searchParams
|
||||
let email: string | null = null
|
||||
let verified = false
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (user) {
|
||||
email = user.email
|
||||
verified = !!user.emailVerified
|
||||
}
|
||||
} catch {
|
||||
/* no db / no session — page still renders */
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page page-verify-email">
|
||||
<Topnav />
|
||||
<main id="content" className="verification">
|
||||
<section className="container" data-od-id="email-verification">
|
||||
<div className="card">
|
||||
<p className="eyebrow">الخطوة التالية · تأكيد البريد</p>
|
||||
<h1 data-od-id="verification-title">افتح بريدك لتفعيل الحساب.</h1>
|
||||
<p className="lead">أرسلنا رابط التأكيد إلى:<strong className="email" id="signupEmail">{email ?? 'بريدك الإلكتروني'}</strong></p>
|
||||
{invalid === '1' && (
|
||||
<p className="status" role="alert">رابط التأكيد غير صالح أو استُخدم من قبل. اطلب رسالة جديدة.</p>
|
||||
)}
|
||||
{verified && (
|
||||
<p className="status" role="status">بريدك الإلكتروني مؤكَّد بالفعل ✓ — تابع إلى الإعداد.</p>
|
||||
)}
|
||||
<ol className="steps" aria-label="خطوات تأكيد البريد">
|
||||
<li><span className="step-num">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>
|
||||
</ol>
|
||||
<VerifyActions verified={verified} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
|
||||
/* Resend button + status — real /api/auth/resend-verification endpoint.
|
||||
Without SMTP configured the API answers 503 with a clear message. */
|
||||
export default function VerifyActions({ verified }: { verified: boolean }) {
|
||||
const [status, setStatus] = useState('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="actions">
|
||||
<Link className="btn btn-primary" href="/onboarding" data-od-id="continue-onboarding-cta">متابعة إعداد الحساب</Link>
|
||||
{!verified && (
|
||||
<button className="btn btn-secondary" id="resendButton" type="button" data-od-id="resend-email-button"
|
||||
onClick={async (e) => {
|
||||
const button = e.currentTarget
|
||||
button.disabled = true
|
||||
setStatus('جارٍ الإرسال…')
|
||||
const res = await fetch('/api/auth/resend-verification', { method: 'POST' })
|
||||
const data = await res.json().catch(() => null)
|
||||
if (res.ok || data?.already) {
|
||||
setStatus('أرسلنا الرسالة من جديد — افحص صندوق الوارد.')
|
||||
} else {
|
||||
setStatus(data?.error || 'تعذّر الإرسال، جرّب بعد قليل.')
|
||||
}
|
||||
setTimeout(() => { button.disabled = false }, 3000)
|
||||
}}
|
||||
>
|
||||
إعادة إرسال الرسالة
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="status" id="resendStatus" role="status" aria-live="polite">{status}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
/* 4-step VIP onboarding wizard — port of vip-onboarding.html step logic
|
||||
(radio choice-grid, next/back, final summary + recommendation).
|
||||
Selections persist in localStorage (faithful to the simulated original). */
|
||||
|
||||
const GOALS = [
|
||||
{ value: 'بناء مشروع ذكاء اصطناعي', copy: 'حوّل فكرة إلى نموذج يعمل ويمكن اختباره مع مستخدمين.' },
|
||||
{ value: 'تطوير مساري المهني', copy: 'ابنِ خبرة عملية ومشروعات تشرح قيمتك بوضوح.' },
|
||||
{ value: 'أتمتة العمل', copy: 'حدد مهمة متكررة وابنِ لها سير عمل يمكن مراقبته.' },
|
||||
{ value: 'فهم الوكلاء بعمق', copy: 'صمم التخطيط والتنفيذ والتحقق وحدود التوقف بأمان.' },
|
||||
]
|
||||
|
||||
const LEVELS = [
|
||||
{ value: 'مبتدئ', label: 'أبدأ من الأساسيات', copy: 'استخدمت أدوات الذكاء الاصطناعي، لكنني لم أبنِ مشروعاً بعد.' },
|
||||
{ value: 'متوسط', label: 'بنيت تجارب بسيطة', copy: 'جرّبت واجهات برمجة التطبيقات (API) أو أتمتة أو تطبيقاً صغيراً.' },
|
||||
{ value: 'متقدم', label: 'أعمل على أنظمة فعلية', copy: 'أحتاج أنماطاً أعمق للقياس والموثوقية والتشغيل.' },
|
||||
]
|
||||
|
||||
const LABELS = ['الخطوة 1 من 4', 'الخطوة 2 من 4', 'الخطوة 3 من 4', 'الخطوة 4 من 4']
|
||||
const PERCENTAGES = ['25٪', '50٪', '75٪', '100٪']
|
||||
const STEP_TITLES = ['هدفك الآن', 'مستواك العملي', 'رسائلك', 'ابدأ مسارك']
|
||||
|
||||
type State = {
|
||||
goal: string
|
||||
level: string
|
||||
weekly: boolean
|
||||
trackUpdates: boolean
|
||||
replies: boolean
|
||||
step: number
|
||||
}
|
||||
|
||||
function recommendation(goal: string): [string, string, string] {
|
||||
if (goal === 'أتمتة العمل') return ['راقب قبل أن تؤتمت', 'ابدأ بتحديد إشارات النجاح والفشل قبل بناء سير العمل، ثم استخدم قائمة المراقبة في أول تجربة.', '/articles?topic=automation']
|
||||
if (goal === 'بناء مشروع ذكاء اصطناعي') return ['من الفكرة إلى نموذج قابل للاختبار', 'اختر مشكلة واحدة ومستخدمًا واحدًا، ثم حوّل الفرضية إلى نموذج صغير يمكن اختباره هذا الأسبوع.', '/posts/prototype-to-production']
|
||||
if (goal === 'تطوير مساري المهني') return ['تشريح وكيل ناجح', 'ابنِ نموذجاً صغيراً يوضح التخطيط والتنفيذ والتحقق، ثم وثّق قراراتك كدراسة حالة.', '/posts/agent-loop-stop-condition']
|
||||
return ['تشريح وكيل ناجح', 'ابدأ بفصل التخطيط عن التنفيذ والتحقق، ثم استخدم قالب حالة الوكيل في مشروع صغير.', '/posts/agent-loop-stop-condition']
|
||||
}
|
||||
|
||||
export default function OnboardingWizard({ viewerId }: { viewerId: number | null }) {
|
||||
const [state, setState] = useState<State>({ goal: '', level: '', weekly: true, trackUpdates: true, replies: true, step: 0 })
|
||||
const [goalStatus, setGoalStatus] = useState('')
|
||||
const [levelStatus, setLevelStatus] = useState('')
|
||||
const [restored, setRestored] = useState(false)
|
||||
|
||||
/* Backend persistence — goal/level/prefs live on the user record. */
|
||||
useEffect(() => {
|
||||
if (!viewerId || !state.goal || !state.level) return
|
||||
fetch(`/api/users/${viewerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
goal: state.goal,
|
||||
level: state.level,
|
||||
prefs: { weekly: state.weekly, trackUpdates: state.trackUpdates, replies: state.replies },
|
||||
}),
|
||||
}).catch(() => null)
|
||||
}, [viewerId, state.goal, state.level, state.weekly, state.trackUpdates, state.replies])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('anwarpi:vip-onboarding')
|
||||
if (raw) {
|
||||
const s = JSON.parse(raw) as Partial<State>
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
goal: s.goal || '',
|
||||
level: s.level && LEVELS.some(l => l.value === s.level) ? s.level : '',
|
||||
weekly: typeof s.weekly === 'boolean' ? s.weekly : true,
|
||||
trackUpdates: typeof s.trackUpdates === 'boolean' ? s.trackUpdates : true,
|
||||
replies: typeof s.replies === 'boolean' ? s.replies : true,
|
||||
step: Math.max(0, Math.min(3, Number(s.step) || 0)),
|
||||
}))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setRestored(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!restored) return
|
||||
try { localStorage.setItem('anwarpi:vip-onboarding', JSON.stringify(state)) } catch { /* ignore */ }
|
||||
}, [state, restored])
|
||||
|
||||
function next() {
|
||||
if (state.step === 0 && !state.goal) { setGoalStatus('اختر هدفاً واحداً للمتابعة.'); return }
|
||||
if (state.step === 1 && !state.level) { setLevelStatus('اختر مستوى واحداً للمتابعة.'); return }
|
||||
setState(s => ({ ...s, step: Math.min(3, s.step + 1) }))
|
||||
}
|
||||
|
||||
const notices = [
|
||||
state.weekly && 'خلاصة الأحد',
|
||||
state.trackUpdates && 'تحديثات المسار',
|
||||
state.replies && 'الردود',
|
||||
].filter(Boolean) as string[]
|
||||
const rec = recommendation(state.goal)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="onboarding-head" data-od-id="onboarding-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">عضو جديد · إعداد سريع</p>
|
||||
<h1 data-od-id="onboarding-title">مرحباً بك. لنبدأ بما يخدم هدفك.</h1>
|
||||
<p className="lead">أجب عن سؤالين وحدد رسائلك المفضلة. سنقترح نقطة بداية واضحة بدلاً من تركك أمام مكتبة كبيرة.</p>
|
||||
<div className="progress-wrap" aria-label="تقدم الإعداد">
|
||||
<div className="progress-copy"><span id="progressLabel">{LABELS[state.step]}</span><span id="progressPercent">{PERCENTAGES[state.step]}</span></div>
|
||||
<div className="progress-track"><div className="progress-bar" id="progressBar" style={{ width: `${(state.step + 1) * 25}%` }}></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container onboarding-layout" data-od-id="onboarding-flow">
|
||||
<nav className="step-nav" aria-label="خطوات إعداد العضوية" data-od-id="onboarding-steps">
|
||||
{STEP_TITLES.map((title, i) => (
|
||||
<button type="button" key={i} aria-current={state.step === i ? 'step' : undefined} disabled className={i < state.step ? 'done' : ''}>
|
||||
<span className="step-num">{'01020304'.slice(i * 2, i * 2 + 2)}</span><span>{title}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<form id="onboardingForm" noValidate onSubmit={e => e.preventDefault()}>
|
||||
<section className={`step-panel${state.step === 0 ? ' active' : ''}`} data-step={0} data-od-id="step-goal">
|
||||
<h2>ما النتيجة التي تريدها أولاً؟</h2>
|
||||
<p className="step-intro">اختر هدفاً واحداً الآن. يمكنك تغييره لاحقاً من إعدادات الحساب.</p>
|
||||
<div className="choice-grid" role="radiogroup" aria-label="هدفك">
|
||||
{GOALS.map(g => (
|
||||
<label className="choice" key={g.value}>
|
||||
<input type="radio" name="goal" value={g.value} required checked={state.goal === g.value} onChange={() => { setState(s => ({ ...s, goal: g.value })); setGoalStatus('') }} />
|
||||
<span className="choice-body"><span className="choice-title">{g.value}</span><span className="choice-copy">{g.copy}</span><span className="choice-mark" aria-hidden="true"></span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="status" id="goalStatus" role="status" aria-live="polite">{goalStatus}</p>
|
||||
<div className="actions"><button className="btn btn-primary" type="button" onClick={next}>تابع إلى مستواك</button></div>
|
||||
</section>
|
||||
|
||||
<section className={`step-panel${state.step === 1 ? ' active' : ''}`} data-step={1} data-od-id="step-level">
|
||||
<h2>ما مستوى خبرتك العملية؟</h2>
|
||||
<p className="step-intro">لا نختبرك. الإجابة تضبط ترتيب المواد وكمية الشرح في اقتراح البداية.</p>
|
||||
<div className="choice-grid" role="radiogroup" aria-label="مستواك">
|
||||
{LEVELS.map(l => (
|
||||
<label className="choice" key={l.value}>
|
||||
<input type="radio" name="level" value={l.value} required checked={state.level === l.value} onChange={() => { setState(s => ({ ...s, level: l.value })); setLevelStatus('') }} />
|
||||
<span className="choice-body"><span className="choice-title">{l.label}</span><span className="choice-copy">{l.copy}</span><span className="choice-mark" aria-hidden="true"></span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="status" id="levelStatus" role="status" aria-live="polite">{levelStatus}</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 0 }))}>رجوع</button>
|
||||
<button className="btn btn-primary" type="button" onClick={next}>تابع إلى الرسائل</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`step-panel${state.step === 2 ? ' active' : ''}`} data-step={2} data-od-id="step-notifications">
|
||||
<h2>اختر ما يستحق الوصول إلى بريدك.</h2>
|
||||
<p className="step-intro">كل الخيارات قابلة للتغيير. لا يؤثر إيقافها على وصولك إلى محتوى العضوية.</p>
|
||||
<div className="notice-card">
|
||||
<div className="notice-row"><div><strong>خلاصة الأحد</strong><p>أفضل ما نُشر هذا الأسبوع مع نقطة تطبيق واحدة.</p></div>
|
||||
<label className="switch"><input type="checkbox" name="weekly" checked={state.weekly} onChange={e => setState(s => ({ ...s, weekly: e.target.checked }))} aria-label="تفعيل خلاصة الأحد" /><span className="track"></span></label></div>
|
||||
<div className="notice-row"><div><strong>تحديثات المسار</strong><p>رسالة عندما نضيف جزءاً جديداً إلى المسار الذي تتبعه.</p></div>
|
||||
<label className="switch"><input type="checkbox" name="trackUpdates" checked={state.trackUpdates} onChange={e => setState(s => ({ ...s, trackUpdates: e.target.checked }))} aria-label="تفعيل تحديثات المسار" /><span className="track"></span></label></div>
|
||||
<div className="notice-row"><div><strong>الردود على تعليقاتك</strong><p>تنبيه فقط عندما يرد عضو أو فريق أنور على سؤالك.</p></div>
|
||||
<label className="switch"><input type="checkbox" name="replies" checked={state.replies} onChange={e => setState(s => ({ ...s, replies: e.target.checked }))} aria-label="تفعيل إشعارات الردود" /><span className="track"></span></label></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 1 }))}>رجوع</button>
|
||||
<button className="btn btn-primary" type="button" onClick={() => setState(s => ({ ...s, step: 3 }))}>اعرض نقطة البداية</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`step-panel${state.step === 3 ? ' active' : ''}`} data-step={3} data-od-id="step-start">
|
||||
<h2>مسارك جاهز.</h2>
|
||||
<p className="step-intro">ابدأ بمادة واحدة، طبّق ملفها العملي، ثم عد إلى المكتبة. لا حاجة لفتح كل شيء اليوم.</p>
|
||||
<dl className="summary">
|
||||
<div className="summary-row"><dt>هدفك</dt><dd id="summaryGoal">{state.goal || '—'}</dd></div>
|
||||
<div className="summary-row"><dt>مستواك</dt><dd id="summaryLevel">{state.level || '—'}</dd></div>
|
||||
<div className="summary-row"><dt>الرسائل</dt><dd id="summaryNotices">{notices.length ? notices.join('، ') : 'لا رسائل بريدية'}</dd></div>
|
||||
</dl>
|
||||
<article className="start-card" data-od-id="recommended-first-guide">
|
||||
<p className="eyebrow">نقطة البداية المقترحة</p>
|
||||
<h3 id="recommendationTitle">{rec[0]}</h3>
|
||||
<p id="recommendationCopy">{rec[1]}</p>
|
||||
<Link className="btn btn-primary" href={rec[2]} id="startGuide" data-od-id="start-first-guide">ابدأ الدليل الأول</Link>
|
||||
</article>
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary" type="button" onClick={() => setState(s => ({ ...s, step: 2 }))}>تعديل الاختيارات</button>
|
||||
<Link className="btn btn-secondary" href="/articles">اذهب إلى المكتبة</Link>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import OnboardingWizard from './onboarding-wizard'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import '../styles/vip-onboarding.css'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'إعداد العضوية' }
|
||||
|
||||
export default async function VipOnboardingPage() {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) redirect('/login?next=%2Fvip-onboarding')
|
||||
if (viewer.role !== 'vip' && viewer.role !== 'admin') redirect('/subscription')
|
||||
return (
|
||||
<div id="site" className="site-page page-vip-onboarding">
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<p className="member-strip" role="status">تم تفعيل عضويتك. خمس دقائق لإعداد مسارك الأول.</p>
|
||||
<OnboardingWizard viewerId={viewer.id ?? null} />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../importMap'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params, searchParams, importMap })
|
||||
|
||||
export default NotFound
|
||||
@@ -0,0 +1,24 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../importMap'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap })
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,52 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import type { ServerFunctionClient } from 'payload'
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
|
||||
import React from 'react'
|
||||
|
||||
import { importMap } from './importMap.js'
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server'
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
})
|
||||
}
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
export default Layout
|
||||
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
export const PUT = REST_PUT(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
@@ -0,0 +1,8 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import type { Media } from '@payload-types'
|
||||
import { resolveCoverImage } from '@/lib/agent-media'
|
||||
import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Agent update endpoint: PUT /api/agent/posts/:slug */
|
||||
export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
|
||||
const key = process.env.AGENT_API_KEY
|
||||
if (!key || req.headers.get('x-agent-key') !== key) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { slug } = await ctx.params
|
||||
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
limit: 1,
|
||||
})
|
||||
const post = existing.docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
// Only writable fields; status is admin-only (except the explicit publish flag)
|
||||
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const field of allowed) {
|
||||
if (field in body) data[field] = body[field]
|
||||
}
|
||||
|
||||
// Explicit image payload (inline base64 or remote URL) → stored as a media document.
|
||||
let coverUrl: string | null = null
|
||||
if ('coverImage' in body && body.coverImage != null) {
|
||||
try {
|
||||
const cover = await resolveCoverImage(payload, body.coverImage)
|
||||
data.cover = cover.id
|
||||
coverUrl = cover.url
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Per-post work files (inline base64) → stored as gated `files` documents.
|
||||
// Like coverImage, `attachments` is an input transform, not a raw field.
|
||||
// Sending it REPLACES the post's file set; omit it to keep existing files.
|
||||
let attachments: Awaited<ReturnType<typeof resolveAttachments>> = []
|
||||
if ('attachments' in body && body.attachments != null) {
|
||||
try {
|
||||
attachments = await resolveAttachments(payload, body.attachments)
|
||||
data.attachments = attachments.map(a => a.id)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// `publish` is an explicit opt-in flag; unpublishing stays admin-only.
|
||||
if (body.publish === true) {
|
||||
data.status = 'published'
|
||||
if (!post.publishedAt) data.publishedAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
let doc
|
||||
try {
|
||||
doc = await payload.update({ collection: 'posts', id: post.id, data, depth: 0 })
|
||||
} catch (err) {
|
||||
// The post write failed — don't orphan the file docs created above
|
||||
// (the post keeps its previous attachments, which are untouched).
|
||||
if (attachments.length) await deleteFileDocs(payload, attachments.map(a => a.id))
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!coverUrl && typeof data.cover === 'number') {
|
||||
const coverDoc: Media = await payload.findByID({ collection: 'media', id: data.cover })
|
||||
coverUrl = coverDoc.url ?? null
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: doc.id, title: doc.title, slug: doc.slug,
|
||||
status: doc.status, access: doc.access, category: doc.category,
|
||||
cover: { id: data.cover ?? post.cover, url: coverUrl },
|
||||
attachments: attachments.map(a => ({ id: a.id, filename: a.filename, access: a.access, url: `/api/files/${a.id}` })),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import type { Payload } from 'payload'
|
||||
import type { Media } from '@payload-types'
|
||||
import configPromise from '@payload-config'
|
||||
import { resolveCoverImage } from '@/lib/agent-media'
|
||||
import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type Body = Record<string, unknown>
|
||||
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
function validKey(req: NextRequest) {
|
||||
const key = process.env.AGENT_API_KEY
|
||||
if (!key) return false
|
||||
return req.headers.get('x-agent-key') === key
|
||||
}
|
||||
|
||||
/** Fields the agent is allowed to write; status is NEVER included. */
|
||||
function pickFields(body: Body): Body {
|
||||
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
|
||||
const out: Body = {}
|
||||
for (const field of allowed) {
|
||||
if (field in body) out[field] = body[field]
|
||||
}
|
||||
// cover omitted → per-category default assigned below (seeded covers)
|
||||
return out
|
||||
}
|
||||
|
||||
const CATEGORY_COVER_SLUGS: Record<string, string> = {
|
||||
'وكلاء': 'cover-agents',
|
||||
'نماذج': 'cover-models',
|
||||
'استرجاع': 'cover-retrieval',
|
||||
'سلامة': 'cover-safety',
|
||||
'أدوات': 'cover-tools',
|
||||
'أعمال': 'cover-business',
|
||||
}
|
||||
|
||||
async function defaultCoverId(payload: Payload, category: unknown) {
|
||||
const filename = CATEGORY_COVER_SLUGS[String(category)] || 'cover-default'
|
||||
const res = await payload.find({
|
||||
collection: 'media',
|
||||
where: { filename: { equals: `${filename}.jpg` } },
|
||||
limit: 1,
|
||||
})
|
||||
return res.docs[0]?.id
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!validKey(req)) return unauthorized()
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const body = (await req.json().catch(() => null)) as Body | null
|
||||
if (!body || typeof body.slug !== 'string' || !body.slug) {
|
||||
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Slug conflict → 409
|
||||
const existing = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: body.slug } },
|
||||
limit: 1,
|
||||
})
|
||||
if (existing.docs.length > 0) {
|
||||
return NextResponse.json({ error: 'slug already exists' }, { status: 409 })
|
||||
}
|
||||
|
||||
const data = pickFields(body)
|
||||
|
||||
// Per-post work files (inline base64) → stored as gated `files` documents.
|
||||
// Like coverImage, `attachments` is an input transform, not a raw field.
|
||||
let attachments: Awaited<ReturnType<typeof resolveAttachments>> = []
|
||||
if ('attachments' in body && body.attachments != null) {
|
||||
try {
|
||||
attachments = await resolveAttachments(payload, body.attachments)
|
||||
data.attachments = attachments.map(a => a.id)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit image payload (inline base64 or remote URL) → stored as a media document.
|
||||
let coverUrl: string | null = null
|
||||
if ('coverImage' in body && body.coverImage != null) {
|
||||
try {
|
||||
const cover = await resolveCoverImage(payload, body.coverImage)
|
||||
data.cover = cover.id
|
||||
coverUrl = cover.url
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
if (!data.cover) data.cover = await defaultCoverId(payload, data.category)
|
||||
|
||||
// `publish` is an explicit opt-in flag — the agent still cannot pass `status`
|
||||
// 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') {
|
||||
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later
|
||||
data.content = {
|
||||
root: {
|
||||
type: 'root', format: '', indent: 0, version: 1, direction: 'rtl',
|
||||
children: [{ type: 'paragraph', version: 1, children: [{ type: 'text', version: 1, text: '…' }] }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let doc
|
||||
try {
|
||||
doc = await payload.create({
|
||||
collection: 'posts',
|
||||
data: { ...data, status: publish ? 'published' : 'draft' },
|
||||
draft: true,
|
||||
depth: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
// The post write failed — don't orphan the file docs created above.
|
||||
if (attachments.length) await deleteFileDocs(payload, attachments.map(a => a.id))
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!coverUrl && data.cover) {
|
||||
const coverDoc: Media = await payload.findByID({ collection: 'media', id: data.cover as number })
|
||||
coverUrl = coverDoc.url ?? null
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
status: doc.status,
|
||||
access: doc.access,
|
||||
category: doc.category,
|
||||
cover: { id: data.cover, url: coverUrl },
|
||||
attachments: attachments.map(a => ({ id: a.id, filename: a.filename, access: a.access, url: `/api/files/${a.id}` })),
|
||||
}, { status: 201 })
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!validKey(req)) return unauthorized()
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const slug = req.nextUrl.pathname.split('/').filter(Boolean).pop() as string
|
||||
const body = (await req.json().catch(() => null)) as Body | null
|
||||
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const post = existing.docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
const data = pickFields(body)
|
||||
delete (data as Body).status // agent can never publish/unpublish
|
||||
|
||||
const doc = await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
status: doc.status,
|
||||
access: doc.access,
|
||||
category: doc.category,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import { randomBytes } from 'crypto'
|
||||
import configPromise from '@payload-config'
|
||||
import { sendVerificationEmail } from '@/lib/mail'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* POST /api/auth/resend-verification — logged-in user asks for a fresh
|
||||
verification email. Regenerates the token so old links die. */
|
||||
export async function POST() {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (user.emailVerified) return NextResponse.json({ already: true })
|
||||
|
||||
const token = randomBytes(32).toString('hex')
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: user.id,
|
||||
data: { verificationToken: token },
|
||||
overrideAccess: true,
|
||||
context: { systemActivation: true },
|
||||
})
|
||||
|
||||
const { sent } = await sendVerificationEmail(user.email, token)
|
||||
if (!sent) {
|
||||
return NextResponse.json(
|
||||
{ error: 'خدمة البريد غير مربوطة بعد — ستعمل تلقائياً بعد ضبط SMTP على الاستضافة.' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ sent: true })
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import { redirect } from 'next/navigation'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* GET /api/auth/verify-email?token=... — the link sent by email.
|
||||
Marks the account verified and lands the user on onboarding. */
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get('token')
|
||||
if (!token) redirect('/verify-email?invalid=1')
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { docs } = await payload.find({
|
||||
collection: 'users',
|
||||
where: { verificationToken: { equals: token } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const user = docs[0]
|
||||
if (!user) redirect('/verify-email?invalid=1')
|
||||
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: user.id,
|
||||
data: { emailVerified: true, verificationToken: null },
|
||||
overrideAccess: true,
|
||||
context: { systemActivation: true },
|
||||
})
|
||||
|
||||
redirect('/onboarding')
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { FILES_DIR } from '@/collections/Files'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type FileDoc = { id: number; access: 'free' | 'vip'; filename: string; mimeType: string }
|
||||
|
||||
/* GET /api/files/[id] — per-post work files, gated per file.
|
||||
`free` streams to anyone; `vip` requires a signed-in vip/admin (same style
|
||||
as the comments route). The Files collection read rule is defense in depth;
|
||||
this route is the enforcement point for the bytes. */
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
let doc: FileDoc | null = null
|
||||
try {
|
||||
doc = (await payload.findByID({ collection: 'files', id, depth: 0, overrideAccess: true })) as unknown as FileDoc
|
||||
} catch {
|
||||
doc = null // unknown id / DB error → not found
|
||||
}
|
||||
if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
|
||||
|
||||
if (doc.access === 'vip') {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 })
|
||||
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'التنزيل متاح لأعضاء العضوية.' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = await readFile(path.join(FILES_DIR, doc.filename))
|
||||
return new NextResponse(new Uint8Array(buf), {
|
||||
headers: {
|
||||
'Content-Type': doc.mimeType,
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(doc.filename)}`,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Row exists but the file is missing on disk — do not leak a fake download.
|
||||
return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* POST /api/posts/[slug]/comments — create a moderated comment.
|
||||
Signed-in only (401); VIP post requires entitlement (403); 1 comment / 60 s
|
||||
per user, counting pending (429); body 8–2000 chars (400).
|
||||
NOTE: deliberately NOT /api/comments — Payload mounts its admin REST there
|
||||
(see docs/adr/0001-comments-route-not-at-api-comments.md). */
|
||||
export async function POST(_request: Request, ctx: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await ctx.params
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (!user) return NextResponse.json({ error: 'سجّل الدخول للتعليق.' }, { status: 401 })
|
||||
|
||||
// Verified-email gate (pentest #7 remediation): admins exempt.
|
||||
const { role, emailVerified } = user as { role?: string; emailVerified?: boolean }
|
||||
if (!emailVerified && role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'أكّد بريدك الإلكتروني قبل التعليق — راجع بريدك الوارد.' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await _request.json().catch(() => null)
|
||||
const text = typeof parsed?.body === 'string' ? parsed.body.trim() : ''
|
||||
if (text.length < 8) return NextResponse.json({ error: 'اكتب تعليقاً من ٨ أحرف على الأقل.' }, { status: 400 })
|
||||
if (text.length > 2000) return NextResponse.json({ error: 'التعليق طويل جداً — ٢٠٠٠ حرف كحد أقصى.' }, { status: 400 })
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
})
|
||||
const post = docs[0] as unknown as { id: number; access: string } | undefined
|
||||
if (!post) return NextResponse.json({ error: 'المقال غير موجود.' }, { status: 404 })
|
||||
|
||||
// Mirror the UI paywall: commenting on a VIP post requires entitlement.
|
||||
if (post.access === 'vip' && role !== 'vip' && role !== 'admin') {
|
||||
return NextResponse.json({ error: 'التعليق على هذا الدليل متاح لأعضاء العضوية.' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Naive per-user rate limit: 1 comment / 60 s, any post, counts pending.
|
||||
const recent = await payload.find({
|
||||
collection: 'comments',
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
where: {
|
||||
and: [
|
||||
{ author: { equals: user.id } },
|
||||
{ createdAt: { greater_than: new Date(Date.now() - 60_000) } },
|
||||
],
|
||||
},
|
||||
})
|
||||
if (recent.docs.length) return NextResponse.json({ error: 'تمهّل قليلاً قبل تعليق جديد.' }, { status: 429 })
|
||||
|
||||
const fullName = (user as { fullName?: string }).fullName
|
||||
await payload.create({
|
||||
collection: 'comments',
|
||||
data: { post: post.id, author: user.id, authorName: fullName || 'عضو', body: text, status: 'pending' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
return NextResponse.json({ ok: true, pending: true }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* POST /api/posts/[slug]/like — toggle. Login required (401 otherwise);
|
||||
one like per user per post (PostLikes uniqueness); likes counter on post. */
|
||||
export async function POST(_request: Request, ctx: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await ctx.params
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
select: { likes: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const post = docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: 'post-likes',
|
||||
where: { and: [{ user: { equals: user.id } }, { post: { equals: post.id } }] },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
let liked: boolean
|
||||
if (existing.docs.length > 0) {
|
||||
await payload.delete({ collection: 'post-likes', id: existing.docs[0].id, overrideAccess: true })
|
||||
liked = false
|
||||
} else {
|
||||
await payload.create({
|
||||
collection: 'post-likes',
|
||||
data: { user: user.id, post: post.id },
|
||||
overrideAccess: true,
|
||||
})
|
||||
liked = true
|
||||
}
|
||||
|
||||
const likes = Math.max(0, (typeof post.likes === 'number' ? post.likes : 0) + (liked ? 1 : -1))
|
||||
await payload.update({ collection: 'posts', id: post.id, data: { likes }, overrideAccess: true })
|
||||
return NextResponse.json({ liked, likes })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
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'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type Ctx = { params: Promise<{ slug: string }> }
|
||||
|
||||
const restGet = REST_GET(configPromise)
|
||||
const restPatch = REST_PATCH(configPromise)
|
||||
const restPut = REST_PUT(configPromise)
|
||||
const restDelete = REST_DELETE(configPromise)
|
||||
|
||||
/* Shadows the Payload REST catch-all for /api/posts/:id-or-slug.
|
||||
- Numeric ids: plain REST passthrough — the /admin edit view depends on them.
|
||||
- String slugs: by-slug lookup with the VIP gate, instead of the catch-all
|
||||
casting the slug as an id (postgres error → 500, QA-01).
|
||||
Mutations pass through to REST for numeric ids (the /admin SPA saves via
|
||||
REST PATCH/PUT/DELETE on /api/posts/:id); the collection's own access
|
||||
control still applies. No string-slug mutations exist. */
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
const { slug } = await ctx.params
|
||||
if (/^\d+$/.test(slug)) {
|
||||
return restGet(req, { params: Promise.resolve({ slug: ['posts', slug] }) })
|
||||
}
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
// Published only — drafts must not leak by slug (numeric-id passthrough
|
||||
// stays for the /admin SPA; its read access already constrains it).
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
||||
depth: 0, limit: 1, overrideAccess: true,
|
||||
})
|
||||
const post = docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (post.access === 'vip') {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'VIP membership required' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
return NextResponse.json(post)
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
const { slug } = await ctx.params
|
||||
return restPatch(req, { params: Promise.resolve({ slug: ['posts', slug] }) })
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
const { slug } = await ctx.params
|
||||
return restPut(req, { params: Promise.resolve({ slug: ['posts', slug] }) })
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
const { slug } = await ctx.params
|
||||
return restDelete(req, { params: Promise.resolve({ slug: ['posts', slug] }) })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* POST /api/posts/[slug]/view — global view counter (client fires this once
|
||||
per browser session per post, from the post page). */
|
||||
export async function POST(_request: Request, ctx: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await ctx.params
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
select: { views: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const post = docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
const views = (typeof post.views === 'number' ? post.views : 0) + 1
|
||||
await payload.update({ collection: 'posts', id: post.id, data: { views }, overrideAccess: true })
|
||||
return NextResponse.json({ views })
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import configPromise from '@payload-config'
|
||||
import { REST_GET, REST_POST } from '@payloadcms/next/routes'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/* Shadows the Payload REST catch-all for /api/posts: the REST list returned
|
||||
full bodies of all published posts (incl. VIP) to anonymous — a paid-content
|
||||
leak. List reads are now admin-only. Create (POST) stays Payload REST —
|
||||
access.create: isAdmin already rejects non-admins. The app frontend and
|
||||
agent API use the local API / custom routes, never the REST list. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
if (viewer.role !== 'admin') return NextResponse.json({ error: 'Admin only' }, { status: 403 })
|
||||
return REST_GET(configPromise)(req, { params: Promise.resolve({ slug: ['posts'] }) })
|
||||
}
|
||||
|
||||
export const POST = REST_POST(configPromise)
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import Stripe from 'stripe'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const secret = process.env.STRIPE_SECRET_KEY
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
|
||||
if (!secret || !webhookSecret) {
|
||||
return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 })
|
||||
}
|
||||
|
||||
const stripe = new Stripe(secret)
|
||||
const signature = req.headers.get('stripe-signature')
|
||||
if (!signature) return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
|
||||
|
||||
const rawBody = await req.text()
|
||||
let event: Stripe.Event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object
|
||||
const userId = session.client_reference_id || session.metadata?.payloadUserId
|
||||
if (userId) {
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: userId,
|
||||
data: { role: 'vip' },
|
||||
overrideAccess: true,
|
||||
context: { systemActivation: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'customer.subscription.deleted') {
|
||||
const subscription = event.data.object
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
|
||||
const { docs } = await payload.find({
|
||||
collection: 'users',
|
||||
where: { stripeCustomerId: { equals: customerId } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const u of docs) {
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: u.id,
|
||||
data: { role: 'free' },
|
||||
overrideAccess: true,
|
||||
context: { systemActivation: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import type { Payload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import Stripe from 'stripe'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST() {
|
||||
const secret = process.env.STRIPE_SECRET_KEY
|
||||
const priceId = process.env.STRIPE_PRICE_ID
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
|
||||
|
||||
// Stripe pending mode: no keys yet → 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.
|
||||
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 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const stripe = new Stripe(secret)
|
||||
let customerId = user.stripeCustomerId as string | undefined
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: { payloadUserId: String(user.id) },
|
||||
})
|
||||
customerId = customer.id
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: user.id,
|
||||
data: { stripeCustomerId: customerId },
|
||||
context: { systemActivation: true },
|
||||
})
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: customerId,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
client_reference_id: String(user.id),
|
||||
success_url: `${siteUrl}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${siteUrl}/payment-cancelled`,
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: session.url })
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Indigo editorial palette. Kept separate so the color trial is easy to revise. */
|
||||
:root {
|
||||
--ink: #ffffff;
|
||||
--ink-2: #f7f8fc;
|
||||
--basalt: #ffffff;
|
||||
--seam: #dde3ef;
|
||||
--seam-2: #c4cddd;
|
||||
--mist: #172033;
|
||||
--stone: #626d80;
|
||||
--dim: #697589;
|
||||
--champagne: #5146c8;
|
||||
--champagne-ink: #ffffff;
|
||||
--accent-hover: #4136ad;
|
||||
--vip: #886421;
|
||||
--accent-soft: #eef0fc;
|
||||
--accent-border: #d5d8f5;
|
||||
}
|
||||
|
||||
#site ::selection { background: #5146c82b; }
|
||||
#site .site-header { border-bottom-color: var(--seam); }
|
||||
#site .site-header nav.nav-links a[aria-current=page] { color: var(--champagne); }
|
||||
#site :is(.hero-panel, .guide-card, .person, .plan) {
|
||||
box-shadow: 0 8px 28px -20px #26345b30;
|
||||
}
|
||||
#site :is(.paywall, .mem-compare .is-member, .status-mark) {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
#site .paywall { border-top-color: var(--champagne); }
|
||||
#site .member-strip {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
border-bottom-color: var(--accent-border);
|
||||
}
|
||||
#site .choice input:checked + .choice-body {
|
||||
box-shadow: 0 0 0 3px #5146c81f;
|
||||
}
|
||||
#site .badge-vip { color: var(--vip); background: #faf5e9; border-color: #e6d6af; }
|
||||
#site .badge:not(.badge-vip), #site .access.free, #site .open-chip {
|
||||
color: #166534;
|
||||
background: #eaf7ee;
|
||||
border-color: #b8dec4;
|
||||
}
|
||||
#site .page-payment-cancelled .status-mark { background: #fdf0e6; color: #b45309; }
|
||||
#site.v2-home .path-name { color: var(--champagne); }
|
||||
#site.page-login .input-wrap .input { padding-inline-end: 94px; }
|
||||
#site.page-login .password-toggle {
|
||||
min-width: 74px;
|
||||
min-height: 40px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-soft);
|
||||
color: var(--champagne);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#site.page-login .password-toggle:hover,
|
||||
#site.page-login .password-toggle[aria-pressed=true] {
|
||||
color: var(--champagne-ink);
|
||||
background: var(--champagne);
|
||||
border-color: var(--champagne);
|
||||
}
|
||||
#site.page-login .password-toggle:focus-visible {
|
||||
outline: 2px solid var(--champagne);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
/* ==================================================================
|
||||
AnwarPi design system v2 — "lattice"
|
||||
Identity: the triangle-lattice mark. Graphite surfaces, a silver
|
||||
luminance ramp borrowed from the logo, one champagne accent kept for
|
||||
the signature triangle, members-only marks and the primary hover.
|
||||
Type: IBM Plex Sans Arabic throughout; hierarchy comes from weight and size.
|
||||
Loaded last; v2 pages are scoped with #site.v2, shared chrome
|
||||
(header, footer, buttons, fields) applies to every page.
|
||||
================================================================== */
|
||||
|
||||
:root {
|
||||
--ink: #131519;
|
||||
--ink-2: #17191d;
|
||||
--basalt: #1c1f24;
|
||||
--seam: #2b2f36;
|
||||
--seam-2: #3a3f47;
|
||||
--mist: #ece9e3;
|
||||
--stone: #a3a09a;
|
||||
--dim: #7b7974;
|
||||
--champagne: #d4b47c;
|
||||
--champagne-ink: #1a150c;
|
||||
|
||||
/* legacy aliases so older page sheets follow the new palette */
|
||||
--bg: var(--ink);
|
||||
--surface: var(--basalt);
|
||||
--fg: var(--mist);
|
||||
--muted: var(--stone);
|
||||
--border: var(--seam);
|
||||
--accent: var(--champagne);
|
||||
--accent-ink: var(--champagne-ink);
|
||||
|
||||
--font-display: var(--font-sans), "Segoe UI", sans-serif;
|
||||
--font-body: var(--font-sans), "SF Arabic", "Segoe UI", sans-serif;
|
||||
|
||||
--wrap: 1200px;
|
||||
--gutter: clamp(18px, 4vw, 40px);
|
||||
--r-sm: 8px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 18px;
|
||||
}
|
||||
|
||||
body { background: var(--ink); color: var(--mist); }
|
||||
#site {
|
||||
background: var(--ink);
|
||||
color: var(--mist);
|
||||
font: 400 17px/1.85 var(--font-body);
|
||||
}
|
||||
#site :is(h1, h2, h3) { font-family: var(--font-display); font-weight: 700; letter-spacing: 0; color: var(--mist); text-wrap: balance; }
|
||||
#site h1 { font-size: clamp(36px, 4.6vw, 60px); line-height: 1.3; max-width: none; }
|
||||
#site h2 { font-size: clamp(26px, 2.6vw, 34px); line-height: 1.4; margin: 0; }
|
||||
#site h3 { font-size: 21px; line-height: 1.5; }
|
||||
#site p { margin: 0; }
|
||||
#site a { color: inherit; }
|
||||
#site ::selection { background: color-mix(in oklch, var(--champagne) 35%, transparent); }
|
||||
#site :focus-visible { outline: 2px solid var(--champagne); outline-offset: 3px; border-radius: 4px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
|
||||
#site .wrap { width: 100%; max-width: var(--wrap); margin-inline: auto; padding-inline: var(--gutter); }
|
||||
#site .wrap.narrow { max-width: 860px; }
|
||||
#site .lede { font-size: clamp(17px, 1.5vw, 20px); line-height: 1.9; color: var(--stone); max-width: 58ch; }
|
||||
#site .muted { color: var(--stone); }
|
||||
#site .fine { font-size: 13px; color: var(--dim); }
|
||||
#site .text-link { color: var(--mist); text-decoration: underline; text-decoration-color: var(--seam-2); text-underline-offset: 5px; text-decoration-thickness: 1px; font-weight: 500; min-height: 0; display: inline; }
|
||||
#site .text-link:hover { text-decoration-color: var(--champagne); color: var(--mist); }
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
#site .btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-height: 48px; padding: 10px 22px; border-radius: var(--r-sm);
|
||||
font: 600 15px/1.4 var(--font-body); border: 1px solid transparent;
|
||||
transition: background-color .18s ease, border-color .18s ease, color .18s ease;
|
||||
transform: none; box-shadow: none; text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
#site .btn::after { content: none; }
|
||||
#site .btn:hover { transform: none; box-shadow: none; }
|
||||
#site .btn-primary { background: var(--mist); border-color: var(--mist); color: var(--ink); }
|
||||
#site .btn-primary:hover { background: var(--champagne); border-color: var(--champagne); color: var(--champagne-ink); }
|
||||
#site :is(.btn-quiet, .btn-secondary, .btn-ghost) { background: transparent; border-color: var(--seam-2); color: var(--mist); }
|
||||
#site :is(.btn-quiet, .btn-secondary, .btn-ghost):hover { border-color: var(--mist); background: transparent; color: var(--mist); }
|
||||
#site .btn:disabled { opacity: .6; cursor: wait; }
|
||||
#site .actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
|
||||
/* ---------- Badges & meta ---------- */
|
||||
#site .badge {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 26px; padding-inline: 10px;
|
||||
border: 1px solid var(--seam-2); border-radius: 999px; font: 500 12.5px/1 var(--font-body); color: var(--stone);
|
||||
}
|
||||
#site .badge svg { width: 12px; height: 12px; }
|
||||
#site .badge-vip { border-color: color-mix(in oklch, var(--champagne) 60%, transparent); color: var(--champagne); }
|
||||
#site .meta-row { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13.5px; color: var(--dim); }
|
||||
#site .meta-row > span + span::before { content: ""; display: inline-block; width: 3px; height: 3px; border-radius: 50%; background: var(--seam-2); margin-inline-end: 14px; vertical-align: middle; }
|
||||
#site .for-you { color: var(--champagne); }
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
#site .site-header {
|
||||
position: sticky; top: 0; z-index: 40;
|
||||
background: color-mix(in oklch, var(--ink) 88%, transparent);
|
||||
backdrop-filter: saturate(140%) blur(14px);
|
||||
border-bottom: 1px solid var(--seam);
|
||||
}
|
||||
#site .site-header .nav-inner { max-width: var(--wrap); padding-inline: var(--gutter); min-height: 72px; gap: 28px; }
|
||||
#site .site-header .logo { font: 600 21px/1 var(--font-display); gap: 12px; color: var(--mist); }
|
||||
#site .site-header img.logo-mark, #site .menu-head img.logo-mark, #site .site-footer img.logo-mark { width: 34px; height: 30px; }
|
||||
#site .site-header nav.nav-links { gap: 4px; margin-inline-start: auto; }
|
||||
#site .site-header nav.nav-links a { color: var(--stone); font: 500 15px/1 var(--font-body); padding: 10px 14px; border-radius: var(--r-sm); background: none; }
|
||||
#site .site-header nav.nav-links a:hover { color: var(--mist); background: none; }
|
||||
#site .site-header nav.nav-links a[aria-current=page] { color: var(--mist); background: none; box-shadow: inset 0 -2px 0 var(--champagne); border-radius: 0; }
|
||||
#site .site-header .nav-login, #site .menu-foot .nav-login {
|
||||
min-height: 40px; padding: 8px 16px; border-radius: var(--r-sm); border: 1px solid var(--seam-2);
|
||||
font: 600 14px/1 var(--font-body); color: var(--mist); background: transparent;
|
||||
}
|
||||
#site .site-header .nav-login:hover { border-color: var(--mist); }
|
||||
#site .icon-btn { border-radius: var(--r-sm); border-color: var(--seam-2); background: transparent; color: var(--mist); }
|
||||
#site .menu-drawer { background: var(--ink); border-color: var(--seam); }
|
||||
#site .menu-links a { font: 600 22px/1.4 var(--font-display); border-color: var(--seam); }
|
||||
#site .menu-links a span { color: var(--dim); }
|
||||
#site .skip-link { background: var(--mist); color: var(--ink); }
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
#site .site-footer { border-top: 1px solid var(--seam); background: var(--ink); padding-block: 56px 32px; margin-top: 0; }
|
||||
#site .site-footer .container { max-width: var(--wrap); padding-inline: var(--gutter); }
|
||||
#site .site-footer .logo { font: 600 20px/1 var(--font-display); gap: 12px; }
|
||||
#site .site-footer .footer-col strong { font: 600 14px/1.6 var(--font-body); color: var(--mist); }
|
||||
#site .site-footer .footer-col a { color: var(--stone); font-size: 14.5px; min-height: 36px; }
|
||||
#site .site-footer .footer-col a:hover { color: var(--mist); }
|
||||
#site .site-footer .footer-bottom { border-top-color: var(--seam); color: var(--dim); font-size: 13px; margin-top: 40px; }
|
||||
|
||||
/* ---------- Fields (all pages) ---------- */
|
||||
#site :is(.input, .textarea, .control, .field input:not([type=checkbox]):not([type=radio]), select, textarea) {
|
||||
background: var(--ink-2); border: 1px solid var(--seam-2); border-radius: var(--r-sm); color: var(--mist);
|
||||
}
|
||||
#site :is(.input, .textarea, .control, select, textarea):focus { outline: none; border-color: var(--champagne); box-shadow: 0 0 0 3px color-mix(in oklch, var(--champagne) 20%, transparent); }
|
||||
#site .field label, #site label > .filter-label { font: 500 14px/1.6 var(--font-body); color: var(--stone); }
|
||||
#site .eyebrow, #site .crumb { font: 500 14px/1.6 var(--font-body); color: var(--champagne); letter-spacing: 0; text-transform: none; }
|
||||
|
||||
/* ---------- Section heads ---------- */
|
||||
#site .section-head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px 40px; margin-bottom: 36px; }
|
||||
#site .section-head p { color: var(--stone); max-width: 52ch; }
|
||||
|
||||
/* ==================================================================
|
||||
HOME
|
||||
================================================================== */
|
||||
#site.v2-home .home-hero { padding-block: clamp(56px, 8vw, 104px) clamp(48px, 6vw, 80px); border-bottom: 1px solid var(--seam); }
|
||||
#site.v2-home .home-hero-grid { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: clamp(32px, 6vw, 88px); align-items: center; }
|
||||
#site.v2-home .home-hero h1 { font-size: clamp(40px, 5.4vw, 72px); line-height: 1.25; max-width: 12ch; }
|
||||
#site.v2-home .home-hero .lede { margin-top: 24px; }
|
||||
#site.v2-home .home-hero .actions { margin-top: 36px; }
|
||||
#site.v2-home .facts { display: flex; gap: clamp(24px, 4vw, 48px); margin: 48px 0 0; padding-top: 24px; border-top: 1px solid var(--seam); }
|
||||
#site.v2-home .facts div { display: flex; flex-direction: column-reverse; }
|
||||
#site.v2-home .facts dt { font-size: 14px; color: var(--stone); }
|
||||
#site.v2-home .facts dd { margin: 0; font: 600 32px/1.2 var(--font-display); color: var(--mist); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* The library lattice */
|
||||
#site .lattice { margin: 0; display: grid; gap: 22px; justify-items: center; }
|
||||
#site .lattice-svg { width: 100%; max-width: 520px; height: auto; overflow: visible; }
|
||||
#site .lattice-link { cursor: pointer; outline: none; }
|
||||
#site .lattice-cell { transition: filter .2s ease, opacity .2s ease; }
|
||||
#site .lattice-cell.is-vip { stroke: var(--champagne); stroke-width: 2.2; stroke-linejoin: round; paint-order: stroke; }
|
||||
#site .lattice-svg:hover .lattice-link .lattice-cell, #site .lattice-svg:focus-within .lattice-link .lattice-cell { opacity: .55; }
|
||||
#site .lattice-svg .lattice-link.is-active .lattice-cell { opacity: 1; filter: brightness(1.12); }
|
||||
#site .lattice-link:focus-visible .lattice-cell { stroke: var(--mist); stroke-width: 3; }
|
||||
#site .lattice-caption { width: 100%; max-width: 520px; min-height: 92px; padding: 16px 20px; border: 1px solid var(--seam); border-radius: var(--r-md); background: var(--ink-2); display: grid; gap: 4px; }
|
||||
#site .lattice-caption-meta { display: flex; gap: 14px; font-size: 13px; color: var(--dim); }
|
||||
#site .lattice-caption-meta .is-vip { color: var(--champagne); }
|
||||
#site .lattice-caption-title { font: 600 19px/1.5 var(--font-display); color: var(--mist); }
|
||||
#site .lattice-caption-title:hover { text-decoration: underline; text-underline-offset: 5px; text-decoration-color: var(--champagne); }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
#site .lattice-cell { animation: lattice-in .6s cubic-bezier(.2, .7, .2, 1) both; animation-delay: calc(var(--i) * 55ms + 120ms); transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes lattice-in { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: scale(1); } }
|
||||
}
|
||||
|
||||
/* Paths */
|
||||
#site.v2-home .home-paths { padding-block: clamp(64px, 8vw, 104px); }
|
||||
#site.v2-home .paths { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--seam); }
|
||||
#site.v2-home .paths li + li { border-inline-start: 1px solid var(--seam); }
|
||||
#site.v2-home .path { display: grid; gap: 6px; padding: 28px 24px 30px; height: 100%; align-content: start; }
|
||||
#site.v2-home .paths li:first-child .path { padding-inline-start: 0; }
|
||||
#site.v2-home .path-art { width: 100%; height: 72px; border-radius: var(--r-sm); margin-bottom: 18px; opacity: .85; transition: opacity .2s ease; }
|
||||
#site.v2-home .path:hover .path-art { opacity: 1; }
|
||||
#site.v2-home .path-name { font: 600 24px/1.4 var(--font-display); }
|
||||
#site.v2-home .path-hint { color: var(--stone); font-size: 15px; }
|
||||
#site.v2-home .path-count { margin-top: 10px; font-size: 14px; color: var(--mist); text-decoration: underline; text-decoration-color: var(--seam-2); text-underline-offset: 5px; }
|
||||
#site.v2-home .path:hover .path-count { text-decoration-color: var(--champagne); }
|
||||
|
||||
/* Start here */
|
||||
#site.v2-home .home-start { padding-block: clamp(64px, 8vw, 104px); background: var(--ink-2); border-block: 1px solid var(--seam); }
|
||||
#site.v2-home .start-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: clamp(28px, 4vw, 56px); }
|
||||
#site.v2-home .start-lead-link { display: grid; gap: 14px; }
|
||||
#site.v2-home .start-lead-art { width: 100%; aspect-ratio: 9 / 5; height: auto; border-radius: var(--r-lg); margin-bottom: 8px; }
|
||||
#site.v2-home .start-lead h3 { font-size: clamp(26px, 2.6vw, 34px); line-height: 1.4; margin: 0; }
|
||||
#site.v2-home .start-lead p { color: var(--stone); max-width: 56ch; }
|
||||
#site.v2-home .start-lead-link:hover h3 { text-decoration: underline; text-decoration-color: var(--champagne); text-underline-offset: 8px; text-decoration-thickness: 1px; }
|
||||
#site.v2-home .start-list { list-style: none; margin: 0; padding: 0; display: grid; align-content: start; }
|
||||
#site.v2-home .start-list li + li { border-top: 1px solid var(--seam); }
|
||||
#site.v2-home .start-item { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 20px; align-items: center; padding-block: 18px; }
|
||||
#site.v2-home .start-list li:first-child .start-item { padding-top: 0; }
|
||||
#site.v2-home .start-item-art { width: 112px; height: 78px; border-radius: var(--r-sm); }
|
||||
#site.v2-home .start-item-body { display: grid; gap: 4px; }
|
||||
#site.v2-home .start-item-title { font: 600 18px/1.55 var(--font-display); }
|
||||
#site.v2-home .start-item:hover .start-item-title { color: var(--champagne); }
|
||||
|
||||
/* Membership */
|
||||
#site.v2-home .home-membership { padding-block: clamp(64px, 8vw, 104px); }
|
||||
#site.v2-home .membership-panel { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: clamp(28px, 5vw, 72px); align-items: start; }
|
||||
#site.v2-home .membership-panel h2 { font-size: clamp(28px, 3vw, 40px); max-width: 18ch; }
|
||||
#site.v2-home .membership-panel > div > p { color: var(--stone); margin-top: 18px; max-width: 50ch; }
|
||||
#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 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); }
|
||||
#site .membership-offer .btn { justify-self: start; }
|
||||
|
||||
/* ==================================================================
|
||||
LIBRARY
|
||||
================================================================== */
|
||||
#site.v2-library .lib-head { padding-block: clamp(48px, 7vw, 88px) 32px; border-bottom: 1px solid var(--seam); }
|
||||
#site.v2-library .lib-head .lede { margin-top: 14px; }
|
||||
#site.v2-library .lib-tools { display: flex; gap: 12px; margin-top: 32px; flex-wrap: wrap; }
|
||||
#site .lib-search { flex: 1 1 320px; position: relative; display: block; }
|
||||
#site .lib-search svg { position: absolute; inset-inline-start: 16px; top: 50%; translate: 0 -50%; width: 18px; height: 18px; color: var(--dim); pointer-events: none; }
|
||||
#site .lib-search input { width: 100%; min-height: 52px; padding: 12px 14px; padding-inline-start: 46px; font-size: 16px; }
|
||||
#site .lib-sort { display: flex; align-items: center; gap: 10px; color: var(--stone); font-size: 14px; }
|
||||
#site .lib-sort select { min-height: 52px; padding-inline: 14px 36px; font-size: 15px; }
|
||||
#site.v2-library .lib-filters { display: flex; flex-wrap: wrap; gap: 12px 24px; margin-top: 18px; }
|
||||
#site .segmented { display: inline-flex; flex-wrap: wrap; gap: 4px; padding: 4px; border: 1px solid var(--seam); border-radius: 999px; background: var(--ink-2); }
|
||||
#site .segmented button { min-height: 38px; padding: 6px 16px; border: 0; border-radius: 999px; background: transparent; color: var(--stone); font: 500 14px/1 var(--font-body); }
|
||||
#site .segmented button:hover { color: var(--mist); }
|
||||
#site .segmented button[aria-pressed=true] { background: var(--mist); color: var(--ink); }
|
||||
#site.v2-library .lib-results { padding-block: 32px clamp(64px, 8vw, 104px); }
|
||||
#site .results-count { color: var(--stone); font-size: 14px; margin-bottom: 20px; }
|
||||
#site .lib-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px 24px; }
|
||||
#site .guide-card { position: relative; display: flex; flex-direction: column; border-radius: var(--r-md); }
|
||||
#site .guide-card-art { width: 100%; aspect-ratio: 2 / 1; height: auto; border-radius: var(--r-md); object-fit: cover; }
|
||||
#site .art-cover { margin: 18px auto 26px; }
|
||||
#site .art-cover img { display: block; width: 100%; height: auto; aspect-ratio: 1200 / 630; object-fit: cover; border-radius: var(--r-md, 10px); }
|
||||
#site .home-start .guide-card-art { margin-bottom: 12px; }
|
||||
#site .guide-card-body { display: flex; flex-direction: column; gap: 8px; padding-top: 16px; flex: 1; }
|
||||
#site .guide-card-title { font: 600 20px/1.5 var(--font-display); margin: 0; }
|
||||
#site .guide-card-title a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r-md); }
|
||||
#site .guide-card:hover .guide-card-title a { text-decoration: underline; text-decoration-color: var(--champagne); text-underline-offset: 6px; text-decoration-thickness: 1px; }
|
||||
#site .guide-card-dek { color: var(--stone); font-size: 15px; line-height: 1.85; }
|
||||
#site .guide-card-foot { margin-top: auto; padding-top: 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
#site .engagement { display: flex; align-items: center; gap: 14px; font-size: 13.5px; color: var(--dim); position: relative; z-index: 1; }
|
||||
#site .like { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 4px 10px; border: 1px solid var(--seam); border-radius: 999px; background: transparent; color: var(--stone); font: 500 13.5px/1 var(--font-body); }
|
||||
#site .like svg { width: 14px; height: 14px; }
|
||||
#site .like:hover { border-color: var(--seam-2); color: var(--mist); }
|
||||
#site .like[aria-pressed=true] { color: var(--champagne); border-color: color-mix(in oklch, var(--champagne) 50%, transparent); }
|
||||
#site .like[aria-pressed=true] svg path { fill: currentColor; }
|
||||
#site .empty { display: none; padding: 48px 0; text-align: center; color: var(--stone); }
|
||||
#site .empty.show { display: block; }
|
||||
#site .empty p:first-child { font: 600 22px/1.5 var(--font-display); color: var(--mist); }
|
||||
|
||||
/* ==================================================================
|
||||
ARTICLE
|
||||
================================================================== */
|
||||
#site.v2-article .art-head { padding-block: clamp(40px, 6vw, 80px); border-bottom: 1px solid var(--seam); }
|
||||
#site.v2-article .art-head-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: clamp(28px, 5vw, 72px); align-items: center; }
|
||||
#site .crumbs { display: flex; gap: 10px; font-size: 14px; color: var(--dim); margin-bottom: 20px; }
|
||||
#site .crumbs a { color: var(--stone); }
|
||||
#site .crumbs a:hover { color: var(--mist); }
|
||||
#site.v2-article .art-head h1 { font-size: clamp(34px, 4.4vw, 56px); line-height: 1.3; }
|
||||
#site.v2-article .art-head .lede { margin-top: 18px; }
|
||||
#site .art-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; margin-top: 28px; font-size: 14px; color: var(--stone); }
|
||||
#site .art-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 20px; color: var(--dim); font-size: 13.5px; }
|
||||
#site .save-btn { min-height: 34px; padding: 4px 14px; border: 1px solid var(--seam); border-radius: 999px; background: transparent; color: var(--stone); font: 500 13.5px/1 var(--font-body); }
|
||||
#site .save-btn[aria-pressed=true] { color: var(--champagne); border-color: color-mix(in oklch, var(--champagne) 50%, transparent); }
|
||||
#site.v2-article .art-head-art { width: 100%; height: auto; aspect-ratio: 26 / 21; border-radius: var(--r-lg); }
|
||||
#site.v2-article .art-layout { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: clamp(32px, 6vw, 96px); padding-block: clamp(40px, 6vw, 72px); align-items: start; }
|
||||
#site .art-body { max-width: 68ch; font-size: 18.5px; line-height: 2; overflow-wrap: anywhere; }
|
||||
#site .art-body .fade-out { -webkit-mask-image: linear-gradient(to bottom, #000 10%, transparent); mask-image: linear-gradient(to bottom, #000 10%, transparent); }
|
||||
#site .art-body > * + * { margin-top: 1.1em; }
|
||||
#site .art-body h2 { font-size: clamp(25px, 2.4vw, 31px); line-height: 1.45; margin-top: 2em; scroll-margin-top: 100px; }
|
||||
#site .art-body h3 { font-size: 22px; margin-top: 1.6em; }
|
||||
#site .art-body :is(ul, ol) { padding-inline-start: 1.3em; }
|
||||
#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 .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 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); }
|
||||
#site .paywall { margin-top: 32px; padding: clamp(24px, 4vw, 40px); border: 1px solid var(--seam); border-top: 2px solid var(--champagne); border-radius: var(--r-md); background: var(--basalt); }
|
||||
#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 .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); }
|
||||
#site .toc a { display: block; padding: 7px 16px; margin-inline-start: -1px; border-inline-start: 1px solid transparent; color: var(--stone); font-size: 14.5px; line-height: 1.6; }
|
||||
#site .toc a:hover { color: var(--mist); border-inline-start-color: var(--champagne); }
|
||||
#site .files ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--seam); }
|
||||
#site .files li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-block: 12px; border-bottom: 1px solid var(--seam); font-size: 14.5px; color: var(--stone); }
|
||||
#site .files.is-locked li { justify-content: flex-start; gap: 10px; }
|
||||
#site .files.is-locked svg { width: 14px; height: 14px; color: var(--champagne); flex: none; }
|
||||
#site .dl-btn { min-height: 32px; padding: 4px 12px; border: 1px solid var(--seam-2); border-radius: var(--r-sm); background: transparent; color: var(--mist); font: 500 13px/1 var(--font-body); }
|
||||
#site .dl-btn:hover { border-color: var(--champagne); color: var(--champagne); }
|
||||
#site .file-access { color: var(--stone); font-size: 13px; }
|
||||
#site .dl-locked { display: inline-flex; align-items: center; gap: 6px; color: var(--stone); font-size: 13px; }
|
||||
#site .dl-locked svg { width: 13px; height: 13px; color: var(--champagne); }
|
||||
#site .art-comments { padding-block: clamp(48px, 6vw, 80px); border-top: 1px solid var(--seam); }
|
||||
#site .art-comments h2 { margin-bottom: 24px; }
|
||||
#site .comment-list { display: grid; gap: 16px; }
|
||||
#site .comment { padding: 18px 20px; border: 1px solid var(--seam); border-radius: var(--r-md); background: var(--ink-2); }
|
||||
#site .comment header { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; color: var(--dim); margin-bottom: 6px; }
|
||||
#site .comment header strong { color: var(--mist); }
|
||||
#site .comment.is-pending { border-style: dashed; }
|
||||
#site .comment-form { display: grid; gap: 14px; margin-top: 28px; }
|
||||
#site .comment-form textarea { width: 100%; min-height: 120px; padding: 14px; font-size: 16px; resize: vertical; }
|
||||
#site .comment-form .btn { justify-self: start; }
|
||||
#site .comment-form .status { color: var(--champagne); font-size: 14px; min-height: 1.6em; }
|
||||
#site .comment-gate { margin-top: 28px; padding: 20px 24px; border: 1px solid var(--seam); border-radius: var(--r-md); background: var(--ink-2); display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
#site .comment-gate p { color: var(--stone); }
|
||||
#site .art-related { padding-block: clamp(48px, 6vw, 80px); border-top: 1px solid var(--seam); background: var(--ink-2); }
|
||||
#site .art-related h2 { margin-bottom: 28px; }
|
||||
#site .related-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; border: 0; }
|
||||
#site .related-item { display: grid; gap: 8px; }
|
||||
#site .related-art { width: 100%; aspect-ratio: 2 / 1; height: auto; border-radius: var(--r-md); margin-bottom: 6px; }
|
||||
#site .related-title { font: 600 19px/1.5 var(--font-display); }
|
||||
#site .related-item:hover .related-title { color: var(--champagne); }
|
||||
|
||||
/* ==================================================================
|
||||
MEMBERSHIP
|
||||
================================================================== */
|
||||
#site.v2-membership .mem-hero { padding-block: clamp(56px, 8vw, 104px); border-bottom: 1px solid var(--seam); }
|
||||
#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; }
|
||||
#site .mem-points { list-style: none; margin: 40px 0 0; padding: 0; display: grid; gap: 0; border-top: 1px solid var(--seam); }
|
||||
#site .mem-points li { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: 16px; padding-block: 16px; border-bottom: 1px solid var(--seam); }
|
||||
#site .mem-points strong { font-weight: 600; color: var(--mist); }
|
||||
#site .mem-points span { color: var(--stone); }
|
||||
#site .plan { position: sticky; top: 104px; padding: 32px; border: 1px solid var(--seam); border-top: 2px solid var(--champagne); border-radius: var(--r-md); background: var(--basalt); display: grid; gap: 14px; }
|
||||
#site .plan h2 { font: 600 20px/1.4 var(--font-display); }
|
||||
#site .plan-price { font: 600 56px/1 var(--font-display); color: var(--mist); display: flex; align-items: baseline; gap: 10px; }
|
||||
#site .plan-price span { font: 400 16px/1 var(--font-body); color: var(--stone); }
|
||||
#site .plan-note { color: var(--stone); font-size: 15px; }
|
||||
#site .plan-cta { display: grid; gap: 10px; margin-top: 8px; }
|
||||
#site .plan-cta .cta-pair { display: grid; gap: 10px; }
|
||||
#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.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%; }
|
||||
#site .sample-art { width: 100%; aspect-ratio: 2 / 1; height: auto; border-radius: var(--r-md); margin-bottom: 8px; }
|
||||
#site .sample-title { font: 600 20px/1.5 var(--font-display); }
|
||||
#site .sample-dek { color: var(--stone); font-size: 15px; }
|
||||
#site .sample-output { margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--seam); font-size: 14.5px; color: var(--mist); display: grid; gap: 2px; }
|
||||
#site .sample-output span { color: var(--champagne); font-size: 13px; }
|
||||
#site .sample:hover .sample-title { color: var(--champagne); }
|
||||
#site.v2-membership .mem-compare { padding-block: clamp(64px, 8vw, 104px); }
|
||||
#site.v2-membership .mem-compare h2 { margin-bottom: 28px; }
|
||||
#site .table-wrap { overflow-x: auto; border: 1px solid var(--seam); border-radius: var(--r-md); }
|
||||
#site .mem-compare table { width: 100%; border-collapse: collapse; font-size: 15.5px; min-width: 520px; }
|
||||
#site .mem-compare :is(th, td) { padding: 16px 20px; text-align: start; border-bottom: 1px solid var(--seam); }
|
||||
#site .mem-compare tbody tr:last-child :is(th, td) { border-bottom: 0; }
|
||||
#site .mem-compare thead th { font: 600 14px/1.4 var(--font-body); color: var(--stone); background: var(--ink-2); }
|
||||
#site .mem-compare tbody th { font-weight: 500; color: var(--mist); }
|
||||
#site .mem-compare td { color: var(--stone); }
|
||||
#site .mem-compare .is-member { color: var(--mist); background: color-mix(in oklch, var(--champagne) 5%, transparent); }
|
||||
#site .mem-compare thead .is-member { color: var(--champagne); }
|
||||
#site.v2-membership .mem-faq { padding-block: clamp(64px, 8vw, 104px); border-top: 1px solid var(--seam); }
|
||||
#site .faq-grid { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: clamp(28px, 5vw, 72px); align-items: start; }
|
||||
#site .faq-grid > div > p { color: var(--stone); margin-top: 14px; }
|
||||
#site .faq-list { border-top: 1px solid var(--seam); }
|
||||
#site .faq-item { border-bottom: 1px solid var(--seam); background: none; border-radius: 0; }
|
||||
#site .faq-button { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 16px; min-height: 64px; padding: 14px 0; background: none; border: 0; color: var(--mist); font: 600 17px/1.5 var(--font-body); text-align: start; }
|
||||
#site .faq-button span:last-child { color: var(--champagne); font-size: 22px; }
|
||||
#site .faq-answer { padding: 0 0 20px; color: var(--stone); max-width: 60ch; }
|
||||
|
||||
/* ==================================================================
|
||||
ABOUT
|
||||
================================================================== */
|
||||
#site.v2-about .about-hero { padding-block: clamp(56px, 8vw, 104px); border-bottom: 1px solid var(--seam); }
|
||||
#site.v2-about .about-hero-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: clamp(32px, 6vw, 96px); align-items: center; }
|
||||
#site.v2-about .about-hero .lede { margin-top: 22px; }
|
||||
#site.v2-about .about-mark { width: 100%; max-width: 340px; height: auto; justify-self: center; }
|
||||
#site.v2-about .about-team { padding-block: clamp(64px, 8vw, 104px); }
|
||||
#site.v2-about .about-team h2 { margin-bottom: 32px; }
|
||||
#site .people { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
|
||||
#site .person { padding: clamp(24px, 3vw, 36px); border: 1px solid var(--seam); border-radius: var(--r-md); background: var(--ink-2); display: grid; gap: 12px; align-content: start; }
|
||||
#site .person-role { color: var(--champagne); font-size: 14px; }
|
||||
#site .person h3 { font-size: 28px; margin: 0; }
|
||||
#site .person > p:not(.person-role) { color: var(--stone); }
|
||||
#site .tags { list-style: none; margin: 4px 0 8px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
#site .tags li { padding: 4px 12px; border: 1px solid var(--seam-2); border-radius: 999px; font-size: 13.5px; color: var(--stone); }
|
||||
#site.v2-about .about-principles { padding-block: clamp(64px, 8vw, 104px); background: var(--ink-2); border-block: 1px solid var(--seam); }
|
||||
#site .principles-grid { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: clamp(28px, 5vw, 72px); }
|
||||
#site .principles { margin: 0; display: grid; border-top: 1px solid var(--seam); }
|
||||
#site .principles div { padding-block: 22px; border-bottom: 1px solid var(--seam); display: grid; gap: 6px; }
|
||||
#site .principles dt { font: 600 21px/1.5 var(--font-display); display: flex; align-items: center; gap: 12px; }
|
||||
#site .principles dt::before { content: ""; width: 12px; height: 10px; background: var(--champagne); clip-path: polygon(50% 0, 100% 100%, 0 100%); flex: none; }
|
||||
#site .principles dd { margin: 0; color: var(--stone); padding-inline-start: 24px; }
|
||||
#site.v2-about .about-contact { padding-block: 48px; }
|
||||
#site .contact-strip { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
#site .contact-strip p { font: 600 22px/1.5 var(--font-display); }
|
||||
|
||||
/* ==================================================================
|
||||
AUTH & UTILITY PAGES (restyle of existing markup)
|
||||
================================================================== */
|
||||
#site .auth { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: calc(100dvh - 73px); }
|
||||
#site .auth-story { background: var(--ink-2); border-inline-end: 1px solid var(--seam); padding: clamp(40px, 6vw, 88px) clamp(24px, 6vw, 88px); display: flex; flex-direction: column; justify-content: center; gap: 18px; color: var(--mist); }
|
||||
#site .auth-mark { width: clamp(120px, 14vw, 180px); height: auto; margin-bottom: 20px; }
|
||||
#site .auth-story h1 { font-size: clamp(32px, 3.6vw, 48px); max-width: 14ch; }
|
||||
#site .auth-story p { color: var(--stone); max-width: 40ch; font-size: 17px; }
|
||||
#site .auth-form-wrap { padding: clamp(40px, 6vw, 88px) clamp(24px, 6vw, 88px); display: flex; align-items: center; justify-content: center; }
|
||||
#site .auth-form { width: min(420px, 100%); }
|
||||
#site .auth-form .eyebrow { display: none; }
|
||||
#site .auth-form h2 { font-size: 32px; margin-bottom: 6px; }
|
||||
#site .auth-form .lead { color: var(--stone); font-size: 16px; margin: 0 0 24px; }
|
||||
#site .auth-form .btn { width: 100%; }
|
||||
#site .auth-form .magic { min-height: 48px; border: 1px solid var(--seam-2); border-radius: var(--r-sm); background: transparent; color: var(--mist); font: 600 15px/1 var(--font-body); }
|
||||
#site .auth-form .magic:hover { border-color: var(--mist); background: transparent; color: var(--mist); }
|
||||
#site .auth-form .divider { color: var(--dim); }
|
||||
#site .auth-form .divider::before, #site .auth-form .divider::after { background: var(--seam); }
|
||||
#site .password-toggle { color: var(--stone); border-radius: var(--r-sm); }
|
||||
|
||||
/* Shared cards on account, onboarding, payment and reset pages */
|
||||
#site :is(.reset-card, .state-card, .card, .settings-card, .profile, .choice-body, .start-card, .upgrade-note, .files-card, .weekly, .hero-note) {
|
||||
background: var(--basalt); border: 1px solid var(--seam); border-radius: var(--r-md); color: var(--mist);
|
||||
}
|
||||
#site :is(.page-privacy, .page-terms) .policy { max-width: 760px; }
|
||||
#site :is(.page-privacy, .page-terms) .policy h2 { font-size: 24px; margin-top: 40px; }
|
||||
#site :is(.page-privacy, .page-terms) .policy p, #site :is(.page-privacy, .page-terms) .policy li { color: var(--stone); }
|
||||
#site .notice { border: 1px solid var(--seam-2); border-inline-start: 2px solid var(--champagne); background: var(--ink-2); color: var(--stone); border-radius: var(--r-sm); padding: 12px 16px; }
|
||||
#site main.nf h1 { font-size: clamp(36px, 5vw, 56px); }
|
||||
|
||||
/* ==================================================================
|
||||
RESPONSIVE
|
||||
================================================================== */
|
||||
@media (max-width: 1024px) {
|
||||
#site .lib-grid, #site .sample-grid, #site .related-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
#site.v2-home .paths { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
#site.v2-home .paths li:nth-child(3) { border-inline-start: 0; }
|
||||
#site.v2-home .paths li:nth-child(n+3) { border-top: 1px solid var(--seam); }
|
||||
#site.v2-home .paths li:nth-child(3) .path { padding-inline-start: 0; }
|
||||
}
|
||||
@media (max-width: 920px) {
|
||||
#site.v2-home .home-hero-grid, #site.v2-home .start-grid, #site.v2-home .membership-panel,
|
||||
#site.v2-article .art-head-grid, #site.v2-article .art-layout, #site.v2-membership .mem-hero-grid,
|
||||
#site .faq-grid, #site.v2-about .about-hero-grid, #site .principles-grid, #site .auth { grid-template-columns: minmax(0, 1fr); }
|
||||
#site.v2-home .lattice { order: -1; }
|
||||
#site .lattice-svg { max-width: 380px; }
|
||||
#site.v2-article .art-head-art { aspect-ratio: 3 / 1; max-height: 180px; }
|
||||
#site .art-aside { position: static; order: -1; }
|
||||
#site .plan { position: static; }
|
||||
#site .auth-story { border-inline-end: 0; border-bottom: 1px solid var(--seam); }
|
||||
#site.v2-about .about-mark { max-width: 200px; justify-self: start; order: -1; }
|
||||
#site .people { grid-template-columns: minmax(0, 1fr); }
|
||||
#site .site-header .nav-inner { min-height: 64px; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
#site { font-size: 16px; }
|
||||
#site .lib-grid, #site .sample-grid, #site .related-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
#site.v2-home .paths { grid-template-columns: minmax(0, 1fr); }
|
||||
#site.v2-home .paths li + li { border-inline-start: 0; border-top: 1px solid var(--seam); }
|
||||
#site.v2-home .path { padding-inline: 0; }
|
||||
#site.v2-home .facts { gap: 20px; }
|
||||
#site.v2-home .facts dd { font-size: 26px; }
|
||||
#site.v2-home .start-item { grid-template-columns: 88px minmax(0, 1fr); gap: 14px; }
|
||||
#site.v2-home .start-item-art { width: 88px; height: 64px; }
|
||||
#site .actions .btn, #site .paywall-actions .btn { flex: 1 1 100%; }
|
||||
#site .art-body { font-size: 17px; }
|
||||
#site .mem-points li { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
||||
#site .segmented { border-radius: var(--r-md); }
|
||||
#site .lib-sort { flex: 1 1 100%; }
|
||||
#site .lib-sort select { flex: 1; }
|
||||
#site .auth-story { padding-block: 32px; }
|
||||
#site .auth-mark { width: 88px; margin-bottom: 4px; }
|
||||
#site .auth-story p { display: none; }
|
||||
}
|
||||
|
||||
/* Neutralise legacy hover lifts and spacing on the new cards. */
|
||||
#site .guide-card, #site .guide-card:hover, #site .weekly:hover { padding: 0; transform: none; box-shadow: none; border: 0; background: none; }
|
||||
#site .post.guide-card { gap: 0; }
|
||||
|
||||
/* ==================================================================
|
||||
v2.1 — calmer: triangles live only in the logo. Text-led cards.
|
||||
================================================================== */
|
||||
#site .membership-offer li::before { width: 6px; height: 6px; border-radius: 50%; clip-path: none; transform: translateY(-3px); }
|
||||
#site .principles dt::before { content: none; }
|
||||
#site .principles dd { padding-inline-start: 0; }
|
||||
|
||||
/* Hero: latest guides panel */
|
||||
#site .hero-panel { border: 1px solid var(--seam); border-radius: var(--r-lg); background: var(--ink-2); padding: 28px 28px 24px; display: grid; gap: 8px; }
|
||||
#site .hero-panel h2 { font-size: 15px; font-family: var(--font-body); font-weight: 600; color: var(--stone); margin-bottom: 4px; }
|
||||
#site .hero-panel ul { list-style: none; margin: 0; padding: 0; }
|
||||
#site .hero-panel li + li { border-top: 1px solid var(--seam); }
|
||||
#site .hero-panel li a { display: grid; gap: 6px; padding-block: 16px; }
|
||||
#site .hero-panel li a:has(.hero-panel-thumb) { display: flex; gap: 12px; align-items: center; }
|
||||
#site .hero-panel-thumb { width: 72px; height: 48px; object-fit: cover; border-radius: 8px; flex: none; }
|
||||
#site .hero-panel-title { font: 600 19px/1.5 var(--font-display); color: var(--mist); }
|
||||
#site .hero-panel li a:hover .hero-panel-title { color: var(--champagne); }
|
||||
#site .hero-panel .is-vip { color: var(--champagne); }
|
||||
#site .hero-panel .text-link { justify-self: start; margin-top: 8px; font-size: 14.5px; }
|
||||
|
||||
/* Paths without art */
|
||||
#site.v2-home .path { padding-block: 26px 28px; }
|
||||
|
||||
/* Start here: lead as a quiet panel, list as text rows */
|
||||
#site.v2-home .start-lead-link { padding: clamp(24px, 3vw, 40px); border: 1px solid var(--seam); border-radius: var(--r-lg); background: var(--ink); height: 100%; align-content: end; min-height: 320px; }
|
||||
#site.v2-home .start-lead h3 { font-size: clamp(28px, 3vw, 40px); }
|
||||
#site.v2-home .start-item { grid-template-columns: minmax(0, 1fr); padding-block: 20px; }
|
||||
|
||||
/* Library cards: bordered, text-led */
|
||||
#site .guide-card, #site .guide-card:hover { border: 1px solid var(--seam); border-radius: var(--r-md); background: var(--ink-2); padding: 24px; }
|
||||
#site .guide-card:hover { border-color: var(--seam-2); }
|
||||
#site .guide-card-body { padding-top: 0; }
|
||||
#site .lib-grid { gap: 20px; }
|
||||
|
||||
/* Article head: single column */
|
||||
#site.v2-article .art-head-grid { grid-template-columns: minmax(0, 1fr); max-width: 900px; }
|
||||
|
||||
/* Related + samples: bordered text cards */
|
||||
#site :is(.related-item, .sample) { padding: 24px; border: 1px solid var(--seam); border-radius: var(--r-md); background: var(--ink); height: 100%; align-content: start; }
|
||||
#site :is(.related-item, .sample):hover { border-color: var(--seam-2); }
|
||||
|
||||
#site .lib-search input { background: var(--ink-2); border: 1px solid var(--seam-2); border-radius: var(--r-sm); color: var(--mist); }
|
||||
#site .lib-search input::placeholder { color: var(--dim); }
|
||||
#site .lib-search input:focus { outline: none; border-color: var(--champagne); box-shadow: 0 0 0 3px color-mix(in oklch, var(--champagne) 20%, transparent); }
|
||||
#site .site-header { background: color-mix(in oklch, var(--ink) 92%, transparent); }
|
||||
#site :is(.lattice-caption-title, .hero-panel-title, .start-item-title, .guide-card-title, .related-title, .sample-title, .path-name) { font-weight: 600; }
|
||||
|
||||
/* ==================================================================
|
||||
Theme C — "مؤسسي هادئ": light grey page, white surfaces, emerald
|
||||
accent, Readex Pro throughout. Tokens remap every rule above.
|
||||
================================================================== */
|
||||
:root {
|
||||
--ink: #ffffff; /* page / white sections */
|
||||
--ink-2: #f7f8fa; /* alternate sections, hero */
|
||||
--basalt: #ffffff; /* raised cards */
|
||||
--seam: #e3e6eb; /* hairlines */
|
||||
--seam-2: #d0d5dd; /* stronger borders */
|
||||
--mist: #101828; /* text */
|
||||
--stone: #5d6675; /* secondary text */
|
||||
--dim: #7a8391; /* tertiary text */
|
||||
--champagne: #0e7c66; /* accent (emerald) — name kept for the rules above */
|
||||
--champagne-ink: #ffffff;
|
||||
--accent-hover: #0b6654;
|
||||
--vip: #a86f12;
|
||||
--font-display: var(--font-sans), "Segoe UI", sans-serif;
|
||||
--font-body: var(--font-sans), "Segoe UI", sans-serif;
|
||||
color-scheme: only light;
|
||||
}
|
||||
body, #site { background: var(--ink); color: var(--mist); }
|
||||
#site { font: 400 17px/1.8 var(--font-body); }
|
||||
#site h1 { font-weight: 700; }
|
||||
#site :is(h2, h3) { font-weight: 700; }
|
||||
#site ::selection { background: #0e7c6626; }
|
||||
|
||||
#site .btn-primary { background: var(--champagne); border-color: var(--champagne); color: #fff; }
|
||||
#site .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
|
||||
#site :is(.btn-quiet, .btn-secondary, .btn-ghost) { background: #fff; border-color: var(--seam-2); color: var(--mist); }
|
||||
#site :is(.btn-quiet, .btn-secondary, .btn-ghost):hover { border-color: var(--mist); background: #fff; }
|
||||
#site .btn { border-radius: 10px; font-weight: 700; }
|
||||
|
||||
#site .site-header { background: rgba(255, 255, 255, .94); }
|
||||
#site .site-header nav.nav-links a { font-weight: 400; }
|
||||
#site .site-header nav.nav-links a[aria-current=page] { box-shadow: none; font-weight: 700; color: var(--mist); }
|
||||
#site .site-header .nav-login { background: #fff; }
|
||||
#site .site-footer { background: var(--ink-2); }
|
||||
|
||||
#site .kicker { color: var(--champagne); font-weight: 700; font-size: 15px; margin-bottom: 18px; }
|
||||
#site .meta-row > span + span::before { content: none; }
|
||||
#site .meta-row > span:first-child { color: var(--champagne); font-weight: 700; }
|
||||
#site .meta-row .is-vip, #site .is-vip { color: var(--vip); }
|
||||
#site .badge-vip { color: var(--vip); border-color: #e7c98f; background: #fdf6e8; }
|
||||
#site .badge { background: #fff; }
|
||||
#site .guide-card .badge-free { color: #166534; background: #eaf7ee; border-color: #b8dec4; }
|
||||
#site .for-you { color: var(--champagne); }
|
||||
|
||||
/* Home */
|
||||
#site.v2-home .home-hero { background: var(--ink-2); }
|
||||
#site.v2-home .home-hero h1 { font-size: clamp(38px, 4.8vw, 62px); max-width: 13ch; }
|
||||
#site .hero-panel { background: #fff; }
|
||||
#site .hero-panel-title { font-weight: 700; font-size: 17.5px; }
|
||||
#site.v2-home .home-paths { background: #fff; }
|
||||
#site.v2-home .path-name { font-weight: 700; }
|
||||
#site.v2-home .home-start { background: var(--ink-2); }
|
||||
#site .card-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; }
|
||||
#site .guide { position: relative; height: 100%; display: flex; flex-direction: column; gap: 8px; padding: 26px; background: #fff; border: 1px solid var(--seam); border-radius: 14px; transition: border-color .15s ease, box-shadow .15s ease; }
|
||||
#site .guide:hover { border-color: #b9c0ca; box-shadow: 0 6px 20px -12px rgba(16, 24, 40, .25); }
|
||||
#site .guide h3 { font-size: 20px; line-height: 1.55; margin: 4px 0 0; }
|
||||
#site .guide h3 a::after { content: ""; position: absolute; inset: 0; border-radius: 14px; }
|
||||
#site .guide-dek { color: var(--stone); font-size: 15.5px; }
|
||||
#site .guide-link { margin-top: auto; padding-top: 10px; color: var(--champagne); font-weight: 700; font-size: 15px; }
|
||||
#site.v2-home .home-membership { background: #fff; }
|
||||
#site .membership-offer { background: #fff; box-shadow: 0 1px 2px rgba(16, 24, 40, .05); }
|
||||
#site .membership-offer li::before { background: var(--champagne); }
|
||||
|
||||
/* Library & cards */
|
||||
#site .guide-card, #site .guide-card:hover { background: #fff; }
|
||||
#site .guide-card:hover { border-color: #b9c0ca; box-shadow: 0 6px 20px -12px rgba(16, 24, 40, .25); }
|
||||
#site .guide-card-title { font-weight: 700; }
|
||||
#site .guide-card:hover .guide-card-title a { text-decoration: none; color: var(--champagne); }
|
||||
#site.v2-library .lib-head { background: var(--ink-2); }
|
||||
#site .segmented { background: #fff; }
|
||||
#site .segmented button[aria-pressed=true] { background: var(--champagne); color: #fff; }
|
||||
#site .lib-search input, #site :is(.input, .textarea, .control, .field input:not([type=checkbox]):not([type=radio]), select, textarea) { background: #fff; }
|
||||
#site .like[aria-pressed=true] { color: #c2410c; border-color: #fdba74; }
|
||||
|
||||
/* Article */
|
||||
#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 .comment, #site .comment-gate { background: #fff; }
|
||||
#site .art-related { background: var(--ink-2); }
|
||||
#site :is(.related-item, .sample) { background: #fff; }
|
||||
#site :is(.related-item, .sample):hover { border-color: #b9c0ca; }
|
||||
#site .related-item:hover .related-title, #site .sample:hover .sample-title { color: var(--champagne); }
|
||||
|
||||
/* Membership */
|
||||
#site.v2-membership .mem-hero { background: var(--ink-2); }
|
||||
#site .plan { background: #fff; box-shadow: 0 12px 32px -20px rgba(16, 24, 40, .35); }
|
||||
#site .mem-compare .is-member { background: #f2faf7; }
|
||||
#site .mem-compare thead th { background: var(--ink-2); }
|
||||
#site .sample-output span { color: var(--champagne); font-weight: 700; }
|
||||
#site .faq-button span:last-child { color: var(--champagne); }
|
||||
|
||||
/* About & auth */
|
||||
#site.v2-about .about-hero { background: var(--ink-2); }
|
||||
#site .person { background: #fff; }
|
||||
#site .person-role { color: var(--champagne); font-weight: 700; }
|
||||
#site .tags li { background: var(--ink-2); border-color: var(--seam); }
|
||||
#site .auth-story { background: var(--ink-2); }
|
||||
#site .auth-form .magic { background: #fff; }
|
||||
|
||||
@media (max-width: 1024px) { #site .card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 620px) { #site .card-grid { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
/* ==================================================================
|
||||
Theme C — account, onboarding, payment, verification, legal pages
|
||||
================================================================== */
|
||||
#site :is(.page-head, .onboarding-head, .page-onboarding .head) { background: var(--ink-2); border-bottom: 1px solid var(--seam); padding-block: clamp(40px, 6vw, 72px); }
|
||||
#site :is(.page-head, .onboarding-head, .page-onboarding .head) .sub { color: var(--stone); margin-top: 10px; }
|
||||
#site .container { max-width: var(--wrap); padding-inline: var(--gutter); }
|
||||
#site :is(.page-privacy, .page-terms) .hero { background: var(--ink-2); border-bottom: 1px solid var(--seam); }
|
||||
#site :is(.reset, .verification, .page-payment-success main, .page-payment-cancelled main, .gate) { background: var(--ink-2); }
|
||||
#site :is(.reset-card, .state-card, .verification .card, .gate-card) { background: #fff; border: 1px solid var(--seam); border-radius: 16px; box-shadow: 0 12px 32px -24px rgba(16, 24, 40, .35); }
|
||||
#site .gate { min-height: 60vh; display: grid; place-items: center; padding: clamp(40px, 8vw, 96px) var(--gutter); }
|
||||
#site .gate-card { width: min(560px, 100%); padding: clamp(28px, 5vw, 48px); display: grid; gap: 14px; }
|
||||
#site .gate-card h1 { font-size: clamp(28px, 3vw, 36px); }
|
||||
#site .gate-card p { color: var(--stone); }
|
||||
#site .gate-card .actions { margin-top: 8px; }
|
||||
|
||||
/* status icons */
|
||||
#site .status-mark { width: 56px; height: 56px; border-radius: 50%; border: 0; display: grid; place-items: center; background: #e7f5f0; color: var(--champagne); }
|
||||
#site .page-payment-cancelled .status-mark { background: #fdf0e6; color: #b45309; }
|
||||
#site .status-mark svg { width: 26px; height: 26px; }
|
||||
|
||||
/* steps lists (verify) & progress */
|
||||
#site .steps { border-top: 1px solid var(--seam); }
|
||||
#site .step-num { color: var(--champagne); font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
#site .email { font-family: var(--font-body); letter-spacing: 0; direction: ltr; unicode-bidi: isolate; background: var(--ink-2); border: 1px solid var(--seam); border-radius: 8px; padding: 4px 12px; display: inline-block; }
|
||||
#site :is(.progress-copy, #progressLabel, #progressPercent) { font-family: var(--font-body); letter-spacing: 0; color: var(--stone); font-size: 14px; }
|
||||
#site .progress-track { background: var(--seam); border-radius: 999px; height: 6px; overflow: hidden; }
|
||||
#site .progress-bar { background: var(--champagne); border-radius: 999px; height: 100%; }
|
||||
|
||||
/* onboarding step nav + choices */
|
||||
#site .step-nav { border-top: 0; }
|
||||
#site .step-nav :is(button, div, a) { font-family: var(--font-body); color: var(--stone); border-color: var(--seam); background: transparent; }
|
||||
#site .step-nav [aria-current=step], #site .step-nav .active { color: var(--mist); font-weight: 700; }
|
||||
#site .step-nav .done { color: var(--champagne); }
|
||||
#site .choice { cursor: pointer; }
|
||||
#site .choice input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
#site .choice-body { background: #fff; border: 1px solid var(--seam); border-radius: 14px; position: relative; transition: border-color .15s ease, box-shadow .15s ease; }
|
||||
#site .choice:hover .choice-body { border-color: #b9c0ca; }
|
||||
#site .choice input:checked + .choice-body { border-color: var(--champagne); box-shadow: 0 0 0 3px #0e7c661f; }
|
||||
#site .choice input:focus-visible + .choice-body { outline: 2px solid var(--champagne); outline-offset: 2px; }
|
||||
#site .choice-mark { width: 20px; height: 20px; border-radius: 50%; border: 1.5px solid var(--seam-2); background: #fff; }
|
||||
#site .choice input:checked + .choice-body .choice-mark { border: 6px solid var(--champagne); }
|
||||
#site .choice-title { font-weight: 700; color: var(--mist); }
|
||||
#site .choice-copy { color: var(--stone); }
|
||||
#site :is(.notice-card, .upgrade-note, .benefit, .membership-card, .prefs, .billing) { background: #fff; border-color: var(--seam); border-radius: 14px; color: var(--mist); }
|
||||
#site .benefit-num { color: var(--champagne); font-family: var(--font-body); }
|
||||
#site .member-strip { background: #e7f5f0; color: #0b5a4a; border-bottom: 1px solid #cfe9e1; font: 700 14.5px/1.6 var(--font-body); letter-spacing: 0; text-align: center; padding: 10px var(--gutter); margin: 0; }
|
||||
|
||||
/* switches */
|
||||
#site .switch .track { background: var(--seam-2); }
|
||||
#site .switch input:checked + .track { background: var(--champagne); }
|
||||
|
||||
/* account settings */
|
||||
#site .settings-layout { padding-block: clamp(32px, 5vw, 56px); }
|
||||
#site .settings-nav :is(a, button) { font-family: var(--font-body); color: var(--stone); border-color: var(--seam); }
|
||||
#site .settings-nav :is(a, button)[aria-current], #site .settings-nav :is(a, button).active, #site .settings-nav :is(a, button)[aria-selected=true] { color: var(--mist); font-weight: 700; }
|
||||
#site .settings-nav :is(a, button)::before, #site .settings-nav :is(a, button)::after { background: var(--champagne); }
|
||||
#site .panels > * { background: #fff; border: 1px solid var(--seam); border-radius: 16px; padding: clamp(22px, 3vw, 32px); }
|
||||
#site :is(.billing-row, .pref-row) { border-color: var(--seam); }
|
||||
#site .billing-row.head { color: var(--stone); font-weight: 700; }
|
||||
#site .price-tag { font-family: var(--font-body); color: var(--mist); font-weight: 700; }
|
||||
#site .text-danger { color: #b42318; }
|
||||
#site .hint, #site .panel-sub, #site .desc { color: var(--stone); }
|
||||
#site .pw-rules li { color: var(--stone); }
|
||||
#site .pw-rules li.ok { color: var(--champagne); }
|
||||
@@ -0,0 +1,445 @@
|
||||
@import './site-system.css';
|
||||
|
||||
/* AnwarPi — shared design tokens. Page styles must stay scoped. */
|
||||
:root {
|
||||
--bg: #0c0d0f;
|
||||
--surface: #15171a;
|
||||
--fg: #f2f1ee;
|
||||
--muted: #a5a39d;
|
||||
--border: #2a2d32;
|
||||
--accent: #d4b47c; /* champagne — used sparingly: highlights, links, VIP */
|
||||
--accent-ink: #17130b; /* text on accent */
|
||||
--surface-2: #1b1e22;
|
||||
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
--fg-soft: color-mix(in oklch, var(--fg) 6%, transparent);
|
||||
--font-display: var(--font-sans), "SF Arabic", "Noto Sans Arabic", "Segoe UI", sans-serif;
|
||||
--font-body: var(--font-sans), "SF Arabic", "Noto Sans Arabic", "Segoe UI", sans-serif;
|
||||
/* Latin glyphs stay monospaced; Arabic glyphs fall through to the body face
|
||||
(monospace fonts have no Arabic, and the generic fallback broke letter joining). */
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, var(--font-sans), "Segoe UI", sans-serif;
|
||||
--container: 1180px;
|
||||
--gutter: 32px;
|
||||
/* v2 design-system tokens (from arabic-ai-learning-catalog.html) */
|
||||
--fs-h1: clamp(46px, 6.3vw, 82px);
|
||||
--fs-h2: clamp(32px, 4vw, 52px);
|
||||
--fs-h3: 22px;
|
||||
--fs-lead: 19px;
|
||||
--fs-body: 16px;
|
||||
--fs-meta: 12px;
|
||||
--gap-xs: 8px;
|
||||
--gap-sm: 12px;
|
||||
--gap-md: 20px;
|
||||
--gap-lg: 32px;
|
||||
--gap-xl: 56px;
|
||||
--gap-2xl: 96px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 20px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; max-width: 100%; overflow-x: clip; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 16px/1.75 var(--font-body);
|
||||
text-rendering: optimizeLegibility;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
img, svg, video { height: auto; }
|
||||
main, section, .container, .grid, [class*="grid"], [class*="layout"] { min-width: 0; }
|
||||
code, pre, bdi { overflow-wrap: anywhere; }
|
||||
pre, table { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
h1, h2, h3 { font-family: var(--font-display); text-wrap: balance; }
|
||||
p { text-wrap: pretty; }
|
||||
:focus-visible { outline: 3px solid color-mix(in oklch, var(--accent) 55%, transparent); outline-offset: 3px; }
|
||||
::selection { background: color-mix(in oklch, var(--accent) 28%, transparent); }
|
||||
|
||||
.container { max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
|
||||
|
||||
/* ---- Top navigation ---- */
|
||||
.topnav {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: color-mix(in oklch, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.logo { display: inline-flex; align-items: center; gap: 10px; font-family: var(--font-display); font-size: 23px; font-weight: 700; }
|
||||
.logo-mark { width: 36px; height: 32px; object-fit: contain; border-radius: 4px; flex: none; }
|
||||
.nav-links { display: flex; align-items: center; gap: 28px; }
|
||||
.nav-links a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); }
|
||||
.nav-links a:hover { color: var(--fg); }
|
||||
.nav-links a[aria-current="page"] { color: var(--fg); font-weight: 700; }
|
||||
.nav-tools { display: flex; align-items: center; gap: 8px; }
|
||||
.nav-login {
|
||||
min-height: 44px; padding-inline: 16px; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--fg); display: inline-grid; place-items: center;
|
||||
}
|
||||
.nav-login:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
min-height: 46px; display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 11px 22px; border: 1px solid var(--border); font-weight: 700;
|
||||
}
|
||||
.btn:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||
.btn .arrow { margin-inline-start: 10px; }
|
||||
.btn-primary { background: var(--accent); color: var(--surface); border-color: var(--accent); }
|
||||
.btn-primary:hover { background: color-mix(in oklch, var(--accent) 86%, black); border-color: var(--accent); }
|
||||
.btn-secondary { border-color: var(--border); background: transparent; color: var(--fg); }
|
||||
.text-link {
|
||||
font-weight: 700; color: var(--fg); text-decoration: underline; text-underline-offset: 4px;
|
||||
min-height: 44px; display: inline-flex; align-items: center;
|
||||
}
|
||||
.text-link:hover { color: var(--accent); }
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.btn, .btn-primary, .btn-secondary {
|
||||
position: relative; overflow: hidden;
|
||||
transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease;
|
||||
}
|
||||
.btn::after, .btn-primary::after, .btn-secondary::after {
|
||||
content: ""; position: absolute; inset-block: 0; inset-inline-start: -45%; width: 34%;
|
||||
background: linear-gradient(100deg, transparent 0%, rgba(255,255,255,.45) 50%, transparent 100%);
|
||||
transform: skewX(-18deg); opacity: 0; pointer-events: none;
|
||||
}
|
||||
.btn:hover::after, .btn-primary:hover::after, .btn-secondary:hover::after {
|
||||
animation: anw-sweep .85s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.btn:hover, .btn-primary:hover, .btn-secondary:hover { transform: translateY(-1px); }
|
||||
.btn-primary:hover { box-shadow: 0 8px 28px color-mix(in oklch, var(--accent) 30%, transparent); }
|
||||
.btn:active { transform: translateY(0); }
|
||||
}
|
||||
@keyframes anw-sweep { 0% { opacity: 1; inset-inline-start: -45%; } 100% { opacity: 1; inset-inline-start: 118%; } }
|
||||
|
||||
/* ---- Hero ---- */
|
||||
.hero { padding-block: clamp(64px, 9vw, 120px); border-bottom: 1px solid var(--border); }
|
||||
.crumb { font: 12px/1.5 var(--font-mono); color: var(--accent); margin: 0 0 20px; }
|
||||
.hero h1 { font-size: clamp(44px, 6.5vw, 84px); line-height: 1.08; max-width: 14ch; margin: 0; }
|
||||
.hero .lead { font-size: clamp(18px, 1.8vw, 22px); line-height: 1.7; color: var(--muted); max-width: 56ch; margin: 26px 0 0; }
|
||||
|
||||
/* ---- Cards / grids ---- */
|
||||
.section { padding-block: 80px; border-bottom: 1px solid var(--border); }
|
||||
.section h2 { font-size: clamp(34px, 4.5vw, 52px); margin: 0 0 34px; }
|
||||
|
||||
/* ---- Chips ---- */
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); padding: 3px 10px; font: 12px/1.6 var(--font-mono); color: var(--muted); }
|
||||
.chip-vip { border-color: var(--fg); color: var(--fg); }
|
||||
.foryou { color: var(--accent); font: 11px/1.5 var(--font-mono); }
|
||||
.like { min-height: 44px; padding: 0 10px; border: 1px solid transparent; background: transparent; color: var(--muted); }
|
||||
.like:hover, .like[aria-pressed="true"] { color: var(--fg); border-color: var(--border); }
|
||||
|
||||
/* ---- Article ---- */
|
||||
.article-head { padding-block: clamp(58px, 9vw, 100px); border-bottom: 1px solid var(--border); }
|
||||
.lock-chip {
|
||||
display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--fg);
|
||||
padding: 6px 14px; font: 12px/1.5 var(--font-mono); color: var(--fg); margin-bottom: 22px;
|
||||
}
|
||||
.lock-chip svg { width: 14px; height: 14px; }
|
||||
.article-head h1 { font-size: clamp(46px, 7vw, 88px); line-height: 1.06; max-width: 12ch; margin: 0; }
|
||||
.article-head .dek { font-size: clamp(19px, 2vw, 24px); line-height: 1.7; color: var(--muted); max-width: 58ch; margin: 28px 0 0; }
|
||||
.article-meta {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px;
|
||||
margin-top: 34px; padding-top: 20px; border-top: 1px solid var(--border);
|
||||
font: 12px/1.5 var(--font-mono); color: var(--muted);
|
||||
}
|
||||
.article-layout {
|
||||
display: grid; grid-template-columns: minmax(0, 740px) 240px;
|
||||
gap: clamp(48px, 8vw, 100px); align-items: start; padding-block: 80px;
|
||||
}
|
||||
.article-body { font-size: 19px; line-height: 2; }
|
||||
.article-body h2 { font-size: clamp(32px, 4vw, 48px); line-height: 1.2; margin: 64px 0 20px; }
|
||||
.article-body h3 { font-size: clamp(24px, 3vw, 32px); margin: 48px 0 14px; }
|
||||
.article-body p { margin: 0 0 26px; }
|
||||
.article-body ul, .article-body ol { padding-inline-start: 26px; margin: 0 0 26px; }
|
||||
.article-body li { margin-bottom: 12px; }
|
||||
.article-body blockquote { border-inline-start: 3px solid var(--accent); margin: 0 0 26px; padding: 8px 24px; color: var(--muted); }
|
||||
.article-body code { background: var(--fg-soft); padding: 2px 8px; font-family: var(--font-mono); font-size: 15px; }
|
||||
.article-body pre { background: var(--surface); border: 1px solid var(--border); padding: 20px; }
|
||||
.article-body img { max-width: 100%; }
|
||||
.lede { font-family: var(--font-display); font-size: 28px; line-height: 1.6; }
|
||||
.free-tag { font: 11px/1.5 var(--font-mono); color: var(--muted); margin-bottom: 6px; display: block; }
|
||||
|
||||
/* ---- Paywall (from post-locked.html) ---- */
|
||||
.lock-cut { position: relative; }
|
||||
.lock-cut::after {
|
||||
content: ""; position: absolute; inset-inline: 0; bottom: 0; height: 120px;
|
||||
background: linear-gradient(to bottom, transparent, var(--bg) 88%); pointer-events: none;
|
||||
}
|
||||
.lock-cut .cut-p { margin-bottom: 0; }
|
||||
.paywall {
|
||||
margin-top: -30px; position: relative; border: 1px solid var(--fg);
|
||||
background: var(--surface); padding: clamp(28px, 4vw, 44px); z-index: 2;
|
||||
}
|
||||
.paywall .crumb { margin-bottom: 12px; }
|
||||
.paywall h2 { font-size: clamp(30px, 3.6vw, 44px); line-height: 1.18; margin: 0 0 14px; max-width: 16ch; }
|
||||
.paywall p { color: var(--muted); max-width: 56ch; margin: 0 0 24px; }
|
||||
.locked-in { border-top: 1px solid var(--fg); margin: 0 0 26px; padding: 0; list-style: none; }
|
||||
.locked-in li {
|
||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
||||
border-bottom: 1px solid var(--border); color: var(--muted); font-size: 16px;
|
||||
}
|
||||
.locked-in svg { width: 14px; height: 14px; flex: none; }
|
||||
.locked-in .num { margin-inline-start: auto; font: 12px/1.5 var(--font-mono); }
|
||||
.paywall-actions { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
||||
.price-line { font: 12px/1.6 var(--font-mono); color: var(--muted); margin-top: 18px; }
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.field { margin-bottom: 18px; }
|
||||
.field label { display: block; font: 12px/1.6 var(--font-mono); color: var(--muted); margin-bottom: 8px; }
|
||||
.field input, .field textarea, .field select {
|
||||
width: 100%; min-height: 48px; padding: 10px 14px;
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--fg);
|
||||
}
|
||||
.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--fg); outline: none; }
|
||||
.form-error { border: 1px solid oklch(60% .2 25); color: oklch(75% .15 25); padding: 12px 16px; font-size: 14px; margin-bottom: 18px; }
|
||||
.form-ok { border: 1px solid var(--accent); color: var(--accent); padding: 12px 16px; font-size: 14px; margin-bottom: 18px; }
|
||||
.form-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 24px; }
|
||||
.form-actions .btn { width: 100%; }
|
||||
.form-foot { margin-top: 22px; font-size: 14px; color: var(--muted); }
|
||||
|
||||
/* ---- Footer ---- */
|
||||
.pagefoot { padding-block: 54px; border-top: 1px solid var(--border); color: var(--muted); }
|
||||
.footer-bottom { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; padding-top: 28px; font-size: 13px; }
|
||||
|
||||
/* ---- Note / callout ---- */
|
||||
.note { border-block: 1px solid var(--fg); padding: 28px 0; margin: 42px 0; }
|
||||
.note strong { display: block; font-family: var(--font-display); font-size: 22px; margin-bottom: 8px; }
|
||||
|
||||
/* ---- Prose for static pages ---- */
|
||||
.prose { max-width: 720px; margin-inline: auto; padding-block: 80px; font-size: 17px; line-height: 2; }
|
||||
.prose h1 { font-size: clamp(38px, 5.5vw, 64px); margin: 0 0 18px; }
|
||||
.prose h2 { font-size: clamp(26px, 3.5vw, 38px); margin: 48px 0 14px; }
|
||||
.prose p { margin: 0 0 22px; color: var(--muted); }
|
||||
.prose strong { color: var(--fg); }
|
||||
|
||||
@media (pointer: coarse) {
|
||||
a, button, input, select, textarea, summary, .btn { min-height: 44px; }
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
:root { --gutter: 22px; }
|
||||
.container { width: 100%; }
|
||||
.nav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
|
||||
.nav-links { display: none; }
|
||||
.article-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
:root { --gutter: 18px; }
|
||||
body { line-height: 1.7; }
|
||||
h1 { font-size: clamp(34px, 9vw, 40px); }
|
||||
.paywall-actions { flex-direction: column; align-items: stretch; }
|
||||
.paywall-actions .btn { width: 100%; }
|
||||
.form-actions .btn { width: 100%; }
|
||||
.section { padding-block: 56px; }
|
||||
.article-layout { padding-block: 56px; }
|
||||
.pagefoot { padding-block: 40px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AnwarPi — shared motion & brand layer (ported verbatim from
|
||||
/Volumes/ssdout/projects/anwar-pi-website/assets/anwarpi.css)
|
||||
============================================================ */
|
||||
|
||||
/* ---- Brand mark: real logo image replaces the geometric span ---- */
|
||||
img.logo-mark {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
transform: none;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ---- Text selection on dark canvas ---- */
|
||||
::selection { background: color-mix(in oklch, var(--accent) 28%, transparent); }
|
||||
|
||||
/* ---- Button hover: light-sweep + lift (AnwarPi "light" motif) ---- */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.btn, .btn-primary, .btn-secondary, .btn-ghost, .icon-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease, color .18s ease;
|
||||
}
|
||||
.btn::after, .btn-primary::after, .btn-secondary::after, .btn-ghost::after, .icon-btn::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: -45%;
|
||||
width: 34%;
|
||||
background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.45) 50%, transparent 100%);
|
||||
transform: skewX(-18deg);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn:hover::after, .btn-primary:hover::after, .btn-secondary:hover::after,
|
||||
.btn-ghost:hover::after, .icon-btn:hover::after {
|
||||
animation: anw-sweep 0.85s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.btn:hover, .btn-primary:hover, .btn-secondary:hover, .btn-ghost:hover, .icon-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:hover, .btn:hover {
|
||||
box-shadow: 0 8px 28px color-mix(in oklch, var(--accent) 30%, transparent);
|
||||
}
|
||||
.btn:active, .btn-primary:active, .btn-secondary:active, .btn-ghost:active, .icon-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes anw-sweep {
|
||||
0% { opacity: 1; inset-inline-start: -45%; }
|
||||
100% { opacity: 1; inset-inline-start: 118%; }
|
||||
}
|
||||
|
||||
/* ---- Scroll reveal (driven by anwarpi.js) ---- */
|
||||
[data-anw] {
|
||||
transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.anw-anim [data-anw]:not(.anw-in) {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
|
||||
/* ---- WebGL canvas sits behind everything, above the root background ---- */
|
||||
#anwarpi-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.9s ease;
|
||||
}
|
||||
|
||||
/* ---- Hero brand mark: the beam logo, black field dissolved into the canvas ---- */
|
||||
.hero-logo {
|
||||
display: block;
|
||||
width: 68px;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
mix-blend-mode: screen; /* drops the jpg's black field — only the beams glow */
|
||||
margin-block-end: 26px;
|
||||
}
|
||||
|
||||
/* ---- Dark-theme correction: scrims must dim with black, not --fg (now white) ---- */
|
||||
.dialog-scrim, .menu-scrim, .modal { background: rgba(5, 5, 5, 0.68); }
|
||||
|
||||
/* ---- Shared responsive safeguards (verbatim) ---- */
|
||||
@media (pointer: coarse) {
|
||||
a, button, input, select, textarea, summary, .btn, .icon-btn { min-height: 44px; }
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
:root { --gutter: 22px; }
|
||||
.container { width: 100%; }
|
||||
|
||||
/* Keep primary navigation visible on phones and tablets. */
|
||||
.topnav .nav-inner,
|
||||
.topnav .topnav-inner {
|
||||
flex-wrap: wrap;
|
||||
gap: 0 16px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.topnav .logo,
|
||||
.topnav .nav-tools { margin-bottom: 10px; }
|
||||
.topnav .logo { min-height: 50px; }
|
||||
input, select, textarea { min-height: 48px; font-size: 16px; }
|
||||
.footer-col a { min-height: 44px; display: flex; align-items: center; }
|
||||
.section-head { gap: 20px; }
|
||||
.table-wrap, .billing-table { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.topnav .nav-links,
|
||||
.topnav .topnav-inner > nav {
|
||||
display: flex;
|
||||
order: 3;
|
||||
flex: 0 0 calc(100% + (var(--gutter) * 2));
|
||||
gap: 8px;
|
||||
margin-inline: calc(var(--gutter) * -1);
|
||||
padding-inline: var(--gutter);
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.topnav .nav-links::-webkit-scrollbar,
|
||||
.topnav .topnav-inner > nav::-webkit-scrollbar { display: none; }
|
||||
.topnav .nav-links a,
|
||||
.topnav .topnav-inner > nav a {
|
||||
flex: none;
|
||||
min-height: 52px;
|
||||
padding-inline: 12px;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
.topnav a[aria-current="page"] {
|
||||
position: relative;
|
||||
color: var(--fg);
|
||||
font-weight: 700;
|
||||
}
|
||||
.topnav a[aria-current="page"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 12px;
|
||||
bottom: 5px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.topnav .nav-links + .nav-tools .menu-trigger,
|
||||
.topnav .topnav-inner > nav + .nav-tools .menu-trigger { display: none; }
|
||||
|
||||
.menu-drawer { width: min(520px, 92vw); }
|
||||
.dialog-card, .modal-card { max-width: calc(100vw - 36px); max-height: calc(100dvh - 36px); overflow-y: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
:root { --gutter: 18px; }
|
||||
body { line-height: 1.7; }
|
||||
h1 { font-size: clamp(34px, 9vw, 40px); overflow-wrap: normal; word-break: normal; }
|
||||
h2 { overflow-wrap: normal; word-break: normal; }
|
||||
.topnav .logo { font-size: 20px; }
|
||||
.topnav .nav-return { display: inline-flex !important; min-height: 44px; align-items: center; font-size: 13px; padding-inline: 4px; }
|
||||
.topnav .nav-inner,
|
||||
.topnav .topnav-inner { gap: 0 10px; }
|
||||
.topnav .nav-links,
|
||||
.topnav .topnav-inner > nav { gap: 4px; }
|
||||
.topnav .nav-links a,
|
||||
.topnav .topnav-inner > nav a { padding-inline: 10px; }
|
||||
.hero-cta, .dialog-actions, .form-actions { align-items: stretch; gap: 12px; }
|
||||
.hero-cta .btn, .dialog-actions .btn, .form-actions .btn { width: 100%; }
|
||||
.pagefoot { padding-block: 40px; }
|
||||
.footer-bottom { gap: 12px; }
|
||||
.card, .membership-card, .paywall, .plan, .team-contact { padding-inline: 20px; }
|
||||
.dialog-card, .modal-card, .dialog-panel, .modal-panel { max-width: calc(100vw - 28px); }
|
||||
|
||||
/* QA-R06: fade edge signals horizontal scrollability (RTL: fades at the left edge). */
|
||||
.topnav nav, .filterbar, .filters, .table-wrap {
|
||||
-webkit-mask-image: linear-gradient(to left, transparent, #000 32px);
|
||||
mask-image: linear-gradient(to left, transparent, #000 32px);
|
||||
}
|
||||
/* QA-R09: 44px minimum touch targets. */
|
||||
.topnav nav a, .nav-return { min-height: 44px; display: inline-flex; align-items: center; }
|
||||
}
|
||||
|
||||
/* QA-04: enforce readable minimum text on phones (doubled specificity beats per-page font shorthands). */
|
||||
@media (max-width: 620px) {
|
||||
.eyebrow.eyebrow, .post-meta.post-meta, .count.count, .crumb.crumb, .chip.chip,
|
||||
.access.access, .free-tag.free-tag, .access-chip.access-chip, .filter-label.filter-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagefoot.pagefoot, .footer-bottom.footer-bottom { font-size: 15px; }
|
||||
}
|
||||
|
||||
/* 404 page — centered minimal layout reusing existing utilities. */
|
||||
main.nf { min-height: 60vh; display: grid; place-content: center; text-align: center; gap: 16px; }
|
||||
.nf-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,47 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Readex_Pro } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import './polish.css'
|
||||
import './design.css'
|
||||
import './color-trial.css'
|
||||
|
||||
/* Readex Pro: a modern Arabic/Latin face drawn for screens and interfaces.
|
||||
Self-hosted at build time by next/font. */
|
||||
const sans = Readex_Pro({
|
||||
subsets: ['arabic', 'latin'],
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
display: 'swap',
|
||||
variable: '--font-sans',
|
||||
})
|
||||
|
||||
/* The design is light-only: stop browsers' forced dark modes from repainting it. */
|
||||
export const viewport: Viewport = { colorScheme: 'only light', themeColor: '#ffffff' }
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'),
|
||||
title: { default: 'أنور بآي', template: '%s — أنور بآي' },
|
||||
description: 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.',
|
||||
alternates: { canonical: './' },
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: 'أنور بآي',
|
||||
locale: 'ar',
|
||||
images: [{ url: '/og-default.png', width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: { card: 'summary_large_image', images: ['/og-default.png'] },
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
return (
|
||||
<html lang="ar" dir="rtl" className={sans.variable}>
|
||||
<head>
|
||||
<meta name="darkreader-lock" />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebSite', name: 'أنور بآي', url: siteUrl, inLanguage: 'ar' }) }} />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from 'next/link'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
|
||||
export const metadata = { title: 'الصفحة غير موجودة' }
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div id="site" className="site-page page-not-found">
|
||||
<Topnav />
|
||||
<main id="content" className="container nf">
|
||||
<h1>الصفحة غير موجودة</h1>
|
||||
<p>الرابط الذي تبحث عنه غير موجود أو نُقل.</p>
|
||||
<div className="nf-actions">
|
||||
<Link className="btn btn-primary" href="/">العودة إلى الصفحة الرئيسية</Link>
|
||||
<Link className="btn btn-secondary" href="/articles">تصفح المكتبة</Link>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/* ============================================================
|
||||
AnwarPi — professional polish layer.
|
||||
Loaded last from the root layout. #site-scoped so it wins over
|
||||
per-route sheets without !important. Keep additions here small
|
||||
and system-wide; route-specific layout stays in styles/*.css.
|
||||
============================================================ */
|
||||
|
||||
/* ---- Arabic typography ----
|
||||
Negative tracking pulls Arabic glyphs into each other and breaks
|
||||
the connected script. Arabic headings get normal tracking and a
|
||||
slightly heavier weight from IBM Plex Sans Arabic instead. */
|
||||
#site :is(h1, h2, h3, h4) { letter-spacing: 0; font-weight: 700; }
|
||||
#site h1 { line-height: 1.35; }
|
||||
#site :is(h2, h3) { line-height: 1.45; }
|
||||
#site { font-feature-settings: "kern"; }
|
||||
#site :is(.num, time, .count, .price, [data-views]) { font-variant-numeric: tabular-nums; }
|
||||
#site :is(.eyebrow, .crumb) { letter-spacing: 0; font-weight: 600; }
|
||||
|
||||
/* Small labels that used the mono stack read better in the body face. */
|
||||
#site :is(.cover-label, .guide-cover span, .related-card span, .field label, .filter-label,
|
||||
.weekly-meta, .post-meta, .card-meta, .guide-meta, .deck-slide-kicker, .free-tag, .price-line,
|
||||
.p-label, .benefit-index, .trial-label) {
|
||||
font-family: var(--font-body);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ---- Interactive surfaces: one consistent hover language ---- */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
#site :is(.catalog-card, .topic, .guide-card, .related-card, .weekly, .post) {
|
||||
transition: transform .22s cubic-bezier(.2, .7, .2, 1), border-color .22s ease, background-color .22s ease, box-shadow .22s ease;
|
||||
}
|
||||
#site :is(.catalog-card, .topic, .guide-card, .weekly):hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 18px 40px -18px #000a, 0 0 0 1px color-mix(in oklch, var(--accent) 35%, transparent);
|
||||
}
|
||||
}
|
||||
#site :is(.catalog-card, .guide-card, .weekly):hover { border-color: color-mix(in oklch, var(--accent) 45%, var(--border)); }
|
||||
#site :is(.catalog-card, .guide-card) { overflow: hidden; }
|
||||
#site .catalog-card { display: flex; flex-direction: column; }
|
||||
#site .catalog-card .card-link { margin-top: auto; }
|
||||
|
||||
/* Covers: soft light from the logo's beam direction instead of flat fills. */
|
||||
#site :is(.cover, .guide-cover) { position: relative; isolation: isolate; }
|
||||
#site :is(.cover, .guide-cover)::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
|
||||
background: radial-gradient(120% 90% at 15% 110%, #ffffff1f, transparent 55%);
|
||||
}
|
||||
|
||||
/* ---- Home: learning-path cards ---- */
|
||||
#site.home-page .topic { display: flex; flex-direction: column; justify-content: flex-end; gap: 4px; }
|
||||
#site.home-page .topic .topic-count {
|
||||
font-style: normal; font-size: 13px; color: var(--accent); margin-top: 10px;
|
||||
padding-top: 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
#site.home-page .section-link { font-size: 14px; color: var(--accent); text-decoration: none; }
|
||||
#site.home-page .section-link:hover { text-decoration: underline; }
|
||||
#site.home-page .membership-lead { margin-top: 20px; }
|
||||
#site.home-page .catalog-card h3 { margin-block: 6px 4px; }
|
||||
#site.home-page .card-meta { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* ---- Subscription: sample covers match the home catalogue ---- */
|
||||
#site.page-subscription .guide-card { background: #141619; border-radius: 16px; }
|
||||
#site.page-subscription .guide-cover { background: linear-gradient(135deg, #3a3d43, #1a1c20); color: #f4f2ee; border-radius: 10px; margin: 14px 14px 0; }
|
||||
#site.page-subscription .guide-cover.guide-cover-light { background: linear-gradient(135deg, #43392d, #1f1b16); color: #f4f2ee; }
|
||||
#site.page-subscription .guide-cover :is(span, strong) { color: inherit; }
|
||||
#site.page-subscription .guide-cover strong { line-height: 1.4; }
|
||||
|
||||
/* ---- Article: related guides as cards ---- */
|
||||
#site.page-posts .related-grid { border-top: 0; gap: 20px; }
|
||||
#site.page-posts .related-card {
|
||||
display: flex; flex-direction: column; gap: 6px; margin: 0;
|
||||
padding: 22px; border: 1px solid var(--border); border-radius: 14px; background: #141619;
|
||||
}
|
||||
#site.page-posts .related-card:hover { border-color: color-mix(in oklch, var(--accent) 45%, var(--border)); }
|
||||
#site.page-posts .related-card h3 { font-size: 20px; margin: 4px 0 12px; }
|
||||
#site.page-posts .related-card a { margin-top: auto; color: var(--accent); }
|
||||
#site.page-posts .article-meta time { color: inherit; }
|
||||
|
||||
/* Comment gate for signed-out readers (replaces a form they could not submit). */
|
||||
#site .comment-gate {
|
||||
margin-top: 32px; padding: 24px; border: 1px dashed var(--border); border-radius: 14px;
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px;
|
||||
background: color-mix(in oklch, var(--surface) 60%, transparent);
|
||||
}
|
||||
#site .comment-gate p { margin: 0; color: var(--muted); max-width: 44ch; }
|
||||
|
||||
/* ---- Forms: consistent radius with the rest of the system ---- */
|
||||
#site:is(.page-login, .page-signup, .page-forgot-password) :is(.input, .btn, .magic) { border-radius: 10px; }
|
||||
|
||||
/* ---- Footer ---- */
|
||||
#site .site-footer .footer-col a[dir="ltr"] { direction: ltr; unicode-bidi: isolate; }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
#site.page-posts .related-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Inverted pricing panel: the shared muted grey is unreadable on the light background. */
|
||||
#site.page-subscription .pricing-grid > :first-child :is(.lead, p:not(.eyebrow)) { color: #4a4740; }
|
||||
#site.page-subscription .pricing-grid > :first-child .eyebrow { color: #8a6a2f; }
|
||||
|
||||
/* ============ Graphite & champagne refinement ============ */
|
||||
#site .hero-logo { mix-blend-mode: normal; width: 64px; height: auto; border-radius: 0; }
|
||||
#site .logo { letter-spacing: 0; }
|
||||
#site .eyebrow { color: var(--accent); }
|
||||
#site .hero-highlight { color: var(--accent); }
|
||||
/* Membership / promo panels: quiet graphite with a hairline of champagne light. */
|
||||
#site.home-page .membership {
|
||||
background:
|
||||
radial-gradient(80% 120% at 100% 0%, color-mix(in oklch, var(--accent) 10%, transparent), transparent 60%),
|
||||
linear-gradient(160deg, #1b1d21, #121316);
|
||||
border-color: color-mix(in oklch, var(--accent) 22%, var(--border));
|
||||
}
|
||||
#site.home-page .hero { background: radial-gradient(60% 70% at 15% 30%, color-mix(in oklch, var(--accent) 6%, transparent), transparent 70%); }
|
||||
#site.home-page .deck-frame { border-color: #2f3237; background: linear-gradient(150deg, #1c1e22, #121316); box-shadow: 0 30px 70px -30px #000c; }
|
||||
#site.home-page .deck-slide::after { background: var(--accent); }
|
||||
#site.home-page .deck-dot.active::before { background: var(--accent); }
|
||||
/* VIP marks read as premium, free marks stay neutral. */
|
||||
#site .access:not(.free), #site .lock-chip { border-color: color-mix(in oklch, var(--accent) 55%, transparent); color: var(--accent); }
|
||||
#site .open-chip { border-color: var(--border); color: var(--muted); }
|
||||
/* Inverted pricing panel → warm ivory. */
|
||||
#site.page-subscription .pricing { background: #efede8; }
|
||||
#site :is(.btn-primary) { font-weight: 600; }
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin', '/api', '/account-settings', '/onboarding', '/vip-onboarding', '/verify-email', '/payment-success', '/payment-cancelled', '/forgot-password'],
|
||||
}],
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/* Shared frontend presentation. #site wins over legacy page selectors,
|
||||
so typography, navigation and controls stay stable after client navigation. */
|
||||
#site {
|
||||
min-height: 100dvh;
|
||||
background: linear-gradient(180deg, #0c0d0ff5, #0c0d0f 80%);
|
||||
color: var(--fg);
|
||||
font: 16px/1.8 var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body.locked { overflow: hidden; }
|
||||
#site [hidden] { display: none !important; }
|
||||
#site :is(main, article, aside, form, label, .auth > *, .flow > *, .settings-layout > *, .article-layout > *) { min-width: 0; }
|
||||
#site :is(h1, h2, h3) { overflow-wrap: anywhere; letter-spacing: -.02em; }
|
||||
#site h1 { font-size: clamp(38px, 4.7vw, 64px); line-height: 1.4; max-width: 20ch; margin: 0; }
|
||||
#site h2 { font-size: clamp(28px, 3vw, 40px); line-height: 1.5; }
|
||||
#site h3 { line-height: 1.6; }
|
||||
#site :is(.lead, .dek) { font-size: clamp(16px, 1.5vw, 19px); line-height: 1.9; color: var(--muted); }
|
||||
#site :is(.eyebrow, .crumb, .filter-label, .weekly-meta, .post-meta, .meta, .count, .access, .foryou, .article-meta, .fine) { font-family: var(--font-body); font-size: 13px; line-height: 1.8; }
|
||||
#site :is(.hero, .article-head) { padding-block: clamp(44px, 6vw, 80px); }
|
||||
#site .hero-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; }
|
||||
#site .topnav { position: sticky; top: 0; background: #0c0d0ff2; backdrop-filter: blur(14px); border-bottom: 1px solid var(--border); }
|
||||
#site :is(.nav-inner, .topnav-inner) { min-height: 80px; gap: 24px; }
|
||||
#site .logo { font-size: 23px; flex: none; text-decoration: none; line-height: 1.4; }
|
||||
#site img.logo-mark { width: 36px; height: 32px; object-fit: contain; transform: none; background: none; border-radius: 4px; }
|
||||
#site .topnav nav { gap: 24px; }
|
||||
#site .topnav nav:empty { display: none; }
|
||||
#site .topnav nav a { font-size: 14px; min-height: 44px; white-space: nowrap; }
|
||||
#site :is(.nav-login, .nav-action, .nav-account) { display: inline-flex; align-items: center; min-height: 44px; padding-inline: 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--fg); font-size: 14px; max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#site :is(.nav-login, .nav-action, .nav-account):hover { border-color: var(--accent); }
|
||||
#site .nav-tools { gap: 10px; min-width: 0; }
|
||||
#site .nav-return { min-height: 44px; font-size: 14px; display: inline-flex; align-items: center; }
|
||||
#site .btn { border-radius: 10px; min-height: 50px; line-height: 1.6; padding: 12px 22px; text-align: center; }
|
||||
#site .btn-primary { background: var(--fg); border-color: var(--fg); color: var(--bg); }
|
||||
#site .btn-primary:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
||||
#site .btn-secondary { background: transparent; border-color: var(--border); color: var(--fg); }
|
||||
#site .btn-secondary:hover { border-color: var(--accent); background: var(--fg-soft); color: var(--fg); }
|
||||
#site button:disabled { cursor: wait; opacity: .65; transform: none; }
|
||||
#site :is(.icon-btn, .menu-trigger) { width: 44px; height: 44px; flex: none; border-radius: 8px; background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||
#site :is(.field input:not([type=checkbox]):not([type=radio]), .input, .textarea, .control) { width: 100%; min-height: 50px; border-radius: 8px; border: 1px solid #34383e; padding: 12px 14px; background: #111316; color: var(--fg); font-size: 16px; }
|
||||
#site :is(input, select, textarea):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
#site .input-wrap .input { padding-inline-end: 88px; }
|
||||
#site .password-toggle { min-height: 44px; top: 3px; }
|
||||
#site input:is([type=checkbox], [type=radio]) { width: 18px; height: 18px; min-height: 18px; accent-color: var(--accent); }
|
||||
#site .check { min-height: 44px; align-items: center; }
|
||||
#site .filter { border-radius: 24px; min-height: 44px; font-size: 14px; }
|
||||
#site .filter:is(.active, [aria-pressed=true]) { background: var(--fg); border-color: var(--fg); color: var(--bg); }
|
||||
#site :is(.filters, .filterbar) { scrollbar-width: none; mask-image: none; -webkit-mask-image: none; padding-bottom: 8px; }
|
||||
#site :is(.filters, .filterbar)::-webkit-scrollbar { display: none; }
|
||||
#site :is(.weekly, .hero-note, .plan, .guide-card, .profile, .card, .reset-card, .state-card, .paywall, .choice-body, .start-card, .upgrade-note, .files-card, .team-contact) { border-radius: 16px; }
|
||||
#site :is(.weekly, .hero-note, .plan, .guide-card, .profile, .card, .reset-card, .state-card, .paywall, .upgrade-note) { background: #15171a; border: 1px solid var(--border); }
|
||||
#site .weekly h2 { margin-top: 28px; font-size: clamp(26px, 2.5vw, 34px); }
|
||||
#site .hero-note { padding: 24px; }
|
||||
#site .profile { padding: 24px; }
|
||||
#site .profile + .profile { padding-inline-start: 24px; }
|
||||
#site .section-head { gap: 28px; }
|
||||
#site .library { background: #101114; }
|
||||
#site .post { gap: 24px; padding-block: 28px; }
|
||||
#site .post h3 { font-size: 23px; line-height: 1.7; }
|
||||
#site .post p { font-size: 16px; line-height: 1.9; }
|
||||
#site .post-link { color: var(--accent); }
|
||||
#site .access { border-radius: 6px; }
|
||||
#site :is(.open-chip, .lock-chip, .access-chip) { font: 13px/1.8 var(--font-body); border-radius: 6px; }
|
||||
#site .auth { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
#site .auth-story { background: linear-gradient(135deg, #1a1c20, #121316); color: var(--fg); padding: 64px max(32px, calc((100vw - 1180px) / 2 + 32px)); padding-inline-end: 48px; }
|
||||
#site .auth-story .eyebrow { color: var(--accent); opacity: 1; }
|
||||
#site .auth-story > div > p, #site .story-foot { color: var(--muted); }
|
||||
#site .story-list, #site .story-list li { border-color: #ffffff20; }
|
||||
#site .story-mark { color: var(--accent); opacity: 1; }
|
||||
#site .auth-form-wrap { padding: 64px max(32px, calc((100vw - 1180px) / 2 + 32px)); padding-inline-start: 48px; justify-content: center; }
|
||||
#site .auth-form h2 { font-size: 32px; }
|
||||
#site .magic { display: flex; align-items: center; justify-content: center; border-radius: 8px; text-align: center; }
|
||||
#site .article-layout { grid-template-columns: minmax(0, 1fr) 260px; gap: 48px; }
|
||||
#site .article-body { min-width: 0; overflow-wrap: anywhere; font-size: 18px; line-height: 2; }
|
||||
#site .article-body h2 { margin-top: 48px; font-size: clamp(26px, 3vw, 34px); }
|
||||
#site .article-body h3 { font-size: 25px; }
|
||||
#site .article-body :is(pre, .code) { direction: ltr; text-align: left; border-radius: 10px; overflow-x: auto; max-width: 100%; background: #101114; color: var(--fg); border: 1px solid var(--border); font-size: 14px; }
|
||||
#site .article-body :is(h2, h3)[id] { scroll-margin-top: 160px; }
|
||||
#site .article-meta { flex-direction: row; align-items: center; gap: 10px 20px; }
|
||||
#site :is(.progress-card, .member-note) { background: #1a1c20; color: var(--fg); border: 1px solid var(--border); border-radius: 12px; }
|
||||
#site .member-note p, #site .progress-card .p-label { color: var(--muted); }
|
||||
#site .toc { top: 110px; }
|
||||
#site .save-btn { font: 13px/1.8 var(--font-body); border-radius: 8px; min-height: 44px; }
|
||||
#site .dl-btn { min-height: 44px; border-radius: 8px; }
|
||||
#site .nf { min-height: 100dvh; padding-block: 64px; }
|
||||
#site .nf h1 { max-width: none; }
|
||||
#site .related-grid { gap: 24px; }
|
||||
#site .related-card + .related-card { margin-inline-start: 0; }
|
||||
#site .pagefoot { font-size: 14px; padding-block: 40px; border-top: 1px solid var(--border); }
|
||||
#site .footer-grid { display: grid; grid-template-columns: 1.4fr repeat(3, minmax(0, 1fr)); gap: 32px; }
|
||||
#site .footer-col a { min-height: 44px; display: flex; align-items: center; overflow-wrap: anywhere; }
|
||||
#site .footer-bottom { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; font-size: 13px; }
|
||||
#site :is(.menu-scrim, .dialog-scrim, .modal) { background: #050506b8; }
|
||||
#site .menu-panel { position: fixed; inset: 0; z-index: 60; visibility: hidden; pointer-events: none; }
|
||||
#site .menu-panel.open { visibility: visible; pointer-events: auto; }
|
||||
#site .menu-scrim { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; opacity: 0; transition: opacity .2s ease; }
|
||||
#site .menu-panel.open .menu-scrim { opacity: 1; }
|
||||
#site .menu-drawer { position: absolute; inset-block: 0; inset-inline-start: 0; width: min(520px, 92vw); padding: 28px clamp(24px, 5vw, 54px); max-height: 100dvh; overflow-y: auto; background: var(--bg); border-inline-end: 1px solid var(--border); display: flex; flex-direction: column; transform: translateX(105%); transition: transform .25s ease; }
|
||||
#site .menu-panel.open .menu-drawer { transform: translateX(0); }
|
||||
#site .menu-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
|
||||
#site .menu-links { display: grid; margin-top: 28px; }
|
||||
#site .menu-links a { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 60px; font-size: 24px; border-bottom: 1px solid var(--border); }
|
||||
#site .menu-foot { margin-top: auto; padding-top: 24px; color: var(--muted); }
|
||||
#site .menu-foot .hidden { display: none; }
|
||||
#site :is(.dialog-panel, .dialog-card, .modal-panel) { max-height: calc(100dvh - 32px); overflow-y: auto; border-radius: 16px; }
|
||||
@media (max-width: 920px) {
|
||||
#site :is(.hero-grid, .article-layout, .auth) { grid-template-columns: minmax(0, 1fr); gap: 28px; }
|
||||
#site :is(.nav-inner, .topnav-inner) { min-height: 66px; flex-wrap: wrap; gap: 0 12px; padding-top: 10px; }
|
||||
#site .topnav :is(.logo, .nav-tools) { margin-bottom: 10px; }
|
||||
#site .toc { position: static; }
|
||||
#site .auth-story { padding: 40px var(--gutter); min-height: auto; }
|
||||
#site .auth-form-wrap { padding: 40px var(--gutter); }
|
||||
#site .auth .story-list { margin-top: 24px; }
|
||||
#site .auth .story-foot { margin-top: 24px; }
|
||||
#site .footer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
#site h1 { font-size: clamp(30px, 9vw, 36px); line-height: 1.5; max-width: none; }
|
||||
#site h2 { font-size: 28px; }
|
||||
#site .logo { font-size: 20px; }
|
||||
#site :is(.nav-login, .nav-action, .nav-account) { max-width: 130px; padding-inline: 10px; font-size: 12px; }
|
||||
#site .nav-return { font-size: 12px; }
|
||||
#site .topnav nav a { font-size: 13px; }
|
||||
#site .auth-story h1 { font-size: 30px; max-width: none; }
|
||||
#site .auth-story > div > p { font-size: 16px; }
|
||||
#site .auth .story-list, #site .auth .story-foot { display: none; }
|
||||
#site .auth-form-wrap { padding-top: 28px; }
|
||||
#site .auth-form h2 { font-size: 28px; }
|
||||
#site .form-row { flex-direction: row; flex-wrap: wrap; align-items: center; }
|
||||
#site .hero-grid { gap: 24px; }
|
||||
#site .weekly { padding: 24px; }
|
||||
#site .weekly h2 { font-size: 26px; }
|
||||
#site .article-layout { grid-template-columns: minmax(0, 1fr); padding-block: 40px; }
|
||||
#site .article-body { font-size: 17px; }
|
||||
#site .article-meta { font-size: 12px; }
|
||||
#site .lede { font-size: 22px; }
|
||||
#site .paywall { padding: 24px 20px; }
|
||||
#site .flow { grid-template-columns: minmax(0, 1fr); }
|
||||
#site .step-nav { min-width: 0; max-width: 100%; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; }
|
||||
#site .step-nav div { grid-template-columns: 1fr; gap: 4px; padding: 10px 8px; font-size: 12px; text-align: center; }
|
||||
#site .choice-body { padding: 20px; }
|
||||
#site .actions { flex-direction: column; align-items: stretch; }
|
||||
#site .actions .btn { width: 100%; }
|
||||
#site .footer-grid { grid-template-columns: minmax(0, 1fr); gap: 24px; }
|
||||
#site :is(.footer-row, .footer-bottom) { flex-direction: column; gap: 12px; }
|
||||
}
|
||||
|
||||
/* Header owns its full layout; route styles cannot change navigation. */
|
||||
#site .skip-link { position: fixed; top: -100px; inset-inline-start: 16px; z-index: 100; padding: 12px 20px; background: var(--accent); color: var(--bg); border-radius: 8px; }
|
||||
#site .skip-link:focus { top: 12px; }
|
||||
#content, #site main section[id] { scroll-margin-top: 100px; }
|
||||
#site .site-header { z-index: 40; }
|
||||
#site .site-header .nav-inner { display: flex; justify-content: space-between; align-items: center; min-height: 80px; gap: 24px; padding-block: 0; flex-wrap: nowrap; }
|
||||
#site .site-header .logo { margin: 0; display: inline-flex; align-items: center; gap: 10px; font-size: 23px; font-weight: 700; }
|
||||
#site .site-header nav.nav-links { display: flex; flex: initial; order: initial; align-items: center; gap: 8px; margin: 0; padding: 0; width: auto; max-width: none; border: 0; overflow: visible; }
|
||||
#site .site-header nav.nav-links a { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 14px; font-size: 14px; font-weight: 500; color: var(--muted); border-radius: 8px; }
|
||||
#site .site-header nav.nav-links a:is(:hover, [aria-current=page]) { color: var(--accent); background: var(--accent-soft); }
|
||||
#site .site-header .nav-tools { display: flex; align-items: center; margin: 0; gap: 10px; }
|
||||
#site .site-header #menuTrigger { display: none; }
|
||||
#site .site-header .nav-login, #site .menu-foot .nav-login { font: 14px/1.8 var(--font-body); font-weight: 600; padding: 8px 16px; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; display: inline-flex; align-items: center; color: var(--fg); background: transparent; max-width: none; }
|
||||
#site .menu-links a[aria-current=page] { color: var(--accent); }
|
||||
#site .menu-links a:hover { color: var(--accent); }
|
||||
#site .menu-drawer { width: min(400px, 92vw); }
|
||||
#site .site-footer .footer-grid { display: grid; grid-template-columns: 1.4fr repeat(3, minmax(0, 1fr)); gap: 32px; }
|
||||
#site .site-footer .footer-grid p { max-width: 30ch; color: var(--muted); margin-top: 16px; }
|
||||
#site .site-footer .footer-col { display: flex; flex-direction: column; align-items: flex-start; }
|
||||
#site .site-footer .footer-col strong { color: var(--fg); margin-bottom: 8px; }
|
||||
#site .site-footer .footer-col a { color: var(--muted); font-size: 14px; min-height: 44px; }
|
||||
#site .site-footer .footer-col a:hover { color: var(--accent); }
|
||||
#site .site-footer .footer-bottom { display: flex; flex-direction: row; justify-content: space-between; border-top: 1px solid var(--border); margin-top: 28px; padding-top: 24px; color: var(--muted); }
|
||||
@media (max-width: 920px) {
|
||||
#site .site-header .nav-inner { min-height: 72px; gap: 12px; }
|
||||
#site .site-header nav.nav-links { display: none; }
|
||||
#site .site-header #menuTrigger { display: inline-grid; place-items: center; font-size: 22px; }
|
||||
#site .site-header .logo { font-size: 21px; }
|
||||
#site .site-footer .footer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
#site .site-header .nav-inner { padding-inline: 16px; gap: 8px; }
|
||||
#site .site-header .logo { font-size: 18px; gap: 6px; }
|
||||
#site .site-header img.logo-mark { width: 30px; height: 26px; }
|
||||
#site .site-header .nav-tools { gap: 6px; }
|
||||
#site .site-header .nav-login { font-size: 12px; padding-inline: 10px; }
|
||||
#site .site-footer .footer-grid { grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
#site .site-footer .footer-grid > :first-child { grid-column: 1 / -1; }
|
||||
#site .site-footer .footer-bottom { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
})
|
||||
const staticPaths = ['', '/articles', '/subscription', '/terms', '/privacy', '/about', '/login', '/signup']
|
||||
return [
|
||||
...staticPaths.map(p => ({ url: `${base}${p}` })),
|
||||
...posts.docs.map(p => ({ url: `${base}/posts/${p.slug}`, lastModified: p.updatedAt ? new Date(String(p.updatedAt)) : undefined })),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user