From 7dd457d2fc76cd3c09cd663aa2227971d3f137df Mon Sep 17 00:00:00 2001 From: AliReza Date: Mon, 14 Sep 2026 01:50:04 +0330 Subject: [PATCH 1/3] expose the fields and endpoint the screens were missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit بعد از merge شدنِ PR اول، صفحه‌های اپ با API مقایسه شدند و چند چیز کم بود. همه‌ی تغییراتِ بک‌اند افزایشی‌اند، پس کدِ فعلی نمی‌شکند — این فقط دسترسی به آن‌ها را اضافه می‌کند. grades() حالا lessonCount دارد: فهرستِ پایه‌ها تا دوازدهم می‌رود ولی محتوا تا نهم است، و بدونِ این عدد کاربر پایه‌ی خالی را انتخاب می‌کند و صفحه‌ی خالی می‌بیند بی‌آنکه بفهمد چرا. home() در هر مبحث icon و color و در هر فصل blurb می‌دهد، تا رنگ‌ها لازم نباشد در کد هاردکد بمانند. nodes.lessons(nodeId) تازه است. home() فقط شناسه‌ی درس‌ها را می‌دهد، ولی کارتِ نقشه‌ی مهارت عنوان، نوع (آزمون/ویدیو)، زیرنویس («۵ سوال» / «۱۲ دقیقه») و «انجام شد» می‌خواهد — بدونِ این، برای هر کارت یک درخواستِ intro جدا لازم بود. lessons.intro() متن را سه‌تکه می‌دهد: introText (توضیح)، example، و steps (حلِ گام‌به‌گام)؛ به‌علاوه kind و videoUrl و durationMinutes. profile.update({ age }) هم پذیرفته می‌شود چون صفحه‌ی ثبت‌نام سن می‌پرسد؛ سرور خودش به سالِ تولدِ شمسی تبدیل می‌کند. هنوز هیچ فایلِ موجودی دست نخورده و پکیجی اضافه نشده. هر ۲۰ صدا در برابرِ یک بک‌اندِ در حالِ اجرا تست شد. Co-Authored-By: Claude Opus 5 --- src/api/README.md | 23 +++++++++++++++++++---- src/api/index.ts | 21 ++++++++++++++++++--- src/api/types.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/api/README.md b/src/api/README.md index a678b23..5b8ff2d 100644 --- a/src/api/README.md +++ b/src/api/README.md @@ -35,12 +35,27 @@ import { ConnectionCheck } from '@/api/ConnectionCheck'; ```tsx import { api, ApiError } from '@/api'; -const grades = await api.grades(); -const home = await api.home(); // پایه‌ی خودِ کاربر -const intro = await api.lessons.intro(lessonId); -const answer = await api.ai.ask('این رو نفهمیدم', { lessonId }); +const grades = await api.grades(); // lessonCount=0 یعنی پایه خالی است +const home = await api.home(); // بدونِ آرگومان: پایه‌ی خودِ کاربر +const steps = await api.nodes.lessons(nodeId); // کارت‌های نقشه‌ی مهارت +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` می‌گوید آزمون است یا ویدیو. +- `profile.update({ age })` هم کار می‌کند؛ سرور خودش به سالِ تولدِ شمسی تبدیل + می‌کند. `age` و `birthYear` را با هم نفرستید. + ### ورود ```tsx diff --git a/src/api/index.ts b/src/api/index.ts index d4385af..d87069c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,8 +14,8 @@ import { ApiError, createHttpClient, localTokenStorage } from './client'; import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client'; import type { - AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, Plan, Question, - ReportReason, ReportResponse, UserProfile, + AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, NodeLessons, Plan, + Question, ReportReason, ReportResponse, UserProfile, } from './types'; export { ApiError, localTokenStorage, createHttpClient }; @@ -66,8 +66,13 @@ export function createApi(options: ClientOptions = {}) { /* ── کاربر ────────────────────────────────────────────────────────── */ profile: { get: (opts?: RequestOptions) => http.get('/user/profile', opts), + /** + * فقط فیلدهایی که می‌فرستید عوض می‌شوند. صفحه‌ی ثبت‌نام سن می‌پرسد، پس + * `age` هم پذیرفته می‌شود و سرور خودش به سالِ تولدِ شمسی تبدیلش می‌کند — + * `age` و `birthYear` را با هم نفرستید. + */ update: (patch: Partial>) => + 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>> & { age?: number }) => http.patch('/user/profile', patch), setGrade: (gradeId: number) => http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), @@ -96,6 +101,16 @@ export function createApi(options: ClientOptions = {}) { http.create>(`/lessons/${lessonId}/complete`, {}), }, + nodes: { + /** + * قدم‌های یک مبحث برای نقشه‌ی مهارت — عنوان، نوع، زیرنویس و «انجام شد». + * `home()` فقط شناسه‌ی درس‌ها را می‌دهد؛ بدونِ این، برای هر کارت یک + * درخواستِ intro جدا لازم بود. + */ + lessons: (nodeId: number | string, opts?: RequestOptions) => + http.get(`/nodes/${nodeId}/lessons`, opts), + }, + questions: { /** * ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد diff --git a/src/api/types.ts b/src/api/types.ts index 3ba424f..5646a71 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -12,6 +12,9 @@ export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed export interface HomeNode { id: string; title: string; + /** آیکون و رنگِ کارتِ مبحث — از سرور می‌آید، هاردکد نکنید. */ + icon: string; + color: string; status: NodeStatusValue; lessonIds: number[]; } @@ -19,6 +22,8 @@ export interface HomeNode { export interface HomeUnit { id: string; title: string; + /** یک جمله‌ی کوتاه زیرِ عنوانِ فصل. */ + blurb: string; nodes: HomeNode[]; } @@ -38,6 +43,8 @@ export interface Grade { title: string; icon: string; color: string; + /** صفر یعنی این پایه هنوز محتوایی ندارد — گزینه‌اش را غیرفعال کنید. */ + lessonCount: number; } /** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */ @@ -53,14 +60,40 @@ export interface UserProfile { bio: string; } +/** یک قدم در مسیر: آزمون یا ویدیو. */ +export type LessonKind = 'quiz' | 'video'; + export interface LessonIntro { lessonId: string; title: string; + kind: LessonKind; + /** متنِ درس سه تکه است: توضیح، مثال، و حلِ گام‌به‌گام. */ introText: string; + example: string; + steps: string[]; + /** فقط وقتی kind === 'video' پر است. */ + videoUrl: string; + durationMinutes: number | null; mascotAnimation: string; 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 { id: string; value: string; -- 2.49.1 From 007c450c0a390feb35706fcb3b762de7246a555e Mon Sep 17 00:00:00 2001 From: AliReza Date: Mon, 14 Sep 2026 02:03:19 +0330 Subject: [PATCH 2/3] add the subscription card fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plans() حالا features و badge می‌دهد؛ بندهای کارتِ اشتراک و برچسبِ بالایش دیگر لازم نیست در کد بمانند و عوض کردنشان به دیپلویِ تازه نیاز ندارد. Co-Authored-By: Claude Opus 5 --- src/api/README.md | 2 ++ src/api/types.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/api/README.md b/src/api/README.md index 5b8ff2d..9d88c1c 100644 --- a/src/api/README.md +++ b/src/api/README.md @@ -53,6 +53,8 @@ const answer = await api.ai.ask('این رو نفهمیدم', { lessonId }); استفاده کنید — یک درخواست برای کلِ مبحث، نه یکی به‌ازای هر کارت. - `lessons.intro()` متن را سه‌تکه می‌دهد: `introText` (توضیح)، `example`، و `steps` (حلِ گام‌به‌گام). `kind` می‌گوید آزمون است یا ویدیو. +- `plans()` حالا `features` و `badge` می‌دهد — بندهای کارتِ اشتراک و برچسبِ + بالایش دیگر لازم نیست در کد بمانند؛ عوض کردنشان به دیپلویِ تازه نیاز ندارد. - `profile.update({ age })` هم کار می‌کند؛ سرور خودش به سالِ تولدِ شمسی تبدیل می‌کند. `age` و `birthYear` را با هم نفرستید. diff --git a/src/api/types.ts b/src/api/types.ts index 5646a71..88add4e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -142,6 +142,10 @@ export interface Plan { popular: boolean; discountPercent: number; durationDays: number; + /** بندهای زیرِ کارتِ پلن — از سرور می‌آید، دیگر لازم نیست در کد بماند. */ + features: string[]; + /** برچسبِ بالای کارت: «محبوب‌ترین»، «بهترین ارزش». خالی یعنی بدونِ برچسب. */ + badge: string; } export type ReportReason = -- 2.49.1 From 21cce5ea2b983b5a017894de091cedabb082c1e6 Mon Sep 17 00:00:00 2001 From: AliReza Date: Mon, 14 Sep 2026 02:25:55 +0330 Subject: [PATCH 3/3] make the relative /api paths in App.tsx actually reach the backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit کدِ 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 --- src/api/index.ts | 15 +++++++++++++++ vite.config.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/api/index.ts b/src/api/index.ts index d87069c..d44957a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -22,6 +22,21 @@ 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; diff --git a/vite.config.ts b/vite.config.ts index 13dfa3a..527936a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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. assetsInclude: ['**/*.svg', '**/*.csv'], }) -- 2.49.1