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:
72
src/nav/Navigator.tsx
Normal file
72
src/nav/Navigator.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user