۲ — فیلدها و اندپوینتی که صفحه‌ها لازم داشتند #3

Closed
AliReza_Zareian wants to merge 3 commits from AliReza_Zareian/MathApp_frontend:zareian/api-client-2 into main
4 changed files with 109 additions and 7 deletions

View File

@@ -35,12 +35,29 @@ import { ConnectionCheck } from '@/api/ConnectionCheck';
```tsx ```tsx
import { api, ApiError } from '@/api'; import { api, ApiError } from '@/api';
const grades = await api.grades(); const grades = await api.grades(); // lessonCount=0 یعنی پایه خالی است
const home = await api.home(); // پایه‌ی خودِ کاربر const home = await api.home(); // بدونِ آرگومان: پایه‌ی خودِ کاربر
const intro = await api.lessons.intro(lessonId); const steps = await api.nodes.lessons(nodeId); // کارت‌های نقشه‌ی مهارت
const answer = await api.ai.ask('این رو نفهمیدم', { lessonId }); const intro = await api.lessons.intro(lessonId); // توضیح + مثال + گام‌به‌گام
const answer = await api.ai.ask('این رو نفهمیدم', { lessonId });
``` ```
### چند نکته که وقت می‌گیرند اگر ندانید
- `grades()` حالا `lessonCount` می‌دهد. صفر یعنی آن پایه هنوز محتوایی ندارد —
گزینه‌اش را غیرفعال کنید، وگرنه کاربر انتخابش می‌کند و صفحه‌ی خالی می‌بیند.
- `home()` در هر مبحث `icon` و `color` و در هر فصل `blurb` می‌دهد؛ لازم نیست
رنگ‌ها را هاردکد کنید.
- `home()` فقط شناسه‌ی درس‌ها را می‌دهد. برای کارت‌های نقشه‌ی مهارت (عنوان،
آزمون یا ویدیو، «۵ سوال»/«۱۲ دقیقه»، انجام‌شده) از `nodes.lessons(nodeId)`
استفاده کنید — یک درخواست برای کلِ مبحث، نه یکی به‌ازای هر کارت.
- `lessons.intro()` متن را سه‌تکه می‌دهد: `introText` (توضیح)، `example`، و
`steps` (حلِ گام‌به‌گام). `kind` می‌گوید آزمون است یا ویدیو.
- `plans()` حالا `features` و `badge` می‌دهد — بندهای کارتِ اشتراک و برچسبِ
بالایش دیگر لازم نیست در کد بمانند؛ عوض کردنشان به دیپلویِ تازه نیاز ندارد.
- `profile.update({ age })` هم کار می‌کند؛ سرور خودش به سالِ تولدِ شمسی تبدیل
می‌کند. `age` و `birthYear` را با هم نفرستید.
### ورود ### ورود
```tsx ```tsx

View File

@@ -14,14 +14,29 @@
import { ApiError, createHttpClient, 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 {
AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question, AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, NodeLessons, Plan,
ReportReason, ReportResponse, UserProfile, Question, ReportReason, ReportResponse, UserProfile,
} from './types'; } from './types';
export { ApiError, localTokenStorage, createHttpClient }; export { ApiError, localTokenStorage, createHttpClient };
export type { Tokens, HttpClient, ClientOptions, RequestOptions }; export type { Tokens, HttpClient, ClientOptions, RequestOptions };
export * from './types'; 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; const ASK_TIMEOUT_MS = 45_000;
@@ -66,8 +81,13 @@ 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),
/**
* فقط فیلدهایی که می‌فرستید عوض می‌شوند. صفحه‌ی ثبت‌نام سن می‌پرسد، پس
* `age` هم پذیرفته می‌شود و سرور خودش به سالِ تولدِ شمسی تبدیلش می‌کند —
* `age` و `birthYear` را با هم نفرستید.
*/
update: (patch: Partial<Pick<UserProfile, update: (patch: Partial<Pick<UserProfile,
'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) => 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>> & { age?: number }) =>
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 }),
@@ -96,6 +116,16 @@ export function createApi(options: ClientOptions = {}) {
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}), 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: {
/** /**
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد * ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد

View File

@@ -12,6 +12,9 @@ export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed
export interface HomeNode { export interface HomeNode {
id: string; id: string;
title: string; title: string;
/** آیکون و رنگِ کارتِ مبحث — از سرور می‌آید، هاردکد نکنید. */
icon: string;
color: string;
status: NodeStatusValue; status: NodeStatusValue;
lessonIds: number[]; lessonIds: number[];
} }
@@ -19,6 +22,8 @@ export interface HomeNode {
export interface HomeUnit { export interface HomeUnit {
id: string; id: string;
title: string; title: string;
/** یک جمله‌ی کوتاه زیرِ عنوانِ فصل. */
blurb: string;
nodes: HomeNode[]; nodes: HomeNode[];
} }
@@ -38,6 +43,8 @@ export interface Grade {
title: string; title: string;
icon: string; icon: string;
color: string; color: string;
/** صفر یعنی این پایه هنوز محتوایی ندارد — گزینه‌اش را غیرفعال کنید. */
lessonCount: number;
} }
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */ /** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */
@@ -53,14 +60,40 @@ export interface UserProfile {
bio: string; bio: string;
} }
/** یک قدم در مسیر: آزمون یا ویدیو. */
export type LessonKind = 'quiz' | 'video';
export interface LessonIntro { export interface LessonIntro {
lessonId: string; lessonId: string;
title: string; title: string;
kind: LessonKind;
/** متنِ درس سه تکه است: توضیح، مثال، و حلِ گام‌به‌گام. */
introText: string; introText: string;
example: string;
steps: string[];
/** فقط وقتی kind === 'video' پر است. */
videoUrl: string;
durationMinutes: number | null;
mascotAnimation: string; mascotAnimation: string;
questionCount: number; questionCount: number;
} }
/** یک کارت در نقشه‌ی مهارت. */
export interface NodeLesson {
id: string;
kind: LessonKind;
title: string;
/** زیرنویسِ کارت: «۵ سوال» یا «۱۲ دقیقه». */
meta: string;
done: boolean;
}
export interface NodeLessons {
nodeId: string;
title: string;
lessons: NodeLesson[];
}
export interface ChoiceOption { export interface ChoiceOption {
id: string; id: string;
value: string; value: string;
@@ -109,6 +142,10 @@ export interface Plan {
popular: boolean; popular: boolean;
discountPercent: number; discountPercent: number;
durationDays: number; durationDays: number;
/** بندهای زیرِ کارتِ پلن — از سرور می‌آید، دیگر لازم نیست در کد بماند. */
features: string[];
/** برچسبِ بالای کارت: «محبوب‌ترین»، «بهترین ارزش». خالی یعنی بدونِ برچسب. */
badge: string;
} }
export type ReportReason = export type ReportReason =

View File

@@ -31,6 +31,24 @@ export default defineConfig({
}, },
}, },
// در حالتِ توسعه، هر درخواستِ /api به بک‌اند فرستاده می‌شود.
//
// چرا لازم است: کدِ اپ مسیرهای نسبی صدا می‌زند (مثلاً /api/auth/login). بدونِ
// این پروکسی، آن درخواست به خودِ سرورِ vite می‌رود و چون مسیرِ ناشناخته است
// index.html برمی‌گردد — یعنی پاسخِ ۲۰۰ ولی HTML، و کد موقعِ res.json() با
// «Unexpected token '<'» می‌شکند. خطایی که علتش از پیامش معلوم نیست.
//
// مقصد را با VITE_API_PROXY_TARGET عوض کنید (پیش‌فرض: بک‌اندِ مشترک).
server: {
proxy: {
'/api': {
target: process.env.VITE_API_PROXY_TARGET || 'https://api.math.tadrisino2.ir',
changeOrigin: true,
secure: true,
},
},
},
// File types to support raw imports. Never add .css, .tsx, or .ts files to this. // File types to support raw imports. Never add .css, .tsx, or .ts files to this.
assetsInclude: ['**/*.svg', '**/*.csv'], assetsInclude: ['**/*.svg', '**/*.csv'],
}) })