Session, navigation, payment and resilience fixes found in a behaviour review

- http: a request whose 401 arrived after another request's refresh had finished refreshed again;
  it now resends with the new token. A refresh finishing after logout no longer restores tokens.
- logout sends the refresh token current at send time (after a 401 -> refresh it had sent the
  rotated-out one and left the new session alive on the server for 30 days).
- Sign-out/sign-in in another browser tab is followed (storage event).
- Navigator removes its history entries when the main app unmounts (Back did nothing after logout).
- Login: +98 / 0098 / 9xxxxxxxxx phone input (autofill, typing, paste) normalised; incomplete OTP
  is not sent (each wrong guess burns one of five).
- Quiz resumes at the same question after hearts refill instead of restarting; the no-hearts
  screen re-checks while due (phone clock ahead of the server left it stuck at zero).
- Payment return: a pending order is re-checked for about a minute instead of shown as failed;
  the pay button is reset when the page comes back from bfcache.
- Tutor: retry keeps the quiz question id; a network error no longer forgets the conversation;
  daily quota detected by code ai_daily_quota.
- Root error boundary and vite:preloadError reload instead of a white screen; deploy.sh keeps
  hashed assets of earlier builds for 30 days so open apps can still load their chunks.
- Sheet: labelled dialog, focus moves in and back, Escape closes only the top sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 17:25:40 +03:30
parent f7009b11e8
commit b75127c237
14 changed files with 253 additions and 41 deletions

View File

@@ -41,6 +41,13 @@ if [ -f dist/config.js ] && ! cmp -s dist/config.js public/config.js; then
printf ' config.jsِ ویرایش‌شده‌ی سرور نگه داشته شد.\n'
fi
# فایل‌های buildهای قبلی (نامشان hash دارد و با هم برخورد نمی‌کنند) تا ۳۰ روز می‌مانند: گوشی‌ای که اپ از قبل
# در آن باز است هنوز index.htmlِ قبلی را دارد و با باز کردنِ صفحه‌ای که بار نشده همان نام‌ها را می‌خواهد؛ بی‌این
# ۴۰۴ و صفحه‌ی سفید می‌گرفت. cp -p زمانِ فایل را نگه می‌دارد، پس هر فایل ۳۰ روز بعد از buildِ خودش کنار می‌رود.
if [ -d dist/assets ]; then
find dist/assets -type f -mtime -30 -exec cp -pn {} dist.next/assets/ \;
fi
step "جابه‌جایی"
rm -rf dist.prev
[ -d dist ] && mv dist dist.prev

View File

@@ -43,13 +43,13 @@ export interface TokenStorage {
set(tokens: Tokens | null): void;
}
const STORAGE_KEY = 'tadrisino.tokens';
export const TOKENS_STORAGE_KEY = 'tadrisino.tokens';
/** localStorage در حالتِ خصوصیِ بعضی مرورگرها throw می‌کند؛ اپ نباید بخوابد. */
export const localTokenStorage: TokenStorage = {
get() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(TOKENS_STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
@@ -57,8 +57,8 @@ export const localTokenStorage: TokenStorage = {
},
set(tokens) {
try {
if (tokens) localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
else localStorage.removeItem(STORAGE_KEY);
if (tokens) localStorage.setItem(TOKENS_STORAGE_KEY, JSON.stringify(tokens));
else localStorage.removeItem(TOKENS_STORAGE_KEY);
} catch {
/* ذخیره نشد؛ نشست فقط تا بستنِ صفحه می‌ماند */
}
@@ -220,6 +220,8 @@ export function createHttpClient(options: HttpClientOptions) {
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 };
// کاربر در همین فاصله خارج شد (یا حسابش را حذف کرد): توکنِ تازه نباید نشستِ پاک‌شده را روی گوشی زنده کند.
if (!storage.get()) return 'invalid';
storage.set(next);
return next;
} finally {
@@ -237,8 +239,10 @@ export function createHttpClient(options: HttpClientOptions) {
body: unknown,
opts: RequestOptions,
): Promise<Response> {
// بدنه‌ی تابعی سرِ هر ارسال از نو ساخته می‌شود (خروج: refreshTokenِ همان لحظه، نه نسخه‌ی پیش از refresh).
const payload = typeof body === 'function' ? (body as () => unknown)() : body;
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (payload !== undefined) headers['Content-Type'] = 'application/json';
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
const timer = timeoutSignal(opts.timeoutMs ?? (method === 'GET' ? readTimeout : defaultTimeout));
@@ -246,7 +250,7 @@ export function createHttpClient(options: HttpClientOptions) {
return await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
body: payload === undefined ? undefined : JSON.stringify(payload),
signal: opts.signal ? anySignal([timer, opts.signal]) : timer,
// احرازِ هویت هدرِ Bearer است، نه کوکی؛ سرور هم CORS را بدونِ credentials دارد.
credentials: 'omit',
@@ -265,9 +269,13 @@ export function createHttpClient(options: HttpClientOptions) {
body: unknown,
opts: RequestOptions,
): Promise<Response> {
const hadToken = Boolean(storage.get()?.token);
const sentToken = storage.get()?.token;
let response = await send(method, path, body, opts);
if (response.status === 401 && hadToken) {
if (response.status === 401 && sentToken) {
// درخواستی که با توکنِ کهنه رفته و ۴۰۱اش بعد از تمامِ refreshِ درخواستِ دیگری رسیده، نباید دوباره
// refresh کند (refreshing آن موقع دیگر null است): توکنِ تازه در storage هست، فقط با آن دوباره بفرست.
const latest = storage.get()?.token;
if (latest && latest !== sentToken) return send(method, path, body, opts);
const refreshed = await refreshTokens();
if (refreshed === 'invalid') signOut();
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه

View File

@@ -71,9 +71,14 @@ export const api = {
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
async logout() {
// بدنه تابع است تا سرِ هر ارسال خوانده شود: اگر access token منقضی بوده، ۴۰۱ ← refresh ← تکرار، و
// refresh token عوض شده است؛ فرستادنِ نسخه‌ی قبلی نشستِ تازه را ۳۰ روز روی سرور باطل‌نشده می‌گذاشت.
const body = () => {
const refreshToken = http.storage.get()?.refreshToken;
return refreshToken ? { refreshToken } : {};
};
try {
await http.post('/auth/logout', refreshToken ? { refreshToken } : {});
await http.post('/auth/logout', body);
} finally {
http.storage.set(null);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import { TOKENS_STORAGE_KEY } from '@/api/http';
import type { Prefetch } from '@/state/AppData';
import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast';
@@ -109,6 +110,25 @@ export function App() {
void boot();
}, [boot]);
// تبِ دیگری از همین مرورگر خارج شد یا وارد شد (localStorage مشترک است). بی‌این، تبِ خارج‌شده داخلِ اپ می‌ماند و
// هر درخواستش بی‌توکن خطا می‌گرفت؛ تبِ بیرون‌مانده هم تا رفرش در صفحه‌ی ورود می‌ماند. تمدیدِ توکن (هر دو مقدار
// پُر) کاری ندارد.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key !== TOKENS_STORAGE_KEY && e.key !== null) return;
const signedIn = api.auth.isSignedIn();
if (!signedIn && (e.key === null || e.oldValue)) {
askStore.clear();
clearAsyncCache();
setFlow({ kind: 'login' });
} else if (signedIn && e.key !== null && !e.oldValue) {
rebootFresh();
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [rebootFresh]);
const afterLogin = async () => {
try {
await enter();

View File

@@ -312,20 +312,45 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSig
* نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
* لینکِ ساختگی می‌تواند «paid» بگذارد.
*/
/** سرور سفارشِ pending را دقیقه‌ای یک بار از درگاه می‌پرسد؛ ۴ بار با فاصله‌ی ۲۰ ثانیه حدودِ یک دقیقه است. */
const PENDING_CHECKS = 4;
const PENDING_CHECK_MS = 20_000;
function PaymentResult({ payment }: { payment: PaymentReturn }) {
const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true);
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
useEffect(() => {
// pending یعنی تأییدِ درگاه هنوز نرسیده (مثلاً verify هنگامِ برگشت جواب نداد)، نه «پرداخت نشد»: سرور با هر
// خواندنِ وضعیت دوباره از زرین‌پال می‌پرسد (حداکثر دقیقه‌ای یک بار)، پس چند بار با فاصله می‌پرسیم.
let alive = true;
let timer: ReturnType<typeof setTimeout> | undefined;
const check = (attempt: number) => {
api.subscription
.order(payment.orderId)
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
.then((order) => {
if (!alive) return;
if (order.status === 'paid') {
setState('paid');
void refreshSummary();
} else if (order.status === 'pending' && attempt < PENDING_CHECKS) {
timer = setTimeout(() => check(attempt + 1), PENDING_CHECK_MS);
} else {
setState(order.status === 'pending' ? 'unknown' : 'failed');
}
})
.catch((err: unknown) => {
console.warn('payment result lookup failed', err);
setState('unknown');
if (alive) setState('unknown');
});
};
check(1);
void refreshSummary();
return () => {
alive = false;
clearTimeout(timer);
};
}, [payment.orderId, refreshSummary]);
if (state === 'checking') {

View File

@@ -47,6 +47,9 @@ function whenLabel(iso: string, now: number): string {
return `ساعت ${time} ${day}`;
}
/** سرور هر بار regen را خودش حساب می‌کند؛ پرسیدنِ هر ۱۵ ثانیه برای یک صفحه‌ی منتظر بار نیست. */
const DUE_RECHECK_MS = 15_000;
export function NoHeartsScreen({
nextRefillAt,
fullRefillAt,
@@ -81,10 +84,14 @@ export function NoHeartsScreen({
setNext(h.nextRefillAt);
setFull(h.fullRefillAt);
}
}).catch(() => undefined);
}).catch((err: unknown) => console.warn('hearts check failed', err));
void check();
// ساعتِ گوشیِ جلوتر از سرور: سرور هنوز «صفر» و همان nextRefillAt را می‌دهد و `due` عوض نمی‌شود؛ بی‌تکرار،
// صفحه روی شمارشِ صفر می‌ماند تا کاربر بیرون برود. پس تا برگشتنِ قلب با فاصله دوباره می‌پرسیم.
const timer = setInterval(() => void check(), DUE_RECHECK_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [due, back]);

View File

@@ -1,4 +1,4 @@
import { useEffect, type ReactNode } from 'react';
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { AnimatePresence, motion, useDragControls } from 'motion/react';
import { X } from 'lucide-react';
import { useBackStep } from '@/nav/Navigator';
@@ -19,14 +19,23 @@ export function Sheet({
children: ReactNode;
}) {
const drag = useDragControls();
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را.
useBackStep(open, onClose);
const titleId = useId();
const panel = useRef<HTMLDivElement>(null);
// فوکوس با باز شدن به برگه می‌رود (صفحه‌خوان و صفحه‌کلید از همان‌جا ادامه می‌دهند) و با بسته شدن به دکمه‌ای
// که بازش کرد برمی‌گردد. ورودی‌ای که خودش autoFocus دارد فوکوسش را نگه می‌دارد.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const t = setTimeout(() => {
if (!panel.current?.contains(document.activeElement)) panel.current?.focus({ preventScroll: true });
}, 0);
return () => {
clearTimeout(t);
if (opener?.isConnected) opener.focus({ preventScroll: true });
};
}, [open]);
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را.
useBackStep(open, onClose);
return (
<AnimatePresence>
@@ -40,9 +49,20 @@ export function Sheet({
onClick={onClose}
/>
<motion.div
ref={panel}
role="dialog"
aria-modal="true"
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3"
aria-labelledby={title ? titleId : undefined}
// Escape فقط برگه‌ی رویی را می‌بندد (فوکوس داخلِ همان است)؛ قبلاً هر برگه‌ی باز روی window گوش می‌داد
// و یک Escape برگه‌ی زیرین را هم می‌بست.
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
}}
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3 outline-none"
style={{ paddingBottom: 'calc(2rem + env(safe-area-inset-bottom))' }}
initial={{ y: '100%' }}
animate={{ y: 0 }}
@@ -62,7 +82,7 @@ export function Sheet({
</div>
{title && (
<div className="mb-4 flex touch-none items-center justify-between gap-3" onPointerDown={(e) => drag.start(e)}>
<h2 className="text-base font-black text-ink">{title}</h2>
<h2 id={titleId} className="text-base font-black text-ink">{title}</h2>
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
<X size={16} />
</button>

View File

@@ -1,4 +1,4 @@
import { StrictMode, lazy, Suspense } from 'react';
import { Component, StrictMode, lazy, Suspense, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles/index.css';
@@ -8,6 +8,58 @@ import { startLineFit } from './lib/lineFit';
unlockAudioOnFirstTouch();
startLineFit();
/**
* فایلِ یک صفحه بعد از انتشارِ تازه پیدا نشد (اپ از قبل باز بود و نامِ فایل‌ها با هر build عوض می‌شود): راهِ پیشنهادیِ
* Vite یک بار بارگذاریِ دوباره است تا index.htmlِ تازه بیاید. تا ۳۰ ثانیه فقط یک بار، تا خرابیِ واقعیِ شبکه حلقه نشود؛
* بعد از آن خطا به RootBoundary می‌رسد.
*/
const RELOAD_KEY = 'tadrisino.chunkReloadAt';
window.addEventListener('vite:preloadError', (event) => {
let last = 0;
try {
last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0;
} catch {
/* sessionStorage بسته؛ بی‌نگهبانِ حلقه بارگذاری نمی‌کنیم */
return;
}
if (Date.now() - last < 30_000) return;
event.preventDefault();
try {
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
} catch {
return;
}
window.location.reload();
});
/**
* آخرین تور: هر خطای رندر که جای دیگری گرفته نشد، به‌جای صفحه‌ی سفید (React بی‌boundary کلِ درخت را برمی‌دارد)
* پیامی با «دوباره» نشان می‌دهد. سبک‌ها درون‌خطی‌اند چون ممکن است خودِ CSS یا فونت هم نرسیده باشد.
*/
class RootBoundary extends Component<{ children: ReactNode }, { error: unknown }> {
state = { error: null as unknown };
static getDerivedStateFromError(error: unknown) {
return { error };
}
componentDidCatch(error: unknown) {
console.error('render crashed', error);
// داخلِ اپِ اندروید اسپلشِ بومی تا appReady می‌ماند و این پیام را می‌پوشاند.
window.TadrisinoAndroid?.appReady?.();
}
render() {
if (!this.state.error) return this.props.children;
return (
<div dir="rtl" role="alert" style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, padding: 24, background: '#fff', fontFamily: 'system-ui, sans-serif', textAlign: 'center' }}>
<p style={{ fontSize: 16, fontWeight: 800, color: '#1a1a2e' }}>یه مشکلی پیش اومد</p>
<p style={{ fontSize: 13, color: '#6b6b80', lineHeight: 1.8 }}>پیشرفتت سر جاشه؛ یه بار دیگه بازش کن.</p>
<button type="button" onClick={() => window.location.reload()} style={{ border: 0, borderRadius: 16, padding: '12px 32px', background: '#ED6956', color: '#fff', fontSize: 15, fontWeight: 800 }}>
دوباره
</button>
</div>
);
}
}
// گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است).
const DiagramGallery = import.meta.env.DEV
? lazy(() => import('./dev/DiagramGallery').then((m) => ({ default: m.DiagramGallery })))
@@ -16,10 +68,12 @@ const showGallery = DiagramGallery && new URLSearchParams(window.location.search
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RootBoundary>
{showGallery && DiagramGallery ? (
<Suspense fallback={null}><DiagramGallery /></Suspense>
) : (
<App />
)}
</RootBoundary>
</StrictMode>,
);

View File

@@ -92,6 +92,22 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
return () => window.removeEventListener('popstate', onPop);
}, [stepBack]);
// اپِ اصلی بسته شد (خروج، نشستِ باطل): ورودی‌هایی که گذاشته بودیم برداشته می‌شوند. بی‌این، در صفحه‌ی ورود
// (و بعد از ورودِ دوباره) هر کدام یک «برگشت»ِ بی‌اثر بود و کاربر باید چند بار می‌زد تا از اپ بیرون برود.
// یک tick صبر: StrictMode افکت را پاک و فوراً دوباره اجرا می‌کند و آن بسته‌شدنِ واقعی نیست.
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
setTimeout(() => {
if (mounted.current || entries.current === 0) return;
window.history.go(-entries.current);
entries.current = 0;
}, 0);
};
}, []);
const desired = steps.length + (stack.length - 1) + (stack[0].name !== 'home' ? 1 : 0);
useEffect(() => {
const diff = desired - entries.current;

View File

@@ -60,7 +60,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
),
)
.catch(() => askStore.reset(null));
.catch((err: unknown) => {
// فقط گفتگوی ناموجود (۴۰۴: حذف شده یا مالِ حسابِ دیگر) کنار می‌رود. قبلاً اینترنتِ قطع هم شناسه را پاک
// می‌کرد و گفتگو برای همیشه گم می‌شد؛ حالا historyLoaded نمی‌خورد و بارِ بعدِ باز شدنِ صفحه دوباره می‌پرسد.
if (err instanceof ApiError && err.status === 404) askStore.reset(null);
else console.warn('ai history lookup failed; keeping the conversation', err);
});
// فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
}, []);
@@ -112,10 +117,10 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
setQuota(done.remainingQuota);
} catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
const quotaReached = err instanceof ApiError && err.code === 'ai_daily_quota';
askStore.update(replyId, {
text: friendlyError(err, 'جوابی نرسید'),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question, failedQuestionId: forQuestionId }),
});
if (quotaReached) setQuota(0);
} finally {
@@ -130,7 +135,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
const question = all[index - 1];
askStore.remove(message.localId);
if (question?.role === 'user') askStore.remove(question.localId);
void send(message.failedQuestion);
void send(message.failedQuestion, message.failedQuestionId);
}
const empty = chat.messages.length === 0;

View File

@@ -28,7 +28,9 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
const otpInput = useRef<HTMLInputElement>(null);
const phoneValid = /^09\d{9}$/.test(phone);
const otpValid = otp.length >= 4;
// کدِ سرور ۶ رقمی است و هر حدسِ غلط یکی از ۵ فرصت را می‌سوزاند؛ کدِ ناقص نباید فرستاده شود.
// فقط در حالتِ توسعه کدِ ثابتِ ۴ رقمیِ DEV_MASTER_OTP هم هست.
const otpValid = otp.length === OTP_LENGTH || (import.meta.env.DEV && otp.length === 4);
useEffect(() => {
if (resendIn <= 0) return;
@@ -75,7 +77,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
}
async function verify(code = otp) {
if (code.length < 4 || loading) return;
if (!(code.length === OTP_LENGTH || (import.meta.env.DEV && code.length === 4)) || loading) return;
setError('');
setLoading('login');
try {
@@ -132,7 +134,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
<span className="mb-2 block text-sm font-bold text-ink">شماره موبایل</span>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
@@ -194,3 +196,16 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
</div>
);
}
/**
* پرکردنِ خودکارِ گوشی و چسباندن معمولاً «+98 912 …» یا «0098…» می‌دهد؛ قبلاً فقط ۱۱ رقمِ اول نگه داشته می‌شد
* («98912…») و دکمه بی‌توضیح خاموش می‌ماند. سرور هم همین شکل‌ها را می‌پذیرد (accounts/phone.py).
*/
function normalizePhoneInput(value: string): string {
let digits = digitsOnly(value);
if (digits.startsWith('0098')) digits = `0${digits.slice(4)}`;
else if (digits.startsWith('98') && digits.length >= 12) digits = `0${digits.slice(2)}`;
// «98…» هنوز در حالِ تایپ است (۱۰ و ۱۱ رقمِ وسطِ «+98912…» هم با ۹ شروع می‌شوند)؛ پیش‌شماره‌ی ۰۹۸ نداریم.
else if (digits.startsWith('9') && !digits.startsWith('98') && digits.length === 10) digits = `0${digits}`;
return digits.slice(0, 11);
}

View File

@@ -223,6 +223,25 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
}
}
/**
* قلب برگشت: آزمون از همان سوال ادامه می‌یابد. قبلاً کلِ آزمون از نو بار می‌شد و سوال و «درست‌ها» صفر می‌شدند؛
* دانش‌آموزی که وسطِ جزیره قلبش تمام شده بود بعد از ساعت‌ها انتظار باید سوال‌های جواب‌داده را دوباره می‌زد.
*/
function resume() {
if (!questions.length) {
setReload((n) => n + 1);
return;
}
api.hearts().then(
(h) => {
setHearts(h);
if (!h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
else setPhase({ kind: 'playing' });
},
(err: unknown) => toast(friendlyError(err, 'وضعیت قلب‌ها معلوم نشد، دوباره بزن'), 'error'),
);
}
const askWhy = () =>
nav.push({
name: 'ask',
@@ -245,7 +264,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
);
}
if (phase.kind === 'noHearts') {
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={resume} />;
}
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} streakUp={streakUp} />;
if (phase.kind === 'finishing' || phase.kind === 'finishError') {

View File

@@ -216,6 +216,15 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
useEffect(() => {
if (plan) setMethod(firstUsable);
}, [plan, firstUsable]);
// برگشت از صفحه‌ی درگاه با دکمه‌ی «برگشت»: مرورگر صفحه را از bfcache با همان busy=true برمی‌گرداند و دکمه‌ی
// «پرداخت» تا ابد می‌چرخید.
useEffect(() => {
const onShow = (e: PageTransitionEvent) => {
if (e.persisted) setBusy(false);
};
window.addEventListener('pageshow', onShow);
return () => window.removeEventListener('pageshow', onShow);
}, []);
const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined;
/**

View File

@@ -18,6 +18,8 @@ export interface ChatMessage {
feedback?: 'up' | 'down';
/** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */
failedQuestion?: string;
/** سوالِ آزمونی که پرسش درباره‌اش بود («چرا غلط بود؟»)؛ «دوباره» باید همان را دوباره بفرستد. */
failedQuestionId?: string;
/** سهمیه‌ی رایگانِ امروز تمام شده؛ به‌جای «دوباره»، راهِ اشتراک نشان داده می‌شود. */
quotaReached?: boolean;
}