Payment return decided by the server only; encoded path ids; no sign-out on a two-tab refresh race
- ?orderId must be numeric; every id in an API path is encodeURIComponent'd.
- The payment sheet shows success only from GET /payment/orders/{id}; a failed lookup says the
status is unknown instead of "paid".
- A 401 from /auth/refresh is not a sign-out when another tab already stored a rotated token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -202,7 +202,13 @@ export function createHttpClient(options: HttpClientOptions) {
|
||||
} catch {
|
||||
return new ApiError(0, 'ارتباط با سرور برقرار نشد');
|
||||
}
|
||||
if (response.status === 401) return 'invalid';
|
||||
if (response.status === 401) {
|
||||
// تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل میکند) و توکنِ تازه
|
||||
// را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند.
|
||||
const latest = storage.get();
|
||||
if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest;
|
||||
return 'invalid';
|
||||
}
|
||||
if (!response.ok) return errorFrom(response, await parseBody(response));
|
||||
const data = (await response.json()) as Tokens;
|
||||
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
|
||||
|
||||
@@ -22,6 +22,9 @@ function saveTokens(data: T.AuthResponse) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** یک بخشِ مسیر؛ شناسهای که از آدرسِ صفحه یا سرور میآید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
|
||||
const seg = (value: string | number) => encodeURIComponent(String(value));
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
isSignedIn: () => Boolean(http.storage.get()?.token),
|
||||
@@ -85,37 +88,37 @@ export const api = {
|
||||
|
||||
/** بدونِ grade، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||||
home: () => http.get<T.HomeResponse>('/home'),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
|
||||
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
|
||||
|
||||
/**
|
||||
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیشفرض ۴). ترتیب برای هر
|
||||
* کاربر ثابت است، پس رفرش همان آزمون را میدهد.
|
||||
*/
|
||||
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${nodeId}/quiz`),
|
||||
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/${nodeId}/quiz/complete`, result, { idempotencyKey }),
|
||||
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
|
||||
|
||||
lesson: {
|
||||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`),
|
||||
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${seg(lessonId)}/intro`),
|
||||
questions: (lessonId: number) =>
|
||||
http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions),
|
||||
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/${questionId}/answer`,
|
||||
`/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/${lessonId}/complete`, result, { idempotencyKey }),
|
||||
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/${questionId}/report`,
|
||||
`/questions/${seg(questionId)}/report`,
|
||||
{ reason, ...(note ? { note } : {}) },
|
||||
),
|
||||
},
|
||||
@@ -157,19 +160,19 @@ export const api = {
|
||||
return done;
|
||||
},
|
||||
feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
|
||||
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, {
|
||||
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/${conversationId}/messages`,
|
||||
`/ai/conversations/${seg(conversationId)}/messages`,
|
||||
),
|
||||
},
|
||||
|
||||
notifications: {
|
||||
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
|
||||
markRead: (id: number) => http.patch(`/notifications/${id}/read`),
|
||||
markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`),
|
||||
markAllRead: () => http.patch('/notifications/read-all'),
|
||||
},
|
||||
|
||||
@@ -190,7 +193,7 @@ export const api = {
|
||||
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
|
||||
redeem: (code: string, orderId: string) =>
|
||||
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
|
||||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`),
|
||||
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${seg(orderId)}`),
|
||||
/** پرداختها و فاکتورهای همین کاربر، تازهترین اول. */
|
||||
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
|
||||
},
|
||||
@@ -200,9 +203,9 @@ export const api = {
|
||||
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/${ticketId}`),
|
||||
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${seg(ticketId)}`),
|
||||
reply: (ticketId: string, text: string) =>
|
||||
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }),
|
||||
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 }),
|
||||
},
|
||||
|
||||
@@ -144,28 +144,46 @@ function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
|
||||
);
|
||||
}
|
||||
|
||||
/** نتیجهی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده میشود. */
|
||||
/**
|
||||
* نتیجهی برگشت از درگاه. فقط پاسخِ سرور تعیین میکند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
|
||||
* لینکِ ساختگی میتواند «paid» بگذارد.
|
||||
*/
|
||||
function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
||||
const { refreshSummary } = useAppData();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [paid, setPaid] = useState(payment.status === 'paid');
|
||||
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
|
||||
|
||||
useEffect(() => {
|
||||
api.subscription
|
||||
.order(payment.orderId)
|
||||
.then((order) => setPaid(order.status === 'paid'))
|
||||
.catch(() => {});
|
||||
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
|
||||
.catch((err: unknown) => {
|
||||
console.warn('payment result lookup failed', err);
|
||||
setState('unknown');
|
||||
});
|
||||
void refreshSummary();
|
||||
}, [payment.orderId, refreshSummary]);
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<Sheet open={open} onClose={() => setOpen(false)}>
|
||||
<LoadingState text="دارم پرداخت رو بررسی میکنم…" />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
const paid = state === 'paid';
|
||||
return (
|
||||
<Sheet open={open} onClose={() => setOpen(false)}>
|
||||
<div className="flex flex-col items-center gap-3 pb-2 text-center">
|
||||
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
|
||||
<h2 className="text-lg font-black text-ink">{paid ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2>
|
||||
<h2 className="text-lg font-black text-ink">
|
||||
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
|
||||
</h2>
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{paid
|
||||
? 'اشتراکت فعال شد؛ از الان قلبهات نامحدوده.'
|
||||
: state === 'unknown'
|
||||
? 'چند دقیقهی دیگه از «پرداختها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال میشه یا پول خودکار برمیگرده.'
|
||||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||||
</p>
|
||||
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
|
||||
|
||||
@@ -22,7 +22,8 @@ function readLaunchParams(): PaymentReturn | null {
|
||||
if (ref || orderId) {
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
return orderId ? { orderId, status: status ?? '' } : null;
|
||||
// فقط شمارهی سفارش: هر چیزِ دیگری (مثلاً «../../user/delete») از یک لینکِ ساختگی آمده است.
|
||||
return orderId && /^\d{1,12}$/.test(orderId) ? { orderId, status: status ?? '' } : null;
|
||||
}
|
||||
|
||||
/** یکبار، هنگامِ بار شدنِ اپ. */
|
||||
|
||||
Reference in New Issue
Block a user