From be9f99d52a2300e031ca254334b95e116cb06c00 Mon Sep 17 00:00:00 2001 From: AliReza Date: Sun, 13 Sep 2026 19:46:05 +0330 Subject: [PATCH] add a typed API layer for the Tadrisino backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit اپ تا الان هیچ درخواستی به سرور نمی‌زد و همه‌ی داده‌ها داخلِ خودِ کامپوننت‌ها هاردکد بود. این کامیت لایه‌ی ارتباط را اضافه می‌کند، بدونِ اینکه هیچ صفحه‌ای را تغییر دهد. عمداً فقط فایلِ جدید اضافه شده و 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 --- .env.example | 11 ++ .gitignore | 5 + src/api/ConnectionCheck.tsx | 87 ++++++++++ src/api/README.md | 91 +++++++++++ src/api/client.ts | 317 ++++++++++++++++++++++++++++++++++++ src/api/index.ts | 118 ++++++++++++++ src/api/types.ts | 93 +++++++++++ 7 files changed, 722 insertions(+) create mode 100644 .env.example create mode 100644 src/api/ConnectionCheck.tsx create mode 100644 src/api/README.md create mode 100644 src/api/client.ts create mode 100644 src/api/index.ts create mode 100644 src/api/types.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8867d77 --- /dev/null +++ b/.env.example @@ -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_ شروع شود داخلِ باندلِ +# نهایی می‌رود و کاربر می‌تواند ببیندش. diff --git a/.gitignore b/.gitignore index 3c3629e..b6eefa0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ node_modules + +# متغیرهای محلی — هیچ‌وقت کامیت نشوند +.env +.env.local +dist diff --git a/src/api/ConnectionCheck.tsx b/src/api/ConnectionCheck.tsx new file mode 100644 index 0000000..c78e40e --- /dev/null +++ b/src/api/ConnectionCheck.tsx @@ -0,0 +1,87 @@ +/** + * بررسیِ اتصال به بک‌اند — ابزارِ توسعه، نه بخشی از محصول. + * + * برای امتحان کردن، یک خط در App.tsx (یا هر جای دیگر) کافی‌ست: + * + * import { ConnectionCheck } from '@/api/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({ 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
در حالِ بررسیِ اتصال به بک‌اند…
; + } + + if (state.kind === 'error') { + return ( +
+ اتصال برقرار نشد +
{state.message}
+
{state.hint}
+
{api.http.baseUrl}
+
+ ); + } + + return ( +
+ اتصال برقرار است +
+ {state.grades.length} پایه از سرور آمد + {state.grades.length > 0 && `: ${state.grades.map((g) => g.title).join('، ')}`} +
+
{api.http.baseUrl}
+
+ ); +} diff --git a/src/api/README.md b/src/api/README.md new file mode 100644 index 0000000..a678b23 --- /dev/null +++ b/src/api/README.md @@ -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: + +``` + +گوشه‌ی صفحه سبز می‌شود و فهرستِ پایه‌ها را از سرور نشان می‌دهد، یا قرمز +می‌شود و می‌گوید دقیقاً کجای کار می‌لنگد. + +## استفاده + +```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` را دست نزده تا با کارِ در جریان + تصادف نکند. diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..584a1bc --- /dev/null +++ b/src/api/client.ts @@ -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 = {}, + ) { + 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 }).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((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; + /** برای درخواست‌های کند مثل پرسش از دستیار. */ + 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>(); + /** فقط یک refresh در هر لحظه. */ + let refreshing: Promise | null = null; + + async function doRefresh(): Promise { + 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( + method: string, + path: string, + body: unknown, + opts: RequestOptions, + allowRefresh: boolean, + ): Promise { + const tokens = storage.get(); + const headers: Record = { 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(method, path, body, opts, false); + storage.set(null); + options.onSignedOut?.(); + } + + const text = await response.text(); + let parsed: Record = {}; + if (text) { + try { + parsed = JSON.parse(text) as Record; + } 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( + method: string, + path: string, + body?: unknown, + opts: RequestOptions = {}, + ): Promise { + const retriable = opts.retry ?? method === 'GET'; + + let lastError: ApiError | undefined; + for (let attempt = 0; attempt <= (retriable ? maxRetries : 0); attempt++) { + try { + return await once(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(path: string, opts: RequestOptions = {}): Promise { + // درخواستِ لغوشدنی را ادغام نمی‌کنیم؛ لغوِ یکی نباید دیگری را بکشد. + if (opts.signal) return request('GET', path, undefined, opts); + + const existing = inFlight.get(path); + if (existing) return existing as Promise; + + const promise = request('GET', path, undefined, opts).finally(() => { + inFlight.delete(path); + }); + inFlight.set(path, promise); + return promise; + } + + return { + baseUrl, + storage, + get, + post: (path: string, body?: unknown, opts?: RequestOptions) => + request('POST', path, body, opts), + patch: (path: string, body?: unknown, opts?: RequestOptions) => + request('PATCH', path, body, opts), + /** POSTی که چیزی می‌سازد: با کلیدِ یکتا، پس تکرارش سفارشِ دوم نمی‌سازد. */ + create: (path: string, body?: unknown, opts: RequestOptions = {}) => + request('POST', path, body, { + ...opts, + retry: true, + idempotencyKey: opts.idempotencyKey ?? newIdempotencyKey(), + }), + }; +} + +export type HttpClient = ReturnType; + +/** 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; +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..fa56793 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,118 @@ +/** + * API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بک‌اند به آن نگاه کند. + * + * import { api } from '@/api'; + * const home = await api.home(); + * + * هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ + * درستِ خطا همه اینجا یک‌جا جمع‌اند. + */ +import { createHttpClient, ApiError, localTokenStorage } from './client'; +import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client'; +import type { + AnswerResult, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question, 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: { + /** «شروع سریع» بدونِ شماره. برای دمو کافی‌ست. */ + async guest(deviceId: string) { + const data = await http.post<{ userId: string; token: string; isNewUser: 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('/user/profile', opts), + update: (patch: Partial>) => + http.patch('/user/profile', patch), + setGrade: (gradeId: number) => + http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), + }, + + /** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */ + grades: (opts?: RequestOptions) => + http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades), + + /** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده می‌شود. */ + home: (gradeId?: number, opts?: RequestOptions) => + http.get(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts), + + /* ── درس و آزمون ──────────────────────────────────────────────────── */ + lessons: { + intro: (lessonId: number, opts?: RequestOptions) => + http.get(`/lessons/${lessonId}/intro`, opts), + questions: (lessonId: number, opts?: RequestOptions) => + http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts) + .then((r) => r.questions), + /** + * ثبتِ پاسخ. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا تکرارِ خودکارِ + * شبکه، دو بار XP ندهد. + */ + answer: (lessonId: number, questionId: number, choiceId: number) => + http.create(`/lessons/${lessonId}/answer`, { questionId, choiceId }), + }, + + /* ── دستیار ───────────────────────────────────────────────────────── */ + ai: { + /** + * فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و + * فصل است و پاسخ را به کتاب مستند می‌کند. + */ + ask: (question: string, ctx: { lessonId?: number; conversationId?: number } = {}, opts?: RequestOptions) => + http.post('/ai/ask', { question, ...ctx }, { + timeoutMs: ASK_TIMEOUT_MS, + ...opts, + }), + /** `reason` اختیاری‌ست ولی برای تحلیلِ کیفیت خیلی ارزش دارد. */ + feedback: (messageId: number, vote: 'up' | 'down', reason?: string) => + http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { vote, reason }), + }, + + /* ── اشتراک ───────────────────────────────────────────────────────── */ + plans: (opts?: RequestOptions) => + http.get<{ plans: Plan[] }>('/subscription/plans', opts).then((r) => r.plans), + }; +} + +export type Api = ReturnType; + +/** نمونه‌ی پیش‌فرض — برای حالتِ عادی همین کافی‌ست. */ +export const api = createApi(); diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..40f1111 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,93 @@ +/** + * تایپ‌های پاسخِ بک‌اند. از روی OpenAPI واقعیِ سرویس نوشته شده، نه حدس. + * نامِ فیلدها دقیقاً همان چیزی‌ست که سرور می‌دهد (camelCase). + */ + +export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed'; + +export interface HomeNode { + id: string; + title: string; + status: NodeStatus; + 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; +} + +export interface UserProfile { + userId: string; + name: string; + avatar: string; + gradeId: number | null; + birthYear?: number | null; + parentPhone?: string | null; + bio?: string | null; + xp: number; + coins: number; + streak: { current: number; longest: number }; + subscription: string; +} + +export interface LessonIntro { + lessonId: number; + title: string; + introText: string; + questionCount: number; +} + +export interface Choice { + id: number; + text: string; +} + +export interface Question { + id: number; + text: string; + choices: Choice[]; +} + +export interface AnswerResult { + correct: boolean; + correctChoiceId: number; + explanation?: string; + xpEarned?: number; +} + +/** پاسخِ دستیار. `grounded=false` یعنی از کتاب پیدا نشد و پاسخ عمومی است. */ +export interface AskResponse { + answer: string; + conversationId: number; + messageId: number; + grounded?: boolean; + sources?: { title: string; score?: number }[]; +} + +export interface Plan { + id: number; + title: string; + price: number; + durationDays: number; + features: string[]; +}