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:
2026-09-26 23:35:48 +03:30
parent b411b636f4
commit 348bfecea6
120 changed files with 5840 additions and 12783 deletions

96
src/screens/MapScreen.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { motion } from 'motion/react';
import { Check } from 'lucide-react';
import { useAppData } from '@/state/AppData';
import { unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
/** نقشه‌ی فصل‌های پایه: هر خانه یک فصل است. */
export function MapScreen() {
const { home, homeError, refreshHome, gradeTitle } = useAppData();
const nav = useNav();
const units = home?.units ?? [];
const points = zigzagPositions(units.length, 150);
const height = points.length ? points[points.length - 1].y + 200 : 400;
const overall = Math.round(home?.stats.progressPercent ?? 0);
return (
<Screen className="bg-[#f7f8ff]">
<ScreenHeader
title="نقشه‌ی فصل‌ها"
subtitle={`${gradeTitle ? `${gradeTitle} · ` : ''}${toFa(overall)}٪ از کلِ مسیر`}
color="#91ADC6"
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
>
<div className="mt-4">
<ProgressBar percent={overall} color="#fff" track="rgba(255,255,255,.25)" />
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم فصل‌ها رو میارم…" />
) : units.length === 0 ? (
<EmptyState text="هنوز فصلی برای این پایه ثبت نشده" />
) : (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath
points={points}
height={height}
colorOf={(i) => (units[i].nodes.some((n) => n.status !== 'locked') ? unitColor(i) : null)}
/>
{units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i);
const complete = p.total > 0 && p.done === p.total;
return (
<motion.div
key={unit.id}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.05, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
<button
type="button"
aria-label={unit.title}
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="relative flex h-[84px] w-[84px] items-center justify-center rounded-[26px] transition-transform active:translate-y-1"
style={{
backgroundColor: complete ? color : '#fff',
border: complete ? 'none' : `4px solid ${color}`,
boxShadow: complete ? `0 6px 0 ${color}99` : `0 6px 0 ${color}40, 0 0 0 5px white`,
}}
>
{complete ? <Check size={30} color="white" strokeWidth={3.5} /> : <Icon size={30} style={{ color }} strokeWidth={2.2} />}
{!complete && p.done > 0 && (
<span className="absolute -left-2 -top-2 rounded-full border bg-white px-1.5 py-0.5 text-[10px] font-black shadow" style={{ color, borderColor: color }}>
{toFa(p.percent)}٪
</span>
)}
</button>
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
</div>
</motion.div>
);
})}
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 150 }}>
<SeyarMascot char="pi" mood={overall >= 100 ? 'excited' : 'happy'} size={72} bob />
<div className="mt-1 whitespace-nowrap rounded-full border border-line bg-white px-3 py-1 text-[10px] font-bold text-[#7857E0] shadow-sm">
{overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همین‌جا منتظرتم'}
</div>
</div>
</div>
)}
</div>
</Screen>
);
}