Files
MathApp_frontend/src/api/index.ts
AliReza 21cce5ea2b make the relative /api paths in App.tsx actually reach the backend
کدِ auth داخلِ App.tsx مسیرهای نسبی صدا می‌زند (`/api/auth/login` و مانندش).
این مسیرها به دامنه‌ی خودِ فرانت می‌روند، nginx چون آن مسیر را نمی‌شناسد
index.html برمی‌گرداند، و نتیجه پاسخِ ۲۰۰ با بدنه‌ی HTML است — یعنی res.ok
درست به نظر می‌رسد و بعد res.json() با «Unexpected token '<'» می‌شکند.
همین الان روی math.tadrisino2.ir قابلِ بازتولید است.

در حالتِ توسعه پروکسیِ vite حلش می‌کند: هر /api به بک‌اند فرستاده می‌شود و
کدِ موجود بدونِ هیچ تغییری کار می‌کند. مقصد با VITE_API_PROXY_TARGET قابلِ
عوض کردن است، پیش‌فرض بک‌اندِ مشترک.

برای بیلدِ نهایی apiUrl() اضافه شد: fetch(apiUrl('/auth/login')) به‌جای
fetch('/api/auth/login'). یا اگر روی سرور مسیرِ /api به بک‌اند پروکسی شود،
مسیرهای نسبی همان‌طور که هستند درست کار می‌کنند — آن راه حتی بهتر است چون
same-origin می‌شود و CORS از میان برمی‌دارد.

App.tsx دست نخورده؛ تصمیمِ اینکه کدام راه را بروند با خودشان است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:25:55 +03:30

178 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بک‌اند به آن نگاه کند.
*
* import { api } from '@/api';
* const home = await api.home();
*
* هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* درستِ خطا همه اینجا یک‌جا جمع‌اند.
*
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
* غافلگیرکننده‌اند کامنت خورده‌اند، چون اشتباهشان خطای ۴۰۰/۴۰۴ می‌دهد که
* از پیامش علت معلوم نیست.
*/
import { ApiError, createHttpClient, localTokenStorage } from './client';
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
import type {
AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, NodeLessons, Plan,
Question, ReportReason, ReportResponse, UserProfile,
} from './types';
export { ApiError, localTokenStorage, createHttpClient };
export type { Tokens, HttpClient, ClientOptions, RequestOptions };
export * from './types';
/**
* آدرسِ کاملِ یک مسیرِ API.
*
* fetch(apiUrl('/auth/login'), …)
*
* برای کدی که هنوز مستقیم fetch می‌زند. مسیرِ نسبی (`/api/auth/login`) در
* بیلدِ نهایی به دامنه‌ی خودِ فرانت می‌رود، nginx چون مسیر را نمی‌شناسد
* index.html برمی‌گرداند، و نتیجه پاسخِ ۲۰۰ با بدنه‌ی HTML است که موقعِ
* res.json() با «Unexpected token '<'» می‌شکند.
*/
export function apiUrl(path: string): string {
const base = createHttpClient().baseUrl;
return `${base}${path.startsWith('/') ? path : `/${path}`}`;
}
/** پرسش از دستیار کند است (تولیدِ متن)؛ ۱۵ ثانیه‌ی پیش‌فرض کم است. */
const ASK_TIMEOUT_MS = 45_000;
export function createApi(options: ClientOptions = {}) {
const http = createHttpClient(options);
return {
http,
/* ── ورود ─────────────────────────────────────────────────────────── */
auth: {
/** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
async guest(deviceId: string) {
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;
},
/** گامِ ۱ — در حالتِ توسعه `devOtp` هم برمی‌گردد. */
requestOtp(phone: string) {
return http.post<{ success: boolean; devOtp?: string }>(
'/auth/otp/request', { phone },
);
},
/** گامِ ۲ — نامِ فیلد در سرور `otp` است، نه `code`. */
async login(phone: string, otp: string) {
const data = await http.post<{ userId: string } & Tokens>(
'/auth/login', { phone, otp },
);
http.storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
},
logout() {
http.storage.set(null);
},
isSignedIn() {
return Boolean(http.storage.get()?.token);
},
},
/* ── کاربر ────────────────────────────────────────────────────────── */
profile: {
get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
/**
* فقط فیلدهایی که می‌فرستید عوض می‌شوند. صفحه‌ی ثبت‌نام سن می‌پرسد، پس
* `age` هم پذیرفته می‌شود و سرور خودش به سالِ تولدِ شمسی تبدیلش می‌کند —
* `age` و `birthYear` را با هم نفرستید.
*/
update: (patch: Partial<Pick<UserProfile,
'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>> & { age?: number }) =>
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),
/** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده می‌شود. */
home: (gradeId?: number, opts?: RequestOptions) =>
http.get<HomeResponse>(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts),
/* ── درس و آزمون ──────────────────────────────────────────────────── */
lessons: {
intro: (lessonId: number | string, opts?: RequestOptions) =>
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
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`, {}),
},
nodes: {
/**
* قدم‌های یک مبحث برای نقشه‌ی مهارت — عنوان، نوع، زیرنویس و «انجام شد».
* `home()` فقط شناسه‌ی درس‌ها را می‌دهد؛ بدونِ این، برای هر کارت یک
* درخواستِ intro جدا لازم بود.
*/
lessons: (nodeId: number | string, opts?: RequestOptions) =>
http.get<NodeLessons>(`/nodes/${nodeId}/lessons`, opts),
},
questions: {
/**
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
* `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
*/
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 }),
},
/* ── دستیار ───────────────────────────────────────────────────────── */
ai: {
/**
* فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* فصل است و پاسخ را به کتاب مستند می‌کند.
*
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
*/
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,
}),
/** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `reason` اختیاری. */
feedback: (messageId: number | string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(
`/ai/messages/${messageId}/feedback`, { isHelpful, reason },
),
},
/* ── اشتراک ───────────────────────────────────────────────────────── */
plans: (opts?: RequestOptions) =>
http.get<{ plans: Plan[] }>('/subscription/plans', opts).then((r) => r.plans),
};
}
export type Api = ReturnType<typeof createApi>;
/** نمونه‌ی پیش‌فرض — برای حالتِ عادی همین کافی‌ست. */
export const api = createApi();