forked from Tadrisino/MathApp_frontend
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:
339
src/api/http.ts
Normal file
339
src/api/http.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user