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

View File

@@ -1,87 +0,0 @@
/**
* بررسیِ اتصال به بک‌اند — ابزارِ توسعه، نه بخشی از محصول.
*
* برای امتحان کردن، یک خط در App.tsx (یا هر جای دیگر) کافی‌ست:
*
* import { ConnectionCheck } from '@/api/ConnectionCheck';
* <ConnectionCheck />
*
* هیچ فایلِ دیگری را عوض نمی‌کند و اگر بک‌اند بالا نباشد هم اپ را نمی‌خواباند.
*/
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { api, ApiError } from '@/api';
import type { Grade } from '@/api';
type State =
| { kind: 'loading' }
| { kind: 'ok'; grades: Grade[] }
| { kind: 'error'; message: string; hint: string };
function hintFor(error: unknown): string {
if (!(error instanceof ApiError)) return 'خطای پیش‌بینی‌نشده — کنسول را ببینید.';
if (error.isNetwork) {
return 'سرور جواب نداد. VITE_API_BASE_URL درست است؟ بک‌اند بالاست؟';
}
if (error.status === 502) {
return 'nginx بالاست ولی جنگو نه — سرویسِ بک‌اند روی سرور اجرا نشده.';
}
if (error.status === 0) return 'ارتباط برقرار نشد.';
if (error.isRateLimited) return 'سقفِ نرخ خورد؛ کلاینت خودش دوباره تلاش می‌کند.';
if (error.isUnauthorized) return 'نیاز به ورود دارد (این اندپوینت نباید داشته باشد).';
return `سرور ${error.status} داد.`;
}
export function ConnectionCheck() {
const [state, setState] = useState<State>({ kind: 'loading' });
useEffect(() => {
const controller = new AbortController();
api
.grades({ signal: controller.signal })
.then((grades) => setState({ kind: 'ok', grades }))
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setState({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
hint: hintFor(error),
});
});
return () => controller.abort();
}, []);
const box: CSSProperties = {
position: 'fixed', bottom: 12, right: 12, zIndex: 9999,
maxWidth: 320, padding: '10px 12px', borderRadius: 10,
font: '12px/1.6 system-ui, sans-serif', direction: 'rtl',
background: '#fff', border: '1px solid #e5e7eb',
boxShadow: '0 4px 16px rgba(0,0,0,.08)',
};
if (state.kind === 'loading') {
return <div style={box}>در حالِ بررسیِ اتصال به بک‌اند…</div>;
}
if (state.kind === 'error') {
return (
<div style={{ ...box, borderColor: '#fecaca' }}>
<b style={{ color: '#b91c1c' }}>اتصال برقرار نشد</b>
<div style={{ marginTop: 4 }}>{state.message}</div>
<div style={{ marginTop: 4, color: '#6b7280' }}>{state.hint}</div>
<div style={{ marginTop: 6, color: '#9ca3af' }}>{api.http.baseUrl}</div>
</div>
);
}
return (
<div style={{ ...box, borderColor: '#bbf7d0' }}>
<b style={{ color: '#15803d' }}>اتصال برقرار است</b>
<div style={{ marginTop: 4 }}>
{state.grades.length} پایه از سرور آمد
{state.grades.length > 0 && `: ${state.grades.map((g) => g.title).join('، ')}`}
</div>
<div style={{ marginTop: 6, color: '#9ca3af' }}>{api.http.baseUrl}</div>
</div>
);
}

View File

@@ -1,91 +0,0 @@
# اتصال به بک‌اند
کلِ ارتباط با سرور از همین پوشه رد می‌شود. **هیچ‌جای اپ مستقیم `fetch` نزنید** —
مدیریتِ توکن، تکرارِ درخواست و رفتارِ درستِ خطا اینجا یک‌جا حل شده‌اند.
## راه‌اندازی
**هیچ تنظیماتی لازم نیست.** به‌طورِ پیش‌فرض به بک‌اندِ مشترک
(`https://api.math.tadrisino2.ir/api`) وصل می‌شود:
```bash
npm install
npm run dev
```
فقط اگر بک‌اند را روی ماشینِ خودتان بالا آورده‌اید:
```bash
cp .env.example .env # و خطِ VITE_API_BASE_URL را باز کنید
```
برای اینکه ببینید اتصال برقرار است، یک خط کافی‌ست (بعداً پاکش کنید):
```tsx
import { ConnectionCheck } from '@/api/ConnectionCheck';
// داخلِ خروجیِ App:
<ConnectionCheck />
```
گوشه‌ی صفحه سبز می‌شود و فهرستِ پایه‌ها را از سرور نشان می‌دهد، یا قرمز
می‌شود و می‌گوید دقیقاً کجای کار می‌لنگد.
## استفاده
```tsx
import { api, ApiError } from '@/api';
const grades = await api.grades();
const home = await api.home(); // پایه‌ی خودِ کاربر
const intro = await api.lessons.intro(lessonId);
const answer = await api.ai.ask('این رو نفهمیدم', { lessonId });
```
### ورود
```tsx
await api.auth.guest(deviceId); // «شروع سریع»، بدونِ شماره
// یا
const { devOtp } = await api.auth.requestOtp('09120000000');
await api.auth.login('09120000000', otp); // نامِ فیلد در سرور otp است
```
توکن خودش در `localStorage` ذخیره می‌شود و به هر درخواست اضافه می‌گردد.
### خطاها
```tsx
try {
await api.ai.ask(text, { lessonId });
} catch (error) {
if (error instanceof ApiError) {
if (error.isRateLimited) { /* ۴۲۹ — خیلی سریع پرسیده */ }
if (error.isProviderDown) { /* ۵۰۳ — سرویسِ AI موقتاً قطع */ }
if (error.isNetwork) { /* اصلاً نرسید یا timeout */ }
}
}
```
## چرا این‌قدر ملاحظه در `client.ts`
هر تکه‌اش برای حالتی‌ست که فقط زیرِ فشار پیش می‌آید:
| مسئله | اگر نباشد چه می‌شود |
|---|---|
| `timeout` روی هر درخواست | یک بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد؛ مرورگر به سقفِ ۶ درخواستِ همزمان می‌خورد و کلِ اپ قفل می‌شود |
| backoff با **jitter** | هزار کلاینت بعد از یک خطا *همزمان* دوباره می‌زنند و موجِ دوم سرور را می‌خواباند |
| تکرارِ خودکار فقط روی `GET` | تکرارِ `POST` یعنی سفارش یا پاسخِ تکراری |
| `Idempotency-Key` روی ثبتِ پاسخ و سفارش | دابل‌کلیکِ کاربر دو بار XP یا دو سفارش می‌سازد |
| `refresh` تک‌نسخه‌ای | ۲۰ درخواستِ همزمانِ ۴۰۱ ⇒ ۲۰ بار refresh ⇒ سرور همه را throttle می‌کند |
| ادغامِ `GET`های یکسان | دو کامپوننت که با هم یک چیز می‌خواهند، دو برابر بار می‌سازند |
| احترام به هدرِ `Retry-After` | بک‌اند صریح می‌گوید چقدر صبر کن؛ نادیده گرفتنش throttle را طولانی‌تر می‌کند |
`credentials: 'omit'` هم عمدی‌ست: احرازِ هویت توکنِ Bearer در هدر است و سمتِ
سرور `CORS_ALLOW_CREDENTIALS=False` تنظیم شده. عوضش نکنید.
## چیزی که هنوز نیست
- صفحه‌ی ورود در اپ وجود ندارد؛ `api.auth` آماده است ولی UI ندارد.
- `api.home()` ساختارِ واقعیِ درس‌ها را می‌دهد، ولی وصل کردنش به صفحه‌ها
هنوز انجام نشده — این PR عمداً `App.tsx` را دست نزده تا با کارِ در جریان
تصادف نکند.

View File

@@ -1,317 +0,0 @@
/**
* هسته‌ی ارتباط با بک‌اندِ تدریسینو.
*
* این فایل عمداً هیچ پکیجی اضافه نمی‌کند — فقط fetch استاندارد. هر چیزی که
* اینجاست برای یک حالتِ خراب است که در فشار پیش می‌آید، نه برای حالتِ خوب:
*
* • timeout — بدونِ آن، یک بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد تا مرورگر
* به سقفِ ۶ درخواستِ همزمان بخورد و کلِ اپ قفل شود.
* • backoff با jitter — وقتی سرور ۴۲۹/۵۰۳ می‌دهد، تکرارِ فوری فقط بار را
* بیشتر می‌کند. jitter لازم است وگرنه هزار کلاینت *همزمان* دوباره می‌زنند
* و موجِ بعدی از موجِ اول بدتر است.
* • فقط GET خودکار تکرار می‌شود — تکرارِ POST یعنی سفارشِ تکراری.
* • refresh تک‌نسخه‌ای — اگر ۲۰ درخواست با هم ۴۰۱ بگیرند، بدونِ این ۲۰ بار
* refresh صدا زده می‌شود و سرور همه را throttle می‌کند.
* • حذفِ درخواستِ تکراری — دو کامپوننت که همزمان یک GET را می‌خواهند، یک
* درخواست می‌فرستند نه دو تا.
*/
export interface Tokens {
token: string;
refreshToken: string;
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly payload: Record<string, unknown> = {},
) {
super(message);
this.name = 'ApiError';
}
/** ۴۰۱ — توکن نامعتبر یا منقضی. کاربر باید دوباره وارد شود. */
get isUnauthorized() { return this.status === 401; }
/** ۴۲۹ — سقفِ نرخ. `retryAfterMs` می‌گوید چقدر صبر کنید. */
get isRateLimited() { return this.status === 429; }
/** ۵۰۳ — سرویسِ بیرونی (هوش مصنوعی/پیامک) موقتاً در دسترس نیست. */
get isProviderDown() { return this.status === 503; }
/** درخواست اصلاً به سرور نرسید یا timeout شد. */
get isNetwork() { return this.status === 0; }
retryAfterMs?: number;
}
/* ─── تنظیمات ───────────────────────────────────────────────────────────── */
export interface ClientOptions {
/** پیش‌فرض از VITE_API_BASE_URL خوانده می‌شود. */
baseUrl?: string;
/** سقفِ زمانِ هر درخواست. پیش‌فرض ۱۵ ثانیه. */
timeoutMs?: number;
/** بیشترین تعدادِ تلاشِ دوباره برای GET. پیش‌فرض ۳. */
maxRetries?: number;
storage?: TokenStorage;
/** وقتی نشست به‌کل باطل شد (refresh هم شکست خورد) صدا زده می‌شود. */
onSignedOut?: () => void;
}
export interface TokenStorage {
get(): Tokens | null;
set(t: Tokens | null): void;
}
const STORAGE_KEY = 'tadrisino.tokens';
/** localStorage در حالتِ خصوصیِ مرورگر throw می‌کند؛ هیچ‌کدام نباید اپ را بخواباند. */
export function localTokenStorage(): TokenStorage {
return {
get() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
}
},
set(t) {
try {
if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
else localStorage.removeItem(STORAGE_KEY);
} catch {
/* نادیده */
}
},
};
}
/**
* بک‌اندِ مشترک. پیش‌فرض است تا کسی که تازه ریپو را clone می‌کند بدونِ هیچ
* تنظیماتی بتواند اپ را اجرا کند و داده‌ی واقعی ببیند.
* برای بک‌اندِ محلی، در فایلِ .env مقدارِ VITE_API_BASE_URL را عوض کنید.
*/
const DEFAULT_API_BASE_URL = 'https://api.math.tadrisino2.ir/api';
function resolveBaseUrl(explicit?: string): string {
if (explicit) return explicit.replace(/\/+$/, '');
const fromEnv = (import.meta as { env?: Record<string, string> }).env?.VITE_API_BASE_URL;
if (fromEnv) return fromEnv.replace(/\/+$/, '');
return DEFAULT_API_BASE_URL;
}
/* ─── کمکی‌ها ───────────────────────────────────────────────────────────── */
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
/** full jitter — هزار کلاینت نباید همزمان بیدار شوند. */
function backoffMs(attempt: number, retryAfterMs?: number): number {
if (retryAfterMs != null) return Math.min(retryAfterMs, 30_000);
const ceiling = Math.min(1000 * 2 ** attempt, 8000);
return Math.random() * ceiling;
}
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>((r) => setTimeout(r, ms));
/** برای POSTهایی که چیزی می‌سازند؛ سرور هدرِ Idempotency-Key را می‌پذیرد. */
function newIdempotencyKey(): string {
const c = globalThis.crypto;
if (c && 'randomUUID' in c) return c.randomUUID();
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export interface RequestOptions {
/** هدرهای اضافه. */
headers?: Record<string, string>;
/** برای درخواست‌های کند مثل پرسش از دستیار. */
timeoutMs?: number;
/** لغو از بیرون (مثلاً کاربر از صفحه رفت). */
signal?: AbortSignal;
/** POST را هم قابلِ تکرار می‌کند — فقط وقتی Idempotency-Key می‌فرستید. */
retry?: boolean;
/** کلیدِ یکتا تا تکرارِ درخواست سفارشِ تکراری نسازد. */
idempotencyKey?: string;
}
/* ─── کلاینت ────────────────────────────────────────────────────────────── */
export function createHttpClient(options: ClientOptions = {}) {
const baseUrl = resolveBaseUrl(options.baseUrl);
const defaultTimeout = options.timeoutMs ?? 15_000;
const maxRetries = options.maxRetries ?? 3;
const storage = options.storage ?? localTokenStorage();
/** درخواست‌های GETِ در جریان، تا دو کامپوننت یک چیز را دوبار نگیرند. */
const inFlight = new Map<string, Promise<unknown>>();
/** فقط یک refresh در هر لحظه. */
let refreshing: Promise<Tokens | null> | null = null;
async function doRefresh(): 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: AbortSignal.timeout(defaultTimeout),
});
if (!response.ok) return null;
const data = (await response.json()) as Tokens;
storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
} catch {
return null;
} finally {
// در میکروتسکِ بعدی آزاد می‌شود تا هم‌زمان‌ها همین نتیجه را ببینند.
queueMicrotask(() => { refreshing = null; });
}
})();
return refreshing;
}
async function once<T>(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
allowRefresh: boolean,
): Promise<T> {
const tokens = storage.get();
const headers: Record<string, string> = { Accept: 'application/json', ...opts.headers };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (tokens?.token) headers.Authorization = `Bearer ${tokens.token}`;
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
// timeoutِ خودمان + لغوِ بیرونی، هر کدام زودتر رسید.
const timer = AbortSignal.timeout(opts.timeoutMs ?? defaultTimeout);
const signal = opts.signal ? anySignal([timer, opts.signal]) : timer;
let response: Response;
try {
response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
// احرازِ هویت هدرِ Bearer است، نه کوکی — سمتِ سرور هم عمداً
// CORS_ALLOW_CREDENTIALS=False است. اینجا هم نباید عوض شود.
credentials: 'omit',
});
} catch (err) {
const aborted = err instanceof DOMException && err.name === 'AbortError';
throw new ApiError(0, aborted ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
}
if (response.status === 401 && allowRefresh) {
const refreshed = await doRefresh();
if (refreshed) return once<T>(method, path, body, opts, false);
storage.set(null);
options.onSignedOut?.();
}
const text = await response.text();
let parsed: Record<string, unknown> = {};
if (text) {
try {
parsed = JSON.parse(text) as Record<string, unknown>;
} catch {
// سرور HTML داد (۵۰۲ از nginx مثلاً) — پیامِ خام گمراه‌کننده است.
parsed = {};
}
}
if (!response.ok) {
const detail =
(parsed.detail as string) ??
(parsed.error as string) ??
(response.status === 502 ? 'سرور در دسترس نیست' : `خطای ${response.status}`);
const error = new ApiError(response.status, detail, parsed);
error.retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
throw error;
}
return parsed as T;
}
async function request<T>(
method: string,
path: string,
body?: unknown,
opts: RequestOptions = {},
): Promise<T> {
const retriable = opts.retry ?? method === 'GET';
let lastError: ApiError | undefined;
for (let attempt = 0; attempt <= (retriable ? maxRetries : 0); attempt++) {
try {
return await once<T>(method, path, body, opts, true);
} catch (err) {
if (!(err instanceof ApiError)) throw err;
lastError = err;
const worthRetrying = err.isNetwork || RETRIABLE_STATUS.has(err.status);
if (!retriable || !worthRetrying || attempt === maxRetries) throw err;
if (opts.signal?.aborted) throw err;
await sleep(backoffMs(attempt, err.retryAfterMs));
}
}
throw lastError ?? new ApiError(0, 'خطای نامشخص');
}
/** GETها با هم ادغام می‌شوند تا رندرِ همزمانِ چند کامپوننت یک درخواست بدهد. */
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;
}
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(),
}),
};
}
export type HttpClient = ReturnType<typeof createHttpClient>;
/** AbortSignal.any هنوز همه‌جا نیست؛ این جایگزینِ کوچکش است. */
function anySignal(signals: AbortSignal[]): AbortSignal {
const anyFn = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any;
if (anyFn) return anyFn(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;
}

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>;

View File

@@ -1,459 +1,184 @@
/**
* کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend).
* تنها درِ ارتباط با بک‌اند. هیچ جای دیگرِ اپ fetch نمی‌زند.
*
* از روی OpenAPI واقعیِ همین بک‌اند نوشته شده، نه از روی حدس. نامِ فیلدها
* دقیقاً همان چیزی‌ست که سرور برمی‌گرداند (camelCase).
*
* نکته‌های مهمِ اتصال:
* - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم
* نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده.
* - توکنِ دسترسی امضاشده و کوتاه‌عمر است؛ با ۴۰۱ باید `refresh` صدا زد.
* این کلاینت خودش یک‌بار refresh می‌کند و درخواست را تکرار.
* - برای پرسش از دستیار کافی‌ست `lessonId` بدهید؛ سرور خودش می‌فهمد آن درس
* مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند می‌کند.
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
*/
import { resolveApiBaseUrl } from '@/config';
import { ApiError, createHttpClient, newIdempotencyKey } from './http';
import type * as T from './types';
export type Json = Record<string, unknown>;
export { ApiError };
export type * from './types';
export interface Tokens {
token: string;
refreshToken: string;
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() });
const q = (params: Record<string, string | number | undefined>) => {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
return entries.length ? `?${new URLSearchParams(entries.map(([k, v]) => [k, String(v)]))}` : '';
};
function saveTokens(data: T.AuthResponse) {
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
return data;
}
/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */
export const api = {
auth: {
isSignedIn: () => Boolean(http.storage.get()?.token),
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
/** مهمان: فقط با شناسه‌ی دستگاه. refreshToken هم می‌گیرد، پس بعد از یک ساعت نمی‌افتد. */
guest: (deviceId: string) =>
http.post<T.AuthResponse>('/auth/session', { type: 'guest', deviceId }).then(saveTokens),
export interface HomeNode {
id: string;
title: string;
status: NodeStatus;
lessonIds: number[];
}
export interface HomeUnit {
id: string;
title: string;
nodes: HomeNode[];
}
export interface HomeResponse {
units: HomeUnit[];
stats: { progressPercent: number; xp: number; coins: number };
}
/** گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمی‌گردد. */
requestOtp: (phone: string) =>
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }),
export interface Grade {
id: number;
title: string;
icon: string;
color: string;
}
/**
* گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه می‌دارد و
* پیشرفتش از دست نمی‌رود (توکنِ مهمان همراهِ همین درخواست می‌رود).
*/
login: (phone: string, otp: string) =>
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
export interface UserSummary {
userId: string;
name: string;
avatar: string;
gradeId: number | null;
xp: number;
coins: number;
streak: { current: number; longest: number };
subscription: string;
}
export interface UserProfile {
userId: string;
phone: string | null;
name: string;
avatar: string;
isGuest: boolean;
gradeId: number | null;
birthYear: number | null;
parentPhone: string;
bio: string;
}
export interface LessonIntro {
lessonId: string;
title: string;
introText: string;
/** برای انتخابِ حالتِ ماسکوت در صفحه‌ی درس */
mascotAnimation: string;
/**
* صفر یعنی متنِ درس هست ولی آزمونش هنوز ساخته نشده — «به‌زودی» نشان دهید،
* نه کویزِ خالی. `complete` هم `totalQuestions: 0` را نمی‌پذیرد.
*/
questionCount: number;
}
export interface QuestionOption {
id: string;
value: string;
}
export interface Question {
id: string;
type: 'multiple_choice' | 'count_objects';
prompt: string;
options: QuestionOption[];
}
export interface AnswerResult {
correct: boolean;
correctOptionId: string;
xpEarned: number;
heartsRemaining: number;
/** حالتِ ماسکوت: درست → happy، غلط → encourage */
mascotMood: string;
}
export interface LessonComplete {
stars: number;
xpEarned: number;
coinsEarned: number;
stats: { progressPercent: number; xp: number; coins: number };
}
export interface Hearts {
currentHearts: number;
maxHearts: number;
nextRefillAt: string | null;
isUnlimited: boolean;
}
export interface Character {
characterId: string;
slug: string;
name: string;
icon: string;
unlockXp: number;
unlocked: boolean;
selected: boolean;
}
export interface AskResponse {
conversationId: string;
messageId: string;
answer: string;
suggestedFollowUps: string[];
remainingQuota: number;
/** تکه‌های کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */
usedChunks: { title: string; summary: string }[];
}
export interface Plan {
id: string;
title: string;
price: number;
durationDays: number;
features: string[];
}
/* ─── خطا ───────────────────────────────────────────────────────────────── */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly detail: string,
readonly body: Json,
) {
super(detail);
this.name = 'ApiError';
}
/** ۴۲۹: throttle یا اتمامِ سهمیه‌ی روزانه‌ی دستیار */
get isRateLimited() {
return this.status === 429;
}
/** ۵۰۳: سرویسِ هوش مصنوعی/پیامک موقتاً در دسترس نیست */
get isProviderDown() {
return this.status === 503;
}
}
/* ─── کلاینت ────────────────────────────────────────────────────────────── */
export interface ClientOptions {
baseUrl?: string;
/** خواندن/نوشتنِ توکن؛ پیش‌فرض localStorage */
storage?: {
get(): Tokens | null;
set(t: Tokens | null): void;
};
}
const STORAGE_KEY = 'tadrisino.tokens';
function defaultStorage() {
return {
get(): Tokens | null {
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
async logout() {
const refreshToken = http.storage.get()?.refreshToken;
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
await http.post('/auth/logout', refreshToken ? { refreshToken } : {});
} finally {
http.storage.set(null);
}
},
set(t: Tokens | null) {
try {
if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
else localStorage.removeItem(STORAGE_KEY);
} catch {
/* حالتِ خصوصیِ مرورگر — نادیده */
}
},
};
}
},
export function createClient(options: ClientOptions = {}) {
const baseUrl = (options.baseUrl ?? '/api').replace(/\/$/, '');
const storage = options.storage ?? defaultStorage();
grades: () => http.get<{ grades: T.Grade[] }>('/grades').then((r) => r.grades),
async function raw<T>(
method: string,
path: string,
body?: unknown,
extraHeaders: Record<string, string> = {},
retryOn401 = true,
): Promise<T> {
const tokens = storage.get();
const headers: Record<string, string> = { ...extraHeaders };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (tokens?.token) headers['Authorization'] = `Bearer ${tokens.token}`;
profile: {
get: () => http.get<T.UserProfile>('/user/profile'),
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
http.patch<T.UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
},
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
summary: () => http.get<T.UserSummary>('/user/summary'),
hearts: () => http.get<T.Hearts>('/user/hearts'),
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
if (response.status === 401 && retryOn401 && tokens?.refreshToken) {
const refreshed = await refresh(tokens.refreshToken).catch(() => null);
if (refreshed) return raw<T>(method, path, body, extraHeaders, false);
storage.set(null);
}
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: () => http.get<T.HomeResponse>('/home'),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
const text = await response.text();
const parsed: Json = text ? (JSON.parse(text) as Json) : {};
if (!response.ok) {
const detail =
(parsed.detail as string) ??
(parsed.error as string) ??
`خطای ${response.status}`;
throw new ApiError(response.status, detail, parsed);
}
return parsed as T;
}
async function refresh(refreshToken: string) {
const response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) return null;
const data = (await response.json()) as Tokens;
storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
}
return {
/* ── ورود (صفحه‌ای که فعلاً در فرانت نیست و باید ساخته شود) ────────── */
auth: {
/** مهمان: بدونِ شماره، فقط با شناسه‌ی دستگاه. برای «شروع سریع». */
async guest(deviceId: string) {
const data = await raw<{ userId: string; token: string; isNewUser: boolean; isGuest: boolean }>(
'POST', '/auth/session', { type: 'guest', deviceId },
);
storage.set({ token: data.token, refreshToken: '' });
return data;
},
/** گامِ ۱: ارسالِ پیامکِ کد. در حالتِ توسعه `devOtp` هم برمی‌گردد. */
requestOtp(phone: string) {
return raw<{ success: boolean; devOtp?: string; note?: string }>(
'POST', '/auth/otp/request', { phone },
);
},
/** گامِ ۲: تأییدِ کد و گرفتنِ توکن. نامِ فیلد در سرور `otp` است. */
async login(phone: string, otp: string) {
const data = await raw<{ userId: string } & Tokens>(
'POST', '/auth/login', { phone, otp },
);
storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
},
logout() {
storage.set(null);
},
isSignedIn() {
return Boolean(storage.get()?.token);
},
},
/* ── صفحه‌ی «بیا باهم آشنا بشیم» ──────────────────────────────────── */
profile: {
get: () => raw<UserProfile>('GET', '/user/profile'),
/** اسم/آواتار/… . فقط فیلدهایی که می‌فرستید عوض می‌شوند. */
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
raw<UserProfile>('PATCH', '/user/profile', patch),
setGrade: (gradeId: number) =>
raw<{ success: boolean; gradeId: number }>('POST', '/user/grade', { gradeId }),
},
/** فهرستِ پایه‌ها را از سرور بگیرید، هاردکد نکنید — پایه‌ها اضافه می‌شوند. */
grades: () => raw<{ grades: Grade[] }>('GET', '/grades').then((r) => r.grades),
/* ── صفحه‌ی خانه و نقشه‌ی مهارت ────────────────────────────────────── */
/** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: (gradeId?: number) =>
raw<HomeResponse>('GET', gradeId ? `/home?grade=${gradeId}` : '/home'),
summary: () => raw<UserSummary>('GET', '/user/summary'),
hearts: () => raw<Hearts>('GET', '/user/hearts'),
streakCalendar: () =>
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
'GET', '/user/streak/calendar',
lesson: {
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`),
questions: (lessonId: number) =>
http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions),
/**
* کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا
* امتیاز و قلب دوبار حساب نشود.
*/
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) =>
http.post<T.AnswerResult>(
`/questions/${questionId}/answer`,
{ selectedOptionId: Number(selectedOptionId) },
{ idempotencyKey, retry: true },
),
leaderboard: (period: 'weekly' | 'all_time' = 'weekly') =>
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
/** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.LessonComplete>(`/lessons/${lessonId}/complete`, result, { idempotencyKey }),
report: (questionId: string, reason: T.ReportReason, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/questions/${questionId}/report`,
{ reason, ...(note ? { note } : {}) },
),
},
/** کاتالوگِ کاراکترهای گیمیفیکیشن — مثلِ ماسکات‌های Duolingo، بازشدن بر اساسِ XP. */
characters: {
list: () => raw<{ characters: Character[] }>('GET', '/characters').then((r) => r.characters),
select: (characterId: string) =>
raw<{ characterId: string; selected: boolean }>('POST', '/characters/select', { characterId }),
leaderboard: (period: T.LeaderboardPeriod) =>
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries),
characters: {
list: () => http.get<{ characters: T.Character[] }>('/characters').then((r) => r.characters),
select: (characterId: string) =>
http.post<{ characterId: string; selected: boolean }>('/characters/select', { characterId }),
},
ai: {
/**
* پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛
* `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده.
*/
async ask(
input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string },
onDelta: (text: string) => void,
signal?: AbortSignal,
): Promise<T.AskDone> {
let done: T.AskDone | null = null;
await http.stream(
'/ai/ask',
{ ...input, lessonId: input.lessonId?.toString(), stream: true },
(event, data) => {
if (event === 'chunk') onDelta(String(data.delta ?? ''));
else if (event === 'done') done = data as unknown as T.AskDone;
else if (event === 'error') throw new ApiError(503, String(data.detail ?? 'دستیار در دسترس نیست'), data);
},
{ signal },
);
if (!done) throw new ApiError(0, 'پاسخ کامل نرسید');
return done;
},
feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, {
isHelpful,
...(reason ? { reason } : {}),
}),
history: (conversationId: string) =>
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
`/ai/conversations/${conversationId}/messages`,
),
},
/* ── صفحه‌ی درس و سوال ─────────────────────────────────────────────── */
lesson: {
intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`),
questions: (lessonId: number) =>
raw<{ questions: Question[] }>('GET', `/lessons/${lessonId}/questions`).then((r) => r.questions),
/**
* `idempotencyKey` را بدهید تا اگر شبکه قطع شد و کاربر دوباره زد،
* امتیاز/قلب دوبار حساب نشود.
*/
answer: (questionId: number, selectedOptionId: number, idempotencyKey?: string) =>
raw<AnswerResult>(
'POST', `/questions/${questionId}/answer`, { selectedOptionId },
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
),
/**
* پایانِ درس. `correctCount` را سرور با تعدادِ واقعیِ سوال‌ها سقف می‌زند.
* مجموعِ XPِ کاربر **اینجا** به‌روز می‌شود، نه سرِ هر پاسخ.
*/
complete: (
lessonId: number,
result: { correctCount: number; totalQuestions: number },
idempotencyKey?: string,
) =>
raw<LessonComplete>(
'POST', `/lessons/${lessonId}/complete`, result,
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
),
},
notifications: {
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
markRead: (id: number) => http.patch(`/notifications/${id}/read`),
markAllRead: () => http.patch('/notifications/read-all'),
},
/* ── صفحه‌ی «از رُبو بپرس» ─────────────────────────────────────────── */
ai: {
/**
* `lessonId` را بدهید تا پاسخ به کتابِ همان درس مستند شود؛ لازم نیست
* subject/grade/chapter را خودتان حساب کنید.
* `conversationId` را از پاسخِ قبلی پاس بدهید تا گفتگو ادامه پیدا کند.
*/
ask: (input: {
userQuestion: string;
lessonId?: number | string;
conversationId?: string;
questionId?: number | string;
idempotencyKey?: string;
}) => {
const { idempotencyKey, ...body } = input;
return raw<AskResponse>(
'POST', '/ai/ask', body,
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
);
},
subscription: {
plans: () => http.get<{ plans: T.Plan[] }>('/subscription/plans').then((r) => r.plans),
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
/** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
redeem: (code: string, orderId: string) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`),
},
/**
* همان ask ولی توکن‌به‌توکن (SSE). اولین کلمه ~۳.۵ ثانیه به‌جای ~۱۰.
* برای حبابِ چتِ «در حالِ تایپ» همین را استفاده کنید.
*/
async askStream(
input: { userQuestion: string; lessonId?: number | string; conversationId?: string },
onDelta: (text: string) => void,
): Promise<{ conversationId?: string; messageId?: string }> {
const tokens = storage.get();
const response = await fetch(`${baseUrl}/ai/ask`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(tokens?.token ? { Authorization: `Bearer ${tokens.token}` } : {}),
},
body: JSON.stringify({ ...input, stream: true }),
});
if (!response.ok || !response.body) {
throw new ApiError(response.status, 'استریم شروع نشد', {});
}
support: {
createTicket: (subject: string, category: string) =>
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${ticketId}`),
reply: (ticketId: string, text: string) =>
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }),
bugReport: (title: string, description: string) =>
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
},
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let done: { conversationId?: string; messageId?: string } = {};
family: {
link: () => http.get<T.FamilyLink>('/family/link'),
invite: (childPhone: string) =>
http.post<{ familyLinkId: string; inviteCode: string; status: string }>('/family/link/invite', { childPhone }),
accept: (inviteCode: string) =>
http.post<{ familyLinkId: string; status: string }>('/family/link/accept', { inviteCode }),
weeklyReport: () => http.get<T.FamilyWeeklyReport>('/family/weekly-report'),
settings: () => http.get<T.FamilySettings>('/family/settings'),
updateSettings: (patch: Partial<T.FamilySettings>) => http.patch('/family/settings', patch),
},
for (;;) {
const { value, done: finished } = await reader.read();
if (finished) 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 dataLine = /^data: (.+)$/m.exec(part)?.[1];
if (!event || !dataLine) continue;
const payload = JSON.parse(dataLine) as Json;
if (event === 'chunk') onDelta(String(payload.delta ?? ''));
else if (event === 'done') done = payload as typeof done;
else if (event === 'error') {
throw new ApiError(503, String(payload.detail ?? 'خطای دستیار'), payload);
}
}
}
return done;
},
/** 👍/👎 روی یک پاسخ. `reason` اختیاری ولی خیلی مفید است. */
feedback: (messageId: string | number, isHelpful: boolean, reason?: string) =>
raw<{ success: boolean }>('POST', `/ai/messages/${messageId}/feedback`, {
isHelpful,
...(reason ? { reason } : {}),
}),
history: (conversationId: string | number) =>
raw<{ conversationId: string; lessonId: string | null; messages: unknown[] }>(
'GET', `/ai/conversations/${conversationId}/messages`,
),
},
/* ── صفحه‌ی اشتراک ─────────────────────────────────────────────────── */
subscription: {
plans: () => raw<{ plans: Plan[] }>('GET', '/subscription/plans').then((r) => r.plans),
status: () =>
raw<{ active: boolean; planId: string | null; expiresAt: string | null }>(
'GET', '/subscription/status',
),
/**
* `paymentUrl` را در مرورگر باز کنید. آن آدرس خودش سراغِ زرین‌پال
* می‌رود؛ اپ نباید مستقیم با درگاه حرف بزند.
*/
purchase: (planId: string, idempotencyKey?: string) =>
raw<{ orderId: string; paymentUrl: string }>(
'POST', '/subscription/purchase', { planId },
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
),
},
notifications: {
list: () => raw<{ items: unknown[]; unreadCount: number }>('GET', '/notifications'),
markRead: (id: number) => raw<Json>('POST', `/notifications/${id}/read`),
},
};
}
export type TadrisinoClient = ReturnType<typeof createClient>;
affiliate: {
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
},
};

View File

@@ -1,24 +1,72 @@
/**
* تایپ‌های پاسخِ بک‌اند — مستقیم از OpenAPI واقعیِ سرویس، نه از حدس.
* شکلِ پاسخ‌های بک‌اند.
*
* دو نکته که موقعِ نوشتن غافلگیرکننده بودند و اگر رعایت نشوند درخواست‌ها
* بی‌سروصدا شکست می‌خورند:
* • شناسه‌ها در پاسخ‌ها اغلب رشته‌اند، نه عدد.
* • فیلدِ پرسش در /ai/ask اسمش userQuestion است، نه question.
* از روی پاسخِ واقعیِ سرور ثبت شده (۲۰۲۶-۰۹-۲۶)، نه از حدس. قراردادِ کامل در
* /api/docs/ همان بک‌اند است؛ اگر فیلدی آنجا عوض شد، اینجا هم عوض کنید.
*
* دو نکته که غافلگیر می‌کنند:
* • شناسه‌ها در پاسخ‌ها رشته‌اند، ولی در بدنه‌ی درخواست گاهی عدد خواسته می‌شوند.
* • birthYear سالِ شمسی است (سرور بیشتر از ۱۵۰۰ را رد می‌کند).
*/
export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface AuthResponse {
userId: string;
token: string;
refreshToken: string;
isNewUser?: boolean;
isGuest?: boolean;
}
export interface Grade {
id: number;
number: number | null;
title: string;
icon: string;
color: string;
lessonCount: number;
}
export interface UserProfile {
userId: string;
phone: string | null;
name: string;
avatar: string;
isGuest: boolean;
gradeId: number | null;
birthYear: number | null;
parentPhone: string;
bio: string;
}
export interface UserSummary {
userId: string;
name: string;
avatar: string;
gradeId: number | null;
xp: number;
coins: number;
streak: { current: number; longest: number };
subscription: string | null;
}
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode {
id: string;
title: string;
status: NodeStatusValue;
/** نامِ آیکنِ lucide (مثلاً "Grid3x3")؛ ممکن است خالی باشد. */
icon: string;
color: string;
status: NodeStatus;
/** درس‌های سوال‌دارِ این جزیره؛ اولی همیشه درسِ بعدی است. */
lessonIds: number[];
nextLessonId: number | null;
}
export interface HomeUnit {
id: string;
title: string;
blurb: string;
nodes: HomeNode[];
}
@@ -33,24 +81,18 @@ export interface HomeResponse {
stats: HomeStats;
}
export interface Grade {
id: number;
export interface NodeLesson {
id: string;
kind: 'quiz' | 'video';
title: string;
icon: string;
color: string;
meta: string;
done: boolean;
}
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */
export interface UserProfile {
userId: string;
phone: string;
name: string;
avatar: string;
isGuest: boolean;
gradeId: number | null;
birthYear: number | null;
parentPhone: string;
bio: string;
export interface NodeLessons {
nodeId: string;
title: string;
lessons: NodeLesson[];
}
export interface LessonIntro {
@@ -58,10 +100,16 @@ export interface LessonIntro {
title: string;
introText: string;
mascotAnimation: string;
/** صفر یعنی متنِ درس هست ولی سوالش هنوز ساخته نشده. */
questionCount: number;
kind: 'quiz' | 'video';
example: string;
steps: string[];
videoUrl: string;
durationMinutes: number | null;
}
export interface ChoiceOption {
export interface QuestionOption {
id: string;
value: string;
}
@@ -70,11 +118,10 @@ export interface Question {
id: string;
type: string;
prompt: string;
options: ChoiceOption[];
options: QuestionOption[];
}
/** `mascotMood`: پاسخِ درست → happy، غلط → encourage. */
export interface AnswerResponse {
export interface AnswerResult {
correct: boolean;
correctOptionId: string;
xpEarned: number;
@@ -82,41 +129,147 @@ export interface AnswerResponse {
mascotMood: string;
}
export interface LessonComplete {
stars: number;
xpEarned: number;
coinsEarned: number;
stats: HomeStats;
}
export type ReportReason = 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
export interface Hearts {
currentHearts: number;
maxHearts: number;
nextRefillAt: string | null;
fullRefillAt: string | null;
isUnlimited: boolean;
}
export interface StreakCalendar {
currentStreak: number;
longestStreak: number;
/** تاریخ‌های میلادی به شکلِ YYYY-MM-DD */
activeDays: string[];
}
export type LeaderboardPeriod = 'weekly' | 'all_time';
export interface LeaderboardEntry {
rank: number;
userId: string;
name: string;
xpInPeriod: number;
}
export interface Character {
characterId: string;
slug: string;
name: string;
icon: string;
unlockXp: number;
unlocked: boolean;
selected: boolean;
}
export interface UsedChunk {
title: string;
summary: string;
}
/**
* `usedChunks` خالی یعنی پاسخ از کتاب پیدا نشده و عمومی است — برای نشان دادنِ
* برچسبِ «بر اساسِ کتاب» در برابرِ «پاسخِ عمومی» همین کافی‌ست.
*/
export interface AskResponse {
/** رویدادِ پایانیِ استریمِ دستیار. */
export interface AskDone {
conversationId: string;
messageId: string;
answer: string;
suggestedFollowUps: string[];
remainingQuota: number;
usedChunks: UsedChunk[];
retrievalDebug?: Record<string, unknown>;
}
export interface ConversationMessage {
messageId: string;
role: 'user' | 'assistant' | string;
text: string;
}
export interface NotificationItem {
id: number;
type: string;
title: string;
body: string;
isRead: boolean;
createdAt: string;
}
export interface Plan {
id: string;
title: string;
/** تومان (currency = "IRT") */
price: number;
currency: string;
popular: boolean;
discountPercent: number;
durationDays: number;
features: string[];
badge: string;
}
export type ReportReason =
| 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
export interface SubscriptionStatus {
active: boolean;
planId: string | null;
expiresAt: string | null;
}
export interface ReportResponse {
export interface PurchaseResponse {
orderId: string;
paymentUrl: string;
}
export interface RedeemResponse {
success: boolean;
reportId: number;
alreadyReported: boolean;
discountAmount: number;
/** "paid" یعنی کد کلِ مبلغ را پوشاند و نباید به درگاه رفت. */
orderStatus: string;
amount: number;
}
export interface OrderStatus {
orderId: string;
status: string;
amount: number;
}
export interface Ticket {
ticketId: string;
subject: string;
status: string;
messages: { id: string; sender: string; text: string }[];
}
export interface FamilyLink {
familyLinkId: string;
status: string;
childName: string;
}
export interface FamilyWeeklyReport {
weekStart: string;
xpEarned: number;
lessonsCompleted: number;
islandsCompleted: number;
currentStreak: number;
}
export interface FamilySettings {
dailyLimitMinutes: number;
quietHoursStart: string;
quietHoursEnd: string;
}
export interface AffiliateMe {
referralCode: string;
commissionRate: number;
referredCount: number;
paidCount: number;
totalCommission: number;
}
*/