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>
47 lines
1.4 KiB
TypeScript
47 lines
1.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type DependencyList } from 'react';
|
|
|
|
export interface AsyncState<T> {
|
|
data: T | undefined;
|
|
error: unknown;
|
|
loading: boolean;
|
|
reload: () => void;
|
|
setData: (update: T | ((prev: T | undefined) => T | undefined)) => void;
|
|
}
|
|
|
|
/**
|
|
* دادهای که از سرور میآید، با حالتهای بارگذاری/خطا و «دوباره».
|
|
* پاسخِ درخواستِ کهنه (بعد از عوض شدنِ deps) نادیده گرفته میشود.
|
|
*/
|
|
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList): AsyncState<T> {
|
|
const [data, setData] = useState<T | undefined>(undefined);
|
|
const [error, setError] = useState<unknown>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [nonce, setNonce] = useState(0);
|
|
const loadRef = useRef(load);
|
|
loadRef.current = load;
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setLoading(true);
|
|
setError(null);
|
|
loadRef.current().then(
|
|
(value) => {
|
|
if (!alive) return;
|
|
setData(value);
|
|
setLoading(false);
|
|
},
|
|
(err: unknown) => {
|
|
if (!alive) return;
|
|
setError(err);
|
|
setLoading(false);
|
|
},
|
|
);
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [...deps, nonce]);
|
|
|
|
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
|
return { data, error, loading, reload, setData };
|
|
}
|