Audit fixes: VIP self-grant, draft leak, onboarding flag, hero personalization, gated downloads
Deploy GitHub Pages / deploy (push) Canceled after 0s
Deploy GitHub Pages / deploy (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user