diff --git a/src/api/index.ts b/src/api/index.ts index fa56793..d4385af 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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('/user/profile', opts), - update: (patch: Partial>) => + update: (patch: Partial>) => http.patch('/user/profile', patch), setGrade: (gradeId: number) => http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), }, + /** xp/سکه/استریک اینجاست، نه در profile. */ + summary: (opts?: RequestOptions) => + http.get>('/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(`/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>(`/lessons/${lessonId}/complete`, {}), + }, + + questions: { /** - * ثبتِ پاسخ. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا تکرارِ خودکارِ - * شبکه، دو بار XP ندهد. + * ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد + * `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا + * تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند. */ - answer: (lessonId: number, questionId: number, choiceId: number) => - http.create(`/lessons/${lessonId}/answer`, { questionId, choiceId }), + answer: (questionId: number | string, selectedOptionId: number | string) => + http.create(`/questions/${questionId}/answer`, { selectedOptionId }), + + /** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمی‌سازد. */ + report: (questionId: number | string, reason: ReportReason, note = '') => + http.post(`/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('/ai/ask', { question, ...ctx }, { + ask: ( + userQuestion: string, + ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {}, + opts?: RequestOptions, + ) => + http.post('/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 }, + ), }, /* ── اشتراک ───────────────────────────────────────────────────────── */ diff --git a/src/api/types.ts b/src/api/types.ts index 40f1111..3ba424f 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; } 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; }