/** * کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend). * * از روی OpenAPI واقعیِ همین بک‌اند نوشته شده، نه از روی حدس. نامِ فیلدها * دقیقاً همان چیزی‌ست که سرور برمی‌گرداند (camelCase). * * نکته‌های مهمِ اتصال: * - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم * نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده. * - توکنِ دسترسی امضاشده و کوتاه‌عمر است؛ با ۴۰۱ باید `refresh` صدا زد. * این کلاینت خودش یک‌بار refresh می‌کند و درخواست را تکرار. * - برای پرسش از دستیار کافی‌ست `lessonId` بدهید؛ سرور خودش می‌فهمد آن درس * مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند می‌کند. */ export type Json = Record; export interface Tokens { token: string; refreshToken: string; } /* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */ export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed'; 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 }; } export interface Grade { id: number; title: string; icon: string; color: string; } export interface UserSummary { userId: string; name: string; avatar: string; gradeId: number | null; xp: number; coins: number; streak: { current: number; longest: number }; subscription: string; } export interface UserProfile { userId: string; phone: string | null; name: string; avatar: string; isGuest: boolean; gradeId: number | null; birthYear: number | null; parentPhone: string; bio: string; } export interface LessonIntro { lessonId: string; title: string; introText: string; /** برای انتخابِ حالتِ ماسکوت در صفحه‌ی درس */ mascotAnimation: string; /** * صفر یعنی متنِ درس هست ولی آزمونش هنوز ساخته نشده — «به‌زودی» نشان دهید، * نه کویزِ خالی. `complete` هم `totalQuestions: 0` را نمی‌پذیرد. */ questionCount: number; } export interface QuestionOption { id: string; value: string; } export interface Question { id: string; type: 'multiple_choice' | 'count_objects'; prompt: string; options: QuestionOption[]; } export interface AnswerResult { correct: boolean; correctOptionId: string; xpEarned: number; heartsRemaining: number; /** حالتِ ماسکوت: درست → happy، غلط → encourage */ mascotMood: string; } export interface LessonComplete { stars: number; xpEarned: number; coinsEarned: number; stats: { progressPercent: number; xp: number; coins: number }; } export interface Hearts { currentHearts: number; maxHearts: number; nextRefillAt: string | null; isUnlimited: boolean; } export interface AskResponse { conversationId: string; messageId: string; answer: string; suggestedFollowUps: string[]; remainingQuota: number; /** تکه‌های کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */ usedChunks: { title: string; summary: string }[]; } 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; }; } const STORAGE_KEY = 'tadrisino.tokens'; 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;