Compare commits
8 Commits
updateApp
...
d223336638
| Author | SHA1 | Date | |
|---|---|---|---|
| d223336638 | |||
| 15d110d824 | |||
| a341ffd314 | |||
| 74e048cc2a | |||
| 4705925022 | |||
| 82c9e870b3 | |||
| f61c58eb76 | |||
| be9f99d52a |
11
.env.example
Normal file
11
.env.example
Normal 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
5
.gitignore
vendored
@@ -1 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# متغیرهای محلی — هیچوقت کامیت نشوند
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
dist
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -5600,22 +5600,6 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
src/api/ConnectionCheck.tsx
Normal file
87
src/api/ConnectionCheck.tsx
Normal 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
91
src/api/README.md
Normal 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
317
src/api/client.ts
Normal 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;
|
||||||
|
}
|
||||||
442
src/api/index.ts
Normal file
442
src/api/index.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
/**
|
||||||
|
* کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend).
|
||||||
|
*
|
||||||
|
* از روی OpenAPI واقعیِ همین بکاند نوشته شده، نه از روی حدس. نامِ فیلدها
|
||||||
|
* دقیقاً همان چیزیست که سرور برمیگرداند (camelCase).
|
||||||
|
*
|
||||||
|
* نکتههای مهمِ اتصال:
|
||||||
|
* - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم
|
||||||
|
* نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده.
|
||||||
|
* - توکنِ دسترسی امضاشده و کوتاهعمر است؛ با ۴۰۱ باید `refresh` صدا زد.
|
||||||
|
* این کلاینت خودش یکبار refresh میکند و درخواست را تکرار.
|
||||||
|
* - برای پرسش از دستیار کافیست `lessonId` بدهید؛ سرور خودش میفهمد آن درس
|
||||||
|
* مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند میکند.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Json = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface Tokens {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
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 HomeResponse {
|
||||||
|
units: HomeUnit[];
|
||||||
|
stats: { progressPercent: number; xp: number; coins: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Grade {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as Tokens) : null;
|
||||||
|
} catch {
|
||||||
|
return 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();
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
leaderboard: (period: 'weekly' | 'monthly' = 'weekly') =>
|
||||||
|
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
|
||||||
|
|
||||||
|
/* ── صفحهی درس و سوال ─────────────────────────────────────────────── */
|
||||||
|
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 } : {},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── صفحهی «از رُبو بپرس» ─────────────────────────────────────────── */
|
||||||
|
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 } : {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همان 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, 'استریم شروع نشد', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let done: { conversationId?: string; messageId?: string } = {};
|
||||||
|
|
||||||
|
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>;
|
||||||
122
src/api/types.ts
Normal file
122
src/api/types.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* تایپهای پاسخِ بکاند — مستقیم از 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;
|
||||||
|
}
|
||||||
|
*/
|
||||||
1530
src/app/App.tsx
1530
src/app/App.tsx
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user