forked from Tadrisino/MathApp_frontend
- profile shows "User482917" under the name, no copy button - plan cards start with "N questions a day from Pi (free: M)" taken from the plan itself - "ورود با حسابِ دیگر" signs out cleanly instead of keeping several accounts on one device - "حذفِ حساب": SMS code for phone users, typing "حذف" for guests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
394 lines
17 KiB
TypeScript
394 lines
17 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
||
import {
|
||
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||
FileText, Pencil, Repeat, Smartphone, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
|
||
} from 'lucide-react';
|
||
import { api } from '@/api';
|
||
import { useAppData } from '@/state/AppData';
|
||
import { askStore } from '@/state/askStore';
|
||
import { useNav } from '@/nav/Navigator';
|
||
import type { Route } from '@/nav/routes';
|
||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||
import { Sheet } from '@/components/Sheet';
|
||
import { useToast } from '@/components/Toast';
|
||
import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDatePicker';
|
||
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
||
import { friendlyError } from '@/lib/errors';
|
||
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
||
import { digitsOnly, toFa } from '@/lib/format';
|
||
|
||
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
|
||
|
||
/** «من»: پروفایل، تنظیمات و میانبُر به بخشهای فرعی. */
|
||
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||
const { profile, gradeTitle, summary, companion } = useAppData();
|
||
const nav = useNav();
|
||
const [editing, setEditing] = useState<Editing>(null);
|
||
const [sound, setSound] = useState(soundEnabled());
|
||
const age = ageOf(profile.birthDate, profile.birthYear);
|
||
|
||
const go = (route: Route) => () => nav.push(route);
|
||
|
||
return (
|
||
<Screen>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<header className="relative overflow-hidden rounded-b-[32px] bg-indigo px-5 pb-6 pt-safe text-white">
|
||
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
||
<div className="relative flex items-center gap-4 pt-8">
|
||
<div className="rounded-3xl bg-white/15 p-2">
|
||
<SeyarMascot char={companion} mood="happy" size={72} />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
|
||
{profile.name || 'بینام'} <Pencil size={14} className="text-white/70" />
|
||
</button>
|
||
<div className="mt-0.5 text-xs text-white/75">
|
||
شناسهی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
|
||
</div>
|
||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
|
||
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
|
||
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
|
||
{profile.isGuest ? (
|
||
<span className="rounded-full bg-[#FFD23F] px-2.5 py-0.5 font-bold text-ink">مهمان</span>
|
||
) : (
|
||
profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="relative mt-5 grid grid-cols-3 gap-2">
|
||
{[
|
||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز' },
|
||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه' },
|
||
{ icon: Flame, value: summary?.streak.longest ?? 0, label: 'بیشترین زنجیره' },
|
||
].map(({ icon: Icon, value, label }) => (
|
||
<div key={label} className="flex flex-col items-center rounded-2xl bg-white/12 py-2.5">
|
||
<Icon size={16} className="text-white/80" />
|
||
<span className="mt-1 text-base font-black">{toFa(value)}</span>
|
||
<span className="text-[10px] text-white/70">{label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex flex-col gap-4 px-4 py-5">
|
||
{profile.isGuest && (
|
||
<button
|
||
type="button"
|
||
onClick={go({ name: 'register' })}
|
||
className="flex items-center gap-3 rounded-3xl bg-[#FFF8E1] p-4 text-right ring-2 ring-[#FFD23F]"
|
||
>
|
||
<Smartphone size={22} className="flex-shrink-0 text-gold" />
|
||
<div className="flex-1">
|
||
<div className="text-sm font-black text-ink">شمارهت رو ثبت کن</div>
|
||
<div className="mt-0.5 text-xs leading-6 text-body">تا اگه گوشیت عوض شد، امتیازها و پیشرفتت گم نشه.</div>
|
||
</div>
|
||
<ChevronLeft size={18} className="text-muted" />
|
||
</button>
|
||
)}
|
||
|
||
<Menu>
|
||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||
<Row
|
||
icon={CalendarDays}
|
||
label="تاریخِ تولد"
|
||
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
|
||
onClick={() => setEditing('birth')}
|
||
/>
|
||
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
|
||
</Menu>
|
||
|
||
<Menu>
|
||
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
|
||
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
|
||
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||
<Row icon={Bell} label="اعلانها" onClick={go({ name: 'notifications' })} />
|
||
</Menu>
|
||
|
||
<Menu>
|
||
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />}
|
||
<Row icon={FileText} label="پرداختها و فاکتورها" onClick={go({ name: 'invoices' })} />
|
||
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
|
||
<Row
|
||
icon={sound ? Volume2 : VolumeX}
|
||
label="صداها"
|
||
value={sound ? 'روشن' : 'خاموش'}
|
||
onClick={() => {
|
||
setSoundEnabled(!sound);
|
||
setSound(!sound);
|
||
}}
|
||
/>
|
||
</Menu>
|
||
|
||
<Menu>
|
||
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} />
|
||
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} />
|
||
</Menu>
|
||
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
|
||
<Trash2 size={14} /> حذفِ حساب
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
||
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
|
||
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
function Menu({ children }: { children: ReactNode }) {
|
||
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
|
||
}
|
||
|
||
function Row({ icon: Icon, label, value, onClick }: { icon: typeof Bell; label: string; value?: string; onClick: () => void }) {
|
||
return (
|
||
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft">
|
||
<Icon size={18} className="flex-shrink-0 text-indigo" />
|
||
<span className="flex-1 text-sm font-bold text-ink">{label}</span>
|
||
{value && <span className="text-xs text-muted">{value}</span>}
|
||
<ChevronLeft size={16} className="text-faint" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function NameSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||
const { profile, setProfile } = useAppData();
|
||
const toast = useToast();
|
||
const [name, setName] = useState(profile.name);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
async function save() {
|
||
if (!name.trim()) return;
|
||
setSaving(true);
|
||
setError('');
|
||
try {
|
||
setProfile(await api.profile.update({ name: name.trim() }));
|
||
toast('اسمت عوض شد', 'success');
|
||
onClose();
|
||
} catch (err) {
|
||
setError(friendlyError(err));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Sheet open={open} onClose={onClose} title="اسمت">
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value.slice(0, 40))}
|
||
className="mb-4 w-full rounded-2xl border-2 border-line px-4 py-3.5 text-sm outline-none focus:border-coral"
|
||
/>
|
||
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
|
||
<PrimaryButton loading={saving} disabled={!name.trim()} onClick={() => void save()}>ذخیره</PrimaryButton>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||
const { profile, grades, setProfile, refreshHome, refreshSummary } = useAppData();
|
||
const toast = useToast();
|
||
const [saving, setSaving] = useState<number | null>(null);
|
||
|
||
async function choose(gradeId: number) {
|
||
if (gradeId === profile.gradeId) return onClose();
|
||
setSaving(gradeId);
|
||
try {
|
||
await api.profile.setGrade(gradeId);
|
||
setProfile({ ...profile, gradeId });
|
||
askStore.reset(null);
|
||
await Promise.all([refreshHome(), refreshSummary()]);
|
||
toast('پایهت عوض شد', 'success');
|
||
onClose();
|
||
} catch (err) {
|
||
toast(friendlyError(err), 'error');
|
||
} finally {
|
||
setSaving(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Sheet open={open} onClose={onClose} title="پایهی تحصیلی">
|
||
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته میشه؛ با برگشتن، سرِ جاشه.</p>
|
||
<div className="grid grid-cols-2 gap-2.5">
|
||
{grades.map((g) => {
|
||
const on = g.id === profile.gradeId;
|
||
return (
|
||
<button
|
||
key={g.id}
|
||
type="button"
|
||
disabled={saving !== null}
|
||
onClick={() => void choose(g.id)}
|
||
className={`flex items-center justify-between rounded-2xl border-2 px-3.5 py-3 text-sm font-bold ${on ? 'border-coral bg-coral text-white' : 'border-line text-body'}`}
|
||
>
|
||
{g.title}
|
||
{on && <Check size={16} strokeWidth={3} />}
|
||
{saving === g.id && <span className="h-4 w-4 animate-spin rounded-full border-2 border-coral border-t-transparent" />}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||
const { profile, setProfile } = useAppData();
|
||
const toast = useToast();
|
||
const [birthDate, setBirthDate] = useState<string | null>(profile.birthDate);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
async function save() {
|
||
if (!birthDate) return;
|
||
setSaving(true);
|
||
try {
|
||
setProfile(await api.profile.update({ birthDate }));
|
||
toast('ذخیره شد', 'success');
|
||
onClose();
|
||
} catch (err) {
|
||
toast(friendlyError(err), 'error');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Sheet open={open} onClose={onClose} title="تاریخِ تولد">
|
||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
|
||
<div className="mb-5">
|
||
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||
</div>
|
||
<PrimaryButton loading={saving} disabled={!birthDate} onClick={() => void save()}>ذخیره</PrimaryButton>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی
|
||
* یک گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ
|
||
* دیگری میشد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر میکرد.
|
||
*/
|
||
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||
const { profile } = useAppData();
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function logout() {
|
||
setBusy(true);
|
||
try {
|
||
await api.auth.logout();
|
||
} catch {
|
||
/* حافظه در هر صورت پاک شده؛ کاربر بیرون است */
|
||
}
|
||
askStore.clear();
|
||
onSignedOut();
|
||
}
|
||
|
||
return (
|
||
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
|
||
<p className="mb-5 text-sm leading-7 text-muted">
|
||
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||
{profile.isGuest
|
||
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمیگردونه، ولی از گوشیِ دیگه نه. برای امنتر شدن، اول شمارهت رو ثبت کن.'
|
||
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
|
||
</p>
|
||
<div className="flex flex-col gap-2.5">
|
||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
||
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
|
||
</PrimaryButton>
|
||
</div>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* حذفِ همیشگیِ حساب (POST /user/delete). شمارهدار با کدِ تازهی پیامکی تأیید میکند تا گوشیِ
|
||
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند؛ مهمان با تایپِ «حذف».
|
||
*/
|
||
function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||
const { profile } = useAppData();
|
||
const [sent, setSent] = useState(false);
|
||
const [value, setValue] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const needsCode = Boolean(profile.phone);
|
||
|
||
async function sendCode() {
|
||
if (!profile.phone) return;
|
||
setBusy(true);
|
||
setError('');
|
||
try {
|
||
await api.auth.requestOtp(profile.phone);
|
||
setSent(true);
|
||
} catch (err) {
|
||
setError(friendlyError(err));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function remove() {
|
||
setBusy(true);
|
||
setError('');
|
||
try {
|
||
await api.auth.deleteAccount(needsCode ? { otp: digitsOnly(value, 6) } : { confirm: value.trim() });
|
||
askStore.clear();
|
||
onSignedOut();
|
||
} catch (err) {
|
||
setError(friendlyError(err));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
|
||
<p className="mb-3 text-sm leading-7 text-body">
|
||
امتیاز، زنجیره، پیشرفتِ جزیرهها، گفتگوها با دستیار و اشتراکِ فعالت پاک میشن و برنمیگردن.
|
||
سوابقِ پرداخت فقط برای حسابداری میمونه.
|
||
</p>
|
||
{needsCode ? (
|
||
sent ? (
|
||
<input
|
||
value={value}
|
||
onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
|
||
placeholder="کدِ پیامکشده"
|
||
inputMode="numeric"
|
||
dir="ltr"
|
||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
|
||
/>
|
||
) : (
|
||
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شمارهی خودت پیامک میشه.</p>
|
||
)
|
||
) : (
|
||
<input
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value.slice(0, 10))}
|
||
placeholder="برای تأیید بنویس: حذف"
|
||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold outline-none focus:border-red-400"
|
||
/>
|
||
)}
|
||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
||
<div className="flex flex-col gap-2.5">
|
||
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
|
||
{needsCode && !sent ? (
|
||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
|
||
) : (
|
||
<PrimaryButton
|
||
color="#ef4444"
|
||
loading={busy}
|
||
disabled={needsCode ? value.length < 4 : value.trim() !== 'حذف'}
|
||
onClick={() => void remove()}
|
||
>
|
||
حذفِ همیشگی
|
||
</PrimaryButton>
|
||
)}
|
||
</div>
|
||
</Sheet>
|
||
);
|
||
}
|