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