449 lines
21 KiB
TypeScript
449 lines
21 KiB
TypeScript
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>
|
||
);
|
||
}
|