Files
MathApp_frontend/src/screens/ProfileScreen.tsx
AliReza ca96a91dee No diacritics or written ezafe ی in the UI; map without the speech banner, companions cheering along the path; familiar payment sheet with gateway logos and per-channel methods; account deletion moved under «حساب کاربری»
- Text: kasra/fatha/damma/shadda/sukun/hamza-above and «ه‌ی» ezafe removed from every user-visible string (comments untouched); tanwin kept. Stale copy fixed (question packs are no longer sold), awkward onboarding sentences rewritten, سؤال → سوال. plainPersian() in MathText cleans the live tutor stream.
- Home: the mascot + speech bubble header is gone; the selected companion hops beside the current island and sits at every third bend (CSS transform animations only).
- Checkout: order summary, payment-method rows with Zarinpal/Bazaar icons and radio, amount on the pay button, lock trust line, darker overlay. paymentMethods in config.js per channel (web: zarinpal, app: zarinpal+bazaar by default).
- Profile: «ورود با حساب دیگر» removed (it was logout); new «حساب کاربری» sheet shows phone/ID and holds «حذف حساب کاربری»; logout is a red row. Delete sheet tells Bazaar subscribers to cancel renewal in Bazaar.

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

449 lines
21 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, Sparkles, Star, Trash2, Trophy, UserRound, 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' | 'account' | 'logout' | '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>
{/* حذفِ حساب یک لایه پایین‌تر، در «حساب کاربری»، مثلِ اپ‌های بزرگ (دیجی‌کالا، اسنپ، دولینگو): پیدا می‌شود
(Google Play و App Store «در دسترس و پیداشدنی» می‌خواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
<Menu>
<Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
<Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
</Menu>
<BrandFooter />
</div>
</div>
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
<AccountSheet open={editing === 'account'} onClose={() => setEditing(null)} onDelete={() => setEditing('delete')} />
<LogoutSheet open={editing === 'logout'} 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>
</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, danger = false, onClick }: { icon: typeof Bell; label: string; value?: string; danger?: boolean; 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 ${danger ? 'text-red-500' : 'text-indigo'}`} />
<span className={`flex-1 text-sm font-bold ${danger ? 'text-red-500' : '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(gradeId), 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, onClose, onSignedOut }: { open: 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="از حسابت خارج بشی؟">
<p className="mb-5 text-sm leading-7 text-muted">هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سر جاشه.</p>
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
</div>
</Sheet>
);
}
/** «حساب کاربری»: شماره و شناسه، و پایینش حذفِ حساب (جای استانداردش؛ نه دمِ دستِ صفحه‌ی اصلی). */
function AccountSheet({ open, onClose, onDelete }: { open: boolean; onClose: () => void; onDelete: () => void }) {
const { profile } = useAppData();
return (
<Sheet open={open} onClose={onClose} title="حساب کاربری">
<div className="mb-6 divide-y divide-line overflow-hidden rounded-2xl border border-line">
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
<span className="font-bold text-body">شماره موبایل</span>
<span dir="ltr" className="font-bold text-ink">{profile.phone ? toFa(profile.phone) : '—'}</span>
</div>
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
<span className="font-bold text-body">شناسه کاربری</span>
<span dir="ltr" className="font-bold tracking-wide text-ink">{profile.publicId}</span>
</div>
</div>
<p className="mb-2 text-xs leading-6 text-muted">با حذف حساب، همه پیشرفت و اطلاعاتت برای همیشه پاک می‌شه.</p>
<button type="button" onClick={onDelete} className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 py-3 text-sm font-bold text-red-500 active:bg-red-50">
<Trash2 size={16} /> حذف حساب کاربری
</button>
</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>
);
}