forked from Tadrisino/MathApp_frontend
Compare commits
3 Commits
7dd457d2fc
...
api-branch
| Author | SHA1 | Date | |
|---|---|---|---|
| 267cf5fca0 | |||
| 15d110d824 | |||
| 74e048cc2a |
11
.env.example
11
.env.example
@@ -1,11 +0,0 @@
|
|||||||
# این فایل فقط نمونه است. اگر بکاندِ محلی دارید، آن را به .env کپی کنید:
|
|
||||||
# cp .env.example .env
|
|
||||||
#
|
|
||||||
# اگر هیچ کاری نکنید هم اپ کار میکند و به بکاندِ مشترک وصل میشود:
|
|
||||||
# https://api.math.tadrisino2.ir/api
|
|
||||||
#
|
|
||||||
# فقط وقتی بکاند را روی ماشینِ خودتان بالا آوردهاید این را باز کنید:
|
|
||||||
# VITE_API_BASE_URL=http://127.0.0.1:8000/api
|
|
||||||
|
|
||||||
# هیچ کلید یا رمزی اینجا نگذارید: هر چیزی که با VITE_ شروع شود داخلِ باندلِ
|
|
||||||
# نهایی میرود و کاربر میتواند ببیندش.
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,27 +35,12 @@ import { ConnectionCheck } from '@/api/ConnectionCheck';
|
|||||||
```tsx
|
```tsx
|
||||||
import { api, ApiError } from '@/api';
|
import { api, ApiError } from '@/api';
|
||||||
|
|
||||||
const grades = await api.grades(); // lessonCount=0 یعنی پایه خالی است
|
const grades = await api.grades();
|
||||||
const home = await api.home(); // بدونِ آرگومان: پایهی خودِ کاربر
|
const home = await api.home(); // پایهی خودِ کاربر
|
||||||
const steps = await api.nodes.lessons(nodeId); // کارتهای نقشهی مهارت
|
const intro = await api.lessons.intro(lessonId);
|
||||||
const intro = await api.lessons.intro(lessonId); // توضیح + مثال + گامبهگام
|
|
||||||
const answer = await api.ai.ask('این رو نفهمیدم', { lessonId });
|
const answer = await api.ai.ask('این رو نفهمیدم', { lessonId });
|
||||||
```
|
```
|
||||||
|
|
||||||
### چند نکته که وقت میگیرند اگر ندانید
|
|
||||||
|
|
||||||
- `grades()` حالا `lessonCount` میدهد. صفر یعنی آن پایه هنوز محتوایی ندارد —
|
|
||||||
گزینهاش را غیرفعال کنید، وگرنه کاربر انتخابش میکند و صفحهی خالی میبیند.
|
|
||||||
- `home()` در هر مبحث `icon` و `color` و در هر فصل `blurb` میدهد؛ لازم نیست
|
|
||||||
رنگها را هاردکد کنید.
|
|
||||||
- `home()` فقط شناسهی درسها را میدهد. برای کارتهای نقشهی مهارت (عنوان،
|
|
||||||
آزمون یا ویدیو، «۵ سوال»/«۱۲ دقیقه»، انجامشده) از `nodes.lessons(nodeId)`
|
|
||||||
استفاده کنید — یک درخواست برای کلِ مبحث، نه یکی بهازای هر کارت.
|
|
||||||
- `lessons.intro()` متن را سهتکه میدهد: `introText` (توضیح)، `example`، و
|
|
||||||
`steps` (حلِ گامبهگام). `kind` میگوید آزمون است یا ویدیو.
|
|
||||||
- `profile.update({ age })` هم کار میکند؛ سرور خودش به سالِ تولدِ شمسی تبدیل
|
|
||||||
میکند. `age` و `birthYear` را با هم نفرستید.
|
|
||||||
|
|
||||||
### ورود
|
### ورود
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
569
src/api/index.ts
569
src/api/index.ts
@@ -1,162 +1,459 @@
|
|||||||
/**
|
/**
|
||||||
* API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بکاند به آن نگاه کند.
|
* کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend).
|
||||||
*
|
*
|
||||||
* import { api } from '@/api';
|
* از روی OpenAPI واقعیِ همین بکاند نوشته شده، نه از روی حدس. نامِ فیلدها
|
||||||
* const home = await api.home();
|
* دقیقاً همان چیزیست که سرور برمیگرداند (camelCase).
|
||||||
*
|
*
|
||||||
* هیچجای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
|
* نکتههای مهمِ اتصال:
|
||||||
* درستِ خطا همه اینجا یکجا جمعاند.
|
* - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم
|
||||||
*
|
* نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده.
|
||||||
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
|
* - توکنِ دسترسی امضاشده و کوتاهعمر است؛ با ۴۰۱ باید `refresh` صدا زد.
|
||||||
* غافلگیرکنندهاند کامنت خوردهاند، چون اشتباهشان خطای ۴۰۰/۴۰۴ میدهد که
|
* این کلاینت خودش یکبار refresh میکند و درخواست را تکرار.
|
||||||
* از پیامش علت معلوم نیست.
|
* - برای پرسش از دستیار کافیست `lessonId` بدهید؛ سرور خودش میفهمد آن درس
|
||||||
|
* مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند میکند.
|
||||||
*/
|
*/
|
||||||
import { ApiError, createHttpClient, localTokenStorage } from './client';
|
|
||||||
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
|
|
||||||
import type {
|
|
||||||
AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, NodeLessons, Plan,
|
|
||||||
Question, ReportReason, ReportResponse, UserProfile,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export { ApiError, localTokenStorage, createHttpClient };
|
export type Json = Record<string, unknown>;
|
||||||
export type { Tokens, HttpClient, ClientOptions, RequestOptions };
|
|
||||||
export * from './types';
|
|
||||||
|
|
||||||
/** پرسش از دستیار کند است (تولیدِ متن)؛ ۱۵ ثانیهی پیشفرض کم است. */
|
export interface Tokens {
|
||||||
const ASK_TIMEOUT_MS = 45_000;
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function createApi(options: ClientOptions = {}) {
|
/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */
|
||||||
const http = createHttpClient(options);
|
|
||||||
|
|
||||||
return {
|
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
|
||||||
http,
|
|
||||||
|
|
||||||
/* ── ورود ─────────────────────────────────────────────────────────── */
|
export interface HomeNode {
|
||||||
auth: {
|
id: string;
|
||||||
/** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
|
title: string;
|
||||||
async guest(deviceId: string) {
|
status: NodeStatus;
|
||||||
const data = await http.post<{
|
lessonIds: number[];
|
||||||
userId: string; token: string; isNewUser: boolean; isGuest: boolean;
|
}
|
||||||
}>('/auth/session', { type: 'guest', deviceId });
|
export interface HomeUnit {
|
||||||
http.storage.set({ token: data.token, refreshToken: '' });
|
id: string;
|
||||||
return data;
|
title: string;
|
||||||
},
|
nodes: HomeNode[];
|
||||||
/** گامِ ۱ — در حالتِ توسعه `devOtp` هم برمیگردد. */
|
}
|
||||||
requestOtp(phone: string) {
|
export interface HomeResponse {
|
||||||
return http.post<{ success: boolean; devOtp?: string }>(
|
units: HomeUnit[];
|
||||||
'/auth/otp/request', { phone },
|
stats: { progressPercent: number; xp: number; coins: number };
|
||||||
);
|
}
|
||||||
},
|
|
||||||
/** گامِ ۲ — نامِ فیلد در سرور `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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ── کاربر ────────────────────────────────────────────────────────── */
|
export interface Grade {
|
||||||
profile: {
|
id: number;
|
||||||
get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
|
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;
|
||||||
/**
|
/**
|
||||||
* فقط فیلدهایی که میفرستید عوض میشوند. صفحهی ثبتنام سن میپرسد، پس
|
* صفر یعنی متنِ درس هست ولی آزمونش هنوز ساخته نشده — «بهزودی» نشان دهید،
|
||||||
* `age` هم پذیرفته میشود و سرور خودش به سالِ تولدِ شمسی تبدیلش میکند —
|
* نه کویزِ خالی. `complete` هم `totalQuestions: 0` را نمیپذیرد.
|
||||||
* `age` و `birthYear` را با هم نفرستید.
|
|
||||||
*/
|
*/
|
||||||
update: (patch: Partial<Pick<UserProfile,
|
questionCount: number;
|
||||||
'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>> & { age?: number }) =>
|
}
|
||||||
http.patch<UserProfile>('/user/profile', patch),
|
|
||||||
setGrade: (gradeId: number) =>
|
|
||||||
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
|
|
||||||
},
|
|
||||||
|
|
||||||
/** xp/سکه/استریک اینجاست، نه در profile. */
|
export interface QuestionOption {
|
||||||
summary: (opts?: RequestOptions) =>
|
id: string;
|
||||||
http.get<Record<string, unknown>>('/user/summary', opts),
|
value: string;
|
||||||
|
}
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
type: 'multiple_choice' | 'count_objects';
|
||||||
|
prompt: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
}
|
||||||
|
|
||||||
/** پایهها را هاردکد نکنید — اضافه میشوند. */
|
export interface AnswerResult {
|
||||||
grades: (opts?: RequestOptions) =>
|
correct: boolean;
|
||||||
http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
|
correctOptionId: string;
|
||||||
|
xpEarned: number;
|
||||||
|
heartsRemaining: number;
|
||||||
|
/** حالتِ ماسکوت: درست → happy، غلط → encourage */
|
||||||
|
mascotMood: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** بدونِ gradeId، پایهی ذخیرهشدهی خودِ کاربر استفاده میشود. */
|
export interface LessonComplete {
|
||||||
home: (gradeId?: number, opts?: RequestOptions) =>
|
stars: number;
|
||||||
http.get<HomeResponse>(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts),
|
xpEarned: number;
|
||||||
|
coinsEarned: number;
|
||||||
|
stats: { progressPercent: number; xp: number; coins: number };
|
||||||
|
}
|
||||||
|
|
||||||
/* ── درس و آزمون ──────────────────────────────────────────────────── */
|
export interface Hearts {
|
||||||
lessons: {
|
currentHearts: number;
|
||||||
intro: (lessonId: number | string, opts?: RequestOptions) =>
|
maxHearts: number;
|
||||||
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
|
nextRefillAt: string | null;
|
||||||
questions: (lessonId: number | string, opts?: RequestOptions) =>
|
isUnlimited: boolean;
|
||||||
http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
|
}
|
||||||
.then((r) => r.questions),
|
|
||||||
complete: (lessonId: number | string) =>
|
|
||||||
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
|
|
||||||
},
|
|
||||||
|
|
||||||
nodes: {
|
export interface Character {
|
||||||
/**
|
characterId: string;
|
||||||
* قدمهای یک مبحث برای نقشهی مهارت — عنوان، نوع، زیرنویس و «انجام شد».
|
slug: string;
|
||||||
* `home()` فقط شناسهی درسها را میدهد؛ بدونِ این، برای هر کارت یک
|
name: string;
|
||||||
* درخواستِ intro جدا لازم بود.
|
icon: string;
|
||||||
*/
|
unlockXp: number;
|
||||||
lessons: (nodeId: number | string, opts?: RequestOptions) =>
|
unlocked: boolean;
|
||||||
http.get<NodeLessons>(`/nodes/${nodeId}/lessons`, opts),
|
selected: boolean;
|
||||||
},
|
}
|
||||||
|
|
||||||
questions: {
|
export interface AskResponse {
|
||||||
/**
|
conversationId: string;
|
||||||
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
|
messageId: string;
|
||||||
* `selectedOptionId` است. کلیدِ یکتا میگیرد تا دابلکلیکِ کاربر یا
|
answer: string;
|
||||||
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
|
suggestedFollowUps: string[];
|
||||||
*/
|
remainingQuota: number;
|
||||||
answer: (questionId: number | string, selectedOptionId: number | string) =>
|
/** تکههای کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */
|
||||||
http.create<AnswerResponse>(`/questions/${questionId}/answer`, { selectedOptionId }),
|
usedChunks: { title: string; summary: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
/** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمیسازد. */
|
export interface Plan {
|
||||||
report: (questionId: number | string, reason: ReportReason, note = '') =>
|
id: string;
|
||||||
http.post<ReportResponse>(`/questions/${questionId}/report`, { reason, note }),
|
title: string;
|
||||||
},
|
price: number;
|
||||||
|
durationDays: number;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/* ── دستیار ───────────────────────────────────────────────────────── */
|
/* ─── خطا ───────────────────────────────────────────────────────────────── */
|
||||||
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` اختیاری. */
|
export class ApiError extends Error {
|
||||||
feedback: (messageId: number | string, isHelpful: boolean, reason?: string) =>
|
constructor(
|
||||||
http.post<{ success: boolean }>(
|
readonly status: number,
|
||||||
`/ai/messages/${messageId}/feedback`, { isHelpful, reason },
|
readonly detail: string,
|
||||||
),
|
readonly body: Json,
|
||||||
},
|
) {
|
||||||
|
super(detail);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
|
||||||
/* ── اشتراک ───────────────────────────────────────────────────────── */
|
/** ۴۲۹: throttle یا اتمامِ سهمیهی روزانهی دستیار */
|
||||||
plans: (opts?: RequestOptions) =>
|
get isRateLimited() {
|
||||||
http.get<{ plans: Plan[] }>('/subscription/plans', opts).then((r) => r.plans),
|
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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Api = ReturnType<typeof createApi>;
|
const STORAGE_KEY = 'tadrisino.tokens';
|
||||||
|
|
||||||
/** نمونهی پیشفرض — برای حالتِ عادی همین کافیست. */
|
function defaultStorage() {
|
||||||
export const api = createApi();
|
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' | 'all_time' = 'weekly') =>
|
||||||
|
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
|
||||||
|
|
||||||
|
/** کاتالوگِ کاراکترهای گیمیفیکیشن — مثلِ ماسکاتهای Duolingo، بازشدن بر اساسِ XP. */
|
||||||
|
characters: {
|
||||||
|
list: () => raw<{ characters: Character[] }>('GET', '/characters').then((r) => r.characters),
|
||||||
|
select: (characterId: string) =>
|
||||||
|
raw<{ characterId: string; selected: boolean }>('POST', '/characters/select', { characterId }),
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── صفحهی درس و سوال ─────────────────────────────────────────────── */
|
||||||
|
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>;
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed
|
|||||||
export interface HomeNode {
|
export interface HomeNode {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
/** آیکون و رنگِ کارتِ مبحث — از سرور میآید، هاردکد نکنید. */
|
|
||||||
icon: string;
|
|
||||||
color: string;
|
|
||||||
status: NodeStatusValue;
|
status: NodeStatusValue;
|
||||||
lessonIds: number[];
|
lessonIds: number[];
|
||||||
}
|
}
|
||||||
@@ -22,8 +19,6 @@ export interface HomeNode {
|
|||||||
export interface HomeUnit {
|
export interface HomeUnit {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
/** یک جملهی کوتاه زیرِ عنوانِ فصل. */
|
|
||||||
blurb: string;
|
|
||||||
nodes: HomeNode[];
|
nodes: HomeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +38,6 @@ export interface Grade {
|
|||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
/** صفر یعنی این پایه هنوز محتوایی ندارد — گزینهاش را غیرفعال کنید. */
|
|
||||||
lessonCount: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary میآید. */
|
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary میآید. */
|
||||||
@@ -60,40 +53,14 @@ export interface UserProfile {
|
|||||||
bio: string;
|
bio: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** یک قدم در مسیر: آزمون یا ویدیو. */
|
|
||||||
export type LessonKind = 'quiz' | 'video';
|
|
||||||
|
|
||||||
export interface LessonIntro {
|
export interface LessonIntro {
|
||||||
lessonId: string;
|
lessonId: string;
|
||||||
title: string;
|
title: string;
|
||||||
kind: LessonKind;
|
|
||||||
/** متنِ درس سه تکه است: توضیح، مثال، و حلِ گامبهگام. */
|
|
||||||
introText: string;
|
introText: string;
|
||||||
example: string;
|
|
||||||
steps: string[];
|
|
||||||
/** فقط وقتی kind === 'video' پر است. */
|
|
||||||
videoUrl: string;
|
|
||||||
durationMinutes: number | null;
|
|
||||||
mascotAnimation: string;
|
mascotAnimation: string;
|
||||||
questionCount: number;
|
questionCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** یک کارت در نقشهی مهارت. */
|
|
||||||
export interface NodeLesson {
|
|
||||||
id: string;
|
|
||||||
kind: LessonKind;
|
|
||||||
title: string;
|
|
||||||
/** زیرنویسِ کارت: «۵ سوال» یا «۱۲ دقیقه». */
|
|
||||||
meta: string;
|
|
||||||
done: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeLessons {
|
|
||||||
nodeId: string;
|
|
||||||
title: string;
|
|
||||||
lessons: NodeLesson[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChoiceOption {
|
export interface ChoiceOption {
|
||||||
id: string;
|
id: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -152,3 +119,4 @@ export interface ReportResponse {
|
|||||||
reportId: number;
|
reportId: number;
|
||||||
alreadyReported: boolean;
|
alreadyReported: boolean;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
1548
src/app/App.tsx
1548
src/app/App.tsx
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user