Files
MathApp_frontend/src/api/http.ts
AliReza 348bfecea6 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>
2026-09-26 23:35:48 +03:30

340 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* هسته‌ی ارتباط با بک‌اند. فقط 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>;