From 74e048cc2a6af68ff2479332ed7ed72a8da17561 Mon Sep 17 00:00:00 2001 From: zrabg Date: Mon, 21 Sep 2026 14:14:38 +0330 Subject: [PATCH] api --- package-lock.json | 16 - src/api/index.ts | 543 +++++++++++++++----- src/api/types.ts | 1 + src/app/App.tsx | 1244 ++++++++++++++++++++++----------------------- 4 files changed, 1024 insertions(+), 780 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34fa3cd..0a30838 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5600,22 +5600,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } } } } diff --git a/src/api/index.ts b/src/api/index.ts index d4385af..15bbfca 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,147 +1,442 @@ /** - * API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بک‌اند به آن نگاه کند. + * کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend). * - * import { api } from '@/api'; - * const home = await api.home(); + * از روی OpenAPI واقعیِ همین بک‌اند نوشته شده، نه از روی حدس. نامِ فیلدها + * دقیقاً همان چیزی‌ست که سرور برمی‌گرداند (camelCase). * - * هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ - * درستِ خطا همه اینجا یک‌جا جمع‌اند. - * - * هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که - * غافلگیرکننده‌اند کامنت خورده‌اند، چون اشتباهشان خطای ۴۰۰/۴۰۴ می‌دهد که - * از پیامش علت معلوم نیست. + * نکته‌های مهمِ اتصال: + * - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم + * نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده. + * - توکنِ دسترسی امضاشده و کوتاه‌عمر است؛ با ۴۰۱ باید `refresh` صدا زد. + * این کلاینت خودش یک‌بار refresh می‌کند و درخواست را تکرار. + * - برای پرسش از دستیار کافی‌ست `lessonId` بدهید؛ سرور خودش می‌فهمد آن درس + * مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند می‌کند. */ -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, -} from './types'; -export { ApiError, localTokenStorage, createHttpClient }; -export type { Tokens, HttpClient, ClientOptions, RequestOptions }; -export * from './types'; +export type Json = Record; -/** پرسش از دستیار کند است (تولیدِ متن)؛ ۱۵ ثانیه‌ی پیش‌فرض کم است. */ -const ASK_TIMEOUT_MS = 45_000; +export interface Tokens { + token: string; + refreshToken: string; +} -export function createApi(options: ClientOptions = {}) { - const http = createHttpClient(options); +/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */ - return { - http, +export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed'; - /* ── ورود ─────────────────────────────────────────────────────────── */ - 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); - }, - }, +export interface HomeNode { + id: string; + title: string; + status: NodeStatus; + lessonIds: number[]; +} +export interface HomeUnit { + id: string; + title: string; + nodes: HomeNode[]; +} +export interface HomeResponse { + units: HomeUnit[]; + stats: { progressPercent: number; xp: number; coins: number }; +} - /* ── کاربر ────────────────────────────────────────────────────────── */ - profile: { - get: (opts?: RequestOptions) => http.get('/user/profile', opts), - update: (patch: Partial>) => - http.patch('/user/profile', patch), - setGrade: (gradeId: number) => - http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), - }, +export interface Grade { + id: number; + title: string; + icon: string; + color: string; +} - /** xp/سکه/استریک اینجاست، نه در profile. */ - summary: (opts?: RequestOptions) => - http.get>('/user/summary', opts), +export interface UserSummary { + userId: string; + name: string; + avatar: string; + gradeId: number | null; + xp: number; + coins: number; + streak: { current: number; longest: number }; + subscription: string; +} - /** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */ - grades: (opts?: RequestOptions) => - http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades), +export interface UserProfile { + userId: string; + phone: string | null; + name: string; + avatar: string; + isGuest: boolean; + gradeId: number | null; + birthYear: number | null; + parentPhone: string; + bio: string; +} - /** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده می‌شود. */ - home: (gradeId?: number, opts?: RequestOptions) => - http.get(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts), +export interface LessonIntro { + lessonId: string; + title: string; + introText: string; + /** برای انتخابِ حالتِ ماسکوت در صفحه‌ی درس */ + mascotAnimation: string; + /** + * صفر یعنی متنِ درس هست ولی آزمونش هنوز ساخته نشده — «به‌زودی» نشان دهید، + * نه کویزِ خالی. `complete` هم `totalQuestions: 0` را نمی‌پذیرد. + */ + questionCount: number; +} - /* ── درس و آزمون ──────────────────────────────────────────────────── */ - lessons: { - intro: (lessonId: number | string, opts?: RequestOptions) => - http.get(`/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>(`/lessons/${lessonId}/complete`, {}), - }, +export interface QuestionOption { + id: string; + value: string; +} +export interface Question { + id: string; + type: 'multiple_choice' | 'count_objects'; + prompt: string; + options: QuestionOption[]; +} - questions: { - /** - * ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد - * `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا - * تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند. - */ - answer: (questionId: number | string, selectedOptionId: number | string) => - http.create(`/questions/${questionId}/answer`, { selectedOptionId }), +export interface AnswerResult { + correct: boolean; + correctOptionId: string; + xpEarned: number; + heartsRemaining: number; + /** حالتِ ماسکوت: درست → happy، غلط → encourage */ + mascotMood: string; +} - /** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمی‌سازد. */ - report: (questionId: number | string, reason: ReportReason, note = '') => - http.post(`/questions/${questionId}/report`, { reason, note }), - }, +export interface LessonComplete { + stars: number; + xpEarned: number; + coinsEarned: number; + stats: { progressPercent: number; xp: number; coins: number }; +} - /* ── دستیار ───────────────────────────────────────────────────────── */ - ai: { - /** - * فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و - * فصل است و پاسخ را به کتاب مستند می‌کند. - * - * ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`. - */ - ask: ( - userQuestion: string, - ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {}, - opts?: RequestOptions, - ) => - http.post('/ai/ask', { userQuestion, ...ctx }, { - timeoutMs: ASK_TIMEOUT_MS, - ...opts, - }), +export interface Hearts { + currentHearts: number; + maxHearts: number; + nextRefillAt: string | null; + isUnlimited: boolean; +} - /** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `reason` اختیاری. */ - feedback: (messageId: number | string, isHelpful: boolean, reason?: string) => - http.post<{ success: boolean }>( - `/ai/messages/${messageId}/feedback`, { isHelpful, reason }, - ), - }, +export interface AskResponse { + conversationId: string; + messageId: string; + answer: string; + suggestedFollowUps: string[]; + remainingQuota: number; + /** تکه‌های کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */ + usedChunks: { title: string; summary: string }[]; +} - /* ── اشتراک ───────────────────────────────────────────────────────── */ - plans: (opts?: RequestOptions) => - http.get<{ plans: Plan[] }>('/subscription/plans', opts).then((r) => r.plans), +export interface Plan { + id: string; + title: string; + price: number; + durationDays: number; + features: string[]; +} + +/* ─── خطا ───────────────────────────────────────────────────────────────── */ + +export class ApiError extends Error { + constructor( + readonly status: number, + readonly detail: string, + readonly body: Json, + ) { + super(detail); + this.name = 'ApiError'; + } + + /** ۴۲۹: throttle یا اتمامِ سهمیه‌ی روزانه‌ی دستیار */ + get isRateLimited() { + return this.status === 429; + } + /** ۵۰۳: سرویسِ هوش مصنوعی/پیامک موقتاً در دسترس نیست */ + get isProviderDown() { + return this.status === 503; + } +} + +/* ─── کلاینت ────────────────────────────────────────────────────────────── */ + +export interface ClientOptions { + baseUrl?: string; + /** خواندن/نوشتنِ توکن؛ پیش‌فرض localStorage */ + storage?: { + get(): Tokens | null; + set(t: Tokens | null): void; }; } -export type Api = ReturnType; +const STORAGE_KEY = 'tadrisino.tokens'; -/** نمونه‌ی پیش‌فرض — برای حالتِ عادی همین کافی‌ست. */ -export const api = createApi(); +function defaultStorage() { + return { + get(): Tokens | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as Tokens) : null; + } catch { + return null; + } + }, + set(t: Tokens | null) { + try { + if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t)); + else localStorage.removeItem(STORAGE_KEY); + } catch { + /* حالتِ خصوصیِ مرورگر — نادیده */ + } + }, + }; +} + +export function createClient(options: ClientOptions = {}) { + const baseUrl = (options.baseUrl ?? '/api').replace(/\/$/, ''); + const storage = options.storage ?? defaultStorage(); + + async function raw( + method: string, + path: string, + body?: unknown, + extraHeaders: Record = {}, + retryOn401 = true, + ): Promise { + const tokens = storage.get(); + const headers: Record = { ...extraHeaders }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (tokens?.token) headers['Authorization'] = `Bearer ${tokens.token}`; + + const response = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (response.status === 401 && retryOn401 && tokens?.refreshToken) { + const refreshed = await refresh(tokens.refreshToken).catch(() => null); + if (refreshed) return raw(method, path, body, extraHeaders, false); + storage.set(null); + } + + const text = await response.text(); + const parsed: Json = text ? (JSON.parse(text) as Json) : {}; + if (!response.ok) { + const detail = + (parsed.detail as string) ?? + (parsed.error as string) ?? + `خطای ${response.status}`; + throw new ApiError(response.status, detail, parsed); + } + return parsed as T; + } + + async function refresh(refreshToken: string) { + const response = await fetch(`${baseUrl}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + if (!response.ok) return null; + const data = (await response.json()) as Tokens; + storage.set({ token: data.token, refreshToken: data.refreshToken }); + return data; + } + + return { + /* ── ورود (صفحه‌ای که فعلاً در فرانت نیست و باید ساخته شود) ────────── */ + auth: { + /** مهمان: بدونِ شماره، فقط با شناسه‌ی دستگاه. برای «شروع سریع». */ + async guest(deviceId: string) { + const data = await raw<{ userId: string; token: string; isNewUser: boolean; isGuest: boolean }>( + 'POST', '/auth/session', { type: 'guest', deviceId }, + ); + storage.set({ token: data.token, refreshToken: '' }); + return data; + }, + /** گامِ ۱: ارسالِ پیامکِ کد. در حالتِ توسعه `devOtp` هم برمی‌گردد. */ + requestOtp(phone: string) { + return raw<{ success: boolean; devOtp?: string; note?: string }>( + 'POST', '/auth/otp/request', { phone }, + ); + }, + /** گامِ ۲: تأییدِ کد و گرفتنِ توکن. نامِ فیلد در سرور `otp` است. */ + async login(phone: string, otp: string) { + const data = await raw<{ userId: string } & Tokens>( + 'POST', '/auth/login', { phone, otp }, + ); + storage.set({ token: data.token, refreshToken: data.refreshToken }); + return data; + }, + logout() { + storage.set(null); + }, + isSignedIn() { + return Boolean(storage.get()?.token); + }, + }, + + /* ── صفحه‌ی «بیا باهم آشنا بشیم» ──────────────────────────────────── */ + profile: { + get: () => raw('GET', '/user/profile'), + /** اسم/آواتار/… . فقط فیلدهایی که می‌فرستید عوض می‌شوند. */ + update: (patch: Partial>) => + raw('PATCH', '/user/profile', patch), + setGrade: (gradeId: number) => + raw<{ success: boolean; gradeId: number }>('POST', '/user/grade', { gradeId }), + }, + + /** فهرستِ پایه‌ها را از سرور بگیرید، هاردکد نکنید — پایه‌ها اضافه می‌شوند. */ + grades: () => raw<{ grades: Grade[] }>('GET', '/grades').then((r) => r.grades), + + /* ── صفحه‌ی خانه و نقشه‌ی مهارت ────────────────────────────────────── */ + /** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ + home: (gradeId?: number) => + raw('GET', gradeId ? `/home?grade=${gradeId}` : '/home'), + summary: () => raw('GET', '/user/summary'), + hearts: () => raw('GET', '/user/hearts'), + streakCalendar: () => + raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>( + 'GET', '/user/streak/calendar', + ), + leaderboard: (period: 'weekly' | 'monthly' = 'weekly') => + raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`), + + /* ── صفحه‌ی درس و سوال ─────────────────────────────────────────────── */ + lesson: { + intro: (lessonId: number) => raw('GET', `/lessons/${lessonId}/intro`), + questions: (lessonId: number) => + raw<{ questions: Question[] }>('GET', `/lessons/${lessonId}/questions`).then((r) => r.questions), + /** + * `idempotencyKey` را بدهید تا اگر شبکه قطع شد و کاربر دوباره زد، + * امتیاز/قلب دوبار حساب نشود. + */ + answer: (questionId: number, selectedOptionId: number, idempotencyKey?: string) => + raw( + 'POST', `/questions/${questionId}/answer`, { selectedOptionId }, + idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, + ), + /** + * پایانِ درس. `correctCount` را سرور با تعدادِ واقعیِ سوال‌ها سقف می‌زند. + * مجموعِ XPِ کاربر **اینجا** به‌روز می‌شود، نه سرِ هر پاسخ. + */ + complete: ( + lessonId: number, + result: { correctCount: number; totalQuestions: number }, + idempotencyKey?: string, + ) => + raw( + 'POST', `/lessons/${lessonId}/complete`, result, + idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, + ), + }, + + /* ── صفحه‌ی «از رُبو بپرس» ─────────────────────────────────────────── */ + ai: { + /** + * `lessonId` را بدهید تا پاسخ به کتابِ همان درس مستند شود؛ لازم نیست + * subject/grade/chapter را خودتان حساب کنید. + * `conversationId` را از پاسخِ قبلی پاس بدهید تا گفتگو ادامه پیدا کند. + */ + ask: (input: { + userQuestion: string; + lessonId?: number | string; + conversationId?: string; + questionId?: number | string; + idempotencyKey?: string; + }) => { + const { idempotencyKey, ...body } = input; + return raw( + 'POST', '/ai/ask', body, + idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, + ); + }, + + /** + * همان ask ولی توکن‌به‌توکن (SSE). اولین کلمه ~۳.۵ ثانیه به‌جای ~۱۰. + * برای حبابِ چتِ «در حالِ تایپ» همین را استفاده کنید. + */ + async askStream( + input: { userQuestion: string; lessonId?: number | string; conversationId?: string }, + onDelta: (text: string) => void, + ): Promise<{ conversationId?: string; messageId?: string }> { + const tokens = storage.get(); + const response = await fetch(`${baseUrl}/ai/ask`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(tokens?.token ? { Authorization: `Bearer ${tokens.token}` } : {}), + }, + body: JSON.stringify({ ...input, stream: true }), + }); + if (!response.ok || !response.body) { + throw new ApiError(response.status, 'استریم شروع نشد', {}); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let done: { conversationId?: string; messageId?: string } = {}; + + for (;;) { + const { value, done: finished } = await reader.read(); + if (finished) break; + buffer += decoder.decode(value, { stream: true }); + + // رویدادها با یک خطِ خالی از هم جدا می‌شوند. + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + for (const part of parts) { + const event = /^event: (.+)$/m.exec(part)?.[1]; + const dataLine = /^data: (.+)$/m.exec(part)?.[1]; + if (!event || !dataLine) continue; + const payload = JSON.parse(dataLine) as Json; + if (event === 'chunk') onDelta(String(payload.delta ?? '')); + else if (event === 'done') done = payload as typeof done; + else if (event === 'error') { + throw new ApiError(503, String(payload.detail ?? 'خطای دستیار'), payload); + } + } + } + return done; + }, + + /** 👍/👎 روی یک پاسخ. `reason` اختیاری ولی خیلی مفید است. */ + feedback: (messageId: string | number, isHelpful: boolean, reason?: string) => + raw<{ success: boolean }>('POST', `/ai/messages/${messageId}/feedback`, { + isHelpful, + ...(reason ? { reason } : {}), + }), + + history: (conversationId: string | number) => + raw<{ conversationId: string; lessonId: string | null; messages: unknown[] }>( + 'GET', `/ai/conversations/${conversationId}/messages`, + ), + }, + + /* ── صفحه‌ی اشتراک ─────────────────────────────────────────────────── */ + subscription: { + plans: () => raw<{ plans: Plan[] }>('GET', '/subscription/plans').then((r) => r.plans), + status: () => + raw<{ active: boolean; planId: string | null; expiresAt: string | null }>( + 'GET', '/subscription/status', + ), + /** + * `paymentUrl` را در مرورگر باز کنید. آن آدرس خودش سراغِ زرین‌پال + * می‌رود؛ اپ نباید مستقیم با درگاه حرف بزند. + */ + purchase: (planId: string, idempotencyKey?: string) => + raw<{ orderId: string; paymentUrl: string }>( + 'POST', '/subscription/purchase', { planId }, + idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, + ), + }, + + notifications: { + list: () => raw<{ items: unknown[]; unreadCount: number }>('GET', '/notifications'), + markRead: (id: number) => raw('POST', `/notifications/${id}/read`), + }, + }; +} + +export type TadrisinoClient = ReturnType; diff --git a/src/api/types.ts b/src/api/types.ts index 3ba424f..904355d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -119,3 +119,4 @@ export interface ReportResponse { reportId: number; alreadyReported: boolean; } + */ \ No newline at end of file diff --git a/src/app/App.tsx b/src/app/App.tsx index 1a59f21..d6f8953 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -8,57 +8,16 @@ import { Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, Flame, Star, Trophy, Heart, } from "lucide-react"; +import { createClient, ApiError } from "../api"; +import type { + Grade as ApiGrade, HomeResponse, HomeUnit, HomeNode, NodeStatus, + LessonIntro, Question as ApiQuestion, AnswerResult, Plan as ApiPlan, +} from "../api"; -// ─── Auth Helpers ─────────────────────────────────────────────────────────── - -let onAuthExpired: (() => void) | null = null; -function setAuthExpiredHandler(fn: (() => void) | null) { onAuthExpired = fn; } - -async function refreshAuthToken(): Promise { - const refreshToken = localStorage.getItem("refreshToken"); - if (!refreshToken) return null; // مهمان‌ها refreshToken ندارن - try { - const res = await fetch("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - }); - if (!res.ok) throw new Error(); - const data: { token: string; expiresIn: number } = await res.json(); - localStorage.setItem("token", data.token); - localStorage.setItem("tokenExpiresAt", String(Date.now() + data.expiresIn * 1000)); - return data.token; - } catch { - return null; - } -}async function authFetch(path: string, options: RequestInit = {}): Promise { - const token = localStorage.getItem("token"); - const res = await fetch(path, { - ...options, - headers: { - ...options.headers, - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}) // ✅ اصلاح شد - }, - }); - - if (res.status !== 401) return res; - - const newToken = await refreshAuthToken(); - if (!newToken) { - onAuthExpired?.(); - return res; - } - - return fetch(path, { - ...options, - headers: { - ...options.headers, - "Content-Type": "application/json", - Authorization: `Bearer ${newToken}` - }, - }); -} +// ─── کلاینتِ API — تنها جایی که با بک‌اند حرف می‌زنیم ───────────────────────── +const api = createClient({ + baseUrl: (import.meta as { env?: Record }).env?.VITE_API_URL ?? "/api", +}); function getOrCreateDeviceId(): string { let id = localStorage.getItem("deviceId"); @@ -66,13 +25,6 @@ function getOrCreateDeviceId(): string { return id; } -function storeAuth(data: AuthData) { - localStorage.setItem("token", data.token); - if (data.refreshToken) localStorage.setItem("refreshToken", data.refreshToken); - localStorage.setItem("userId", data.userId); - localStorage.setItem("isGuest", String(!!data.isGuest)); -} - // ─── Robot Mascot ───────────────────────────────────────────────────────────── type RobotMood = "wave" | "point" | "explain" | "think" | "neutral" | "happy" | "encourage"; @@ -203,6 +155,10 @@ function RobotMascot({ mood, size = 120 }: { mood: RobotMood; size?: number }) { // ─── سه‌یار: مسکات‌های سیگما / دلتا / پی ───────────────────────────────────── // موتور تولید SVG سه کاراکتر، هر کدوم با ۵ حالت احساسی + یک حالت عادی. +// داستان: هر سه از دل یک دفترچه‌ی بی‌نهایت متولد شدن؛ هر کدوم از جنس یک نماد ریاضی. +// سیگما (Σ) → راهنمای آزمون و کوییز دلتا (Δ) → هم‌تیمیِ تمرین‌های سریع +// پی (π) → همراهِ دلگرمی (بعدِ جواب غلط یا غیبتِ طولانی) + type SeyarChar = "sigma" | "delta" | "pi"; type SeyarMood = "idle" | "happy" | "excited" | "sad" | "angry" | "depressed"; @@ -349,86 +305,48 @@ interface Profile { name: string; age: number; grade: string; + gradeName?: string; } -// ─── Math Topics (مباحث) ──────────────────────────────────────────────────── -interface Topic { id: string; name: string; icon: React.ElementType; color: string } -interface LessonNode { id: number; type: "video" | "quiz"; title: string; meta: string; done: boolean } +// ─── فصل‌ها و درس‌ها — کاملاً پویا از روی api.home() ──────────────────────── +// دیگه چیزی هاردکد نیست: هر پایه هر چند فصل و هر فصل هر چند ایستگاه که سرور +// برگردونه همونه. رنگ و آیکون رو چون سرور نمی‌ده، خودمون چرخشی می‌دیم. -const TOPICS: Topic[] = [ - { id: "linear-algebra", name: "جبر خطی", icon: Grid3x3, color: "#ED6956" }, - { id: "limits", name: "حد", icon: TrendingUp, color: "#91ADC6" }, - { id: "derivatives", name: "مشتق", icon: LineChart, color: "#97C0C0" }, - { id: "integrals", name: "انتگرال", icon: Sigma, color: "#9B7BFF" }, - { id: "geometry", name: "هندسه تحلیلی", icon: Compass, color: "#F3A9B0" }, - { id: "sequences", name: "دنباله و سری", icon: Repeat, color: "#FF7A3D" }, -]; +const UNIT_PALETTE = ["#ED6956", "#91ADC6", "#97C0C0", "#9B7BFF", "#FF7A3D", "#F3A9B0", "#4C5FD5", "#5FBF8F", "#E06BAE"]; +const UNIT_ICONS: React.ElementType[] = [Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain]; -const LESSONS_BY_TOPIC: Record = { - "linear-algebra": [ - { id: 1, type: "video", title: "بردار و فضای برداری", meta: "۱۲ دقیقه", done: true }, - { id: 2, type: "quiz", title: "آزمون بردارها", meta: "۵ سوال", done: true }, - { id: 3, type: "video", title: "ماتریس و عملیات آن", meta: "۱۴ دقیقه", done: true }, - { id: 4, type: "quiz", title: "آزمون ماتریس", meta: "۶ سوال", done: false }, - { id: 5, type: "video", title: "دترمینان", meta: "۱۰ دقیقه", done: false }, - { id: 6, type: "quiz", title: "آزمون دترمینان", meta: "۵ سوال", done: false }, - { id: 7, type: "video", title: "دستگاه معادلات خطی", meta: "۱۵ دقیقه", done: false }, - { id: 8, type: "quiz", title: "آزمون پایانی", meta: "۸ سوال", done: false }, - ], - "limits": [ - { id: 1, type: "video", title: "مفهوم حد", meta: "۱۰ دقیقه", done: true }, - { id: 2, type: "quiz", title: "آزمون مفهوم حد", meta: "۵ سوال", done: true }, - { id: 3, type: "video", title: "حد در بی‌نهایت", meta: "۹ دقیقه", done: false }, - { id: 4, type: "quiz", title: "آزمون حد در بی‌نهایت", meta: "۵ سوال", done: false }, - { id: 5, type: "video", title: "پیوستگی توابع", meta: "۱۱ دقیقه", done: false }, - { id: 6, type: "quiz", title: "آزمون پایانی", meta: "۷ سوال", done: false }, - ], - "derivatives": [ - { id: 1, type: "video", title: "مفهوم مشتق", meta: "۱۳ دقیقه", done: false }, - { id: 2, type: "quiz", title: "آزمون مفهوم مشتق", meta: "۵ سوال", done: false }, - { id: 3, type: "video", title: "قواعد مشتق‌گیری", meta: "۱۲ دقیقه", done: false }, - { id: 4, type: "quiz", title: "آزمون قواعد مشتق", meta: "۶ سوال", done: false }, - { id: 5, type: "video", title: "کاربرد مشتق", meta: "۱۴ دقیقه", done: false }, - { id: 6, type: "quiz", title: "آزمون پایانی", meta: "۸ سوال", done: false }, - ], - "integrals": [ - { id: 1, type: "video", title: "انتگرال نامعین", meta: "۱۲ دقیقه", done: false }, - { id: 2, type: "quiz", title: "آزمون انتگرال نامعین", meta: "۵ سوال", done: false }, - { id: 3, type: "video", title: "انتگرال معین", meta: "۱۳ دقیقه", done: false }, - { id: 4, type: "quiz", title: "آزمون انتگرال معین", meta: "۶ سوال", done: false }, - { id: 5, type: "video", title: "کاربرد انتگرال", meta: "۱۵ دقیقه", done: false }, - { id: 6, type: "quiz", title: "آزمون پایانی", meta: "۸ سوال", done: false }, - ], - "geometry": [ - { id: 1, type: "video", title: "معادله خط و دایره", meta: "۱۱ دقیقه", done: false }, - { id: 2, type: "quiz", title: "آزمون خط و دایره", meta: "۵ سوال", done: false }, - { id: 3, type: "video", title: "مقاطع مخروطی", meta: "۱۴ دقیقه", done: false }, - { id: 4, type: "quiz", title: "آزمون پایانی", meta: "۷ سوال", done: false }, - ], - "sequences": [ - { id: 1, type: "video", title: "دنباله‌های عددی", meta: "۱۰ دقیقه", done: false },{ id: 2, type: "quiz", title: "آزمون دنباله‌ها", meta: "۵ سوال", done: false }, - { id: 3, type: "video", title: "سری‌های همگرا", meta: "۱۳ دقیقه", done: false }, - { id: 4, type: "quiz", title: "آزمون پایانی", meta: "۷ سوال", done: false }, - ], +const unitColor = (i: number) => UNIT_PALETTE[i % UNIT_PALETTE.length]; +const unitIcon = (i: number) => UNIT_ICONS[i % UNIT_ICONS.length]; + +function unitProgress(unit: HomeUnit) { + const total = unit.nodes.length; + const done = unit.nodes.filter((n) => n.status === "completed").length; + return { done, total, percent: total ? Math.round((done / total) * 100) : 0 }; } -// ─── دسته‌بندیِ درس‌ها (مناسبِ دانش‌آموزِ دبیرستانی) ────────────────────────── -// مباحث بالا رو زیرِ سه گروهِ آشنا برای دانش‌آموزِ ریاضی/تجربیِ دبیرستان جمع می‌کنیم -interface LessonCategory { id: string; title: string; blurb: string; topicIds: string[] } - -const LESSON_CATEGORIES: LessonCategory[] = [ - { id: "algebra", title: "جبر و معادلات", blurb: "پایه‌ی همه‌چیز", topicIds: ["linear-algebra"] }, - { id: "calculus", title: "حسابان", blurb: "حد، مشتق، انتگرال", topicIds: ["limits", "derivatives", "integrals"] }, - { id: "geo-seq", title: "هندسه و دنباله", blurb: "دید فضایی و الگو", topicIds: ["geometry", "sequences"] }, -]; - -// اولین درسِ ناتمومِ دانش‌آموز رو پیدا می‌کنه (برای کارتِ «ادامه بده» تو خانه و درس‌ها) -function findContinueLesson(): { topic: Topic; lesson: LessonNode } { - for (const topic of TOPICS) { - const items = LESSONS_BY_TOPIC[topic.id] ?? []; - const next = items.find((item) => !item.done); - if (next) return { topic, lesson: next }; +/** اولین ایستگاهِ ناتمام رو تو کلِ نقشه پیدا می‌کنه (برای کارتِ «ادامه بده») */ +function findContinueNode(home: HomeResponse | null): { unit: HomeUnit; node: HomeNode; unitIndex: number } | null { + if (!home) return null; + for (let i = 0; i < home.units.length; i++) { + const node = home.units[i].nodes.find((n) => n.status !== "completed"); + if (node) return { unit: home.units[i], node, unitIndex: i }; } - return { topic: TOPICS[0], lesson: LESSONS_BY_TOPIC[TOPICS[0].id][0] }; + return null; +} + +/** چیدمانِ مارپیچِ نقشه، برای هر تعداد ایستگاه که لازم باشه */ +function zigzagPositions(count: number, spacingY = 148, startY = 70): { x: number; y: number }[] { + return Array.from({ length: count }, (_, i) => ({ x: i % 2 === 0 ? 30 : 74, y: startY + i * spacingY })); +} + +/** حالتِ ماسکوت که سرور با متن می‌ده (مثلاً «happy»/«encourage») رو به حالتِ سه‌یار نگاشت می‌کنه */ +function mapMascotMood(server: string | undefined | null): SeyarMood { + const s = (server ?? "").toLowerCase(); + if (s.includes("excited") || s.includes("celebrat")) return "excited"; + if (s.includes("happy")) return "happy"; + if (s.includes("sad") || s.includes("encourage")) return "sad"; + if (s.includes("angry")) return "angry"; + if (s.includes("depress") || s.includes("tired")) return "depressed"; + return "idle"; } // ─── Bottom Nav ─────────────────────────────────────────────────────────────── @@ -500,22 +418,22 @@ function OnboardingScreen({ onDone }: { onDone: () => void }) { const [step, setStep] = useState(0); const slides = [ { - mood: "happy" as SeyarMood, bg: "#ED6956", - title: "به تدریسینو خوش آمدی! 👋", + mood: "wave" as RobotMood, bg: "#ED6956", + title: "به تدریسینو خوش آمدی! 🤖", sub: "دستیار ریاضی هوشمند", - desc: "با سیگما ریاضی یاد بگیر و هر روز باهوش‌تر بشو. یادگیری می‌تونه هم آسون هم جالب باشه!", + desc: "با رُبو ریاضی یاد بگیر و هر روز باهوش‌تر بشو. یادگیری می‌تونه هم آسون هم جالب باشه!", }, { - mood: "idle" as SeyarMood, bg: "#91ADC6", + mood: "point" as RobotMood, bg: "#91ADC6", title: "مسیر ریاضیت رو بساز", sub: "گام به گام پیشرفت کن", - desc: "از جمع و تفریق شروع کن تا به ضرب، تقسیم و کسرها برسی. سیگما همه‌جا کنارته!", + desc: "از جمع و تفریق شروع کن تا به ضرب، تقسیم و کسرها برسی. رُبو همه‌جا کنارته!", }, { - mood: "happy" as SeyarMood, bg: "#97C0C0", + mood: "explain" as RobotMood, bg: "#97C0C0", title: "هر چیزی نفهمیدی بپرس!", sub: "توضیح ساده و صبورانه", - desc: "سیگما هر مسئله‌ای رو با مثال‌های جالب برات توضیح می‌ده تا مطمئن بشی خوب یاد گرفتی.", + desc: "رُبو هر مسئله‌ای رو با مثال‌های جالب برات توضیح می‌ده تا مطمئن بشی خوب یاد گرفتی.", }, ]; const slide = slides[step]; @@ -534,8 +452,12 @@ function OnboardingScreen({ onDone }: { onDone: () => void }) { transition={{ duration: 0.38 }} className="flex flex-col items-center gap-6">
- -
+ +

{slide.sub}

{slide.title}

@@ -576,10 +498,7 @@ function OnboardingScreen({ onDone }: { onDone: () => void }) { //-----login screen interface AuthData { userId: string; - token: string; - refreshToken?: string; // مهمان‌ها refreshToken ندارن - isGuest?: boolean; - isNewUser?: boolean; + isGuest: boolean; } function LoginScreen({ onDone }: { onDone: (auth: AuthData) => void }) { const [step, setStep] = useState<"phone" | "otp">("phone"); @@ -588,32 +507,29 @@ function LoginScreen({ onDone }: { onDone: (auth: AuthData) => void }) { const [devOtp, setDevOtp] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - // API تا وصل بشه، بعداً حذف - const devBypass = () => { - const fakeAuth: AuthData = { userId: "dev", token: "dev-token", refreshToken: "dev-refresh" }; - storeAuth(fakeAuth); - onDone(fakeAuth); - }; const phoneValid = /^09\d{9}$/.test(phone); const otpValid = otp.trim().length >= 4; + const friendlyError = (err: unknown, fallback: string) => { + if (err instanceof ApiError) { + if (err.isRateLimited) return "تعداد تلاش زیاد بوده، کمی صبر کن و دوباره امتحان کن"; + if (err.isProviderDown) return "سرویسِ پیامک موقتاً در دسترس نیست"; + return err.detail || fallback; + } + return fallback; + }; + const requestOtp = async () => { if (!phoneValid || loading) return; setError(""); setLoading(true); try { - const res = await fetch("/api/auth/otp/request", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ phone }), - }); - if (!res.ok) throw new Error(); - const data: { success: boolean; devOtp: string; note: string } = await res.json(); + const data = await api.auth.requestOtp(phone); setDevOtp(data.devOtp ?? null); setStep("otp"); - } catch { - setError("ارسال کد با خطا مواجه شد، دوباره امتحان کن"); + } catch (err) { + setError(friendlyError(err, "ارسال کد با خطا مواجه شد، دوباره امتحان کن")); } finally { setLoading(false); } @@ -624,41 +540,39 @@ function LoginScreen({ onDone }: { onDone: (auth: AuthData) => void }) { setError(""); setLoading(true); try { - const res = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ phone, otp: otp.trim() }), - }); - if (!res.ok) throw new Error(); - const data: AuthData = await res.json(); - storeAuth(data); - onDone(data); - } catch { - setError("کد وارد شده اشتباهه، دوباره امتحان کن"); + const data = await api.auth.login(phone, otp.trim()); + onDone({ userId: data.userId, isGuest: false }); + } catch (err) { + setError(friendlyError(err, "کد وارد شده اشتباهه، دوباره امتحان کن")); } finally { setLoading(false); } }; const continueAsGuest = async () => { - setError(""); - setLoading(true); + setError(""); + setLoading(true); + + try { + const deviceId = getOrCreateDeviceId(); + try { - const res = await fetch("/api/auth/session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "guest", deviceId: getOrCreateDeviceId() }), + const data = await api.auth.guest(deviceId); + onDone({ + userId: data.userId, + isGuest: true, }); - if (!res.ok) throw new Error(); - const data: AuthData = await res.json(); - storeAuth(data); - onDone(data); } catch { - setError("اتصال مهمان با خطا مواجه شد، دوباره امتحان کن"); - } finally { - setLoading(false); + // Backend guest endpoint unavailable → local guest + onDone({ + userId: `guest-${deviceId}`, + isGuest: true, + }); } - }; + } finally { + setLoading(false); + } +}; return (
@@ -736,25 +650,12 @@ function LoginScreen({ onDone }: { onDone: (auth: AuthData) => void }) { className="text-[#9e9ec0] text-xs font-bold text-center disabled:opacity-40"> ادامه به عنوان مهمان -
); } // ─── Profile Setup ──────────────────────────────────────────────────────────── -const GRADES = [ - { id: "7", label: "پایه هفتم", emoji: "7" }, - { id: "8", label: "پایه هشتم", emoji: "8" }, - { id: "9", label: "پایه نهم", emoji: "9" }, - { id: "10", label: "پایه دهم", emoji: "۱۰" }, - { id: "11", label: "پایه یازدهم", emoji: "۱۱" }, - { id: "12", label: "پایه دوازدهم", emoji: "۱۲" }, -]; - const AGES = Array.from({ length: 7 }, (_, i) => i + 12); const toPersianNumber = (value: number | string) => @@ -773,6 +674,14 @@ function ProfileSetupScreen({ const [age, setAge] = useState(15); const [grade, setGrade] = useState(""); const [touched, setTouched] = useState(false); + const [grades, setGrades] = useState([]); + const [gradesError, setGradesError] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + + useEffect(() => { + api.grades().then(setGrades).catch(() => setGradesError(true)); + }, []); const nameErr = touched && !name.trim(); const gradeErr = touched && !grade; @@ -847,17 +756,29 @@ const changeAge = (direction: number) => { return next; }); }; - const handleSubmit = () => { + const handleSubmit = async () => { setTouched(true); - if (!name.trim() || !grade) return; + if (!name.trim() || !grade || saving) return; - onDone({ - name: name.trim(), - age, - grade, - }); + setSaving(true); + setSaveError(""); + try { + await api.profile.update({ name: name.trim() }); + await api.profile.setGrade(Number(grade)); + const g = grades.find((x) => String(x.id) === grade); + onDone({ + name: name.trim(), + age, + grade, + gradeName: g?.title ?? "", + }); + } catch (err) { + setSaveError(err instanceof ApiError ? err.detail : "ذخیره نشد، دوباره امتحان کن"); + } finally { + setSaving(false); + } }; @@ -893,10 +814,11 @@ const changeAge = (direction: number) => {
- +
@@ -1057,18 +979,32 @@ const changeAge = (direction: number) => { کدوم پایه‌ای؟ + {grades.length === 0 && !gradesError && ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {gradesError && ( +
+ فهرستِ پایه‌ها نیومد؛ اتصالت رو چک کن و دوباره باز کن. +
+ )} +
- {GRADES.map((g) => { + {grades.map((g) => { - const selected = grade === g.id; + const selected = grade === String(g.id); return (
- {g.label} + {g.title} {selected && ( @@ -1131,31 +1068,17 @@ const changeAge = (direction: number) => { )}
- - - {/* ───────── ROBOT MESSAGE ───────── */} - -
- -
- + {saveError && ( +
+ {saveError}
- -

- رُبو بر اساس سن و پایه‌ات مسیر یادگیری مناسب خودت رو می‌سازه! -

- -
- + )} {/* ───────── START ───────── */} -
- {/* Progress bar */} -
-
+ ) : ( +
+ +

+ {home ? "همه‌ی فصل‌های این پایه رو تموم کردی! 🎉" : "در حال آوردنِ مسیر یادگیری..."} +

- -
+ )} {/* Quick access grid */}
@@ -1383,22 +1319,13 @@ function HomeScreen({ // ─── Topic Select ─────────────────────────────────────────────────────────── // چیدمانِ فصل‌ها روی نقشه — به‌شکلِ مارپیچ، مثلِ نقشه‌ی مهارت ولی برای کلِ فصل‌ها -const CHAPTER_POSITIONS = [ - { x: 30, y: 66 }, - { x: 74, y: 214 }, - { x: 26, y: 362 }, - { x: 74, y: 510 }, - { x: 30, y: 658 }, - { x: 70, y: 806 }, -]; - -function TopicSelectScreen({ onSelect }: { onSelect: (topic: Topic) => void }) { +function TopicSelectScreen({ home, onSelect }: { home: HomeResponse | null; onSelect: (unitId: string) => void }) { const toFa = (n: number) => String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]); - const mapHeight = CHAPTER_POSITIONS[CHAPTER_POSITIONS.length - 1].y + 190; + const units = home?.units ?? []; + const positions = zigzagPositions(units.length); + const mapHeight = positions.length ? positions[positions.length - 1].y + 190 : 400; - const overallDone = TOPICS.reduce((a, t) => a + (LESSONS_BY_TOPIC[t.id]?.filter((l) => l.done).length ?? 0), 0); - const overallTotal = TOPICS.reduce((a, t) => a + (LESSONS_BY_TOPIC[t.id]?.length ?? 0), 0); - const overallPct = overallTotal ? Math.round((overallDone / overallTotal) * 100) : 0; + const overallPct = home?.stats.progressPercent ?? 0; return (
@@ -1419,61 +1346,71 @@ function TopicSelectScreen({ onSelect }: { onSelect: (topic: Topic) => void }) { {/* Map */}
+ {!home ? ( +
+ +

در حال آوردنِ فصل‌ها...

+
+ ) : units.length === 0 ? ( +
+ +

هنوز فصلی برای این پایه ثبت نشده

+
+ ) : (
{/* مسیرِ اتصال بینِ خونه‌های فصل‌ها */} - {CHAPTER_POSITIONS.slice(0, -1).map((p1, i) => { - const p2 = CHAPTER_POSITIONS[i + 1]; + {positions.slice(0, -1).map((p1, i) => { + const p2 = positions[i + 1]; const midY = (p1.y + p2.y) / 2; const d = `M ${p1.x} ${p1.y} C ${p1.x} ${midY}, ${p2.x} ${midY}, ${p2.x} ${p2.y}`; - const started = (LESSONS_BY_TOPIC[TOPICS[i]?.id] ?? []).some((l) => l.done); + const started = units[i]?.nodes.some((n) => n.status !== "locked"); return ( + stroke={started ? unitColor(i) : "#dedced"} opacity={started ? 1 : 0.7}/> ); })} {/* خونه‌های فصل‌ها */} - {TOPICS.map((topic, i) => { - const pos = CHAPTER_POSITIONS[i]; + {units.map((unit, i) => { + const pos = positions[i]; if (!pos) return null; - const items = LESSONS_BY_TOPIC[topic.id] ?? []; - const done = items.filter((l) => l.done).length; - const percent = items.length ? Math.round((done / items.length) * 100) : 0; - const complete = percent === 100; - const Icon = topic.icon; + const prog = unitProgress(unit); + const complete = prog.percent === 100; + const color = unitColor(i); + const Icon = unitIcon(i); return ( - -
-
{topic.name}
-
{toFa(done)} از {toFa(items.length)} درس
+
{unit.title}
+
{toFa(prog.done)} از {toFa(prog.total)} ایستگاه
); @@ -1488,6 +1425,7 @@ function TopicSelectScreen({ onSelect }: { onSelect: (topic: Topic) => void }) {
+ )}
); @@ -1513,18 +1451,22 @@ const MAP_POSITIONS = [ ]; function SkillMapScreen({ - topicId, + unitId, + home, onOpenLesson, onBack, }: { - topicId: string; + unitId: string; + home: HomeResponse | null; onOpenLesson: (lessonId: number) => void; onBack: () => void; }) { - const topic = TOPICS.find((t) => t.id === topicId) ?? TOPICS[0]; - const items = LESSONS_BY_TOPIC[topic.id] ?? []; + const unitIndex = home?.units.findIndex((u) => u.id === unitId) ?? -1; + const unit = unitIndex >= 0 ? home!.units[unitIndex] : undefined; + const items = unit?.nodes ?? []; + const color = unitIndex >= 0 ? unitColor(unitIndex) : "#91ADC6"; - const doneCount = items.filter((it) => it.done).length; + const doneCount = items.filter((it) => it.status === "completed").length; const percent = items.length ? Math.round((doneCount / items.length) * 100) : 0; @@ -1567,7 +1509,7 @@ function SkillMapScreen({
{/* Decorative circles */} @@ -1597,7 +1539,7 @@ function SkillMapScreen({

- {topic.name} + {unit?.title ?? "..."}

@@ -1728,7 +1670,7 @@ function SkillMapScreen({ ${p2.x} ${p2.y} `; - const completed = items[i]?.done; + const completed = items[i]?.status === "completed"; return ( 0 && !items[i - 1].done; + const locked = item.status === "locked"; - const current = - !locked && !item.done; + const current = item.status === "unlocked" || item.status === "in_progress"; - const completed = - item.done; + const completed = item.status === "completed"; - const Icon = - item.type === "video" - ? Play - : Brain; + const Icon = Play; return ( @@ -1827,7 +1763,7 @@ function SkillMapScreen({

@@ -1853,7 +1789,7 @@ function SkillMapScreen({ }} className="absolute inset-[-13px] rounded-full" style={{ - backgroundColor: topic.color, + backgroundColor: color, }} /> @@ -1865,7 +1801,7 @@ function SkillMapScreen({ - {/* Lesson type */} - - {!locked && !current && ( - -
- {item.type === "video" - ? "ویدیو" - : "آزمون"} -
- - )} - ); @@ -1965,13 +1887,13 @@ function SkillMapScreen({
@@ -1992,18 +1914,22 @@ function SkillMapScreen({ // و یه دعوتِ کوچیک از دلتا برای یه چالشِ سریع. function LessonsHubScreen({ - onOpenTopic, + home, + onOpenUnit, onNavigate, }: { - onOpenTopic: (topicId: string) => void; + home: HomeResponse | null; + onOpenUnit: (unitId: string) => void; onNavigate: (s: MainScreen) => void; }) { const toFa = (n: number) => String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]); - // اولین درسِ ناتمام رو برای کارتِ «ادامه بده» پیدا می‌کنیم - const { topic: continueTopic, lesson: continueLesson } = findContinueLesson(); - const continueItems = LESSONS_BY_TOPIC[continueTopic.id] ?? []; - const continueDone = continueItems.filter((i) => i.done).length; + // اولین ایستگاهِ ناتمام رو برای کارتِ «ادامه بده» پیدا می‌کنیم + const cont = findContinueNode(home); + const cUnit = cont?.unit; + const cProg = cUnit ? unitProgress(cUnit) : { done: 0, total: 0, percent: 0 }; + const cColor = cont ? unitColor(cont.unitIndex) : "#97C0C0"; + const units = home?.units ?? []; return (
@@ -2022,59 +1948,65 @@ function LessonsHubScreen({
{/* Continue card */} -
-
-
-

ادامه از جایی که موندی

-

{continueTopic.name}

-

{continueLesson.title} · {toFa(continueDone)} از {toFa(continueItems.length)} درس

+ {cont && cUnit ? ( +
+
+
+

ادامه از جایی که موندی

+

{cUnit.title}

+

{cont.node.title} · {toFa(cProg.done)} از {toFa(cProg.total)} درس

+
+
+ +
-
- +
+
+
-
-
+ ) : ( +
+ +

+ {home ? "همه‌ی فصل‌ها تموم شدن! 🎉" : "در حال آوردنِ درس‌ها..."} +

- -
+ )} - {/* Categories */} - {LESSON_CATEGORIES.map((cat) => ( -
+ {/* همه‌ی فصل‌ها */} + {units.length > 0 && ( +
-

{cat.title}

- {cat.blurb} +

فصل‌های این پایه

+ {toFa(units.length)} فصل
- {cat.topicIds.map((tid) => { - const topic = TOPICS.find((t) => t.id === tid); - if (!topic) return null; - const items = LESSONS_BY_TOPIC[topic.id] ?? []; - const done = items.filter((i) => i.done).length; - const percent = items.length ? Math.round((done / items.length) * 100) : 0; - const Icon = topic.icon; + {units.map((unit, i) => { + const prog = unitProgress(unit); + const color = unitColor(i); + const Icon = unitIcon(i); return ( - ); })}
- ))} + )} {/* دعوتِ دلتا به یه چالشِ سریع */}
-
- درس {toFa(idx + 1)} از {toFa(items.length)} · {topic.name} -
-

{lesson.title}

-

{topic.name}

+ {unit && ( +
+ ایستگاه {toFa(idx + 1)} از {toFa(unit.nodes.length)} · {unit.title} +
+ )} +

{intro?.title ?? "..."}

- +
-
- {[`⏱ ${lesson.meta}`, lesson.type === "quiz" ? "آزمون" : "ویدیو"].map(t => ( - {t} - ))} -
@@ -2203,39 +2083,27 @@ function LessonScreen({
-

- {content - ? content.explain - : `این درس بخشی از فصلِ «${topic.name}»‌ه. تو ویدیو، «${lesson.title}» رو قدم‌به‌قدم با هم جلو می‌ریم.`} -

- {content && ( -
-

مثال:

-

{content.example}

-
+ {loadErr ? ( +

این درس الان بارگذاری نشد. یه بار دیگه امتحان کن.

+ ) : !intro ? ( +

در حال آوردنِ توضیح درس...

+ ) : ( +

{intro.introText}

)}
- {content && ( -
-

مرحله به مرحله

- {content.steps.map((t, i) => ( -
-
- {toFa(i + 1)} -
-

{t}

-
- ))} + {intro && intro.questionCount === 0 ? ( +
+ آزمونِ این درس به‌زودی اضافه می‌شه
+ ) : ( + )} - -
); @@ -2243,43 +2111,93 @@ function LessonScreen({ // ─── Question Screen ────────────────────────────────────────────────────────── -function QuestionScreen({ onNavigate, onBack }: { onNavigate: (s: MainScreen) => void; onBack: () => void }) { - const [sel, setSel] = useState(null); - const [qIdx, setQIdx] = useState(0); - const [lives, setLives] = useState(5); +function QuestionScreen({ lessonId, onNavigate, onBack, onComplete }: { lessonId: number; onNavigate: (s: MainScreen) => void; onBack: () => void; onComplete?: () => void }) { + const [questions, setQuestions] = useState(null); + const [loadErr, setLoadErr] = useState(false); + const [qIdx, setQIdx] = useState(0); + const [sel, setSel] = useState(null); + const [result, setResult] = useState(null); + const [answering, setAnswering] = useState(false); + const [lives, setLives] = useState(5); + const [correctCount, setCorrectCount] = useState(0); const outOfLives = lives <= 0; - const Qs = [ - { q: "جواب این مسئله چیه؟", p: "۱۲ × ۳ = ?", opts: ["۳۴","۳۶","۳۸","۳۰"], c: 1 }, - { q: "کدوم جواب درسته؟", p: "۲۵ × ۴ = ?", opts: ["۹۰","۱۰۵","۱۰۰","۸۰"], c: 2 }, - { q: "حساب کن:", p: "۱۵ × ۶ = ?", opts: ["۸۰","۹۵","۹۰","۸۵"], c: 2 }, - { q: "جواب درست رو پیدا کن:", p: "۱۱ × ۹ = ?", opts: ["۹۹","۱۰۱","۹۸","۱۰۰"], c: 0 }, - ]; - const q = Qs[qIdx]; - const correct = sel === q.c; + useEffect(() => { + api.hearts().then((h) => setLives(h.currentHearts)).catch(() => {}); + }, []); + + useEffect(() => { + setQuestions(null); + setLoadErr(false); + api.lesson.questions(lessonId).then(setQuestions).catch(() => setLoadErr(true)); + }, [lessonId]); + + const q = questions?.[qIdx]; + const correct = result?.correct ?? false; // سه‌یار: قبل از جواب سیگما راهنماست، جوابِ درست یعنی دلتا، جوابِ غلط یعنی پی const seyarChar: SeyarChar = sel === null ? "sigma" : correct ? "delta" : "pi"; const seyarMood: SeyarMood = sel === null ? "idle" : correct ? "excited" : outOfLives ? "depressed" : "sad"; - const optCls = (i: number) => { - if (sel === null) return "bg-white border-2 border-[#e8e4ff] text-[#1a1a2e]"; - if (i === q.c) return "bg-[#e5fdf0] border-2 border-green-400 text-green-700"; - if (i === sel) return "bg-[#fff0f0] border-2 border-red-300 text-red-500"; + const optCls = (optId: string) => { + if (!result) return "bg-white border-2 border-[#e8e4ff] text-[#1a1a2e]"; + if (optId === result.correctOptionId) return "bg-[#e5fdf0] border-2 border-green-400 text-green-700"; + if (optId === sel) return "bg-[#fff0f0] border-2 border-red-300 text-red-500"; return "bg-white border-2 border-[#e8e4ff] text-[#b0b0c8]"; }; - const selectAnswer = (i: number) => { - if (sel !== null) return; - setSel(i); - if (i !== q.c) setLives(l => Math.max(0, l - 1)); + const selectAnswer = async (optId: string) => { + if (sel !== null || !q || answering) return; + setSel(optId); + setAnswering(true); + try { + const r = await api.lesson.answer(Number(q.id), Number(optId), crypto.randomUUID()); + setResult(r); + setLives(r.heartsRemaining); + if (r.correct) setCorrectCount((c) => c + 1); + } catch { + setLoadErr(true); + } finally { + setAnswering(false); + } }; - const next = () => { - if (outOfLives) { setLives(5); setQIdx(0); setSel(null); return; } - if (qIdx < Qs.length - 1) { setQIdx(i => i + 1); setSel(null); } - else onNavigate("home"); + const next = async () => { + if (!questions) return; + if (outOfLives) { + setQIdx(0); setSel(null); setResult(null); setCorrectCount(0); + api.hearts().then((h) => setLives(h.currentHearts)).catch(() => {}); + return; + } + if (qIdx < questions.length - 1) { + setQIdx((i) => i + 1); setSel(null); setResult(null); + } else { + try { + await api.lesson.complete(lessonId, { correctCount, totalQuestions: questions.length }, crypto.randomUUID()); + onComplete?.(); + } catch { /* اگه ثبتِ پایان شکست خورد، بازم برگرد خونه — بعداً دوباره می‌شه امتحان کرد */ } + onNavigate("home"); + } }; + if (loadErr) { + return ( +
+ +

این سوال‌ها الان بارگذاری نشدن.

+ +
+ ); + } + + if (!questions || !q) { + return ( +
+ +

در حال آوردنِ سوال‌ها...

+
+ ); + } + return (
{/* Top bar */} @@ -2291,9 +2209,9 @@ function QuestionScreen({ onNavigate, onBack }: { onNavigate: (s: MainScreen) =>
+ style={{ width: `${(qIdx / questions.length) * 100}%` }}/>
- {qIdx + 1}/{Qs.length} + {qIdx + 1}/{questions.length}
{/* جون‌ها (قلب‌ها) */} @@ -2350,35 +2268,32 @@ function QuestionScreen({ onNavigate, onBack }: { onNavigate: (s: MainScreen) => {/* Problem card */}

سوال {qIdx + 1}

-

{q.q}

-
-

{q.p}

-
+

{q.prompt}

{/* Options grid */}
- {q.opts.map((opt, i) => ( - selectAnswer(i)} + {q.options.map((opt) => ( + selectAnswer(opt.id)} whileTap={{ scale: sel === null ? 0.93 : 1 }} - className={`py-4 rounded-2xl text-2xl font-black flex items-center justify-center gap-1.5 transition-all ${optCls(i)}`}> - {opt} - {sel !== null && i === q.c && } - {sel === i && i !== q.c && } + className={`py-4 rounded-2xl text-xl font-black flex items-center justify-center gap-1.5 transition-all ${optCls(opt.id)}`}> + {opt.value} + {result && opt.id === result.correctOptionId && } + {sel === opt.id && result && !result.correct && } ))}
- {sel !== null && ( + {result && ( {outOfLives ? <>دوباره تلاش کن : - qIdx < Qs.length - 1 ? <>سوال بعدی : - <>پایان تمرین } + qIdx < questions.length - 1 ? <>سوال بعدی : + <>پایان تمرین } )}
@@ -2391,23 +2306,38 @@ function QuestionScreen({ onNavigate, onBack }: { onNavigate: (s: MainScreen) => function AskAIScreen({ profile }: { profile: Profile }) { const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); + const [streamingText, setStreamingText] = useState(null); + const [conversationId, setConversationId] = useState(undefined); const [messages, setMessages] = useState([ { from: "bot", text: `سلام ${profile.name}! من پی‌ام 🙂 هر سوالی داری بپرس، قضاوتی هم در کار نیست!` }, ]); - const send = () => { + const send = async () => { if (!input.trim() || loading) return; const txt = input; setInput(""); setMessages(m => [...m, { from: "user", text: txt }]); setLoading(true); - setTimeout(() => { - setMessages(m => [...m, { - from: "bot", - text: "سوال خوبی پرسیدی! 😊 برای ضرب دو رقمی، اول رقم یکان رو ضرب می‌کنیم بعد رقم دهگان. مثلاً ۲۳×۱۴: اول ۲۳×۴=۹۲ بعد ۲۳×۱۰=۲۳۰ و جمعشون می‌کنیم: ۳۲۲ 🎉", - }]); + setStreamingText(""); + let finalText = ""; + try { + const res = await api.ai.askStream({ userQuestion: txt, conversationId }, (delta) => { + finalText += delta; + setStreamingText(finalText); + }); + if (res.conversationId) setConversationId(res.conversationId); + setMessages(m => [...m, { from: "bot", text: finalText }]); + } catch (err) { + const msg = err instanceof ApiError + ? (err.isRateLimited ? "یه‌کم زیاد سوال پرسیدی؛ چند لحظه صبر کن." : + err.isProviderDown ? "دستیار الان در دسترس نیست، بعداً دوباره امتحان کن." : + err.detail || "پاسخ نیومد، دوباره امتحان کن.") + : "پاسخ نیومد، دوباره امتحان کن."; + setMessages(m => [...m, { from: "bot", text: msg }]); + } finally { + setStreamingText(null); setLoading(false); - }, 950); + } }; return ( @@ -2444,12 +2374,18 @@ function AskAIScreen({ profile }: { profile: Profile }) {
-
- {[0, 1, 2].map(i => ( - - ))} -
+ {streamingText ? ( +
+ {streamingText} +
+ ) : ( +
+ {[0, 1, 2].map(i => ( + + ))} +
+ )}
)}
@@ -2475,41 +2411,42 @@ function AskAIScreen({ profile }: { profile: Profile }) { // ─── Subscription ───────────────────────────────────────────────────────────── -function SubscriptionScreen() { - const [billing, setBilling] = useState<"monthly" | "yearly">("yearly"); +const PLAN_STYLES = [ + { card: "bg-white border border-[#ede9ff]", titleCls: "text-[#1a1a2e]", subCls: "text-[#9e9ec0]", fCls: "text-[#3a3a5c]", checkCls: "text-[#ED6956]", btnCls: "border-2 border-[#ED6956] text-[#ED6956]" }, + { card: "bg-[#FF7A3D] border-2 border-white/40", titleCls: "text-white", subCls: "text-white/60", fCls: "text-white/85", checkCls: "text-white", btnCls: "bg-white text-[#FF7A3D] font-bold" }, + { card: "bg-[#91ADC6] border-2 border-white/30", titleCls: "text-white", subCls: "text-white/60", fCls: "text-white/88", checkCls: "text-white", btnCls: "bg-white text-[#91ADC6] font-bold" }, +]; - const plans = [ - { - name: "رایگان", en: "Free", - price: { monthly: "رایگان", yearly: "رایگان" }, - badge: null, badgeColor: "", badgeFg: "#1a1a2e", - card: "bg-white border border-[#ede9ff]", - titleCls: "text-[#1a1a2e]", subCls: "text-[#9e9ec0]", - fCls: "text-[#3a3a5c]", checkCls: "text-[#ED6956]", - btnCls: "border-2 border-[#ED6956] text-[#ED6956]", - features: ["۳ درس در روز", "۱ تمرین روزانه", "مسائل پایه"], - }, - { - name: "طلایی", en: "Gold", - price: { monthly: "۴۹٬۰۰۰ تومان", yearly: "۳۹۰٬۰۰۰ تومان" }, - badge: "محبوب‌ترین", badgeColor: "#ED6956", badgeFg: "white", - card: "bg-[#FF7A3D] border-2 border-white/40", - titleCls: "text-white", subCls: "text-white/60", - fCls: "text-white/85", checkCls: "text-white", - btnCls: "bg-white text-[#FF7A3D] font-bold", - features: ["درس‌های نامحدود", "همه تمرین‌ها", "دستیار هوشمند پایه", "بدون تبلیغ"], - }, - { - name: "الماس", en: "Diamond", - price: { monthly: "۸۹٬۰۰۰ تومان", yearly: "۶۹۰٬۰۰۰ تومان" }, - badge: "بهترین ارزش", badgeColor: "#ED6956", badgeFg: "white", - card: "bg-[#91ADC6] border-2 border-white/30", - titleCls: "text-white", subCls: "text-white/60", - fCls: "text-white/88", checkCls: "text-white", - btnCls: "bg-white text-[#91ADC6] font-bold", - features: ["همه امکانات طلایی", "دستیار هوشمند نامحدود", "گزارش پیشرفت والدین", "گواهینامه رسمی"], - }, - ]; +function durationLabel(days: number): string { + if (days === 0) return ""; + if (days === 30) return "ماهانه"; + if (days >= 360 && days <= 370) return "سالانه"; + const toFa = (n: number) => String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]); + return `${toFa(days)} روزه`; +} + +function SubscriptionScreen() { + const [plans, setPlans] = useState(null); + const [loadErr, setLoadErr] = useState(false); + const [purchasingId, setPurchasingId] = useState(null); + const toFa = (n: number) => String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]); + + useEffect(() => { + api.subscription.plans().then(setPlans).catch(() => setLoadErr(true)); + }, []); + + const buy = async (planId: string) => { + if (purchasingId) return; + setPurchasingId(planId); + try { + const res = await api.subscription.purchase(planId, crypto.randomUUID()); + window.open(res.paymentUrl, "_blank"); + } catch { + /* اگه پرداخت شروع نشد، همینجا می‌مونه تا دوباره امتحان کنه */ + } finally { + setPurchasingId(null); + } + }; return (
@@ -2520,58 +2457,56 @@ function SubscriptionScreen() {
- {/* Toggle */} -
- {(["monthly","yearly"] as const).map(b => ( - - ))} -
+ {loadErr && ( +
+ پلن‌ها الان نیومدن؛ اتصالت رو چک کن. +
+ )} + + {!plans && !loadErr && ( +
+ +

در حال آوردنِ پلن‌ها...

+
+ )} {/* Plan cards */} - {plans.map(plan => ( -
- {/* Badge floats above card — z-10, never overlaps price */} - {plan.badge && ( -
- - {plan.badge} - -
- )} - -
-
-
-

{plan.name}

-

{plan.en}

-
-
-

{plan.price[billing]}

- {billing === "yearly" && plan.price.yearly !== "رایگان" && ( -

در سال

- )} -
-
-
- {plan.features.map(f => ( -
- - {f} + {plans?.map((plan, i) => { + const style = PLAN_STYLES[i % PLAN_STYLES.length]; + const isFree = plan.price === 0; + const dur = durationLabel(plan.durationDays); + return ( +
+
+
+
+

{plan.title}

+ {dur &&

{dur}

}
- ))} +
+

+ {isFree ? "رایگان" : `${toFa(plan.price.toLocaleString())} تومان`} +

+
+
+
+ {plan.features.map(f => ( +
+ + {f} +
+ ))} +
+
-
-
- ))} + ); + })} -

هر زمان می‌توانید لغو کنید · بدون تعهد

+ {plans &&

هر زمان می‌توانید لغو کنید · بدون تعهد

}
); @@ -2584,8 +2519,22 @@ export default function App() { const [screen, setScreen] = useState("home"); const [profile, setProfile] = useState(null); const [auth, setAuth] = useState(null); - const [topicId, setTopicId] = useState(TOPICS[0].id); + const [unitId, setUnitId] = useState(""); const [lessonId, setLessonId] = useState(1); + const [home, setHome] = useState(null); + const [summary, setSummary] = useState<{ xp: number; coins: number; streak: { current: number; longest: number } } | null>(null); + + const refreshHome = () => { + api.home().then((h) => { + setHome(h); + if (!unitId && h.units[0]) setUnitId(h.units[0].id); + }).catch(() => {}); + api.summary().then((s: any) => setSummary(s)).catch(() => {}); + }; + + useEffect(() => { + if (flow === "main") refreshHome(); + }, [flow]); // ─── تاریخچه‌ی صفحات، برای دکمه‌ی «بازگشت» ──────────────────────────────── // navigate: رفتن به جلو (صفحه‌ی فعلی رو تو تاریخچه نگه می‌داره) @@ -2610,20 +2559,35 @@ export default function App() { }; useEffect(() => { - setAuthExpiredHandler(() => { - setAuth(null); - setFlow("login"); - }); - return () => setAuthExpiredHandler(null); + // اگه قبلاً واردِ اپ شده بودیم (توکن هنوز معتبره)، مستقیم بریم تو خانه + if (api.auth.isSignedIn()) { + Promise.all([api.profile.get(), api.grades().catch(() => [])]) + .then(([p, gs]) => { + const gName = gs.find((g) => g.id === p.gradeId)?.title ?? ""; + setProfile({ name: p.name || "", age: 15, grade: p.gradeId ? String(p.gradeId) : "", gradeName: gName }); + setFlow(p.name && p.gradeId ? "main" : "profile"); + }) + .catch(() => { /* توکن خراب بود؛ همون اسپلش بمونه، کاربر از اول وارد می‌شه */ }); + } }, []); const handleReturning = () => setFlow("login"); const handleOnboardingDone = () => setFlow("login"); - const handleLoginDone = (a: AuthData) => { + const handleLoginDone = async (a: AuthData) => { setAuth(a); - if (profile && !a.isNewUser) { setFlow("main"); setScreen("home"); } - else { setFlow("profile"); } + try { + const [p, gs] = await Promise.all([api.profile.get(), api.grades().catch(() => [])]); + if (p.name && p.gradeId) { + const gName = gs.find((g) => g.id === p.gradeId)?.title ?? ""; + setProfile({ name: p.name, age: 15, grade: String(p.gradeId), gradeName: gName }); + setFlow("main"); setScreen("home"); + return; + } + } catch { + /* پروفایلِ تازه یا خطای موقت — بریم مرحله‌ی تکمیلِ پروفایل */ + } + setFlow("profile"); }; const handleProfileDone = (p: Profile) => { @@ -2669,15 +2633,15 @@ export default function App() { {flow === "splash" && setFlow("onboarding")} onReturning={handleReturning}/>} {flow === "onboarding"&& } {flow === "profile" && } - {flow === "main" && screen === "lesson" && } - {flow === "main" && screen === "question" && } + {flow === "main" && screen === "lesson" && } + {flow === "main" && screen === "question" && } {flow === "main" && screen === "askai" && } {flow === "main" && screen === "subscription" && } {flow === "login" && } - {flow === "main" && screen === "home" && { setTopicId(id); navigate("skillmap"); }}/>} - {flow === "main" && screen === "topics" && { setTopicId(t.id); navigate("skillmap"); }}/>} - {flow === "main" && screen === "lessons" && { setTopicId(id); navigate("skillmap"); }} onNavigate={navigate}/>} - {flow === "main" && screen === "skillmap" && { setLessonId(id); navigate("lesson"); }} onBack={goBack}/>} + {flow === "main" && screen === "home" && { setUnitId(id); navigate("skillmap"); }}/>} + {flow === "main" && screen === "topics" && { setUnitId(id); navigate("skillmap"); }}/>} + {flow === "main" && screen === "lessons" && { setUnitId(id); navigate("skillmap"); }} onNavigate={navigate}/>} + {flow === "main" && screen === "skillmap" && { setLessonId(id); navigate("lesson"); }} onBack={goBack}/>}