افزودنِ لایه‌ی اتصال به 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 بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* درستِ خطا همه اینجا یک‌جا جمع‌اند.
*
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
* غافلگیرکننده‌اند کامنت خورده‌اند، چون اشتباهشان خطای ۴۰۰/۴۰۴ می‌دهد که
* از پیامش علت معلوم نیست.
*/
import { createHttpClient, ApiError, localTokenStorage } from './client';
import { ApiError, createHttpClient, localTokenStorage } from './client';
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
import type {
AnswerResult, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question, UserProfile,
AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question,
ReportReason, ReportResponse, UserProfile,
} from './types';
export { ApiError, localTokenStorage, createHttpClient };
@@ -28,11 +33,11 @@ export function createApi(options: ClientOptions = {}) {
/* ── ورود ─────────────────────────────────────────────────────────── */
auth: {
/** «شروع سریع» بدونِ شماره. برای دمو کافی‌ست. */
/** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
async guest(deviceId: string) {
const data = await http.post<{ userId: string; token: string; isNewUser: boolean }>(
'/auth/session', { type: 'guest', deviceId },
);
const data = await http.post<{
userId: string; token: string; isNewUser: boolean; isGuest: boolean;
}>('/auth/session', { type: 'guest', deviceId });
http.storage.set({ token: data.token, refreshToken: '' });
return data;
},
@@ -61,12 +66,17 @@ export function createApi(options: ClientOptions = {}) {
/* ── کاربر ────────────────────────────────────────────────────────── */
profile: {
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),
setGrade: (gradeId: number) =>
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) =>
http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
@@ -77,17 +87,27 @@ export function createApi(options: ClientOptions = {}) {
/* ── درس و آزمون ──────────────────────────────────────────────────── */
lessons: {
intro: (lessonId: number, opts?: RequestOptions) =>
intro: (lessonId: number | string, opts?: RequestOptions) =>
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)
.then((r) => r.questions),
complete: (lessonId: number | string) =>
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
},
questions: {
/**
* ثبتِ پاسخ. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا تکرارِ خودکارِ
* شبکه، دو بار XP ندهد.
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
* `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
*/
answer: (lessonId: number, questionId: number, choiceId: number) =>
http.create<AnswerResult>(`/lessons/${lessonId}/answer`, { questionId, choiceId }),
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 }),
},
/* ── دستیار ───────────────────────────────────────────────────────── */
@@ -95,15 +115,24 @@ export function createApi(options: ClientOptions = {}) {
/**
* فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* فصل است و پاسخ را به کتاب مستند می‌کند.
*
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
*/
ask: (question: string, ctx: { lessonId?: number; conversationId?: number } = {}, opts?: RequestOptions) =>
http.post<AskResponse>('/ai/ask', { question, ...ctx }, {
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,
}),
/** `reason` اختیاری‌ست ولی برای تحلیلِ کیفیت خیلی ارزش دارد. */
feedback: (messageId: number, vote: 'up' | 'down', reason?: string) =>
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { vote, reason }),
/** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `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 واقعیِ سرویس نوشته شده، نه حدس.
* نامِ فیلدها دقیقاً همان چیزی‌ست که سرور می‌دهد (camelCase).
* تایپ‌های پاسخِ بک‌اند — مستقیم از OpenAPI واقعیِ سرویس، نه از حدس.
*
* دو نکته که موقعِ نوشتن غافلگیرکننده بودند و اگر رعایت نشوند درخواست‌ها
* بی‌سروصدا شکست می‌خورند:
* • شناسه‌ها در پاسخ‌ها اغلب رشته‌اند، نه عدد.
* • فیلدِ پرسش در /ai/ask اسمش userQuestion است، نه question.
*/
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode {
id: string;
title: string;
status: NodeStatus;
status: NodeStatusValue;
lessonIds: number[];
}
@@ -36,58 +40,82 @@ export interface Grade {
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 | null;
bio?: string | null;
xp: number;
coins: number;
streak: { current: number; longest: number };
subscription: string;
birthYear: number | null;
parentPhone: string;
bio: string;
}
export interface LessonIntro {
lessonId: number;
lessonId: string;
title: string;
introText: string;
mascotAnimation: string;
questionCount: number;
}
export interface Choice {
id: number;
text: string;
export interface ChoiceOption {
id: string;
value: string;
}
export interface Question {
id: number;
text: string;
choices: Choice[];
id: string;
type: string;
prompt: string;
options: ChoiceOption[];
}
export interface AnswerResult {
/** `mascotMood`: پاسخِ درست → happy، غلط → encourage. */
export interface AnswerResponse {
correct: boolean;
correctChoiceId: number;
explanation?: string;
xpEarned?: number;
correctOptionId: string;
xpEarned: number;
heartsRemaining: number;
mascotMood: string;
}
/** پاسخِ دستیار. `grounded=false` یعنی از کتاب پیدا نشد و پاسخ عمومی است. */
export interface UsedChunk {
title: string;
summary: string;
}
/**
* `usedChunks` خالی یعنی پاسخ از کتاب پیدا نشده و عمومی است — برای نشان دادنِ
* برچسبِ «بر اساسِ کتاب» در برابرِ «پاسخِ عمومی» همین کافی‌ست.
*/
export interface AskResponse {
conversationId: string;
messageId: string;
answer: string;
conversationId: number;
messageId: number;
grounded?: boolean;
sources?: { title: string; score?: number }[];
suggestedFollowUps: string[];
remainingQuota: number;
usedChunks: UsedChunk[];
retrievalDebug?: Record<string, unknown>;
}
export interface Plan {
id: number;
id: string;
title: string;
price: number;
currency: string;
popular: boolean;
discountPercent: number;
durationDays: number;
features: string[];
}
export type ReportReason =
| 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
export interface ReportResponse {
success: boolean;
reportId: number;
alreadyReported: boolean;
}