Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e69e419721 | ||
|
|
8e577165cf | ||
|
|
5ee706ebd6 | ||
|
|
c34611dc36 | ||
|
|
9cfffa4c66 | ||
|
|
6c8632315f | ||
|
|
a1ba197910 | ||
|
|
2cacdfb926 |
@@ -106,6 +106,9 @@
|
||||
.deck-frame { position: relative; min-height: 470px; border: 1px solid var(--fg); background: var(--surface); overflow: hidden; }
|
||||
.deck-slide { position: absolute; inset: 0; padding: clamp(28px, 4vw, 52px); display: none; grid-template-rows: auto 1fr auto; }
|
||||
.deck-slide.active { display: grid; }
|
||||
.deck-slide-link { position: absolute; inset: 0; z-index: 2; cursor: pointer; }
|
||||
.deck-slide-link:focus-visible { outline-offset: -8px; }
|
||||
.deck-slide:hover .deck-slide-body h2 { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 8px; }
|
||||
.deck-slide::after { content: ""; position: absolute; inset-inline-start: 0; bottom: 0; width: 100%; height: 4px; background: var(--fg); }
|
||||
.deck-slide-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.deck-slide-kicker { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; }
|
||||
@@ -212,9 +215,10 @@
|
||||
<div class="container topnav-inner">
|
||||
<a class="logo" href="#top" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a>
|
||||
<nav aria-label="التنقل الرئيسي">
|
||||
<a href="#catalog">المكتبة</a>
|
||||
<a href="articles.html" data-od-id="path-0-0-1-0">المكتبة</a>
|
||||
<a href="#topics">المسارات</a>
|
||||
<a href="#membership">العضوية</a>
|
||||
<a href="about.html" data-od-id="nav-about-team">عن الفريق</a>
|
||||
</nav>
|
||||
<button class="nav-action" data-open-membership>تسجيل الدخول</button>
|
||||
</div>
|
||||
@@ -237,16 +241,19 @@
|
||||
<div class="deck" data-od-id="featured-deck" aria-label="مختارات مميزة">
|
||||
<div class="deck-frame" aria-live="polite">
|
||||
<article class="deck-slide active" data-slide="0">
|
||||
<a class="deck-slide-link" href="articles.html?topic=agents" aria-label="افتح قسم مقالات الوكلاء في المكتبة"></a>
|
||||
<div class="deck-slide-top"><span class="deck-slide-kicker">دليل حصري · المستوى المتوسط</span><span class="deck-slide-index num">01</span></div>
|
||||
<div class="deck-slide-body"><h2>ابنِ وكيلك الأول دون فوضى</h2><p>خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.</p></div>
|
||||
<div class="deck-slide-foot"><span class="deck-slide-format"><span class="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</span><span class="meta"><bdi dir="ltr">AGENTIC AI</bdi></span></div>
|
||||
</article>
|
||||
<article class="deck-slide" data-slide="1">
|
||||
<a class="deck-slide-link" href="articles.html?topic=models" aria-label="افتح قسم مقالات النماذج في المكتبة"></a>
|
||||
<div class="deck-slide-top"><span class="deck-slide-kicker">مقال تطبيقي · للمبتدئين</span><span class="deck-slide-index num">02</span></div>
|
||||
<div class="deck-slide-body"><h2>كيف تختار نموذجاً لمهمتك؟</h2><p>مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.</p></div>
|
||||
<div class="deck-slide-foot"><span class="deck-slide-format">قراءة · 12 دقيقة</span><span class="meta">النماذج</span></div>
|
||||
</article>
|
||||
<article class="deck-slide" data-slide="2">
|
||||
<a class="deck-slide-link" href="articles.html?topic=models" aria-label="افتح قسم مقالات RAG في المكتبة"></a>
|
||||
<div class="deck-slide-top"><span class="deck-slide-kicker">مسار متقدم · للأعضاء</span><span class="deck-slide-index num">03</span></div>
|
||||
<div class="deck-slide-body"><h2>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</h2><p>صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.</p></div>
|
||||
<div class="deck-slide-foot"><span class="deck-slide-format"><span class="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</span><span class="meta"><bdi dir="ltr">RAG</bdi></span></div>
|
||||
@@ -274,10 +281,10 @@
|
||||
<p class="lead">تصفح المحتوى حسب ما تريد بناءه، لا حسب المصطلحات التي تحفظها.</p>
|
||||
</div>
|
||||
<div class="topic-grid">
|
||||
<a class="topic" href="#catalog" data-topic-link="agents" data-od-id="topic-agents"><strong>الوكلاء</strong><span>تخطيط · أدوات · ذاكرة</span></a>
|
||||
<a class="topic" href="#catalog" data-topic-link="models" data-od-id="topic-models"><strong>النماذج</strong><span>اختيار · تقييم · تشغيل</span></a>
|
||||
<a class="topic" href="#catalog" data-topic-link="automation" data-od-id="topic-automation"><strong>الأتمتة</strong><span>سير عمل · ربط · مراقبة</span></a>
|
||||
<a class="topic" href="#catalog" data-topic-link="foundations" data-od-id="topic-foundations"><strong>الأساسيات</strong><span>مفاهيم · تطبيق · أمان</span></a>
|
||||
<a class="topic" href="articles.html?topic=agents" data-od-id="topic-agents"><strong>الوكلاء</strong><span>تخطيط · أدوات · ذاكرة</span></a>
|
||||
<a class="topic" href="articles.html?topic=models" data-od-id="topic-models"><strong>النماذج</strong><span>اختيار · تقييم · تشغيل</span></a>
|
||||
<a class="topic" href="articles.html?topic=automation" data-od-id="topic-automation"><strong>الأتمتة</strong><span>سير عمل · ربط · مراقبة</span></a>
|
||||
<a class="topic" href="articles.html?topic=foundations" data-od-id="topic-foundations"><strong>الأساسيات</strong><span>مفاهيم · تطبيق · أمان</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -369,7 +376,18 @@
|
||||
</main>
|
||||
|
||||
<footer class="pagefoot" data-od-id="footer">
|
||||
<div class="container row-between"><span>© أنور بي آي 2026 · تعلّم بوضوح</span><span class="meta">العربية أولاً · خصوصيتك محفوظة</span></div>
|
||||
<div class="container row-between">
|
||||
<span>© أنور بي آي 2026 · تعلّم بوضوح</span>
|
||||
<nav aria-label="روابط الموقع" style="display:flex; flex-wrap:wrap; align-items:center; gap:8px 24px;">
|
||||
<a href="arabic-ai-learning-catalog-v2.html" style="min-height:44px; display:inline-flex; align-items:center;">الرئيسية</a>
|
||||
<a href="articles.html" style="min-height:44px; display:inline-flex; align-items:center;">المكتبة</a>
|
||||
<a href="subscription.html" style="min-height:44px; display:inline-flex; align-items:center;">الاشتراك</a>
|
||||
<a href="about.html" style="min-height:44px; display:inline-flex; align-items:center;">عن الفريق</a>
|
||||
<a href="privacy.html" style="min-height:44px; display:inline-flex; align-items:center;">الخصوصية</a>
|
||||
<a href="login.html" style="min-height:44px; display:inline-flex; align-items:center;">تسجيل الدخول</a>
|
||||
</nav>
|
||||
<span class="meta">العربية أولاً · خصوصيتك محفوظة</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="modal" id="previewModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-hidden="true" data-od-id="preview-dialog">
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<!doctype html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src 'none'; worker-src 'none'" />
|
||||
<meta name="description" content="جميع مقالات وأدلة أنور بي آي، مع تصفية حسب الفئة ونوع الوصول." />
|
||||
<title>المكتبة — أنور بي آي</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: oklch(16% 0.03 225);
|
||||
--surface: oklch(22% 0.04 220);
|
||||
--fg: oklch(96% 0.015 210);
|
||||
--muted: oklch(74% 0.03 215);
|
||||
--border: oklch(36% 0.05 215);
|
||||
--accent: oklch(78% 0.17 190);
|
||||
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
--fg-soft: color-mix(in oklch, var(--fg) 6%, transparent);
|
||||
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--container: 1180px;
|
||||
--gutter: 32px;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.7 var(--font-body); text-rendering: optimizeLegibility; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { color: inherit; font: inherit; cursor: pointer; }
|
||||
h1, h2, h3 { margin: 0; font-family: var(--font-display); text-wrap: balance; }
|
||||
p { text-wrap: pretty; }
|
||||
:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||
.container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
|
||||
.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); }
|
||||
.topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
|
||||
.logo { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 23px; font-weight: 700; }
|
||||
.logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; }
|
||||
.topnav nav { display: flex; align-items: center; gap: 28px; }
|
||||
.topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); border-bottom: 1px solid transparent; }
|
||||
.topnav nav a:hover, .topnav nav a[aria-current="page"] { color: var(--fg); border-color: var(--accent); }
|
||||
.nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); }
|
||||
.nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||
.hero { padding-block: clamp(64px, 9vw, 112px); border-bottom: 1px solid var(--border); }
|
||||
.eyebrow { margin: 0 0 18px; color: var(--accent); font: 11px/1.5 var(--font-mono); letter-spacing: .06em; }
|
||||
h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; letter-spacing: -.025em; }
|
||||
.lead { max-width: 56ch; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2vw, 22px); }
|
||||
.library { padding-block: clamp(52px, 8vw, 88px); }
|
||||
.filter-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-bottom: 44px; padding-block: 22px; border-block: 1px solid var(--border); }
|
||||
.filter-group { min-width: 0; }
|
||||
.filter-label { display: block; margin-bottom: 10px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||
.filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: none; }
|
||||
.filters::-webkit-scrollbar { display: none; }
|
||||
.filter { flex: none; min-height: 44px; padding: 9px 15px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
|
||||
.filter:hover { color: var(--fg); border-color: var(--fg); }
|
||||
.filter[aria-pressed="true"] { background: var(--fg); color: var(--surface); border-color: var(--fg); }
|
||||
.results-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
|
||||
h2 { font-size: clamp(30px, 4vw, 46px); line-height: 1.15; }
|
||||
.count { color: var(--muted); font: 12px/1.5 var(--font-mono); }
|
||||
.posts { border-top: 1px solid var(--fg); }
|
||||
.post { display: grid; grid-template-columns: 138px minmax(0, 1fr) 130px; gap: 28px; align-items: start; padding-block: 28px; border-bottom: 1px solid var(--border); }
|
||||
.post[hidden] { display: none; }
|
||||
.post-meta { color: var(--muted); font: 12px/1.8 var(--font-mono); }
|
||||
.post-meta span { display: block; }
|
||||
.post h3 { font-size: clamp(22px, 2.5vw, 30px); line-height: 1.3; }
|
||||
.post p { max-width: 62ch; margin: 9px 0 0; color: var(--muted); }
|
||||
.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); }
|
||||
.access.free { border-color: var(--accent); color: var(--accent); }
|
||||
.post-link { display: inline-flex; align-items: center; min-height: 44px; margin-top: 14px; font-weight: 700; border-bottom: 1px solid transparent; }
|
||||
.post-link:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.soon { color: var(--muted); cursor: default; }
|
||||
.empty { display: none; padding: 42px 0; color: var(--muted); }
|
||||
.empty.show { display: block; }
|
||||
.pagefoot { padding-block: 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
|
||||
.footer-row { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||
@media (max-width: 920px) {
|
||||
:root { --gutter: 22px; }
|
||||
.topnav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
|
||||
.logo, .nav-action { margin-bottom: 10px; }
|
||||
.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; }
|
||||
.topnav nav::-webkit-scrollbar { display: none; }
|
||||
.topnav nav a { flex: none; min-height: 52px; padding-inline: 12px; white-space: nowrap; }
|
||||
.filter-panel { grid-template-columns: 1fr; gap: 20px; }
|
||||
.post { grid-template-columns: 112px minmax(0, 1fr); }
|
||||
.access { justify-self: start; grid-column: 2; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
:root { --gutter: 18px; }
|
||||
.nav-action { padding-inline: 12px; }
|
||||
h1 { font-size: clamp(42px, 13vw, 58px); }
|
||||
.results-head { align-items: start; flex-direction: column; }
|
||||
.post { grid-template-columns: 1fr; gap: 12px; }
|
||||
.access { grid-column: 1; }
|
||||
.post-link { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="assets/anwarpi.css?v=5" />
|
||||
<script src="assets/anwarpi.js?v=4" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topnav" data-od-id="topnav">
|
||||
<div class="container topnav-inner">
|
||||
<a class="logo" href="arabic-ai-learning-catalog-v2.html" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352" />أنور بي آي</a>
|
||||
<nav aria-label="التنقل الرئيسي">
|
||||
<a href="articles.html" aria-current="page" data-od-id="nav-library">المكتبة</a>
|
||||
<a href="arabic-ai-learning-catalog-v2.html#topics">المسارات</a>
|
||||
<a href="subscription.html">العضوية</a>
|
||||
</nav>
|
||||
<a class="nav-action" href="login.html">تسجيل الدخول</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="content">
|
||||
<section class="hero" data-od-id="library-hero">
|
||||
<div class="container">
|
||||
<p class="eyebrow">مكتبة أنور بي آي</p>
|
||||
<h1 data-od-id="library-title">كل الأدلة في مكان واحد.</h1>
|
||||
<p class="lead">صفِّ المحتوى حسب الفئة ونوع الوصول، ثم افتح المقال المجاني أو معاينة محتوى العضوية.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="library" data-od-id="article-library">
|
||||
<div class="container">
|
||||
<div class="filter-panel" data-od-id="library-filters">
|
||||
<div class="filter-group" role="group" aria-label="تصفية حسب الفئة">
|
||||
<span class="filter-label">الفئة</span>
|
||||
<div class="filters">
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="all" aria-pressed="true">الكل</button>
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="agents" aria-pressed="false">الوكلاء</button>
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="models" aria-pressed="false">النماذج</button>
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="automation" aria-pressed="false">الأتمتة</button>
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="foundations" aria-pressed="false">الأساسيات</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group" role="group" aria-label="تصفية حسب نوع الوصول">
|
||||
<span class="filter-label">نوع الوصول</span>
|
||||
<div class="filters">
|
||||
<button class="filter" type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
|
||||
<button class="filter" type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
|
||||
<button class="filter" type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">VIP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-head">
|
||||
<h2 data-od-id="posts-heading">جميع المواد</h2>
|
||||
<span class="count" id="resultCount" aria-live="polite">٦ مواد</span>
|
||||
</div>
|
||||
|
||||
<div class="posts" id="postList">
|
||||
<article class="post" data-topic="agents" data-access="vip" data-od-id="post-agent-loop">
|
||||
<div class="post-meta"><span>الوكلاء</span><span>١٨ دقيقة</span></div>
|
||||
<div><h3>البنية التي تمنع وكيلك من الدوران في حلقة</h3><p>حدود واضحة بين التخطيط والتنفيذ والتحقق، مع نموذج بسيط لسجل الحالة.</p><a class="post-link" href="post-locked.html">شاهد المعاينة ←</a></div>
|
||||
<span class="access">VIP · معاينة</span>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="models" data-access="free" data-od-id="post-model-selection">
|
||||
<div class="post-meta"><span>النماذج</span><span>١٢ دقيقة</span></div>
|
||||
<div><h3>لا تبدأ باسم النموذج؛ ابدأ بطبيعة المهمة</h3><p>مصفوفة قرار عملية توازن بين الدقة، السياق، السرعة، والكلفة.</p><a class="post-link" href="article-agent-guide.html">ابدأ القراءة ←</a></div>
|
||||
<span class="access free">مجاني · كامل</span>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="automation" data-access="vip" data-od-id="post-automation-observability">
|
||||
<div class="post-meta"><span>الأتمتة</span><span>٢٤ دقيقة</span></div>
|
||||
<div><h3>كيف تكتشف فشل سير العمل قبل المستخدم؟</h3><p>إشارات قليلة تكفي لمراقبة التكلفة، التأخير، ونقاط الانقطاع.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
|
||||
<span class="access">VIP</span>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="foundations" data-access="free" data-od-id="post-prompt-context">
|
||||
<div class="post-meta"><span>الأساسيات</span><span>٩ دقائق</span></div>
|
||||
<div><h3>لماذا لا يصلح تحسين الصياغة بياناتك الرديئة؟</h3><p>افصل بين التعليمات، المعرفة، وذاكرة المحادثة لتحصل على نتيجة مستقرة.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
|
||||
<span class="access free">مجاني</span>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="agents" data-access="vip" data-od-id="post-tool-safety">
|
||||
<div class="post-meta"><span>الوكلاء</span><span>١٥ دقيقة</span></div>
|
||||
<div><h3>قبل أن تمنح الوكيل صلاحية التنفيذ</h3><p>حدود الصلاحيات، التأكيد البشري، وسجل تدقيق يمكنك الرجوع إليه.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
|
||||
<span class="access">VIP</span>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="models" data-access="vip" data-od-id="post-rag-evaluation">
|
||||
<div class="post-meta"><span>النماذج</span><span>٤ أجزاء</span></div>
|
||||
<div><h3>اختبر الاسترجاع والإجابة كلّاً على حدة</h3><p>منهج تقييم يفصل فشل المصدر عن فشل النموذج ويقودك إلى إصلاح صحيح.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
|
||||
<span class="access">VIP</span>
|
||||
</article>
|
||||
</div>
|
||||
<p class="empty" id="emptyState">لا توجد مواد تطابق هذين الفلترين.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="pagefoot" data-od-id="footer">
|
||||
<div class="container footer-row"><span>© أنور بي آي ٢٠٢٦ · تعلّم بوضوح</span><a href="privacy.html">الخصوصية</a></div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const state = { topic: 'all', access: 'all' };
|
||||
const buttons = [...document.querySelectorAll('[data-filter-kind]')];
|
||||
const posts = [...document.querySelectorAll('.post')];
|
||||
const count = document.getElementById('resultCount');
|
||||
const empty = document.getElementById('emptyState');
|
||||
const arabicCounts = ['٠', '١', '٢', '٣', '٤', '٥', '٦'];
|
||||
|
||||
function applyFilters() {
|
||||
let visible = 0;
|
||||
posts.forEach(post => {
|
||||
const topicMatch = state.topic === 'all' || post.dataset.topic === state.topic;
|
||||
const accessMatch = state.access === 'all' || post.dataset.access === state.access;
|
||||
post.hidden = !(topicMatch && accessMatch);
|
||||
if (!post.hidden) visible += 1;
|
||||
});
|
||||
count.textContent = `${arabicCounts[visible]} ${visible === 1 ? 'مادة' : 'مواد'}`;
|
||||
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));
|
||||
});
|
||||
applyFilters();
|
||||
}));
|
||||
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,308 @@
|
||||
<!doctype html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src 'none'; worker-src 'none'" />
|
||||
<meta name="description" content="جميع مقالات وأدلة أنور بي آي، مع تصفية حسب الفئة ونوع الوصول." />
|
||||
<title>المكتبة — أنور بي آي</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: oklch(16% 0.03 225);
|
||||
--surface: oklch(22% 0.04 220);
|
||||
--fg: oklch(96% 0.015 210);
|
||||
--muted: oklch(74% 0.03 215);
|
||||
--border: oklch(36% 0.05 215);
|
||||
--accent: oklch(78% 0.17 190);
|
||||
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
--fg-soft: color-mix(in oklch, var(--fg) 6%, transparent);
|
||||
--font-display: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-body: "SF Arabic", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--container: 1180px;
|
||||
--gutter: 32px;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.7 var(--font-body); text-rendering: optimizeLegibility; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { color: inherit; font: inherit; cursor: pointer; }
|
||||
h1, h2, h3 { margin: 0; font-family: var(--font-display); text-wrap: balance; }
|
||||
p { text-wrap: pretty; }
|
||||
:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||
.container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); }
|
||||
.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); }
|
||||
.topnav-inner { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
|
||||
.logo { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; font-size: 23px; font-weight: 700; }
|
||||
.logo-mark { width: 30px; height: 34px; object-fit: contain; border-radius: 4px; }
|
||||
.topnav nav { display: flex; align-items: center; gap: 28px; }
|
||||
.topnav nav a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); border-bottom: 1px solid transparent; }
|
||||
.topnav nav a:hover, .topnav nav a[aria-current="page"] { color: var(--fg); border-color: var(--accent); }
|
||||
.nav-action { min-height: 44px; display: inline-flex; align-items: center; padding-inline: 16px; border: 1px solid var(--border); }
|
||||
.nav-action:hover { border-color: var(--fg); background: var(--fg-soft); }
|
||||
.hero { padding-block: clamp(56px, 8vw, 96px); border-bottom: 1px solid var(--border); }
|
||||
.hero-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(360px, 1.1fr); gap: clamp(36px, 7vw, 88px); align-items: end; }
|
||||
.eyebrow { margin: 0 0 18px; color: var(--accent); font: 11px/1.5 var(--font-mono); letter-spacing: .06em; }
|
||||
h1 { max-width: 10ch; font-size: clamp(48px, 7vw, 88px); line-height: 1.06; letter-spacing: -.025em; }
|
||||
.lead { max-width: 56ch; margin: 24px 0 0; color: var(--muted); font-size: clamp(18px, 2vw, 22px); }
|
||||
.weekly { display: block; padding: clamp(24px, 4vw, 40px); background: var(--surface); border: 1px solid var(--border); border-top-color: var(--accent); }
|
||||
.weekly:hover { border-color: var(--fg); background: color-mix(in oklch, var(--surface) 88%, var(--fg)); }
|
||||
.weekly-meta { display: flex; justify-content: space-between; gap: 16px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||
.weekly h2 { margin-top: 42px; font-size: clamp(28px, 4vw, 44px); line-height: 1.18; }
|
||||
.weekly p { margin: 14px 0 30px; color: var(--muted); }
|
||||
.weekly-link { min-height: 44px; display: inline-flex; align-items: center; font-weight: 700; border-bottom: 1px solid var(--fg); }
|
||||
.library { padding-block: clamp(52px, 8vw, 88px); }
|
||||
.library-tools { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 16px; margin-bottom: 22px; }
|
||||
.control { width: 100%; min-height: 48px; padding: 11px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--fg); font: inherit; }
|
||||
.control::placeholder { color: var(--muted); }
|
||||
.control:hover { border-color: var(--fg); }
|
||||
.control:focus { outline: 3px solid var(--accent-soft); border-color: var(--accent); }
|
||||
.filter-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-bottom: 44px; padding-block: 22px; border-block: 1px solid var(--border); }
|
||||
.filter-group { min-width: 0; }
|
||||
.filter-label { display: block; margin-bottom: 10px; color: var(--muted); font: 11px/1.5 var(--font-mono); }
|
||||
.filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: none; }
|
||||
.filters::-webkit-scrollbar { display: none; }
|
||||
.filter { flex: none; min-height: 44px; padding: 9px 15px; border: 1px solid var(--border); background: transparent; color: var(--muted); }
|
||||
.filter:hover { color: var(--fg); border-color: var(--fg); }
|
||||
.filter[aria-pressed="true"] { background: var(--fg); color: var(--surface); border-color: var(--fg); }
|
||||
.results-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
|
||||
h2 { font-size: clamp(30px, 4vw, 46px); line-height: 1.15; }
|
||||
.count { color: var(--muted); font: 12px/1.5 var(--font-mono); }
|
||||
.posts { border-top: 1px solid var(--fg); }
|
||||
.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); }
|
||||
.post[hidden] { display: none; }
|
||||
.post-meta { color: var(--muted); font: 12px/1.8 var(--font-mono); }
|
||||
.post-meta span { display: block; }
|
||||
.post h3 { font-size: clamp(22px, 2.5vw, 30px); line-height: 1.3; }
|
||||
.post p { max-width: 62ch; margin: 9px 0 0; color: var(--muted); }
|
||||
.post-side { justify-self: stretch; display: grid; gap: 12px; }
|
||||
.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); }
|
||||
.access.free { border-color: var(--fg); color: var(--fg); }
|
||||
.engagement { display: flex; justify-content: flex-end; gap: 12px; color: var(--muted); 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); }
|
||||
.post-link { display: inline-flex; align-items: center; min-height: 44px; margin-top: 14px; font-weight: 700; border-bottom: 1px solid transparent; }
|
||||
.post-link:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.soon { color: var(--muted); cursor: default; }
|
||||
.empty { display: none; padding: 42px 0; color: var(--muted); }
|
||||
.empty.show { display: block; }
|
||||
.pagefoot { padding-block: 48px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
|
||||
.footer-row { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||
@media (max-width: 920px) {
|
||||
:root { --gutter: 22px; }
|
||||
.topnav-inner { flex-wrap: wrap; gap: 0 16px; padding-top: 10px; }
|
||||
.logo, .nav-action { margin-bottom: 10px; }
|
||||
.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; }
|
||||
.topnav nav::-webkit-scrollbar { display: none; }
|
||||
.topnav nav a { flex: none; min-height: 52px; padding-inline: 12px; white-space: nowrap; }
|
||||
.hero-grid, .filter-panel { grid-template-columns: 1fr; gap: 20px; }
|
||||
.weekly { margin-top: 8px; }
|
||||
.post { grid-template-columns: 112px minmax(0, 1fr); }
|
||||
.post-side { justify-self: start; grid-column: 2; }
|
||||
.access { justify-self: start; }
|
||||
.engagement { justify-content: flex-start; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
:root { --gutter: 18px; }
|
||||
.nav-action { padding-inline: 12px; }
|
||||
h1 { font-size: clamp(42px, 13vw, 58px); }
|
||||
.library-tools { grid-template-columns: 1fr; }
|
||||
.results-head { align-items: start; flex-direction: column; }
|
||||
.post { grid-template-columns: 1fr; gap: 12px; }
|
||||
.post-side { grid-column: 1; }
|
||||
.post-link { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="assets/anwarpi.css?v=5" />
|
||||
<script src="assets/anwarpi.js?v=4" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topnav" data-od-id="topnav">
|
||||
<div class="container topnav-inner">
|
||||
<a class="logo" href="arabic-ai-learning-catalog-v2.html" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352" />أنور بي آي</a>
|
||||
<nav aria-label="التنقل الرئيسي">
|
||||
<a href="articles.html" aria-current="page" data-od-id="nav-library">المكتبة</a>
|
||||
<a href="arabic-ai-learning-catalog-v2.html#topics">المسارات</a>
|
||||
<a href="subscription.html">العضوية</a>
|
||||
<a href="about.html">عن الفريق</a>
|
||||
</nav>
|
||||
<a class="nav-action" href="login.html">تسجيل الدخول</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="content">
|
||||
<section class="hero" data-od-id="library-hero">
|
||||
<div class="container hero-grid">
|
||||
<div>
|
||||
<p class="eyebrow">مكتبة أنور بي آي</p>
|
||||
<h1 data-od-id="library-title">كل المقالات. بلا ضياع.</h1>
|
||||
<p class="lead">ابحث، اختر مسارك، ورتّب النتائج حسب الأحدث أو التفاعل المسجل على جهازك.</p>
|
||||
</div>
|
||||
<a class="weekly" href="article-agent-guide.html" data-od-id="weekly-post">
|
||||
<div class="weekly-meta"><span>مقال هذا الأسبوع</span><span>مجاني · ١٢ دقيقة</span></div>
|
||||
<h2>لا تبدأ باسم النموذج؛ ابدأ بطبيعة المهمة</h2>
|
||||
<p>مصفوفة قرار عملية توازن بين الدقة، السياق، السرعة، والكلفة.</p>
|
||||
<span class="weekly-link">اقرأ المقال ←</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="library" data-od-id="article-library">
|
||||
<div class="container">
|
||||
<div class="library-tools" data-od-id="library-search-sort">
|
||||
<label><span class="filter-label">ابحث في العناوين والوصف</span><input class="control" id="postSearch" type="search" autocomplete="off" placeholder="مثال: الوكلاء، RAG، اختيار النموذج" /></label>
|
||||
<label><span class="filter-label">الترتيب</span><select class="control" id="postSort"><option value="latest">الأحدث أولاً</option><option value="views">الأكثر مشاهدة على هذا الجهاز</option><option value="likes">الأكثر إعجاباً على هذا الجهاز</option></select></label>
|
||||
</div>
|
||||
<div class="filter-panel" data-od-id="library-filters">
|
||||
<div class="filter-group" role="group" aria-label="تصفية حسب المسار">
|
||||
<span class="filter-label">المسار / الفئة</span>
|
||||
<div class="filters">
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="all" aria-pressed="true">الكل</button>
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="agents" aria-pressed="false">الوكلاء</button>
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="models" aria-pressed="false">النماذج</button>
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="automation" aria-pressed="false">الأتمتة</button>
|
||||
<button class="filter" type="button" data-filter-kind="topic" data-filter-value="foundations" aria-pressed="false">الأساسيات</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group" role="group" aria-label="تصفية حسب نوع الوصول">
|
||||
<span class="filter-label">نوع الوصول</span>
|
||||
<div class="filters">
|
||||
<button class="filter" type="button" data-filter-kind="access" data-filter-value="all" aria-pressed="true">الكل</button>
|
||||
<button class="filter" type="button" data-filter-kind="access" data-filter-value="free" aria-pressed="false">مجاني</button>
|
||||
<button class="filter" type="button" data-filter-kind="access" data-filter-value="vip" aria-pressed="false">VIP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-head">
|
||||
<h2 data-od-id="posts-heading">جميع المواد</h2>
|
||||
<span class="count" id="resultCount" aria-live="polite">٦ مواد</span>
|
||||
</div>
|
||||
|
||||
<div class="posts" id="postList">
|
||||
<article class="post" data-topic="agents" data-access="vip" data-order="6" data-id="agent-loop" data-od-id="post-agent-loop">
|
||||
<div class="post-meta"><span>الوكلاء</span><span>١٨ دقيقة</span></div>
|
||||
<div><h3>البنية التي تمنع وكيلك من الدوران في حلقة</h3><p>حدود واضحة بين التخطيط والتنفيذ والتحقق، مع نموذج بسيط لسجل الحالة.</p><a class="post-link" href="post-locked.html">شاهد المعاينة ←</a></div>
|
||||
<div class="post-side"><span class="access">VIP · معاينة</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="models" data-access="free" data-order="5" data-id="model-selection" data-od-id="post-model-selection">
|
||||
<div class="post-meta"><span>النماذج</span><span>١٢ دقيقة</span></div>
|
||||
<div><h3>لا تبدأ باسم النموذج؛ ابدأ بطبيعة المهمة</h3><p>مصفوفة قرار عملية توازن بين الدقة، السياق، السرعة، والكلفة.</p><a class="post-link" href="article-agent-guide.html">ابدأ القراءة ←</a></div>
|
||||
<div class="post-side"><span class="access free">مجاني · كامل</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="automation" data-access="vip" data-order="4" data-id="automation-observability" data-od-id="post-automation-observability">
|
||||
<div class="post-meta"><span>الأتمتة</span><span>٢٤ دقيقة</span></div>
|
||||
<div><h3>كيف تكتشف فشل سير العمل قبل المستخدم؟</h3><p>إشارات قليلة تكفي لمراقبة التكلفة، التأخير، ونقاط الانقطاع.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
|
||||
<div class="post-side"><span class="access">VIP</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="foundations" data-access="free" data-order="3" data-id="prompt-context" data-od-id="post-prompt-context">
|
||||
<div class="post-meta"><span>الأساسيات</span><span>٩ دقائق</span></div>
|
||||
<div><h3>لماذا لا يصلح تحسين الصياغة بياناتك الرديئة؟</h3><p>افصل بين التعليمات، المعرفة، وذاكرة المحادثة لتحصل على نتيجة مستقرة.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
|
||||
<div class="post-side"><span class="access free">مجاني</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="agents" data-access="vip" data-order="2" data-id="tool-safety" data-od-id="post-tool-safety">
|
||||
<div class="post-meta"><span>الوكلاء</span><span>١٥ دقيقة</span></div>
|
||||
<div><h3>قبل أن تمنح الوكيل صلاحية التنفيذ</h3><p>حدود الصلاحيات، التأكيد البشري، وسجل تدقيق يمكنك الرجوع إليه.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
|
||||
<div class="post-side"><span class="access">VIP</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="post" data-topic="models" data-access="vip" data-order="1" data-id="rag-evaluation" data-od-id="post-rag-evaluation">
|
||||
<div class="post-meta"><span>النماذج</span><span>٤ أجزاء</span></div>
|
||||
<div><h3>اختبر الاسترجاع والإجابة كلّاً على حدة</h3><p>منهج تقييم يفصل فشل المصدر عن فشل النموذج ويقودك إلى إصلاح صحيح.</p><span class="post-link soon" aria-label="هذه المادة ستتوفر قريباً">قريباً</span></div>
|
||||
<div class="post-side"><span class="access">VIP</span><div class="engagement"><span data-views>٠ مشاهدة</span><button class="like" type="button" data-like aria-pressed="false">إعجاب · <span>٠</span></button></div></div>
|
||||
</article>
|
||||
</div>
|
||||
<p class="empty" id="emptyState">لا توجد مواد تطابق البحث والفلاتر الحالية.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="pagefoot" data-od-id="footer">
|
||||
<div class="container footer-row"><span>© أنور بي آي ٢٠٢٦ · تعلّم بوضوح</span><a href="privacy.html">الخصوصية</a></div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const allowedTopics = ['all', 'agents', 'models', 'automation', 'foundations'];
|
||||
const state = { topic: allowedTopics.includes(params.get('topic')) ? params.get('topic') : 'all', access: 'all', query: '', sort: 'latest' };
|
||||
const buttons = [...document.querySelectorAll('[data-filter-kind]')];
|
||||
const posts = [...document.querySelectorAll('.post')];
|
||||
const list = document.getElementById('postList');
|
||||
const count = document.getElementById('resultCount');
|
||||
const empty = document.getElementById('emptyState');
|
||||
const search = document.getElementById('postSearch');
|
||||
const sort = document.getElementById('postSort');
|
||||
const formatNumber = value => new Intl.NumberFormat('ar').format(value);
|
||||
const readMetric = (post, metric) => Number(localStorage.getItem(`anwarpi:${metric}:${post.dataset.id}`) || 0);
|
||||
|
||||
function normalize(value) {
|
||||
return value.toLocaleLowerCase('ar').normalize('NFKD').replace(/[\u064B-\u065F\u0670]/g, '').replace(/[إأآ]/g, 'ا').replace(/ى/g, 'ي');
|
||||
}
|
||||
|
||||
function paintMetrics(post) {
|
||||
const views = readMetric(post, 'views');
|
||||
const liked = localStorage.getItem(`anwarpi:liked:${post.dataset.id}`) === '1';
|
||||
post.querySelector('[data-views]').textContent = `${formatNumber(views)} مشاهدة`;
|
||||
const like = post.querySelector('[data-like]');
|
||||
like.setAttribute('aria-pressed', String(liked));
|
||||
like.querySelector('span').textContent = formatNumber(liked ? 1 : 0);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const ordered = [...posts].sort((a, b) => {
|
||||
if (state.sort === 'views') return readMetric(b, 'views') - readMetric(a, 'views') || Number(b.dataset.order) - Number(a.dataset.order);
|
||||
if (state.sort === 'likes') return Number(localStorage.getItem(`anwarpi:liked:${b.dataset.id}`) === '1') - Number(localStorage.getItem(`anwarpi:liked:${a.dataset.id}`) === '1') || 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 === 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;
|
||||
});
|
||||
count.textContent = `${formatNumber(visible)} ${visible === 1 ? 'مادة' : 'مواد'}`;
|
||||
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));
|
||||
});
|
||||
applyFilters();
|
||||
}));
|
||||
|
||||
search.addEventListener('input', () => { state.query = search.value.trim(); applyFilters(); });
|
||||
sort.addEventListener('change', () => { state.sort = sort.value; applyFilters(); });
|
||||
posts.forEach(post => {
|
||||
paintMetrics(post);
|
||||
post.querySelector('[data-like]').addEventListener('click', () => {
|
||||
const key = `anwarpi:liked:${post.dataset.id}`;
|
||||
localStorage.setItem(key, localStorage.getItem(key) === '1' ? '0' : '1');
|
||||
paintMetrics(post);
|
||||
applyFilters();
|
||||
});
|
||||
post.querySelectorAll('a.post-link').forEach(link => link.addEventListener('click', () => {
|
||||
localStorage.setItem(`anwarpi:views:${post.dataset.id}`, String(readMetric(post, 'views') + 1));
|
||||
}));
|
||||
});
|
||||
|
||||
buttons.filter(button => button.dataset.filterKind === 'topic').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.filterValue === state.topic)));
|
||||
applyFilters();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -197,12 +197,15 @@
|
||||
"article-agent-guide.html": "nav a[href='article-agent-guide.html']",
|
||||
"post-locked.html": "nav a[href='article-agent-guide.html']",
|
||||
"post-unlocked.html": "nav a[href='article-agent-guide.html']",
|
||||
"articles.html": "nav a[href='articles.html']",
|
||||
"subscription.html": "nav a[href='#exclusive-guides']",
|
||||
"vip-onboarding.html": "nav a[href='subscription.html']",
|
||||
"account-settings.html": ".nav-account",
|
||||
"login.html": ".logo",
|
||||
"signup.html": ".logo",
|
||||
"forgot-password.html": ".logo",
|
||||
"privacy.html": "nav a[href='privacy.html']"
|
||||
"privacy.html": "nav a[href='privacy.html']",
|
||||
"about.html": "nav a[href='about.html']"
|
||||
}[page];
|
||||
|
||||
var active = selector && topnav.querySelector(selector);
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<header class="topnav" data-od-id="topnav"><div class="container nav-inner"><a class="logo" href="arabic-ai-learning-catalog.html"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a><div class="nav-tools"><a class="nav-return" href="arabic-ai-learning-catalog.html">العودة إلى المكتبة</a><button class="icon-btn" id="menuTrigger" aria-label="فتح القائمة" aria-expanded="false" aria-controls="siteMenu" data-od-id="menu-button">☰</button></div></div></header>
|
||||
<main class="auth">
|
||||
<section class="auth-story" data-od-id="login-story"><div><p class="eyebrow">مساحتك في أنور بي آي</p><h1 data-od-id="login-story-title">أكمل من حيث توقفت.</h1><p>احفظ أدلتك، تابع مسارات الفيديو، وارجع إلى ملاحظاتك من أي جهاز.</p><ul class="story-list"><li><span>المقالات المحفوظة</span><span class="story-mark">مرتبّة</span></li><li><span>تقدّم المسارات</span><span class="story-mark">متزامن</span></li><li><span>ملفات العضوية</span><span class="story-mark">جاهزة</span></li></ul></div><p class="story-foot">العربية أولاً · خصوصيتك محفوظة</p></section>
|
||||
<section class="auth-form-wrap" data-od-id="login-form-section"><form class="auth-form" id="loginForm" novalidate data-od-id="login-form"><p class="eyebrow">أهلاً بعودتك</p><h2 data-od-id="login-title">تسجيل الدخول</h2><p class="lead">استخدم بريدك وكلمة المرور للوصول إلى حسابك.</p><div class="field"><label for="email">البريد الإلكتروني</label><input class="input" id="email" name="email" type="email" inputmode="email" autocomplete="email" placeholder="name@example.com" dir="ltr" required /></div><div class="field"><label for="password">كلمة المرور</label><div class="input-wrap"><input class="input" id="password" name="password" type="password" autocomplete="current-password" minlength="8" required /><button class="password-toggle" id="passwordToggle" type="button" aria-controls="password">إظهار</button></div></div><div class="form-row"><label class="check"><input type="checkbox" name="remember" /> تذكّرني</label><a href="forgot-password.html">نسيت كلمة المرور؟</a></div><button class="btn btn-primary" type="submit">ادخل إلى حسابك</button><div class="status" id="loginStatus" role="status"></div><div class="divider">أو</div><button class="magic" id="magicLink" type="button">أرسل رابط دخول إلى بريدي</button><p class="signup">ليس لديك حساب؟ <a href="subscription.html">اكتشف العضوية</a></p><p class="fine">بالمتابعة، أنت توافق على شروط الاستخدام و<a href="privacy.html">سياسة الخصوصية</a>.</p></form></section>
|
||||
<section class="auth-form-wrap" data-od-id="login-form-section"><form class="auth-form" id="loginForm" novalidate data-od-id="login-form"><p class="eyebrow">أهلاً بعودتك</p><h2 data-od-id="login-title">تسجيل الدخول</h2><p class="lead">استخدم بريدك وكلمة المرور للوصول إلى حسابك.</p><div class="field"><label for="email">البريد الإلكتروني</label><input class="input" id="email" name="email" type="email" inputmode="email" autocomplete="email" placeholder="name@example.com" dir="ltr" required /></div><div class="field"><label for="password">كلمة المرور</label><div class="input-wrap"><input class="input" id="password" name="password" type="password" autocomplete="current-password" minlength="8" required /><button class="password-toggle" id="passwordToggle" type="button" aria-controls="password">إظهار</button></div></div><div class="form-row"><label class="check"><input type="checkbox" name="remember" /> تذكّرني</label><a href="forgot-password.html">نسيت كلمة المرور؟</a></div><button class="btn btn-primary" type="submit">ادخل إلى حسابك</button><div class="status" id="loginStatus" role="status"></div><div class="divider">أو</div><button class="magic" id="magicLink" type="button">أرسل رابط دخول إلى بريدي</button><p class="signup">ليس لديك حساب؟ <a href="signup.html">أنشئ حساباً جديداً</a></p><p class="fine">بالمتابعة، أنت توافق على شروط الاستخدام و<a href="privacy.html">سياسة الخصوصية</a>.</p></form></section>
|
||||
</main>
|
||||
<aside class="menu-panel" id="siteMenu" aria-hidden="true" data-od-id="site-menu"><button class="menu-scrim" type="button" aria-label="إغلاق القائمة" data-menu-close></button><div class="menu-drawer" role="dialog" aria-modal="true" aria-label="قائمة أنور بي آي"><div class="menu-head"><a class="logo" href="arabic-ai-learning-catalog.html"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352">أنور بي آي</a><button class="icon-btn" type="button" aria-label="إغلاق القائمة" data-menu-close>×</button></div><nav class="menu-links"><a href="arabic-ai-learning-catalog.html#catalog">المكتبة <span>←</span></a><a href="article-agent-guide.html">مقال الأسبوع <span>←</span></a><a href="subscription.html">العضوية والأسعار <span>←</span></a></nav><div class="menu-foot"><p>محتوى عربي عملي، بلا ضجيج.</p></div></div></aside>
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src 'none'; worker-src 'none'" />
|
||||
<meta name="description" content="إنشاء حساب جديد في أنور بي آي." />
|
||||
<title>إنشاء حساب — أنور بي آي</title>
|
||||
<style>
|
||||
:root{--bg:oklch(16% .03 225);--surface:oklch(22% .04 220);--fg:oklch(96% .015 210);--muted:oklch(74% .03 215);--border:oklch(36% .05 215);--accent:oklch(78% .17 190);--accent-soft:color-mix(in oklch,var(--accent) 12%,transparent);--fg-soft:color-mix(in oklch,var(--fg) 6%,transparent);--font-display:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-body:"SF Arabic","Noto Sans Arabic",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:ui-monospace,"SF Mono",Menlo,monospace;--container:1180px;--gutter:32px}
|
||||
*,*::before,*::after{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.7 var(--font-body);text-rendering:optimizeLegibility}a{color:inherit;text-decoration:none}button,input{font:inherit}button{cursor:pointer}h1,h2{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}.container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.topnav{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-size:23px;font-weight:700}.logo-mark{width:30px;height:34px;object-fit:contain;border-radius:4px}.nav-return{min-height:44px;display:inline-flex;align-items:center;color:var(--muted);border-bottom:1px solid transparent}.nav-return:hover{color:var(--fg);border-color:var(--fg)}
|
||||
.auth{min-height:calc(100vh - 70px);display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.auth>*{min-width:0}.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}.eyebrow{font:11px/1.5 var(--font-mono);letter-spacing:0;color:var(--accent);margin:0 0 20px}.auth-story .eyebrow{color:var(--surface);opacity:.78}.auth-story h1{font-size:clamp(46px,6vw,82px);line-height:1.08;letter-spacing:-.025em;max-width:10ch;margin:0}.auth-story>div>p{color:color-mix(in oklch,var(--surface) 78%,transparent);font-size:19px;max-width:40ch}.story-list{list-style:none;margin:54px 0 0;padding:0;border-top:1px solid color-mix(in oklch,var(--surface) 32%,transparent)}.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}.story-mark{font:12px/1.5 var(--font-mono);opacity:.74}.story-foot{font-size:13px;color:color-mix(in oklch,var(--surface) 72%,transparent)}
|
||||
.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}.auth-form{width:min(440px,100%)}.auth-form h2{font-size:clamp(38px,4vw,54px);line-height:1.15;margin:0}.lead{color:var(--muted);font-size:18px;margin:12px 0 28px}.field{display:grid;gap:7px;margin-top:16px}.field label{font-size:13px;color:var(--muted)}.input-wrap{position:relative}.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}.input:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}.password-toggle{position:absolute;inset-inline-end:6px;top:6px;min-width:70px;height:40px;border:0;background:transparent;color:var(--muted)}.password-toggle:hover{color:var(--fg);background:var(--fg-soft)}.check{display:flex;align-items:flex-start;gap:10px;margin:20px 0}.check input{margin-top:6px;flex:none}.check a{text-decoration:underline;text-underline-offset:3px}.check a:hover{color:var(--accent)}.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}.btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.signin{text-align:center;color:var(--muted);font-size:14px;margin-top:22px}.signin a{color:var(--fg);text-decoration:underline;text-underline-offset:3px}.signin a:hover{color:var(--accent)}.fine{font-size:12px;color:var(--muted);margin-top:14px}.status{min-height:28px;margin-top:12px;color:var(--accent);font-size:13px}
|
||||
@media(max-width:920px){:root{--gutter:22px}.auth{grid-template-columns:1fr}.auth-story{min-height:390px;padding:58px var(--gutter)}.auth-form-wrap{padding:58px var(--gutter);justify-content:center}.story-foot{margin-top:48px}}
|
||||
@media(max-width:620px){:root{--gutter:18px}.nav-inner{gap:12px}.nav-return{font-size:13px}.auth-story{min-height:auto}.auth-story h1{font-size:46px}.auth-form-wrap{padding-block:46px}.story-list{margin-top:38px}}
|
||||
@media(prefers-reduced-motion:reduce){*{transition-duration:.01ms!important}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="assets/anwarpi.css?v=5" />
|
||||
<script src="assets/anwarpi.js?v=4" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topnav" data-od-id="topnav">
|
||||
<div class="container nav-inner">
|
||||
<a class="logo" href="arabic-ai-learning-catalog.html" data-od-id="brand"><img class="logo-mark" src="assets/anwarpi-logo.jpg" alt="" width="1200" height="1352" />أنور بي آي</a>
|
||||
<a class="nav-return" href="login.html" data-od-id="login-link">لديك حساب؟ سجّل الدخول</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="auth">
|
||||
<section class="auth-story" data-od-id="signup-story">
|
||||
<div>
|
||||
<p class="eyebrow">ابدأ مسارك</p>
|
||||
<h1 data-od-id="signup-story-title">ابنِ معرفتك، خطوة بخطوة.</h1>
|
||||
<p>أنشئ حساباً لحفظ المقالات ومتابعة تقدّمك والوصول إلى مزايا العضوية من أي جهاز.</p>
|
||||
<ul class="story-list">
|
||||
<li><span>مكتبتك الشخصية</span><span class="story-mark">محفوظة</span></li>
|
||||
<li><span>تقدّم المسارات</span><span class="story-mark">متزامن</span></li>
|
||||
<li><span>إعدادات واضحة</span><span class="story-mark">تحت سيطرتك</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="story-foot">العربية أولاً · خصوصيتك محفوظة</p>
|
||||
</section>
|
||||
<section class="auth-form-wrap" data-od-id="signup-form-section">
|
||||
<form class="auth-form" id="signupForm" novalidate data-od-id="signup-form">
|
||||
<p class="eyebrow">حساب جديد</p>
|
||||
<h2 data-od-id="signup-title">إنشاء حساب</h2>
|
||||
<p class="lead">أدخل بياناتك الأساسية للبدء.</p>
|
||||
<div class="field"><label for="name">الاسم</label><input class="input" id="name" name="name" type="text" autocomplete="name" minlength="2" required /></div>
|
||||
<div class="field"><label for="email">البريد الإلكتروني</label><input class="input" id="email" name="email" type="email" inputmode="email" autocomplete="email" placeholder="name@example.com" dir="ltr" required /></div>
|
||||
<div class="field"><label for="password">كلمة المرور</label><div class="input-wrap"><input class="input" id="password" name="password" type="password" autocomplete="new-password" minlength="8" aria-describedby="passwordHint" required /><button class="password-toggle" type="button" data-toggle-password="password" aria-controls="password">إظهار</button></div><span class="fine" id="passwordHint">ثمانية أحرف على الأقل.</span></div>
|
||||
<div class="field"><label for="confirmPassword">تأكيد كلمة المرور</label><div class="input-wrap"><input class="input" id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required /><button class="password-toggle" type="button" data-toggle-password="confirmPassword" aria-controls="confirmPassword">إظهار</button></div></div>
|
||||
<label class="check"><input id="terms" name="terms" type="checkbox" required /><span>أوافق على <a href="privacy.html">سياسة الخصوصية</a> وشروط الاستخدام.</span></label>
|
||||
<button class="btn btn-primary" type="submit" data-od-id="create-account-cta">أنشئ حسابي</button>
|
||||
<div class="status" id="signupStatus" role="status" aria-live="polite"></div>
|
||||
<p class="signin">لديك حساب بالفعل؟ <a href="login.html">سجّل الدخول</a></p>
|
||||
<p class="fine">هذه معاينة للواجهة؛ إنشاء الحساب الفعلي يتطلب ربط خدمة مصادقة آمنة.</p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
(()=>{
|
||||
const form=document.getElementById('signupForm');
|
||||
const status=document.getElementById('signupStatus');
|
||||
const password=document.getElementById('password');
|
||||
const confirmPassword=document.getElementById('confirmPassword');
|
||||
document.querySelectorAll('[data-toggle-password]').forEach(button=>button.addEventListener('click',()=>{
|
||||
const input=document.getElementById(button.dataset.togglePassword);
|
||||
const show=input.type==='password';
|
||||
input.type=show?'text':'password';
|
||||
button.textContent=show?'إخفاء':'إظهار';
|
||||
}));
|
||||
form.addEventListener('submit',event=>{
|
||||
event.preventDefault();
|
||||
confirmPassword.setCustomValidity(password.value===confirmPassword.value?'':'كلمتا المرور غير متطابقتين.');
|
||||
if(!form.checkValidity()){
|
||||
status.textContent=password.value!==confirmPassword.value?'كلمتا المرور غير متطابقتين.':'تحقق من جميع الحقول المطلوبة.';
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
status.textContent='تم التحقق من النموذج. ربط إنشاء الحساب بالخادم هو الخطوة التالية.';
|
||||
});
|
||||
confirmPassword.addEventListener('input',()=>confirmPassword.setCustomValidity(''));
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
.next/
|
||||
media/
|
||||
files-private/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
payload-types.ts
|
||||
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# NEXT_PUBLIC_SITE_URL is baked into the client bundle at build time
|
||||
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
ENV PAYLOAD_SECRET=build-secret
|
||||
ENV DATABASE_URI=postgres://build:build@127.0.0.1:5432/build
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
# full node_modules: the standalone server plus payload CLI for migrate/seed
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/next.config.mjs ./next.config.mjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
||||
|
||||
# seed sources — `npx payload run src/seed.ts` (media dir is created by Payload on upload)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src ./src
|
||||
# media upload dir must be writable by nextjs (mounted as volume in compose)
|
||||
RUN mkdir -p /app/media && chown nextjs:nodejs /app/media
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||
|
||||
# Apply migrations, then start
|
||||
CMD ["sh", "-c", "npx payload migrate && npm run start"]
|
||||
@@ -0,0 +1,67 @@
|
||||
# أنور بي آي — web
|
||||
|
||||
Next.js 15 + Payload 3 app: Arabic RTL paywalled content site.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js 15.4 (App Router, standalone output) + React 19
|
||||
- Payload 3.90 (Postgres adapter, Lexical richText)
|
||||
- Stripe subscriptions (35 SAR/month, test mode)
|
||||
|
||||
## Collections
|
||||
|
||||
- **users** — auth; `role: free | vip | admin`, `stripeCustomerId`
|
||||
- **posts** — `title, slug, dek, access (free|vip), teaser, content (lexical), category, cover, readingMinutes, publishedAt, status (draft|published)`
|
||||
- **media** — image uploads
|
||||
|
||||
Access control: public REST reads only `status=published`; VIP `content` is withheld from non-VIP (field-level read rule + paywall page never selects it for them).
|
||||
|
||||
## Env vars
|
||||
|
||||
| Var | Purpose |
|
||||
|---|---|
|
||||
| `DATABASE_URI` | Postgres connection string |
|
||||
| `PAYLOAD_SECRET` | Payload encryption/ signing secret |
|
||||
| `AGENT_API_KEY` | Agent API auth (`x-agent-key` header) |
|
||||
| `NEXT_PUBLIC_SITE_URL` | Public URL (baked at build) |
|
||||
| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_PRICE_ID` / `STRIPE_WEBHOOK_SECRET` | Optional — unset = "pending" mode |
|
||||
|
||||
## Agent API
|
||||
|
||||
```sh
|
||||
# create draft (always draft; publish via /admin only)
|
||||
curl -X POST http://localhost:3000/api/agent/posts \
|
||||
-H "x-agent-key: $AGENT_API_KEY" -H 'Content-Type: application/json' \
|
||||
-d '{"title":"...","slug":"my-post","dek":"...","category":"وكلاء","access":"vip","teaser":"...","readingMinutes":8,"content":{"root":{"type":"root","children":[],"format":"","indent":0,"version":1}}}'
|
||||
|
||||
# update (cannot change status)
|
||||
curl -X PUT http://localhost:3000/api/agent/posts/my-post -H "x-agent-key: $AGENT_API_KEY" ...
|
||||
```
|
||||
|
||||
Omitting `cover` assigns the per-category default cover.
|
||||
|
||||
## Seed
|
||||
|
||||
```sh
|
||||
npm run covers # regenerate public/covers/*.jpg
|
||||
npx payload migrate
|
||||
npx payload run src/seed.ts # idempotent (skips existing slugs)
|
||||
```
|
||||
|
||||
Seeds: 12 posts (6 free + 6 VIP, 6 categories), admin `admin@anwarpi.ai` / `AnwarPi-Admin-2026!`, test users `free@test.anwarpi.ai` / `Free1234!`, `vip@test.anwarpi.ai` / `Vip1234!`.
|
||||
|
||||
## Docker deploy
|
||||
|
||||
```sh
|
||||
cp .env.production.example .env.production # fill secrets
|
||||
docker compose --env-file .env.production up -d --build
|
||||
docker compose exec web npx payload run src/seed.ts # first time
|
||||
```
|
||||
|
||||
## Stripe webhook (testing on LAN)
|
||||
|
||||
```sh
|
||||
stripe listen --forward-to localhost:3000/api/stripe/webhook
|
||||
```
|
||||
|
||||
On production (Railway), point the webhook endpoint at the public URL.
|
||||
@@ -0,0 +1,75 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: anwarpi
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: anwarpi
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U anwarpi -d anwarpi"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
NEXT_PUBLIC_SITE_URL: https://anwarpy.ai
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- media-uploads:/app/media
|
||||
- files-uploads:/app/files-private
|
||||
environment:
|
||||
DATABASE_URI: postgres://anwarpi:${DB_PASSWORD}@db:5432/anwarpi
|
||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
|
||||
AGENT_API_KEY: ${AGENT_API_KEY}
|
||||
NEXT_PUBLIC_SITE_URL: https://anwarpy.ai
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID}
|
||||
MAIL_FROM: noreply@anwarpy.ai
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi.entrypoints=websecure
|
||||
- traefik.http.routers.anwarpi.tls.certresolver=mytlschallenge
|
||||
- traefik.http.routers.anwarpi.middlewares=pagelimit@docker,connlimit@docker
|
||||
- traefik.http.services.anwarpi.loadbalancer.server.port=3000
|
||||
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && (Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`))
|
||||
- traefik.http.routers.anwarpi-api.entrypoints=websecure
|
||||
- traefik.http.routers.anwarpi-api.tls=true
|
||||
- traefik.http.routers.anwarpi-api.tls.certresolver=mytlschallenge
|
||||
- traefik.http.routers.anwarpi-api.service=anwarpi
|
||||
- traefik.http.routers.anwarpi-api.middlewares=apilimit@docker
|
||||
- traefik.http.routers.anwarpi-api.priority=100
|
||||
- traefik.http.middlewares.apilimit.ratelimit.average=90
|
||||
- traefik.http.middlewares.apilimit.ratelimit.burst=180
|
||||
- traefik.http.middlewares.apilimit.ratelimit.period=1m
|
||||
# F1: the pentest flood froze pages while /api held. Cap per-IP page
|
||||
# rate and in-flight requests; apilimit already guards /api itself.
|
||||
- traefik.http.middlewares.pagelimit.ratelimit.average=60
|
||||
- traefik.http.middlewares.pagelimit.ratelimit.period=1m
|
||||
- traefik.http.middlewares.pagelimit.ratelimit.burst=120
|
||||
- traefik.http.middlewares.connlimit.inflightreq.amount=50
|
||||
- traefik.docker.network=proxy
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default
|
||||
- proxy
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
media-uploads:
|
||||
files-uploads:
|
||||
|
||||
networks:
|
||||
default:
|
||||
proxy:
|
||||
external: true
|
||||
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
# Expose to the LAN so local dev (web/.env DATABASE_URI) can reach it.
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: anwarpi
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: anwarpi
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U anwarpi -d anwarpi"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- media-uploads:/app/media
|
||||
- files-uploads:/app/files-private
|
||||
environment:
|
||||
DATABASE_URI: postgres://anwarpi:${DB_PASSWORD}@db:5432/anwarpi
|
||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
|
||||
AGENT_API_KEY: ${AGENT_API_KEY}
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
||||
# --- Stripe (pending mode until these are set) ---
|
||||
# STRIPE_SECRET_KEY: sk_test_...
|
||||
# STRIPE_PUBLISHABLE_KEY: pk_test_...
|
||||
# STRIPE_PRICE_ID: price_...
|
||||
# STRIPE_WEBHOOK_SECRET: whsec_...
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
media-uploads:
|
||||
files-uploads:
|
||||
@@ -0,0 +1,100 @@
|
||||
# Agent Publish API — /api/agent/posts
|
||||
|
||||
Publish and manage blog posts programmatically. Auth: send the agent key in the
|
||||
`x-agent-key` header. The key lives on the VPS in `/root/anwarpi/.env.production`
|
||||
(`AGENT_API_KEY`).
|
||||
|
||||
- Base URL: `https://anwarpy.ai`
|
||||
- Content-Type: `application/json`
|
||||
- Posts are created **draft** by default. Pass `"publish": true` to publish
|
||||
(sets `status: published` + `publishedAt` now). Unpublishing stays admin-only
|
||||
(`/admin`).
|
||||
- `access` controls gating: `"free"` (public) or `"vip"` (members only).
|
||||
- `content` (Lexical) may be omitted on create — a minimal valid doc is
|
||||
inserted; update it later with PUT. Other writable fields: `title`, `slug`,
|
||||
`dek`, `teaser`, `category` (`agents` | `models` | `automation` |
|
||||
`foundations`), `readingMinutes`, `cover`.
|
||||
- `attachments` attaches per-post work files: an array of
|
||||
`{ "filename": "agent-state.md", "dataBase64": "…", "access": "vip" }`.
|
||||
`access` is per file (`"free"` downloads for anyone, `"vip"` members only)
|
||||
and defaults to `"vip"`. Max 5 files, ≤5MB each, extensions pdf/zip/md/csv/
|
||||
txt/json. On PUT, sending `attachments` **replaces** the post's file set —
|
||||
omit the field to keep existing files.
|
||||
|
||||
## Create + publish a post with an image
|
||||
|
||||
```bash
|
||||
KEY=033d1eed44ca6abf483de10735c47155d38f7b5bc9d46449
|
||||
|
||||
# Inline base64 image (jpeg/png/webp, ≤5MB)
|
||||
python3 - <<'EOF' > /tmp/post.json
|
||||
import base64, json, pathlib
|
||||
print(json.dumps({
|
||||
"title": "دليلي الجديد",
|
||||
"slug": "my-new-guide",
|
||||
"dek": "وصف قصير يظهر في القوائم.",
|
||||
"teaser": "نص المعاينة للزوار غير المشتركين.",
|
||||
"category": "وكلاء",
|
||||
"access": "vip", # or "free"
|
||||
"readingMinutes": 9,
|
||||
"publish": True, # omit → draft
|
||||
"coverImage": {
|
||||
"filename": "my-cover.jpg",
|
||||
"mimeType": "image/jpeg",
|
||||
"dataBase64": base64.b64encode(pathlib.Path("my-cover.jpg").read_bytes()).decode(),
|
||||
},
|
||||
}))
|
||||
EOF
|
||||
curl -s -X POST https://anwarpy.ai/api/agent/posts \
|
||||
-H "x-agent-key: $KEY" -H "Content-Type: application/json" \
|
||||
--data @/tmp/post.json
|
||||
```
|
||||
|
||||
Response `201`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 113, "title": "دليلي الجديد", "slug": "my-new-guide",
|
||||
"status": "published", "access": "vip", "category": "agents",
|
||||
"cover": { "id": 113, "url": "/api/media/file/my-cover.jpg" },
|
||||
"attachments": [
|
||||
{ "id": 42, "filename": "agent-state.md", "access": "vip", "url": "/api/files/42" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Update a post
|
||||
|
||||
```bash
|
||||
curl -s -X PUT https://anwarpy.ai/api/agent/posts/my-new-guide \
|
||||
-H "x-agent-key: $KEY" -H "Content-Type: application/json" \
|
||||
-d '{"access": "free", "publish": true,
|
||||
"coverImage": {"url": "https://example.com/cover.png"}}'
|
||||
```
|
||||
|
||||
- `coverImage` accepts `{ "dataBase64": "…" }` or `{ "url": "https://…" }`
|
||||
(server fetches and stores it; `filename`/`mimeType` optional).
|
||||
- `cover` may also be an existing media ID (integer).
|
||||
|
||||
## Where the image appears
|
||||
|
||||
The cover is rendered on:
|
||||
|
||||
1. **Post page** (`/posts/<slug>`) — full-width cover under the header.
|
||||
2. **Articles listing** (`/articles`) — thumbnail on each card.
|
||||
3. **Homepage** (`/`) — "أحدث الأدلة" section (latest 6 published posts).
|
||||
|
||||
If a post has no cover, the catalog card falls back to the category's default
|
||||
decorative cover; the post page and listing simply omit the image.
|
||||
|
||||
## Rules
|
||||
|
||||
- The agent **cannot** set `status` directly — only via `"publish": true`.
|
||||
- The agent **cannot** unpublish or delete — do that in `/admin`.
|
||||
- Images are validated: jpeg/png/webp, ≤5MB; stored as Payload `media`
|
||||
documents and served from `/api/media/file/<filename>`.
|
||||
- Attachments are stored as Payload `files` documents under `files-private/`
|
||||
(volume-mount it like `media/` on the VPS). Downloads go through
|
||||
`GET /api/files/<id>`: `free` files stream to anyone, `vip` files return
|
||||
401 anonymous / 403 for non-members. The `vip` badge on the post page links
|
||||
only for entitled viewers.
|
||||
@@ -0,0 +1,24 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
poweredByHeader: false,
|
||||
async headers() {
|
||||
return [{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' },
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
|
||||
],
|
||||
}]
|
||||
},
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: false },
|
||||
experimental: {},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig)
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "anwarpi-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "payload generate:types && next build",
|
||||
"start": "next start",
|
||||
"generate:types": "payload generate:types",
|
||||
"seed": "payload run src/seed.ts",
|
||||
"covers": "node scripts/generate-covers.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-postgres": "^3.60.0",
|
||||
"@payloadcms/next": "^3.60.0",
|
||||
"@payloadcms/richtext-lexical": "^3.60.0",
|
||||
"next": "15.4.11",
|
||||
"payload": "^3.60.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sharp": "^0.34.0",
|
||||
"stripe": "^18.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,79 @@
|
||||
/* Cover generator — run: npm run covers
|
||||
Produces 12 JPGs (1600x900) in public/covers/ plus per-category defaults. */
|
||||
import sharp from 'sharp'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const outDir = path.resolve(dirname, '../public/covers')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const W = 1600
|
||||
const H = 900
|
||||
|
||||
const POSTS = [
|
||||
// [file, title, category, kind: free|vip]
|
||||
['agents-free', 'بناء أول وكيل بالعربية', 'وكلاء'],
|
||||
['agents-vip', 'حلقة الوكيل: تخطيط وتنفيذ وتحقق', 'وكلاء'],
|
||||
['models-free', 'كيف تختار نموذجك', 'نماذج'],
|
||||
['models-vip', 'تقييم النماذج منهجياً', 'نماذج'],
|
||||
['retrieval-free', 'الاسترجاع بلا تعقيد', 'استرجاع'],
|
||||
['retrieval-vip', 'قواعد بيانات المتجهات عملياً', 'استرجاع'],
|
||||
['safety-free', 'حدود الوكيل الآمنة', 'سلامة'],
|
||||
['safety-vip', 'مراجعة مخرجات الوكلاء', 'سلامة'],
|
||||
['tools-free', 'أدوات لا غنى عنها', 'أدوات'],
|
||||
['tools-vip', 'أنابيب الأدوات والصلاحيات', 'أدوات'],
|
||||
['business-free', 'الذكاء الاصطناعي في عملك', 'أعمال'],
|
||||
['business-vip', 'من نموذج أولي إلى إنتاج', 'أعمال'],
|
||||
// fallback default cover for the agent API
|
||||
['cover-default', 'أنور بي آي', 'وكلاء'],
|
||||
// per-category default covers for the agent API
|
||||
['cover-agents', 'وكلاء', 'وكلاء'],
|
||||
['cover-models', 'نماذج', 'نماذج'],
|
||||
['cover-retrieval', 'استرجاع', 'استرجاع'],
|
||||
['cover-safety', 'سلامة', 'سلامة'],
|
||||
['cover-tools', 'أدوات', 'أدوات'],
|
||||
['cover-business', 'أعمال', 'أعمال'],
|
||||
['cover-default', 'أنور بي آي', 'وكلاء'],
|
||||
]
|
||||
|
||||
function svgCover(title, category) {
|
||||
const titleEsc = title.replace(/&/g, '&').replace(/</g, '<')
|
||||
const catEsc = category.replace(/&/g, '&').replace(/</g, '<')
|
||||
return Buffer.from(`
|
||||
<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg" dir="rtl">
|
||||
<defs>
|
||||
<radialGradient id="g" cx="20%" cy="15%" r="90%">
|
||||
<stop offset="0%" stop-color="#16202a"/>
|
||||
<stop offset="55%" stop-color="#0c1016"/>
|
||||
<stop offset="100%" stop-color="#050708"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="beam" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7fd8cc"/>
|
||||
<stop offset="100%" stop-color="#3a6a8a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="${W}" height="${H}" fill="url(#g)"/>
|
||||
<g opacity="0.5" stroke="#31414f" stroke-width="1">
|
||||
${Array.from({ length: 9 }, (_, i) => `<line x1="0" y1="${(i + 1) * 90}" x2="${W}" y2="${(i + 1) * 90}"/>`).join('')}
|
||||
</g>
|
||||
<g transform="translate(${W - 140}, 110)">
|
||||
<rect x="-26" y="-26" width="52" height="52" transform="rotate(45)" fill="none" stroke="url(#beam)" stroke-width="5"/>
|
||||
<rect x="-9" y="-9" width="18" height="18" transform="rotate(45)" fill="#e8f6f3"/>
|
||||
</g>
|
||||
<text x="${W - 80}" y="480" text-anchor="end" fill="#f2f7f6"
|
||||
font-family="SF Arabic, Noto Sans Arabic, sans-serif" font-weight="700" font-size="72">${titleEsc}</text>
|
||||
<text x="${W - 80}" y="560" text-anchor="end" fill="#8fa3ad"
|
||||
font-family="SF Mono, monospace" font-size="30" letter-spacing="4">${catEsc} · أنور بي آي</text>
|
||||
<rect x="${W - 80 - 180}" y="620" width="180" height="3" fill="url(#beam)"/>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
for (const [name, title, category] of POSTS) {
|
||||
await sharp(svgCover(title, category))
|
||||
.jpeg({ quality: 82 })
|
||||
.toFile(path.join(outDir, `${name}.jpg`))
|
||||
console.log(`✓ ${name}.jpg`)
|
||||
}
|
||||
console.log(`Done: ${POSTS.length} covers → ${outDir}`)
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||