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:
180
src/screens/HomeScreen.tsx
Normal file
180
src/screens/HomeScreen.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findContinue, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
export function HomeScreen() {
|
||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||
const nav = useNav();
|
||||
const cont = findContinue(home);
|
||||
const contLesson = cont ? nextLessonOf(cont.node) : null;
|
||||
const contColor = cont ? unitColor(cont.unitIndex) : '#ED6956';
|
||||
const contProgress = cont ? unitProgress(cont.unit) : null;
|
||||
|
||||
const openLesson = (quiz = false) => {
|
||||
if (!cont || contLesson == null) return;
|
||||
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id });
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', fill: true, to: 'streak' as const },
|
||||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const },
|
||||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white">
|
||||
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" />
|
||||
<div className="relative flex items-center justify-between pt-8">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white/75">سلام {profile.name || 'دوستِ من'}!</p>
|
||||
<h1 className="mt-0.5 text-xl font-black leading-snug">آمادهای ریاضی رو ادامه بدیم؟</h1>
|
||||
{gradeTitle && (
|
||||
<span className="mt-1.5 inline-block rounded-full bg-white/15 px-2.5 py-0.5 text-xs text-white/85">{gradeTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<button type="button" aria-label="جدولِ امتیازها" onClick={() => nav.push({ name: 'leaderboard' })} className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
|
||||
<Trophy size={18} />
|
||||
</button>
|
||||
<button type="button" aria-label="اعلانها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
|
||||
<Bell size={18} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -left-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
|
||||
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative z-10 -mt-7 px-4">
|
||||
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10">
|
||||
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => (
|
||||
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5">
|
||||
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}>
|
||||
<Icon size={18} style={{ color: fg }} strokeWidth={2.4} fill={fill ? fg : 'none'} />
|
||||
</span>
|
||||
<span className="text-lg font-black leading-none text-ink">{toFa(value)}</span>
|
||||
<span className="mt-1 text-[10px] text-muted">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 pb-6 pt-4">
|
||||
{homeError && !home ? (
|
||||
<ErrorState error={homeError} onRetry={() => void refreshHome()} />
|
||||
) : cont && contProgress ? (
|
||||
<Card className="shadow-md shadow-coral/10">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-muted">ادامه از جایی که موندی</p>
|
||||
<h3 className="mt-0.5 text-base font-black text-ink">{cont.node.title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-muted">
|
||||
{cont.unit.title} · {toFa(contProgress.done)} از {toFa(contProgress.total)} جزیره
|
||||
</p>
|
||||
</div>
|
||||
<SeyarMascot char={companion} mood="happy" size={62} className="mr-3" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<ProgressBar percent={contProgress.percent} color={contColor} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLesson()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl py-3 text-sm font-bold text-white active:scale-[0.98]"
|
||||
style={{ backgroundColor: contColor }}
|
||||
>
|
||||
<Play size={15} fill="white" /> ادامه بده
|
||||
</button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="flex items-center gap-3 p-5">
|
||||
<SeyarMascot char={companion} mood={home ? 'excited' : 'idle'} size={56} />
|
||||
<p className="text-sm font-medium text-muted">
|
||||
{!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همهی جزیرههای این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card onClick={() => nav.switchTab('map')} className="p-4">
|
||||
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-[#EAF2F6]">
|
||||
<Map size={20} className="text-steel" />
|
||||
</span>
|
||||
<div className="text-sm font-bold text-ink">نقشهی فصلها</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted">ببین کجای مسیری</div>
|
||||
</Card>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nav.switchTab('ask')}
|
||||
className="rounded-3xl bg-violet p-4 text-right shadow-md shadow-violet/30 active:scale-[0.98]"
|
||||
>
|
||||
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-white/15">
|
||||
<Brain size={20} className="text-white" />
|
||||
</span>
|
||||
<div className="text-sm font-bold text-white">از پی بپرس</div>
|
||||
<div className="mt-0.5 text-[11px] text-white/75">هر جا گیر کردی</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{home && home.units.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-black text-ink">فصلهای {gradeTitle || 'این پایه'}</h2>
|
||||
<span className="text-[11px] text-muted">{toFa(home.units.length)} فصل</span>
|
||||
</div>
|
||||
<div className="no-scrollbar -mx-4 flex gap-3 overflow-x-auto px-4 pb-1">
|
||||
{home.units.map((unit, i) => {
|
||||
const p = unitProgress(unit);
|
||||
const color = unitColor(i);
|
||||
const Icon = iconFor(unit.nodes[0]?.icon, i);
|
||||
return (
|
||||
<button
|
||||
key={unit.id}
|
||||
type="button"
|
||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||||
className="w-[140px] flex-shrink-0 rounded-2xl border border-line bg-white p-3.5 text-right shadow-sm active:scale-95"
|
||||
>
|
||||
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1f` }}>
|
||||
<Icon size={19} style={{ color }} />
|
||||
</span>
|
||||
<div className="line-clamp-2 min-h-[2.5em] text-[12.5px] font-bold leading-snug text-ink">{unit.title}</div>
|
||||
<div className="mt-1 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar percent={p.percent} color={color} track="#f0eeff" height={6} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{cont && contLesson != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLesson(true)}
|
||||
className="flex items-center gap-3 rounded-3xl bg-orange p-4 text-right shadow-md shadow-orange/25 active:scale-[0.98]"
|
||||
>
|
||||
<SeyarMascot char="delta" mood="excited" size={64} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div>
|
||||
</div>
|
||||
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user