/** * تنها درِ ارتباط با بک‌اند. هیچ جای دیگرِ اپ 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; } /** یک بخشِ مسیر؛ شناسه‌ای که از آدرسِ صفحه یا سرور می‌آید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */ const seg = (value: string | number) => encodeURIComponent(String(value)); export const api = { auth: { isSignedIn: () => Boolean(http.storage.get()?.token), /** * گامِ ۱: پیامکِ کد. `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), /** * حذفِ همیشگیِ حساب. کاربر کدِ تازه‌ی پیامکی می‌دهد (اول requestOtp با شماره‌ی خودش). * بعد از موفقیت توکن‌ها پاک می‌شوند؛ دیگر کاربری پشتشان نیست. */ deleteAccount: (body: { otp: string }) => http.post<{ success: boolean }>('/user/delete', body).then((r) => { http.storage.set(null); return r; }), /** خروج در سرور (باطل کردنِ 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'), /** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */ weeklyReport: () => http.get('/user/weekly-report'), /** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ home: () => http.get('/home'), nodeLessons: (nodeId: string) => http.get(`/nodes/${seg(nodeId)}/lessons`), /** * آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر * کاربر ثابت است، پس رفرش همان آزمون را می‌دهد. */ nodeQuiz: (nodeId: string) => http.get(`/nodes/${seg(nodeId)}/quiz`), /** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */ nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => http.create(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }), lesson: { intro: (lessonId: number) => http.get(`/lessons/${seg(lessonId)}/intro`), questions: (lessonId: number) => http.get<{ questions: T.Question[] }>(`/lessons/${seg(lessonId)}/questions`).then((r) => r.questions), /** * کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا * امتیاز و قلب دوبار حساب نشود. */ answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) => http.post( `/questions/${seg(questionId)}/answer`, { selectedOptionId: Number(selectedOptionId) }, { idempotencyKey, retry: true }, ), /** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */ complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => http.create(`/lessons/${seg(lessonId)}/complete`, result, { idempotencyKey }), report: (questionId: string, reason: T.ReportReason, note?: string) => http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>( `/questions/${seg(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: { /** «امروز X سوالِ رایگان + Y سوالِ خریده‌شده»، پیش از اولین سوال. */ quota: () => http.get('/ai/quota'), /** * پرسش با پاسخِ تکه‌تکه. `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/${seg(messageId)}/feedback`, { isHelpful, ...(reason ? { reason } : {}), }), history: (conversationId: string) => http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>( `/ai/conversations/${seg(conversationId)}/messages`, ), }, notifications: { list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'), markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`), markAllRead: () => http.patch('/notifications/read-all'), }, subscription: { plans: () => http.get('/subscription/plans'), status: () => http.get('/subscription/status'), /** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */ purchase: (planId: string) => http.create('/subscription/purchase', { planId }), /** * خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت * همان سفارش را برگرداند (src/lib/bazaar.ts). */ purchaseBazaar: (planId: string, idempotencyKey: string) => http.create('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }), /** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */ verifyBazaar: (orderId: string, purchaseToken: string) => http.post('/payment/bazaar/verify', { orderId, purchaseToken }), /** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */ redeem: (code: string, orderId: string) => http.post('/discount/redeem', { code, orderId }), order: (orderId: string) => http.get(`/payment/orders/${seg(orderId)}`), /** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */ invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices), }, support: { createTicket: (subject: string, category: string) => http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }), /** تیکت‌های همین کاربر، تازه‌ترین اول (۲۰ تای آخر). */ tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets), ticket: (ticketId: string) => http.get(`/support/tickets/${seg(ticketId)}`), reply: (ticketId: string, text: string) => http.create<{ messageId: string; success: boolean }>(`/support/tickets/${seg(ticketId)}/messages`, { text }), bugReport: (title: string, description: string) => http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }), }, affiliate: { me: () => http.get('/affiliate/me'), /** کاربرِ تازه را به صاحبِ کدِ معرف وصل می‌کند. */ track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }), }, };