rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused packages, 45 unused UI components, no type-check). The live site could not reach the backend, Persian-keyboard digits were erased from the phone field, guests were signed out after an hour, "mark read" hit a method the server does not accept (405), and iOS Safari blocked the payment window. What changed: - src/ split into api/ (one typed client: timeouts, GET retry, single-flight refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/. - The API address is configuration only: public/config.js at runtime, then VITE_API_BASE_URL at build time, then same-origin /api. No domain in code. - Responsive shell instead of a fixed 390x844 phone frame; self-hosted Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build` type-checks first. - Everything on the handoff list: tutor answers keep their line breaks, have thumbs up/down, follow-ups, "from the book" badge, retry, history that survives tab switches and reloads, "why was I wrong" from a wrong answer with questionId; question reports; hearts with maxHearts/unlimited and a no-hearts screen with the refill time; result screen and a visible error when lesson completion fails; subscription status, badge, discount code before the gateway and the payment-return message; guest refresh token; 6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH read-all; leaderboard refetched on open with "you" highlighted; selected companion shown across the app; logout that revokes the refresh token. - New screens for endpoints that had none: profile, streak calendar, support tickets and bug reports, parents, invite-a-friend (with ?ref= tracking), guest-to-phone upgrade. - Browser/phone back button walks the screen stack. Checked with a headless-Chrome walkthrough against a local backend at 390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map, island sheet, every tab and sub-screen, one real tutor question, reload, logout): no console errors, no failed API calls. Real SMS login and a real Zarinpal payment were not exercised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
283
src/screens/ProfileScreen.tsx
Normal file
283
src/screens/ProfileScreen.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||||
Pencil, Smartphone, Sparkles, Star, Trophy, Users, 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 { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
|
||||
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
type Editing = 'name' | 'grade' | 'age' | 'logout' | 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 = ageFromBirthYear(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-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={age ? `${toFa(age)} سال` : 'وارد نشده'} onClick={() => setEditing('age')} />
|
||||
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<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' })} />}
|
||||
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />}
|
||||
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
|
||||
<Row
|
||||
icon={sound ? Volume2 : VolumeX}
|
||||
label="صداها"
|
||||
value={sound ? 'روشن' : 'خاموش'}
|
||||
onClick={() => {
|
||||
setSoundEnabled(!sound);
|
||||
setSound(!sound);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
|
||||
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500">
|
||||
<LogOut size={16} /> خروج از حساب
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||||
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
||||
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} />
|
||||
<LogoutSheet open={editing === 'logout'} 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 AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { profile, setProfile } = useAppData();
|
||||
const toast = useToast();
|
||||
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function save() {
|
||||
if (!age) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) }));
|
||||
toast('ذخیره شد', 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast(friendlyError(err), 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="چند سالته؟">
|
||||
<div className="mb-5">
|
||||
<AgePicker value={age} onChange={setAge} />
|
||||
</div>
|
||||
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutSheet({ open, onClose, onSignedOut }: { open: 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="از حسابت خارج بشی؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{profile.isGuest
|
||||
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمیگردونه، ولی از گوشیِ دیگه نه. برای امنتر شدن، اول شمارهت رو ثبت کن.'
|
||||
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user