/** * تنها درِ ارتباط با بک‌اند. هیچ جای دیگرِ اپ fetch نمی‌زند. * * هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است. */ import { resolveApiBaseUrl } from '@/config'; import { ApiError, createHttpClient, newIdempotencyKey } from './http'; import type * as T from './types'; export { ApiError }; export type * from './types'; export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() }); const q = (params: Record) => { const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); return entries.length ? `?${new URLSearchParams(entries.map(([k, v]) => [k, String(v)]))}` : ''; }; function saveTokens(data: T.AuthResponse) { http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' }); return data; } export const api = { auth: { isSignedIn: () => Boolean(http.storage.get()?.token), /** مهمان: فقط با شناسه‌ی دستگاه. refreshToken هم می‌گیرد، پس بعد از یک ساعت نمی‌افتد. */ guest: (deviceId: string) => http.post('/auth/session', { type: 'guest', deviceId }).then(saveTokens), /** * گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمی‌گردد. * مهلتِ ۴۵ ثانیه، نه ۱۵ِ پیش‌فرض: بک‌اند تا ۱۰+۳۰ ثانیه منتظرِ کاوه‌نگار می‌ماند * (۲۰۲۶-۰۹-۲۷، پیامک رسید ولی جوابِ کاوه‌نگار دیر آمد). اگر این زودتر قطع کند، * کاربر «ارتباط برقرار نشد» می‌بیند در حالی که کد در گوشی‌اش است. */ requestOtp: (phone: string) => http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }, { timeoutMs: 45_000 }), /** * گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه می‌دارد و * پیشرفتش از دست نمی‌رود (توکنِ مهمان همراهِ همین درخواست می‌رود). */ login: (phone: string, otp: string) => http.post('/auth/login', { phone, otp }).then(saveTokens), /** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */ async logout() { const refreshToken = http.storage.get()?.refreshToken; try { await http.post('/auth/logout', refreshToken ? { refreshToken } : {}); } finally { http.storage.set(null); } }, }, grades: () => http.get<{ grades: T.Grade[] }>('/grades').then((r) => r.grades), profile: { get: () => http.get('/user/profile'), update: (patch: Partial>) => http.patch('/user/profile', patch), setGrade: (gradeId: number) => http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), }, summary: () => http.get('/user/summary'), hearts: () => http.get('/user/hearts'), streakCalendar: () => http.get('/user/streak/calendar'), /** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ home: () => http.get('/home'), nodeLessons: (nodeId: string) => http.get(`/nodes/${nodeId}/lessons`), /** * آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر * کاربر ثابت است، پس رفرش همان آزمون را می‌دهد. */ nodeQuiz: (nodeId: string) => http.get(`/nodes/${nodeId}/quiz`), /** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */ nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => http.create(`/nodes/${nodeId}/quiz/complete`, result, { idempotencyKey }), lesson: { intro: (lessonId: number) => http.get(`/lessons/${lessonId}/intro`), questions: (lessonId: number) => http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions), /** * کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا * امتیاز و قلب دوبار حساب نشود. */ answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) => http.post( `/questions/${questionId}/answer`, { selectedOptionId: Number(selectedOptionId) }, { idempotencyKey, retry: true }, ), /** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */ complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => http.create(`/lessons/${lessonId}/complete`, result, { idempotencyKey }), report: (questionId: string, reason: T.ReportReason, note?: string) => http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>( `/questions/${questionId}/report`, { reason, ...(note ? { note } : {}) }, ), }, leaderboard: (period: T.LeaderboardPeriod) => http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries), characters: { list: () => http.get<{ characters: T.Character[] }>('/characters').then((r) => r.characters), select: (characterId: string) => http.post<{ characterId: string; selected: boolean }>('/characters/select', { characterId }), }, ai: { /** * پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛ * `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده. */ async ask( input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string }, onDelta: (text: string) => void, signal?: AbortSignal, ): Promise { let done: T.AskDone | null = null; await http.stream( '/ai/ask', { ...input, lessonId: input.lessonId?.toString(), stream: true }, (event, data) => { if (event === 'chunk') onDelta(String(data.delta ?? '')); else if (event === 'done') done = data as unknown as T.AskDone; else if (event === 'error') throw new ApiError(503, String(data.detail ?? 'دستیار در دسترس نیست'), data); }, { signal }, ); if (!done) throw new ApiError(0, 'پاسخ کامل نرسید'); return done; }, feedback: (messageId: string, isHelpful: boolean, reason?: string) => http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { isHelpful, ...(reason ? { reason } : {}), }), history: (conversationId: string) => http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>( `/ai/conversations/${conversationId}/messages`, ), }, notifications: { list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'), markRead: (id: number) => http.patch(`/notifications/${id}/read`), markAllRead: () => http.patch('/notifications/read-all'), }, subscription: { plans: () => http.get<{ plans: T.Plan[] }>('/subscription/plans').then((r) => r.plans), status: () => http.get('/subscription/status'), /** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */ purchase: (planId: string) => http.create('/subscription/purchase', { planId }), /** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */ redeem: (code: string, orderId: string) => http.post('/discount/redeem', { code, orderId }), order: (orderId: string) => http.get(`/payment/orders/${orderId}`), }, support: { createTicket: (subject: string, category: string) => http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }), ticket: (ticketId: string) => http.get(`/support/tickets/${ticketId}`), reply: (ticketId: string, text: string) => http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }), bugReport: (title: string, description: string) => http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }), }, family: { link: () => http.get('/family/link'), invite: (childPhone: string) => http.post<{ familyLinkId: string; inviteCode: string; status: string }>('/family/link/invite', { childPhone }), accept: (inviteCode: string) => http.post<{ familyLinkId: string; status: string }>('/family/link/accept', { inviteCode }), weeklyReport: () => http.get('/family/weekly-report'), settings: () => http.get('/family/settings'), updateSettings: (patch: Partial) => http.patch('/family/settings', patch), }, affiliate: { me: () => http.get('/affiliate/me'), /** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */ track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }), }, };