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

339
src/api/http.ts Normal file
View File

@@ -0,0 +1,339 @@
/**
* هسته‌ی ارتباط با بک‌اند. فقط fetch استاندارد، بدونِ پکیج.
*
* هر چیزی که اینجاست برای یک حالتِ خراب است، نه حالتِ خوب:
* • timeout — بک‌اندِ کند نباید سوکت‌ها را تا ابد باز نگه دارد.
* • تکرارِ خودکار فقط برای GET، با backoff و jitter؛ تکرارِ POST یعنی ثبتِ دوباره.
* • refreshِ تک‌نسخه‌ای — ده درخواستِ هم‌زمان با ۴۰۱ یک refresh می‌زنند، نه ده تا.
* • ادغامِ GETهای هم‌زمانِ یکسان.
* • وقتی نشست به‌کل باطل شد، onSignedOut خبر می‌دهد تا اپ به صفحه‌ی ورود برگردد.
*/
export interface Tokens {
token: string;
refreshToken: string;
}
export class ApiError extends Error {
retryAfterMs?: number;
constructor(
readonly status: number,
readonly detail: string,
readonly body: Record<string, unknown> = {},
) {
super(detail);
this.name = 'ApiError';
}
/** کدِ ماشینیِ خطا، اگر سرور داده باشد (مثلاً "no_hearts"). */
get code(): string | undefined {
return typeof this.body.code === 'string' ? this.body.code : undefined;
}
get isNetwork() { return this.status === 0; }
get isUnauthorized() { return this.status === 401; }
get isRateLimited() { return this.status === 429; }
get isProviderDown() { return this.status === 503; }
}
export interface TokenStorage {
get(): Tokens | null;
set(tokens: Tokens | null): void;
}
const STORAGE_KEY = 'tadrisino.tokens';
/** localStorage در حالتِ خصوصیِ بعضی مرورگرها throw می‌کند؛ اپ نباید بخوابد. */
export const localTokenStorage: TokenStorage = {
get() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
}
},
set(tokens) {
try {
if (tokens) localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
else localStorage.removeItem(STORAGE_KEY);
} catch {
/* ذخیره نشد؛ نشست فقط تا بستنِ صفحه می‌ماند */
}
},
};
export interface RequestOptions {
timeoutMs?: number;
signal?: AbortSignal;
/** POST را هم تکرارپذیر می‌کند؛ فقط همراهِ idempotencyKey معنا دارد. */
retry?: boolean;
idempotencyKey?: string;
}
export interface HttpClientOptions {
baseUrl: string;
storage?: TokenStorage;
timeoutMs?: number;
maxRetries?: number;
}
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
function backoffMs(attempt: number, retryAfterMs?: number): number {
if (retryAfterMs != null) return Math.min(retryAfterMs, 30_000);
return Math.random() * Math.min(1000 * 2 ** attempt, 8000);
}
function parseRetryAfter(header: string | null): number | undefined {
if (!header) return undefined;
const seconds = Number(header);
if (Number.isFinite(seconds)) return seconds * 1000;
const at = Date.parse(header);
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export function newIdempotencyKey(): string {
const c = globalThis.crypto;
if (c && 'randomUUID' in c) return c.randomUUID();
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** AbortSignal.any هنوز همه‌جا نیست؛ جایگزینِ کوچکش. */
function anySignal(signals: AbortSignal[]): AbortSignal {
const native = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any;
if (native) return native(signals);
const controller = new AbortController();
for (const s of signals) {
if (s.aborted) {
controller.abort(s.reason);
break;
}
s.addEventListener('abort', () => controller.abort(s.reason), { once: true });
}
return controller.signal;
}
function timeoutSignal(ms: number): AbortSignal {
if ('timeout' in AbortSignal) return AbortSignal.timeout(ms);
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
async function parseBody(response: Response): Promise<Record<string, unknown>> {
const text = await response.text();
if (!text) return {};
try {
const parsed: unknown = JSON.parse(text);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
// HTML به‌جای JSON: معمولاً ۵۰۲ِ nginx، یا آدرسِ API اشتباه است و
// سرورِ فرانت index.html را برگردانده.
return {};
}
}
function errorFrom(response: Response, body: Record<string, unknown>): ApiError {
const detail =
(typeof body.detail === 'string' && body.detail) ||
(typeof body.error === 'string' && body.error) ||
firstFieldError(body) ||
(response.status >= 500 ? 'سرور موقتاً در دسترس نیست' : `خطای ${response.status}`);
const error = new ApiError(response.status, detail, body);
error.retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
return error;
}
/** خطای اعتبارسنجیِ DRF به شکلِ {field: ["پیام"]} است. */
function firstFieldError(body: Record<string, unknown>): string | undefined {
for (const value of Object.values(body)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return undefined;
}
export function createHttpClient(options: HttpClientOptions) {
const baseUrl = options.baseUrl.replace(/\/+$/, '');
const storage = options.storage ?? localTokenStorage;
const defaultTimeout = options.timeoutMs ?? 15_000;
const maxRetries = options.maxRetries ?? 2;
const inFlight = new Map<string, Promise<unknown>>();
const signedOutListeners = new Set<() => void>();
let refreshing: Promise<Tokens | null> | null = null;
function authHeaders(): Record<string, string> {
const token = storage.get()?.token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
function signOut() {
storage.set(null);
signedOutListeners.forEach((listener) => listener());
}
async function refreshTokens(): Promise<Tokens | null> {
const current = storage.get();
if (!current?.refreshToken) return null;
if (refreshing) return refreshing;
refreshing = (async () => {
try {
const response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: current.refreshToken }),
signal: timeoutSignal(defaultTimeout),
credentials: 'omit',
});
if (!response.ok) return null;
const data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
storage.set(next);
return next;
} catch {
return null;
} finally {
queueMicrotask(() => {
refreshing = null;
});
}
})();
return refreshing;
}
async function send(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
const timer = timeoutSignal(opts.timeoutMs ?? defaultTimeout);
try {
return await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: opts.signal ? anySignal([timer, opts.signal]) : timer,
// احرازِ هویت هدرِ Bearer است، نه کوکی؛ سرور هم CORS را بدونِ credentials دارد.
credentials: 'omit',
});
} catch (err) {
if (opts.signal?.aborted) throw err;
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
}
}
/** یک درخواست، با یک‌بار refresh در صورتِ ۴۰۱. */
async function sendWithRefresh(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
): Promise<Response> {
const hadToken = Boolean(storage.get()?.token);
let response = await send(method, path, body, opts);
if (response.status === 401 && hadToken) {
const refreshed = await refreshTokens();
if (refreshed) response = await send(method, path, body, opts);
else signOut();
}
return response;
}
async function request<T>(method: string, path: string, body?: unknown, opts: RequestOptions = {}): Promise<T> {
const retriable = opts.retry ?? method === 'GET';
for (let attempt = 0; ; attempt++) {
try {
const response = await sendWithRefresh(method, path, body, opts);
const parsed = await parseBody(response);
if (!response.ok) throw errorFrom(response, parsed);
return parsed as T;
} catch (err) {
if (!(err instanceof ApiError)) throw err;
const worthRetrying = err.isNetwork || RETRIABLE_STATUS.has(err.status);
if (!retriable || !worthRetrying || attempt >= maxRetries || opts.signal?.aborted) throw err;
await sleep(backoffMs(attempt, err.retryAfterMs));
}
}
}
function get<T>(path: string, opts: RequestOptions = {}): Promise<T> {
// درخواستِ لغوشدنی ادغام نمی‌شود؛ لغوِ یکی نباید دیگری را بکشد.
if (opts.signal) return request<T>('GET', path, undefined, opts);
const existing = inFlight.get(path);
if (existing) return existing as Promise<T>;
const promise = request<T>('GET', path, undefined, opts).finally(() => inFlight.delete(path));
inFlight.set(path, promise);
return promise;
}
/**
* درخواستِ Server-Sent Events (پاسخِ تکه‌تکه‌ی دستیار).
* رویدادها با خطِ خالی جدا می‌شوند: `event: <نام>\ndata: <json>`.
*/
async function stream(
path: string,
body: unknown,
onEvent: (event: string, data: Record<string, unknown>) => void,
opts: RequestOptions = {},
): Promise<void> {
const response = await sendWithRefresh('POST', path, body, { timeoutMs: 120_000, ...opts });
if (!response.ok || !response.body) {
throw errorFrom(response, await parseBody(response));
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
const event = /^event: (.+)$/m.exec(part)?.[1];
const data = /^data: (.+)$/m.exec(part)?.[1];
if (!event || !data) continue;
try {
onEvent(event, JSON.parse(data) as Record<string, unknown>);
} catch (err) {
if (err instanceof ApiError) throw err;
/* تکه‌ی ناقص؛ نادیده */
}
}
}
}
return {
baseUrl,
storage,
get,
post: <T>(path: string, body?: unknown, opts?: RequestOptions) => request<T>('POST', path, body, opts),
patch: <T>(path: string, body?: unknown, opts?: RequestOptions) => request<T>('PATCH', path, body, opts),
/** POSTی که چیزی ثبت می‌کند: با کلیدِ یکتا، پس تکرارش دوبار ثبت نمی‌کند. */
create: <T>(path: string, body?: unknown, opts: RequestOptions = {}) =>
request<T>('POST', path, body, {
...opts,
retry: true,
idempotencyKey: opts.idempotencyKey ?? newIdempotencyKey(),
}),
stream,
onSignedOut(listener: () => void) {
signedOutListeners.add(listener);
return () => {
signedOutListeners.delete(listener);
};
},
};
}
export type HttpClient = ReturnType<typeof createHttpClient>;