forked from Tadrisino/MathApp_frontend
- Checkout sheet always shows both methods as two tiles; Bazaar tile disabled with the reason
(not in Bazaar / Android app only / update the app) instead of hidden; default is Bazaar inside an app that can buy.
- Bridge v2: TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken); inAndroidApp() split
from canBuyFromBazaar() so the pay.html workaround still applies to the installed APK.
- Bazaar flow: order -> optional discount code -> POST /payment/bazaar/orders/{id}/start -> Poolakey -> verify;
consume only packs.
- Android: RSA key, subscribeProduct/getSubscribedProducts for subscriptions, dynamicPriceToken, versionCode 2.
compileDebugKotlin green; no purchase tested on a phone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
12 KiB
TypeScript
234 lines
12 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;
|
||
}
|
||
|
||
/** یک بخشِ مسیر؛ شناسهای که از آدرسِ صفحه یا سرور میآید نباید با «/» یا «..» مسیرِ دیگری از 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<T.AuthResponse>('/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<T.UserProfile>('/user/profile'),
|
||
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'birthDate' | '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'),
|
||
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
|
||
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
|
||
|
||
/**
|
||
* بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛
|
||
* کتابِ ناموجود در پایه ۴۰۰ میدهد (برای کلیدِ ذخیرهشدهی قدیمی از `fetchHome` استفاده کنید).
|
||
*/
|
||
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
|
||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
|
||
|
||
/**
|
||
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیشفرض ۴). ترتیب برای هر
|
||
* کاربر ثابت است، پس رفرش همان آزمون را میدهد.
|
||
*/
|
||
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${seg(nodeId)}/quiz`),
|
||
/** پایانِ آزمون، جزیره را با هر نمرهای تمام میکند و جزیرهی بعدی را باز. */
|
||
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
||
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
|
||
|
||
lesson: {
|
||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/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<T.AnswerResult>(
|
||
`/questions/${seg(questionId)}/answer`,
|
||
{ selectedOptionId: Number(selectedOptionId) },
|
||
{ idempotencyKey, retry: true },
|
||
),
|
||
/** مجموعِ XP و سکهی کاربر اینجا بهروز میشود، نه سرِ هر جواب. */
|
||
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
|
||
http.create<T.LessonComplete>(`/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 } : {}) },
|
||
),
|
||
},
|
||
|
||
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پسگرفتنِ رأی). */
|
||
feedback: {
|
||
vote: (kind: 'question' | 'lesson', id: string, value: 1 | -1 | 0) =>
|
||
http.post<{ value: number }>(`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/vote`, { value }),
|
||
report: (kind: 'question' | 'lesson', id: string, reason: string, note?: string) =>
|
||
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
|
||
`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/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<T.TutorQuota>('/ai/quota'),
|
||
|
||
/**
|
||
* پرسش با پاسخِ تکهتکه. `lessonId` پاسخ را به کتابِ همان درس مستند میکند؛
|
||
* `questionId` به دستیار میگوید دانشآموز کدام سوال را غلط زده.
|
||
*/
|
||
async ask(
|
||
input: {
|
||
userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string;
|
||
/** نامِ کتاب («هندسه»)؛ برای پرسشِ بیدرس تا دستیار از کتابِ همان انتخابِ کاربر جواب بدهد. */
|
||
subject?: 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/${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<T.PlansResponse>('/subscription/plans'),
|
||
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
|
||
/** سفارش میسازد؛ paymentUrl خودش سراغِ درگاه میرود. */
|
||
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
|
||
/**
|
||
* خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت
|
||
* همان سفارش را برگرداند (src/lib/bazaar.ts).
|
||
*/
|
||
purchaseBazaar: (planId: string, idempotencyKey: string) =>
|
||
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
|
||
/** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمینشیند. */
|
||
startBazaar: (orderId: string) => http.post<T.BazaarStartResponse>(`/payment/bazaar/orders/${seg(orderId)}/start`),
|
||
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار میسنجد؛ گفتهی اپ کافی نیست. */
|
||
verifyBazaar: (orderId: string, purchaseToken: string) =>
|
||
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
|
||
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
|
||
redeem: (code: string, orderId: string) =>
|
||
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
||
order: (orderId: string) => http.get<T.OrderStatus>(`/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<T.Ticket>(`/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<T.AffiliateMe>('/affiliate/me'),
|
||
/** کاربرِ تازه را به صاحبِ کدِ معرف وصل میکند. */
|
||
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
|
||
},
|
||
};
|