Files
MathApp_frontend/src/screens/ProfileScreen.tsx
AliReza 44871894e0 User-style public ID, tutor quota on plan cards, switch and delete account
- 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>
2026-10-02 06:10:17 +03:30

394 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}