From b75127c2374caaf134264812c6ad33a9ce7d3a9b Mon Sep 17 00:00:00 2001 From: AliReza Date: Sun, 18 Oct 2026 17:25:40 +0330 Subject: [PATCH] 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 --- deploy.sh | 7 ++++ src/api/http.ts | 24 +++++++---- src/api/index.ts | 9 +++- src/app/App.tsx | 20 +++++++++ src/app/MainApp.tsx | 39 ++++++++++++++---- src/components/NoHeartsScreen.tsx | 9 +++- src/components/Sheet.tsx | 38 +++++++++++++---- src/main.tsx | 66 +++++++++++++++++++++++++++--- src/nav/Navigator.tsx | 16 ++++++++ src/screens/AskScreen.tsx | 13 ++++-- src/screens/LoginScreen.tsx | 21 ++++++++-- src/screens/QuizScreen.tsx | 21 +++++++++- src/screens/SubscriptionScreen.tsx | 9 ++++ src/state/askStore.ts | 2 + 14 files changed, 253 insertions(+), 41 deletions(-) diff --git a/deploy.sh b/deploy.sh index 3cc9235..9e06efc 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/src/api/http.ts b/src/api/http.ts index c31195e..5c6ba09 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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 { + // بدنه‌ی تابعی سرِ هر ارسال از نو ساخته می‌شود (خروج: refreshTokenِ همان لحظه، نه نسخه‌ی پیش از refresh). + const payload = typeof body === 'function' ? (body as () => unknown)() : body; const headers: Record = { 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 { - 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; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه diff --git a/src/api/index.ts b/src/api/index.ts index d68fd9d..d5e788d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -71,9 +71,14 @@ export const api = { /** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */ async logout() { - const refreshToken = http.storage.get()?.refreshToken; + // بدنه تابع است تا سرِ هر ارسال خوانده شود: اگر 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); } diff --git a/src/app/App.tsx b/src/app/App.tsx index 4869f57..2d4c1c5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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(); diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index e2c327f..86e7b7a 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -312,20 +312,45 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick('checking'); useEffect(() => { - api.subscription - .order(payment.orderId) - .then((order) => setState(order.status === 'paid' ? 'paid' : 'failed')) - .catch((err: unknown) => { - console.warn('payment result lookup failed', err); - setState('unknown'); - }); + // pending یعنی تأییدِ درگاه هنوز نرسیده (مثلاً verify هنگامِ برگشت جواب نداد)، نه «پرداخت نشد»: سرور با هر + // خواندنِ وضعیت دوباره از زرین‌پال می‌پرسد (حداکثر دقیقه‌ای یک بار)، پس چند بار با فاصله می‌پرسیم. + let alive = true; + let timer: ReturnType | undefined; + const check = (attempt: number) => { + api.subscription + .order(payment.orderId) + .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); + if (alive) setState('unknown'); + }); + }; + check(1); void refreshSummary(); + return () => { + alive = false; + clearTimeout(timer); + }; }, [payment.orderId, refreshSummary]); if (state === 'checking') { diff --git a/src/components/NoHeartsScreen.tsx b/src/components/NoHeartsScreen.tsx index b5617d2..341f84c 100644 --- a/src/components/NoHeartsScreen.tsx +++ b/src/components/NoHeartsScreen.tsx @@ -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]); diff --git a/src/components/Sheet.tsx b/src/components/Sheet.tsx index c3775bb..2801aab 100644 --- a/src/components/Sheet.tsx +++ b/src/components/Sheet.tsx @@ -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(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 ( @@ -40,9 +49,20 @@ export function Sheet({ onClick={onClose} /> { + 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({ {title && (
drag.start(e)}> -

{title}

+

{title}

diff --git a/src/main.tsx b/src/main.tsx index 4db3be6..83a20a7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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 ( +
+

یه مشکلی پیش اومد

+

پیشرفتت سر جاشه؛ یه بار دیگه بازش کن.

+ +
+ ); + } +} + // گالریِ شکل‌ها فقط در حالتِ توسعه؛ در 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( - {showGallery && DiagramGallery ? ( - - ) : ( - - )} + + {showGallery && DiagramGallery ? ( + + ) : ( + + )} + , ); diff --git a/src/nav/Navigator.tsx b/src/nav/Navigator.tsx index 1b9405d..d42937c 100644 --- a/src/nav/Navigator.tsx +++ b/src/nav/Navigator.tsx @@ -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; diff --git a/src/screens/AskScreen.tsx b/src/screens/AskScreen.tsx index aa6579f..c63f9b4 100644 --- a/src/screens/AskScreen.tsx +++ b/src/screens/AskScreen.tsx @@ -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; diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 3574857..cab6950 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -28,7 +28,9 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel const otpInput = useRef(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 شماره موبایل 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
); } + +/** + * پرکردنِ خودکارِ گوشی و چسباندن معمولاً «+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); +} diff --git a/src/screens/QuizScreen.tsx b/src/screens/QuizScreen.tsx index dc5fbf5..8ae69b6 100644 --- a/src/screens/QuizScreen.tsx +++ b/src/screens/QuizScreen.tsx @@ -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 setReload((n) => n + 1)} />; + return ; } if (phase.kind === 'result') return ; if (phase.kind === 'finishing' || phase.kind === 'finishError') { diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index 56105fe..a349648 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -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; /** diff --git a/src/state/askStore.ts b/src/state/askStore.ts index 751067e..dd7abf7 100644 --- a/src/state/askStore.ts +++ b/src/state/askStore.ts @@ -18,6 +18,8 @@ export interface ChatMessage { feedback?: 'up' | 'down'; /** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */ failedQuestion?: string; + /** سوالِ آزمونی که پرسش درباره‌اش بود («چرا غلط بود؟»)؛ «دوباره» باید همان را دوباره بفرستد. */ + failedQuestionId?: string; /** سهمیه‌ی رایگانِ امروز تمام شده؛ به‌جای «دوباره»، راهِ اشتراک نشان داده می‌شود. */ quotaReached?: boolean; }