افزودنِ لایهی اتصال به API بکاند (بدونِ تغییر در App.tsx) #1
@@ -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 },
|
||||
),
|
||||
},
|
||||
|
||||
/* ── اشتراک ───────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user