Files
MathApp_frontend/src/screens/ProfileScreen.tsx
AliReza eafee76a93 Smooth screen transitions: new screen waits hidden until its data arrives, no half-built flash
- Navigator stage: the next screen mounts hidden under the current one and is revealed only when its
  first data has arrived (ScreenGate held by useAsync/useGateHold), at most 450 ms; a thin top bar shows
  after 120 ms of waiting. Push slides from the left, back from the right, tabs only fade.
- Session cache (stale-while-revalidate) for screen GETs: revisited screens draw instantly.
- Home map and stats are fetched during the splash, so the first screen arrives complete.
- Entrance pop-ins (mascot, islands, lesson cards) run on first view only, not on every revisit.
- Shop status card shows placeholders instead of a wrong «نداری» while loading.
- Re-tapping the open tab scrolls to top instead of reloading; sheets close with a swipe down.
- Quiz: «۱ از ۴» instead of «۱/۴» (read as a fraction), varied praise, «N تا پشتِ هم درست»,
  growth-mindset copy after a weak round, zero-reward tiles hidden.
- Streak: a concrete next step when the streak is 0 or today is still empty.

Checked in headless Chrome against the local backend (frame captures at 150 ms and 700 ms latency,
rapid tab tapping, full quiz run). Not checked on a real phone or iOS Safari.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:55:47 +03:30

437 lines
20 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, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
FileText, Pencil, Repeat, 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, formatJalaliDate, toFa } from '@/lib/format';
import { useAsync } from '@/lib/useAsync';
import { daysLeft } from '@/lib/subscription';
import { BrandFooter } from '@/components/BrandFooter';
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.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">
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
<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>
<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>
<BrandFooter />
</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>
);
}
/**
* اشتراک به‌صورتِ نوار: چند روز از کلِ دوره مانده. مخرج طولِ پلن است (planDurationDays)؛ تمدیدِ زودتر از
* موعد روزها را روی هم می‌گذارد، پس نوار در ۱۰۰٪ بسته می‌شود. زدنِ کارت فروشگاه را باز می‌کند.
*/
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
const s = status.data;
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
const days = s.active ? daysLeft(s.expiresAt) : 0;
const total = s.planDurationDays ?? 30;
// اشتراکِ بی‌تاریخ (هدیه) پر نشان داده می‌شود.
const percent = s.active ? (days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)))) : 0;
const low = s.active && days !== null && days <= 5;
return (
<button
type="button"
onClick={onOpen}
className={`relative overflow-hidden rounded-3xl p-4 text-right active:scale-[0.99] ${s.active ? 'bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] text-white shadow-lg shadow-orange/25' : 'border-2 border-dashed border-orange/50 bg-white text-ink'}`}
>
<div className="flex items-center gap-3">
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
<span className="min-w-0 flex-1">
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span>
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
</span>
<span className="text-left">
{s.active && days !== null ? (
<><b className="block text-2xl font-black leading-none">{toFa(days)}</b><span className="text-[10px] text-white/80">روز مونده</span></>
) : (
<ChevronLeft size={18} className={s.active ? 'text-white/80' : 'text-faint'} />
)}
</span>
</div>
<div
className={`relative mt-3 h-3 overflow-hidden rounded-full ${s.active ? 'bg-black/15' : 'bg-soft'}`}
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={days ?? total}
aria-label="روزهای ماندهٔ اشتراک"
>
{percent > 0 && (
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
<span className="shine absolute inset-y-0 w-1/3 bg-gradient-to-l from-transparent via-white/70 to-transparent" style={{ mixBlendMode: 'overlay' }} />
</div>
)}
</div>
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}>
{s.active ? (
<>
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
</>
) : (
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همه‌ی فصل‌ها و قلبِ نامحدود باز می‌شه.</span>
)}
</div>
{s.questionCredits > 0 && (
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریده‌شده در اعتبارت
</div>
)}
</button>
);
}
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 [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 && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '}
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
</p>
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد می‌گیری 💛</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('');
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({ otp: digitsOnly(value, 6) });
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>
{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>
)}
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
{!sent ? (
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
) : (
<PrimaryButton
color="#ef4444"
loading={busy}
disabled={value.length < 6}
onClick={() => void remove()}
>
حذفِ همیشگی
</PrimaryButton>
)}
</div>
</Sheet>
);
}