Compare commits

..

3 Commits

Author SHA1 Message Date
82c9e870b3 Merge pull request 'افزودنِ لایه‌ی اتصال به API بک‌اند (بدونِ تغییر در App.tsx)' (#1) from AliReza_Zareian/MathApp_frontend:zareian/api-client into main
Reviewed-on: http://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend/pulls/1
2026-09-13 22:07:55 +03:30
f61c58eb76 match the real API contract, verified against a running backend
اولین نسخه از روی حافظه و حدس نوشته شده بود و سه صدای مهم اصلاً کار نمی‌کردند.
این بار هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شد و بعد کلِ
کلاینت در برابرِ یک بک‌اندِ در حالِ اجرا تست شد.

چیزهایی که غلط بودند:
  • ثبتِ پاسخ به /lessons/{id}/answer می‌رفت — چنین مسیری وجود ندارد.
    مسیرِ درست /questions/{id}/answer است و فیلدش selectedOptionId، نه
    questionId/choiceId.
  • /ai/ask فیلدِ userQuestion می‌خواهد، نه question. با نامِ غلط، پرسش
    اصلاً به مدل نمی‌رسید.
  • فیدبک isHelpful بولین می‌خواهد، نه رشته‌ی up/down.
  • UserProfile فیلدهای xp/coins/streak را نداشت (آن‌ها در /user/summary
    هستند) و isGuest/phone را نداشت.
  • شناسه‌ها در پاسخ‌ها رشته‌اند، نه عدد.

پاسخِ دستیار هم usedChunks دارد نه sources؛ خالی بودنش یعنی از کتاب چیزی
پیدا نشده، که همان چیزی‌ست که برچسبِ «بر اساسِ کتاب» به آن نیاز دارد.

questions.report تازه اضافه شد: بک‌اند حالا POST /questions/{id}/report
دارد تا کاربر بتواند ایرادِ خودِ سوال را گزارش کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:41:24 +03:30
be9f99d52a add a typed API layer for the Tadrisino backend
اپ تا الان هیچ درخواستی به سرور نمی‌زد و همه‌ی داده‌ها داخلِ خودِ کامپوننت‌ها
هاردکد بود. این کامیت لایه‌ی ارتباط را اضافه می‌کند، بدونِ اینکه هیچ صفحه‌ای
را تغییر دهد.

عمداً فقط فایلِ جدید اضافه شده و App.tsx دست نخورده است، چون کارِ در جریانِ
برنچِ updateApp روی همان فایل است و نباید با هم تصادف کنند. وصل کردنِ
صفحه‌ها به این لایه کارِ بعدی‌ست.

هیچ پکیجِ جدیدی اضافه نشد — فقط fetch استاندارد.

چیزهایی که در client.ts هست برای حالتِ خراب‌اند، نه حالتِ خوب:
  • timeout روی هر درخواست — وگرنه بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد تا
    مرورگر به سقفِ ۶ درخواستِ همزمان بخورد و اپ قفل شود.
  • backoff با jitter — بدونِ jitter همه‌ی کلاینت‌ها بعد از یک خطا همزمان
    دوباره می‌زنند و موجِ دوم از موجِ اول بدتر است.
  • تکرارِ خودکار فقط روی GET؛ POST فقط وقتی Idempotency-Key دارد (ثبتِ پاسخ،
    سفارش) تا دابل‌کلیکِ کاربر دو بار XP یا دو سفارش نسازد.
  • refresh تک‌نسخه‌ای — ۲۰ درخواستِ همزمانِ ۴۰۱ نباید ۲۰ بار refresh بزنند.
  • ادغامِ GETهای یکسانِ در جریان.
  • احترام به هدرِ Retry-After که بک‌اند موقعِ throttle می‌فرستد.

credentials: 'omit' عمدی‌ست: احرازِ هویت توکنِ Bearer در هدر است و سمتِ سرور
CORS_ALLOW_CREDENTIALS=False تنظیم شده.

.env هم به .gitignore اضافه شد؛ قبلاً فقط node_modules داخلش بود و آدرس/
تنظیماتِ محلی می‌توانست ناخواسته کامیت شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:06:28 +03:30
7 changed files with 779 additions and 0 deletions

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# این فایل فقط نمونه است. اگر بک‌اندِ محلی دارید، آن را به .env کپی کنید:
# cp .env.example .env
#
# اگر هیچ کاری نکنید هم اپ کار می‌کند و به بک‌اندِ مشترک وصل می‌شود:
# https://api.math.tadrisino2.ir/api
#
# فقط وقتی بک‌اند را روی ماشینِ خودتان بالا آورده‌اید این را باز کنید:
# VITE_API_BASE_URL=http://127.0.0.1:8000/api
# هیچ کلید یا رمزی اینجا نگذارید: هر چیزی که با VITE_ شروع شود داخلِ باندلِ
# نهایی می‌رود و کاربر می‌تواند ببیندش.

5
.gitignore vendored
View File

@@ -1 +1,6 @@
node_modules
# متغیرهای محلی — هیچ‌وقت کامیت نشوند
.env
.env.local
dist

View File

@@ -0,0 +1,87 @@
/**
* بررسیِ اتصال به بک‌اند — ابزارِ توسعه، نه بخشی از محصول.
*
* برای امتحان کردن، یک خط در 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>
);
}

91
src/api/README.md Normal file
View File

@@ -0,0 +1,91 @@
# اتصال به بک‌اند
کلِ ارتباط با سرور از همین پوشه رد می‌شود. **هیچ‌جای اپ مستقیم `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` را دست نزده تا با کارِ در جریان
تصادف نکند.

317
src/api/client.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* هسته‌ی ارتباط با بک‌اندِ تدریسینو.
*
* این فایل عمداً هیچ پکیجی اضافه نمی‌کند — فقط 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;
}

147
src/api/index.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بک‌اند به آن نگاه کند.
*
* import { api } from '@/api';
* const home = await api.home();
*
* هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* درستِ خطا همه اینجا یک‌جا جمع‌اند.
*
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
* غافلگیرکننده‌اند کامنت خورده‌اند، چون اشتباهشان خطای ۴۰۰/۴۰۴ می‌دهد که
* از پیامش علت معلوم نیست.
*/
import { ApiError, createHttpClient, localTokenStorage } from './client';
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
import type {
AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question,
ReportReason, ReportResponse, UserProfile,
} from './types';
export { ApiError, localTokenStorage, createHttpClient };
export type { Tokens, HttpClient, ClientOptions, RequestOptions };
export * from './types';
/** پرسش از دستیار کند است (تولیدِ متن)؛ ۱۵ ثانیه‌ی پیش‌فرض کم است. */
const ASK_TIMEOUT_MS = 45_000;
export function createApi(options: ClientOptions = {}) {
const http = createHttpClient(options);
return {
http,
/* ── ورود ─────────────────────────────────────────────────────────── */
auth: {
/** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
async guest(deviceId: string) {
const data = await http.post<{
userId: string; token: string; isNewUser: boolean; isGuest: boolean;
}>('/auth/session', { type: 'guest', deviceId });
http.storage.set({ token: data.token, refreshToken: '' });
return data;
},
/** گامِ ۱ — در حالتِ توسعه `devOtp` هم برمی‌گردد. */
requestOtp(phone: string) {
return http.post<{ success: boolean; devOtp?: string }>(
'/auth/otp/request', { phone },
);
},
/** گامِ ۲ — نامِ فیلد در سرور `otp` است، نه `code`. */
async login(phone: string, otp: string) {
const data = await http.post<{ userId: string } & Tokens>(
'/auth/login', { phone, otp },
);
http.storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
},
logout() {
http.storage.set(null);
},
isSignedIn() {
return Boolean(http.storage.get()?.token);
},
},
/* ── کاربر ────────────────────────────────────────────────────────── */
profile: {
get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
update: (patch: Partial<Pick<UserProfile,
'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
http.patch<UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
},
/** xp/سکه/استریک اینجاست، نه در profile. */
summary: (opts?: RequestOptions) =>
http.get<Record<string, unknown>>('/user/summary', opts),
/** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */
grades: (opts?: RequestOptions) =>
http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
/** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده می‌شود. */
home: (gradeId?: number, opts?: RequestOptions) =>
http.get<HomeResponse>(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts),
/* ── درس و آزمون ──────────────────────────────────────────────────── */
lessons: {
intro: (lessonId: number | string, opts?: RequestOptions) =>
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
questions: (lessonId: number | string, opts?: RequestOptions) =>
http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
.then((r) => r.questions),
complete: (lessonId: number | string) =>
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
},
questions: {
/**
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
* `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
*/
answer: (questionId: number | string, selectedOptionId: number | string) =>
http.create<AnswerResponse>(`/questions/${questionId}/answer`, { selectedOptionId }),
/** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمی‌سازد. */
report: (questionId: number | string, reason: ReportReason, note = '') =>
http.post<ReportResponse>(`/questions/${questionId}/report`, { reason, note }),
},
/* ── دستیار ───────────────────────────────────────────────────────── */
ai: {
/**
* فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* فصل است و پاسخ را به کتاب مستند می‌کند.
*
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
*/
ask: (
userQuestion: string,
ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {},
opts?: RequestOptions,
) =>
http.post<AskResponse>('/ai/ask', { userQuestion, ...ctx }, {
timeoutMs: ASK_TIMEOUT_MS,
...opts,
}),
/** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `reason` اختیاری. */
feedback: (messageId: number | string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(
`/ai/messages/${messageId}/feedback`, { isHelpful, reason },
),
},
/* ── اشتراک ───────────────────────────────────────────────────────── */
plans: (opts?: RequestOptions) =>
http.get<{ plans: Plan[] }>('/subscription/plans', opts).then((r) => r.plans),
};
}
export type Api = ReturnType<typeof createApi>;
/** نمونه‌ی پیش‌فرض — برای حالتِ عادی همین کافی‌ست. */
export const api = createApi();

121
src/api/types.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* تایپ‌های پاسخِ بک‌اند — مستقیم از OpenAPI واقعیِ سرویس، نه از حدس.
*
* دو نکته که موقعِ نوشتن غافلگیرکننده بودند و اگر رعایت نشوند درخواست‌ها
* بی‌سروصدا شکست می‌خورند:
* • شناسه‌ها در پاسخ‌ها اغلب رشته‌اند، نه عدد.
* • فیلدِ پرسش در /ai/ask اسمش userQuestion است، نه question.
*/
export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode {
id: string;
title: string;
status: NodeStatusValue;
lessonIds: number[];
}
export interface HomeUnit {
id: string;
title: string;
nodes: HomeNode[];
}
export interface HomeStats {
progressPercent: number;
xp: number;
coins: number;
}
export interface HomeResponse {
units: HomeUnit[];
stats: HomeStats;
}
export interface Grade {
id: number;
title: string;
icon: string;
color: string;
}
/** آمارِ بازی (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 LessonIntro {
lessonId: string;
title: string;
introText: string;
mascotAnimation: string;
questionCount: number;
}
export interface ChoiceOption {
id: string;
value: string;
}
export interface Question {
id: string;
type: string;
prompt: string;
options: ChoiceOption[];
}
/** `mascotMood`: پاسخِ درست → happy، غلط → encourage. */
export interface AnswerResponse {
correct: boolean;
correctOptionId: string;
xpEarned: number;
heartsRemaining: number;
mascotMood: string;
}
export interface UsedChunk {
title: string;
summary: string;
}
/**
* `usedChunks` خالی یعنی پاسخ از کتاب پیدا نشده و عمومی است — برای نشان دادنِ
* برچسبِ «بر اساسِ کتاب» در برابرِ «پاسخِ عمومی» همین کافی‌ست.
*/
export interface AskResponse {
conversationId: string;
messageId: string;
answer: string;
suggestedFollowUps: string[];
remainingQuota: number;
usedChunks: UsedChunk[];
retrievalDebug?: Record<string, unknown>;
}
export interface Plan {
id: string;
title: string;
price: number;
currency: string;
popular: boolean;
discountPercent: number;
durationDays: number;
}
export type ReportReason =
| 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
export interface ReportResponse {
success: boolean;
reportId: number;
alreadyReported: boolean;
}