rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused packages, 45 unused UI components, no type-check). The live site could not reach the backend, Persian-keyboard digits were erased from the phone field, guests were signed out after an hour, "mark read" hit a method the server does not accept (405), and iOS Safari blocked the payment window. What changed: - src/ split into api/ (one typed client: timeouts, GET retry, single-flight refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/. - The API address is configuration only: public/config.js at runtime, then VITE_API_BASE_URL at build time, then same-origin /api. No domain in code. - Responsive shell instead of a fixed 390x844 phone frame; self-hosted Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build` type-checks first. - Everything on the handoff list: tutor answers keep their line breaks, have thumbs up/down, follow-ups, "from the book" badge, retry, history that survives tab switches and reloads, "why was I wrong" from a wrong answer with questionId; question reports; hearts with maxHearts/unlimited and a no-hearts screen with the refill time; result screen and a visible error when lesson completion fails; subscription status, badge, discount code before the gateway and the payment-return message; guest refresh token; 6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH read-all; leaderboard refetched on open with "you" highlighted; selected companion shown across the app; logout that revokes the refresh token. - New screens for endpoints that had none: profile, streak calendar, support tickets and bug reports, parents, invite-a-friend (with ?ref= tracking), guest-to-phone upgrade. - Browser/phone back button walks the screen stack. Checked with a headless-Chrome walkthrough against a local backend at 390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map, island sheet, every tab and sub-screen, one real tutor question, reload, logout): no console errors, no failed API calls. Real SMS login and a real Zarinpal payment were not exercised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
589
src/api/index.ts
589
src/api/index.ts
@@ -1,459 +1,184 @@
|
||||
/**
|
||||
* کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend).
|
||||
* تنها درِ ارتباط با بکاند. هیچ جای دیگرِ اپ fetch نمیزند.
|
||||
*
|
||||
* از روی OpenAPI واقعیِ همین بکاند نوشته شده، نه از روی حدس. نامِ فیلدها
|
||||
* دقیقاً همان چیزیست که سرور برمیگرداند (camelCase).
|
||||
*
|
||||
* نکتههای مهمِ اتصال:
|
||||
* - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم
|
||||
* نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده.
|
||||
* - توکنِ دسترسی امضاشده و کوتاهعمر است؛ با ۴۰۱ باید `refresh` صدا زد.
|
||||
* این کلاینت خودش یکبار refresh میکند و درخواست را تکرار.
|
||||
* - برای پرسش از دستیار کافیست `lessonId` بدهید؛ سرور خودش میفهمد آن درس
|
||||
* مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند میکند.
|
||||
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
|
||||
*/
|
||||
import { resolveApiBaseUrl } from '@/config';
|
||||
import { ApiError, createHttpClient, newIdempotencyKey } from './http';
|
||||
import type * as T from './types';
|
||||
|
||||
export type Json = Record<string, unknown>;
|
||||
export { ApiError };
|
||||
export type * from './types';
|
||||
|
||||
export interface Tokens {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
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),
|
||||
|
||||
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
|
||||
/** مهمان: فقط با شناسهی دستگاه. refreshToken هم میگیرد، پس بعد از یک ساعت نمیافتد. */
|
||||
guest: (deviceId: string) =>
|
||||
http.post<T.AuthResponse>('/auth/session', { type: 'guest', deviceId }).then(saveTokens),
|
||||
|
||||
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 };
|
||||
}
|
||||
/** گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمیگردد. */
|
||||
requestOtp: (phone: string) =>
|
||||
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }),
|
||||
|
||||
export interface Grade {
|
||||
id: number;
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
/**
|
||||
* گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه میدارد و
|
||||
* پیشرفتش از دست نمیرود (توکنِ مهمان همراهِ همین درخواست میرود).
|
||||
*/
|
||||
login: (phone: string, otp: string) =>
|
||||
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
|
||||
|
||||
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 Character {
|
||||
characterId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
unlockXp: number;
|
||||
unlocked: boolean;
|
||||
selected: 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 {
|
||||
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
|
||||
async logout() {
|
||||
const refreshToken = http.storage.get()?.refreshToken;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as Tokens) : null;
|
||||
} catch {
|
||||
return null;
|
||||
await http.post('/auth/logout', refreshToken ? { refreshToken } : {});
|
||||
} finally {
|
||||
http.storage.set(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();
|
||||
grades: () => http.get<{ grades: T.Grade[] }>('/grades').then((r) => r.grades),
|
||||
|
||||
async function raw<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
retryOn401 = true,
|
||||
): Promise<T> {
|
||||
const tokens = storage.get();
|
||||
const headers: Record<string, string> = { ...extraHeaders };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (tokens?.token) headers['Authorization'] = `Bearer ${tokens.token}`;
|
||||
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 }),
|
||||
},
|
||||
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
summary: () => http.get<T.UserSummary>('/user/summary'),
|
||||
hearts: () => http.get<T.Hearts>('/user/hearts'),
|
||||
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
|
||||
|
||||
if (response.status === 401 && retryOn401 && tokens?.refreshToken) {
|
||||
const refreshed = await refresh(tokens.refreshToken).catch(() => null);
|
||||
if (refreshed) return raw<T>(method, path, body, extraHeaders, false);
|
||||
storage.set(null);
|
||||
}
|
||||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||||
home: () => http.get<T.HomeResponse>('/home'),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
|
||||
|
||||
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<UserProfile>('GET', '/user/profile'),
|
||||
/** اسم/آواتار/… . فقط فیلدهایی که میفرستید عوض میشوند. */
|
||||
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
|
||||
raw<UserProfile>('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<HomeResponse>('GET', gradeId ? `/home?grade=${gradeId}` : '/home'),
|
||||
summary: () => raw<UserSummary>('GET', '/user/summary'),
|
||||
hearts: () => raw<Hearts>('GET', '/user/hearts'),
|
||||
streakCalendar: () =>
|
||||
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
|
||||
'GET', '/user/streak/calendar',
|
||||
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 },
|
||||
),
|
||||
leaderboard: (period: 'weekly' | 'all_time' = 'weekly') =>
|
||||
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
|
||||
/** مجموعِ 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 } : {}) },
|
||||
),
|
||||
},
|
||||
|
||||
/** کاتالوگِ کاراکترهای گیمیفیکیشن — مثلِ ماسکاتهای Duolingo، بازشدن بر اساسِ XP. */
|
||||
characters: {
|
||||
list: () => raw<{ characters: Character[] }>('GET', '/characters').then((r) => r.characters),
|
||||
select: (characterId: string) =>
|
||||
raw<{ characterId: string; selected: boolean }>('POST', '/characters/select', { characterId }),
|
||||
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`,
|
||||
),
|
||||
},
|
||||
|
||||
/* ── صفحهی درس و سوال ─────────────────────────────────────────────── */
|
||||
lesson: {
|
||||
intro: (lessonId: number) => raw<LessonIntro>('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<AnswerResult>(
|
||||
'POST', `/questions/${questionId}/answer`, { selectedOptionId },
|
||||
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||||
),
|
||||
/**
|
||||
* پایانِ درس. `correctCount` را سرور با تعدادِ واقعیِ سوالها سقف میزند.
|
||||
* مجموعِ XPِ کاربر **اینجا** بهروز میشود، نه سرِ هر پاسخ.
|
||||
*/
|
||||
complete: (
|
||||
lessonId: number,
|
||||
result: { correctCount: number; totalQuestions: number },
|
||||
idempotencyKey?: string,
|
||||
) =>
|
||||
raw<LessonComplete>(
|
||||
'POST', `/lessons/${lessonId}/complete`, result,
|
||||
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||||
),
|
||||
},
|
||||
notifications: {
|
||||
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
|
||||
markRead: (id: number) => http.patch(`/notifications/${id}/read`),
|
||||
markAllRead: () => http.patch('/notifications/read-all'),
|
||||
},
|
||||
|
||||
/* ── صفحهی «از رُبو بپرس» ─────────────────────────────────────────── */
|
||||
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<AskResponse>(
|
||||
'POST', '/ai/ask', body,
|
||||
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||||
);
|
||||
},
|
||||
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}`),
|
||||
},
|
||||
|
||||
/**
|
||||
* همان 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, 'استریم شروع نشد', {});
|
||||
}
|
||||
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 }),
|
||||
},
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let done: { conversationId?: string; messageId?: string } = {};
|
||||
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),
|
||||
},
|
||||
|
||||
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<Json>('POST', `/notifications/${id}/read`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type TadrisinoClient = ReturnType<typeof createClient>;
|
||||
affiliate: {
|
||||
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
|
||||
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل میکند. */
|
||||
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user