1 Commits

Author SHA1 Message Date
7dd457d2fc expose the fields and endpoint the screens were missing
بعد از merge شدنِ PR اول، صفحه‌های اپ با API مقایسه شدند و چند چیز کم بود.
همه‌ی تغییراتِ بک‌اند افزایشی‌اند، پس کدِ فعلی نمی‌شکند — این فقط دسترسی به
آن‌ها را اضافه می‌کند.

grades() حالا lessonCount دارد: فهرستِ پایه‌ها تا دوازدهم می‌رود ولی محتوا
تا نهم است، و بدونِ این عدد کاربر پایه‌ی خالی را انتخاب می‌کند و صفحه‌ی
خالی می‌بیند بی‌آنکه بفهمد چرا.

home() در هر مبحث icon و color و در هر فصل blurb می‌دهد، تا رنگ‌ها لازم
نباشد در کد هاردکد بمانند.

nodes.lessons(nodeId) تازه است. home() فقط شناسه‌ی درس‌ها را می‌دهد، ولی
کارتِ نقشه‌ی مهارت عنوان، نوع (آزمون/ویدیو)، زیرنویس («۵ سوال» / «۱۲
دقیقه») و «انجام شد» می‌خواهد — بدونِ این، برای هر کارت یک درخواستِ intro
جدا لازم بود.

lessons.intro() متن را سه‌تکه می‌دهد: introText (توضیح)، example، و steps
(حلِ گام‌به‌گام)؛ به‌علاوه kind و videoUrl و durationMinutes.

profile.update({ age }) هم پذیرفته می‌شود چون صفحه‌ی ثبت‌نام سن می‌پرسد؛
سرور خودش به سالِ تولدِ شمسی تبدیل می‌کند.

هنوز هیچ فایلِ موجودی دست نخورده و پکیجی اضافه نشده. هر ۲۰ صدا در برابرِ
یک بک‌اندِ در حالِ اجرا تست شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:50:04 +03:30
6 changed files with 839 additions and 1444 deletions

11
.env.example Normal file
View File

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

16
package-lock.json generated
View File

@@ -5600,6 +5600,22 @@
"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"
}
} }
} }
} }

View File

@@ -35,12 +35,27 @@ import { ConnectionCheck } from '@/api/ConnectionCheck';
```tsx ```tsx
import { api, ApiError } from '@/api'; import { api, ApiError } from '@/api';
const grades = await api.grades(); const grades = await api.grades(); // lessonCount=0 یعنی پایه خالی است
const home = await api.home(); // پایه‌ی خودِ کاربر const home = await api.home(); // بدونِ آرگومان: پایه‌ی خودِ کاربر
const intro = await api.lessons.intro(lessonId); const steps = await api.nodes.lessons(nodeId); // کارت‌های نقشه‌ی مهارت
const answer = await api.ai.ask('این رو نفهمیدم', { lessonId }); const intro = await api.lessons.intro(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

View File

@@ -1,459 +1,162 @@
/** /**
* کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend). * API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بک‌اند به آن نگاه کند.
* *
* از روی OpenAPI واقعیِ همین بک‌اند نوشته شده، نه از روی حدس. نامِ فیلدها * import { api } from '@/api';
* دقیقاً همان چیزی‌ست که سرور برمی‌گرداند (camelCase). * const home = await api.home();
* *
* نکته‌های مهمِ اتصال: * هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم * درستِ خطا همه اینجا یک‌جا جمع‌اند.
* نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده. *
* - توکنِ دسترسی امضاشده و کوتاه‌عمر است؛ با ۴۰۱ باید `refresh` صدا زد. * هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
* این کلاینت خودش یک‌بار 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 type Json = Record<string, unknown>; export { ApiError, localTokenStorage, createHttpClient };
export type { Tokens, HttpClient, ClientOptions, RequestOptions };
export * from './types';
export interface Tokens { /** پرسش از دستیار کند است (تولیدِ متن)؛ ۱۵ ثانیه‌ی پیش‌فرض کم است. */
token: string; const ASK_TIMEOUT_MS = 45_000;
refreshToken: string;
}
/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */ export function createApi(options: ClientOptions = {}) {
const http = createHttpClient(options);
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 Character {
characterId: string;
slug: string;
name: string;
icon: string;
unlockXp: number;
unlocked: boolean;
selected: boolean;
}
export interface AskResponse {
conversationId: string;
messageId: string;
answer: string;
suggestedFollowUps: string[];
remainingQuota: number;
/** تکه‌های کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */
usedChunks: { title: string; summary: string }[];
}
export interface Plan {
id: string;
title: string;
price: number;
durationDays: number;
features: string[];
}
/* ─── خطا ───────────────────────────────────────────────────────────────── */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly detail: string,
readonly body: Json,
) {
super(detail);
this.name = 'ApiError';
}
/** ۴۲۹: throttle یا اتمامِ سهمیه‌ی روزانه‌ی دستیار */
get isRateLimited() {
return this.status === 429;
}
/** ۵۰۳: سرویسِ هوش مصنوعی/پیامک موقتاً در دسترس نیست */
get isProviderDown() {
return this.status === 503;
}
}
/* ─── کلاینت ────────────────────────────────────────────────────────────── */
export interface ClientOptions {
baseUrl?: string;
/** خواندن/نوشتنِ توکن؛ پیش‌فرض localStorage */
storage?: {
get(): Tokens | null;
set(t: Tokens | null): void;
};
}
const STORAGE_KEY = 'tadrisino.tokens';
function defaultStorage() {
return {
get(): Tokens | null {
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 { return {
/* ── ورود (صفحه‌ای که فعلاً در فرانت نیست و باید ساخته شود) ────────── */ http,
/* ── ورود ─────────────────────────────────────────────────────────── */
auth: { auth: {
/** مهمان: بدونِ شماره، فقط با شناسه‌ی دستگاه. برای «شروع سریع». */ /** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
async guest(deviceId: string) { async guest(deviceId: string) {
const data = await raw<{ userId: string; token: string; isNewUser: boolean; isGuest: boolean }>( const data = await http.post<{
'POST', '/auth/session', { type: 'guest', deviceId }, userId: string; token: string; isNewUser: boolean; isGuest: boolean;
); }>('/auth/session', { type: 'guest', deviceId });
storage.set({ token: data.token, refreshToken: '' }); http.storage.set({ token: data.token, refreshToken: '' });
return data; return data;
}, },
/** گامِ ۱: ارسالِ پیامکِ کد. در حالتِ توسعه `devOtp` هم برمی‌گردد. */ /** گامِ ۱ — در حالتِ توسعه `devOtp` هم برمی‌گردد. */
requestOtp(phone: string) { requestOtp(phone: string) {
return raw<{ success: boolean; devOtp?: string; note?: string }>( return http.post<{ success: boolean; devOtp?: string }>(
'POST', '/auth/otp/request', { phone }, '/auth/otp/request', { phone },
); );
}, },
/** گامِ ۲: تأییدِ کد و گرفتنِ توکن. نامِ فیلد در سرور `otp` است. */ /** گامِ ۲ — نامِ فیلد در سرور `otp` است، نه `code`. */
async login(phone: string, otp: string) { async login(phone: string, otp: string) {
const data = await raw<{ userId: string } & Tokens>( const data = await http.post<{ userId: string } & Tokens>(
'POST', '/auth/login', { phone, otp }, '/auth/login', { phone, otp },
); );
storage.set({ token: data.token, refreshToken: data.refreshToken }); http.storage.set({ token: data.token, refreshToken: data.refreshToken });
return data; return data;
}, },
logout() { logout() {
storage.set(null); http.storage.set(null);
}, },
isSignedIn() { isSignedIn() {
return Boolean(storage.get()?.token); return Boolean(http.storage.get()?.token);
}, },
}, },
/* ── صفحه‌ی «بیا باهم آشنا بشیم» ──────────────────────────────────── */ /* ── کاربر ────────────────────────────────────────────────────────── */
profile: { profile: {
get: () => raw<UserProfile>('GET', '/user/profile'), get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
/** اسم/آواتار/… . فقط فیلدهایی که می‌فرستید عوض می‌شوند. */ /**
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) => * فقط فیلدهایی که می‌فرستید عوض می‌شوند. صفحه‌ی ثبت‌نام سن می‌پرسد، پس
raw<UserProfile>('PATCH', '/user/profile', patch), * `age` هم پذیرفته می‌شود و سرور خودش به سالِ تولدِ شمسی تبدیلش می‌کند —
* `age` و `birthYear` را با هم نفرستید.
*/
update: (patch: Partial<Pick<UserProfile,
'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>> & { age?: number }) =>
http.patch<UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) => setGrade: (gradeId: number) =>
raw<{ success: boolean; gradeId: number }>('POST', '/user/grade', { gradeId }), http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
}, },
/** فهرستِ پایه‌ها را از سرور بگیرید، هاردکد نکنید — پایه‌ها اضافه می‌شوند. */ /** xp/سکه/استریک اینجاست، نه در profile. */
grades: () => raw<{ grades: Grade[] }>('GET', '/grades').then((r) => r.grades), summary: (opts?: RequestOptions) =>
http.get<Record<string, unknown>>('/user/summary', opts),
/* ── صفحه‌ی خانه و نقشه‌ی مهارت ────────────────────────────────────── */ /** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */
/** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ grades: (opts?: RequestOptions) =>
home: (gradeId?: number) => http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
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. */ /** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده می‌شود. */
characters: { home: (gradeId?: number, opts?: RequestOptions) =>
list: () => raw<{ characters: Character[] }>('GET', '/characters').then((r) => r.characters), http.get<HomeResponse>(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts),
select: (characterId: string) =>
raw<{ characterId: string; selected: boolean }>('POST', '/characters/select', { characterId }), /* ── درس و آزمون ──────────────────────────────────────────────────── */
lessons: {
intro: (lessonId: number | string, opts?: RequestOptions) =>
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
questions: (lessonId: number | string, opts?: RequestOptions) =>
http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
.then((r) => r.questions),
complete: (lessonId: number | string) =>
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
}, },
/* ── صفحه‌ی درس و سوال ─────────────────────────────────────────────── */ nodes: {
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` را بدهید تا اگر شبکه قطع شد و کاربر دوباره زد، * قدم‌های یک مبحث برای نقشه‌ی مهارت — عنوان، نوع، زیرنویس و «انجام شد».
* امتیاز/قلب دوبار حساب نشود. * `home()` فقط شناسه‌ی درس‌ها را می‌دهد؛ بدونِ این، برای هر کارت یک
* درخواستِ intro جدا لازم بود.
*/ */
answer: (questionId: number, selectedOptionId: number, idempotencyKey?: string) => lessons: (nodeId: number | string, opts?: RequestOptions) =>
raw<AnswerResult>( http.get<NodeLessons>(`/nodes/${nodeId}/lessons`, opts),
'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 } : {},
),
}, },
/* ── صفحه‌ی «از رُبو بپرس» ─────────────────────────────────────────── */ questions: {
/**
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
* `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
*/
answer: (questionId: number | string, selectedOptionId: number | string) =>
http.create<AnswerResponse>(`/questions/${questionId}/answer`, { selectedOptionId }),
/** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمی‌سازد. */
report: (questionId: number | string, reason: ReportReason, note = '') =>
http.post<ReportResponse>(`/questions/${questionId}/report`, { reason, note }),
},
/* ── دستیار ───────────────────────────────────────────────────────── */
ai: { ai: {
/** /**
* `lessonId` را بدهید تا پاسخ به کتابِ همان درس مستند شود؛ لازم نیست * فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* subject/grade/chapter را خودتان حساب کنید. * فصل است و پاسخ را به کتاب مستند می‌کند.
* `conversationId` را از پاسخِ قبلی پاس بدهید تا گفتگو ادامه پیدا کند. *
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
*/ */
ask: (input: { ask: (
userQuestion: string; userQuestion: string,
lessonId?: number | string; ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {},
conversationId?: string; opts?: RequestOptions,
questionId?: number | string; ) =>
idempotencyKey?: string; http.post<AskResponse>('/ai/ask', { userQuestion, ...ctx }, {
}) => { timeoutMs: ASK_TIMEOUT_MS,
const { idempotencyKey, ...body } = input; ...opts,
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) => /** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `reason` اختیاری. */
raw<{ conversationId: string; lessonId: string | null; messages: unknown[] }>( feedback: (messageId: number | string, isHelpful: boolean, reason?: string) =>
'GET', `/ai/conversations/${conversationId}/messages`, http.post<{ success: boolean }>(
`/ai/messages/${messageId}/feedback`, { isHelpful, reason },
), ),
}, },
/* ── صفحه‌ی اشتراک ─────────────────────────────────────────────────── */ /* ── اشتراک ───────────────────────────────────────────────────────── */
subscription: { plans: (opts?: RequestOptions) =>
plans: () => raw<{ plans: Plan[] }>('GET', '/subscription/plans').then((r) => r.plans), http.get<{ plans: Plan[] }>('/subscription/plans', opts).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>; export type Api = ReturnType<typeof createApi>;
/** نمونه‌ی پیش‌فرض — برای حالتِ عادی همین کافی‌ست. */
export const api = createApi();

View File

@@ -12,6 +12,9 @@ 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[];
} }
@@ -19,6 +22,8 @@ export interface HomeNode {
export interface HomeUnit { export interface HomeUnit {
id: string; id: string;
title: string; title: string;
/** یک جمله‌ی کوتاه زیرِ عنوانِ فصل. */
blurb: string;
nodes: HomeNode[]; nodes: HomeNode[];
} }
@@ -38,6 +43,8 @@ export interface Grade {
title: string; title: string;
icon: string; icon: string;
color: string; color: string;
/** صفر یعنی این پایه هنوز محتوایی ندارد — گزینه‌اش را غیرفعال کنید. */
lessonCount: number;
} }
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */ /** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */
@@ -53,14 +60,40 @@ 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;
@@ -119,4 +152,3 @@ export interface ReportResponse {
reportId: number; reportId: number;
alreadyReported: boolean; alreadyReported: boolean;
} }
*/

File diff suppressed because it is too large Load Diff