افزودنِ لایه‌ی اتصال به API بک‌اند (بدونِ تغییر در App.tsx) #1

Merged
Zrabg merged 2 commits from AliReza_Zareian/MathApp_frontend:zareian/api-client into main 2026-09-13 22:07:56 +03:30
2 changed files with 104 additions and 47 deletions
Showing only changes of commit f61c58eb76 - Show all commits

View File

@@ -6,11 +6,16 @@
* *
* هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ * هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* درستِ خطا همه اینجا یک‌جا جمع‌اند. * درستِ خطا همه اینجا یک‌جا جمع‌اند.
*
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
* غافلگیرکننده‌اند کامنت خورده‌اند، چون اشتباهشان خطای ۴۰۰/۴۰۴ می‌دهد که
* از پیامش علت معلوم نیست.
*/ */
import { createHttpClient, ApiError, localTokenStorage } from './client'; import { ApiError, createHttpClient, localTokenStorage } from './client';
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client'; import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
import type { import type {
AnswerResult, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question, UserProfile, AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question,
ReportReason, ReportResponse, UserProfile,
} from './types'; } from './types';
export { ApiError, localTokenStorage, createHttpClient }; export { ApiError, localTokenStorage, createHttpClient };
@@ -28,11 +33,11 @@ export function createApi(options: ClientOptions = {}) {
/* ── ورود ─────────────────────────────────────────────────────────── */ /* ── ورود ─────────────────────────────────────────────────────────── */
auth: { auth: {
/** «شروع سریع» بدونِ شماره. برای دمو کافی‌ست. */ /** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
async guest(deviceId: string) { async guest(deviceId: string) {
const data = await http.post<{ userId: string; token: string; isNewUser: boolean }>( const data = await http.post<{
'/auth/session', { type: 'guest', deviceId }, userId: string; token: string; isNewUser: boolean; isGuest: boolean;
); }>('/auth/session', { type: 'guest', deviceId });
http.storage.set({ token: data.token, refreshToken: '' }); http.storage.set({ token: data.token, refreshToken: '' });
return data; return data;
}, },
@@ -61,12 +66,17 @@ export function createApi(options: ClientOptions = {}) {
/* ── کاربر ────────────────────────────────────────────────────────── */ /* ── کاربر ────────────────────────────────────────────────────────── */
profile: { profile: {
get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts), get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) => update: (patch: Partial<Pick<UserProfile,
'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
http.patch<UserProfile>('/user/profile', patch), http.patch<UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) => setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
}, },
/** xp/سکه/استریک اینجاست، نه در profile. */
summary: (opts?: RequestOptions) =>
http.get<Record<string, unknown>>('/user/summary', opts),
/** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */ /** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */
grades: (opts?: RequestOptions) => grades: (opts?: RequestOptions) =>
http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades), http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
@@ -77,17 +87,27 @@ export function createApi(options: ClientOptions = {}) {
/* ── درس و آزمون ──────────────────────────────────────────────────── */ /* ── درس و آزمون ──────────────────────────────────────────────────── */
lessons: { lessons: {
intro: (lessonId: number, opts?: RequestOptions) => intro: (lessonId: number | string, opts?: RequestOptions) =>
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts), http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
questions: (lessonId: number, opts?: RequestOptions) => questions: (lessonId: number | string, opts?: RequestOptions) =>
http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts) http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
.then((r) => r.questions), .then((r) => r.questions),
complete: (lessonId: number | string) =>
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
},
questions: {
/** /**
* ثبتِ پاسخ. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا تکرارِ خودکارِ * ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
* شبکه، دو بار XP ندهد. * `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
*/ */
answer: (lessonId: number, questionId: number, choiceId: number) => answer: (questionId: number | string, selectedOptionId: number | string) =>
http.create<AnswerResult>(`/lessons/${lessonId}/answer`, { questionId, choiceId }), http.create<AnswerResponse>(`/questions/${questionId}/answer`, { selectedOptionId }),
/** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمی‌سازد. */
report: (questionId: number | string, reason: ReportReason, note = '') =>
http.post<ReportResponse>(`/questions/${questionId}/report`, { reason, note }),
}, },
/* ── دستیار ───────────────────────────────────────────────────────── */ /* ── دستیار ───────────────────────────────────────────────────────── */
@@ -95,15 +115,24 @@ export function createApi(options: ClientOptions = {}) {
/** /**
* فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و * فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* فصل است و پاسخ را به کتاب مستند می‌کند. * فصل است و پاسخ را به کتاب مستند می‌کند.
*
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
*/ */
ask: (question: string, ctx: { lessonId?: number; conversationId?: number } = {}, opts?: RequestOptions) => ask: (
http.post<AskResponse>('/ai/ask', { question, ...ctx }, { userQuestion: string,
ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {},
opts?: RequestOptions,
) =>
http.post<AskResponse>('/ai/ask', { userQuestion, ...ctx }, {
timeoutMs: ASK_TIMEOUT_MS, timeoutMs: ASK_TIMEOUT_MS,
...opts, ...opts,
}), }),
/** `reason` اختیاری‌ست ولی برای تحلیلِ کیفیت خیلی ارزش دارد. */
feedback: (messageId: number, vote: 'up' | 'down', reason?: string) => /** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `reason` اختیاری. */
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { vote, reason }), feedback: (messageId: number | string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(
`/ai/messages/${messageId}/feedback`, { isHelpful, reason },
),
}, },
/* ── اشتراک ───────────────────────────────────────────────────────── */ /* ── اشتراک ───────────────────────────────────────────────────────── */

View File

@@ -1,14 +1,18 @@
/** /**
* تایپ‌های پاسخِ بک‌اند. از روی OpenAPI واقعیِ سرویس نوشته شده، نه حدس. * تایپ‌های پاسخِ بک‌اند — مستقیم از OpenAPI واقعیِ سرویس، نه از حدس.
* نامِ فیلدها دقیقاً همان چیزی‌ست که سرور می‌دهد (camelCase). *
* دو نکته که موقعِ نوشتن غافلگیرکننده بودند و اگر رعایت نشوند درخواست‌ها
* بی‌سروصدا شکست می‌خورند:
* • شناسه‌ها در پاسخ‌ها اغلب رشته‌اند، نه عدد.
* • فیلدِ پرسش در /ai/ask اسمش userQuestion است، نه question.
*/ */
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed'; export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode { export interface HomeNode {
id: string; id: string;
title: string; title: string;
status: NodeStatus; status: NodeStatusValue;
lessonIds: number[]; lessonIds: number[];
} }
@@ -36,58 +40,82 @@ export interface Grade {
color: string; color: string;
} }
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */
export interface UserProfile { export interface UserProfile {
userId: string; userId: string;
phone: string;
name: string; name: string;
avatar: string; avatar: string;
isGuest: boolean;
gradeId: number | null; gradeId: number | null;
birthYear?: number | null; birthYear: number | null;
parentPhone?: string | null; parentPhone: string;
bio?: string | null; bio: string;
xp: number;
coins: number;
streak: { current: number; longest: number };
subscription: string;
} }
export interface LessonIntro { export interface LessonIntro {
lessonId: number; lessonId: string;
title: string; title: string;
introText: string; introText: string;
mascotAnimation: string;
questionCount: number; questionCount: number;
} }
export interface Choice { export interface ChoiceOption {
id: number; id: string;
text: string; value: string;
} }
export interface Question { export interface Question {
id: number; id: string;
text: string; type: string;
choices: Choice[]; prompt: string;
options: ChoiceOption[];
} }
export interface AnswerResult { /** `mascotMood`: پاسخِ درست → happy، غلط → encourage. */
export interface AnswerResponse {
correct: boolean; correct: boolean;
correctChoiceId: number; correctOptionId: string;
explanation?: string; xpEarned: number;
xpEarned?: number; heartsRemaining: number;
mascotMood: string;
} }
/** پاسخِ دستیار. `grounded=false` یعنی از کتاب پیدا نشد و پاسخ عمومی است. */ export interface UsedChunk {
title: string;
summary: string;
}
/**
* `usedChunks` خالی یعنی پاسخ از کتاب پیدا نشده و عمومی است — برای نشان دادنِ
* برچسبِ «بر اساسِ کتاب» در برابرِ «پاسخِ عمومی» همین کافی‌ست.
*/
export interface AskResponse { export interface AskResponse {
conversationId: string;
messageId: string;
answer: string; answer: string;
conversationId: number; suggestedFollowUps: string[];
messageId: number; remainingQuota: number;
grounded?: boolean; usedChunks: UsedChunk[];
sources?: { title: string; score?: number }[]; retrievalDebug?: Record<string, unknown>;
} }
export interface Plan { export interface Plan {
id: number; id: string;
title: string; title: string;
price: number; price: number;
currency: string;
popular: boolean;
discountPercent: number;
durationDays: number; durationDays: number;
features: string[]; }
export type ReportReason =
| 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
export interface ReportResponse {
success: boolean;
reportId: number;
alreadyReported: boolean;
} }