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,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user