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

72
src/nav/Navigator.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { Route, TabName } from './routes';
/**
* ناوبریِ پشته‌ای، هماهنگ با تاریخچه‌ی مرورگر.
*
* هر push یک ورودی به history اضافه می‌کند، پس دکمه‌ی back گوشی یا مرورگر
* یک صفحه عقب می‌رود به‌جای بیرون انداختنِ کاربر از اپ. تعویضِ تب پشته را
* از نو می‌سازد (مثلِ اپ‌های بومی).
*/
interface Nav {
route: Route;
/** تبی که ریشه‌ی پشته‌ی فعلی است؛ برای روشن کردنِ نوارِ پایین. */
tab: TabName | null;
canGoBack: boolean;
push: (route: Route) => void;
/** صفحه‌ی فعلی را عوض می‌کند بدونِ اضافه کردن به تاریخچه. */
replace: (route: Route) => void;
back: () => void;
switchTab: (tab: TabName) => void;
}
const NavContext = createContext<Nav | null>(null);
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
const [stack, setStack] = useState<Route[]>([initial]);
useEffect(() => {
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const push = useCallback((route: Route) => {
setStack((s) => [...s, route]);
window.history.pushState({ tadrisino: true }, '');
}, []);
const replace = useCallback((route: Route) => {
setStack((s) => [...s.slice(0, -1), route]);
}, []);
const back = useCallback(() => {
// popstate خودش صفحه را برمی‌دارد؛ پس دو منبعِ حقیقت نداریم.
window.history.back();
}, []);
const switchTab = useCallback((tab: TabName) => {
setStack([{ name: tab } as Route]);
}, []);
const value = useMemo<Nav>(() => {
const root = stack[0].name;
return {
route: stack[stack.length - 1],
tab: ['home', 'map', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
canGoBack: stack.length > 1,
push,
replace,
back,
switchTab,
};
}, [stack, push, replace, back, switchTab]);
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
}
export function useNav(): Nav {
const nav = useContext(NavContext);
if (!nav) throw new Error('useNav بیرون از NavigatorProvider صدا زده شد');
return nav;
}