forked from Tadrisino/MathApp_frontend
- Home is now the island path of every unit with one clear Start button on the
current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
9.8 KiB
TypeScript
199 lines
9.8 KiB
TypeScript
/**
|
||
* تنها درِ ارتباط با بکاند. هیچ جای دیگرِ اپ 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<string, string | number | undefined>) => {
|
||
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<T.AuthResponse>('/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<T.AuthResponse>('/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<T.UserProfile>('/user/profile'),
|
||
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
|
||
http.patch<T.UserProfile>('/user/profile', patch),
|
||
setGrade: (gradeId: number) =>
|
||
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
|
||
},
|
||
|
||
summary: () => http.get<T.UserSummary>('/user/summary'),
|
||
hearts: () => http.get<T.Hearts>('/user/hearts'),
|
||
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
|
||
|
||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||
home: () => http.get<T.HomeResponse>('/home'),
|
||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
|
||
|
||
/**
|
||
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیشفرض ۴). ترتیب برای هر
|
||
* کاربر ثابت است، پس رفرش همان آزمون را میدهد.
|
||
*/
|
||
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${nodeId}/quiz`),
|
||
/** پایانِ آزمون، جزیره را با هر نمرهای تمام میکند و جزیرهی بعدی را باز. */
|
||
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
||
http.create<T.NodeQuizComplete>(`/nodes/${nodeId}/quiz/complete`, result, { idempotencyKey }),
|
||
|
||
lesson: {
|
||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/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<T.AnswerResult>(
|
||
`/questions/${questionId}/answer`,
|
||
{ selectedOptionId: Number(selectedOptionId) },
|
||
{ idempotencyKey, retry: true },
|
||
),
|
||
/** مجموعِ XP و سکهی کاربر اینجا بهروز میشود، نه سرِ هر جواب. */
|
||
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
||
http.create<T.LessonComplete>(`/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<T.AskDone> {
|
||
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<T.SubscriptionStatus>('/subscription/status'),
|
||
/** سفارش میسازد؛ paymentUrl خودش سراغِ درگاه میرود. */
|
||
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
|
||
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
|
||
redeem: (code: string, orderId: string) =>
|
||
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`),
|
||
},
|
||
|
||
support: {
|
||
createTicket: (subject: string, category: string) =>
|
||
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
|
||
ticket: (ticketId: string) => http.get<T.Ticket>(`/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<T.FamilyLink>('/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<T.FamilyWeeklyReport>('/family/weekly-report'),
|
||
settings: () => http.get<T.FamilySettings>('/family/settings'),
|
||
updateSettings: (patch: Partial<T.FamilySettings>) => http.patch('/family/settings', patch),
|
||
},
|
||
|
||
affiliate: {
|
||
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
|
||
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل میکند. */
|
||
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
|
||
},
|
||
};
|