4 Commits

Author SHA1 Message Date
3a03c5088a deploy.sh: cp --update=none (coreutils 9 warns on -n)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:31:02 +03:30
0a91ffc059 deploy.sh: DEPLOY_BUNDLE for when the server cannot fetch from Gitea
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:26:41 +03:30
b75127c237 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>
2026-10-18 17:25:40 +03:30
f7009b11e8 Instant home on reopen: snapshot of profile/grades/map/stats on the device, refreshed behind the scenes
A signed-in user with a snapshot goes straight to home; the same boot requests run in the
background and replace it. Snapshot is cleared whenever tokens are cleared and on a new login,
ignored from another build or when older than 7 days, and dropped when the fresh profile is a
different user (reboot remounts MainApp via a generation key; AnimatePresence otherwise kept the
exiting one). Notifications/characters wait for the profile answer so an expired access token
costs the same requests as before. refreshHome applies only the latest answer. Home shows a
"saved data" strip when the map could not be refreshed.

Measured locally (old vs new build, same backend, Chrome throttling, median of 3, time to map):
Fast 3G 2.46s -> 1.80s (expired token 3.63s -> 1.80s); Slow 3G 8.52s -> 6.14s (expired 12.6s ->
6.13s); API requests per open unchanged (6 valid, 11 expired). 12/12 behaviour checks passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:57:42 +03:30
21 changed files with 501 additions and 66 deletions

View File

@@ -70,6 +70,10 @@ pay.html · src/pay.ts صفحه‌ی واسطِ پرداختِ زرین‌پا
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد). - **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد).
- **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، … - **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، …
- **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور. - **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور.
- **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی می‌بیند (`state/homeSnapshot.ts`: پروفایل، پایه‌ها، نقشه،
آمار) و همان درخواست‌های بوت پشتِ صحنه تازه‌اش می‌کنند. عکس با پاک شدنِ توکن و ورودِ تازه پاک می‌شود، با بیلدِ دیگر
یا کهنه‌تر از ۷ روز خوانده نمی‌شود، و درستی (قفل، اشتراک، قلب) همیشه با سرور است. داده‌ی تازه‌ای که در خانه دیده
می‌شود باید در `AppData` بماند تا در عکس هم برود.
- **متنِ فارسی** بی‌اعراب و بی «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») می‌ماند. متنِ تازه - **متنِ فارسی** بی‌اعراب و بی «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») می‌ماند. متنِ تازه
را همین‌طور بنویسید. متنِ سرور را بک‌اند هنگامِ ساختنِ پاسخ پاک می‌کند و `plainPersian` در `MathText` همان کار را برای را همین‌طور بنویسید. متنِ سرور را بک‌اند هنگامِ ساختنِ پاسخ پاک می‌کند و `plainPersian` در `MathText` همان کار را برای
پاسخِ زنده‌ی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده می‌کند و باید با `content/plain_math.py`ِ پاسخِ زنده‌ی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده می‌کند و باید با `content/plain_math.py`ِ

View File

@@ -18,7 +18,16 @@ step() { printf '\n\033[0;32m▸ %s\033[0m\n' "$1"; }
fail() { printf '\033[0;31m✖ %s\033[0m\n' "$1" >&2; exit 1; } fail() { printf '\033[0;31m✖ %s\033[0m\n' "$1" >&2; exit 1; }
step "گرفتنِ کدِ تازه ($BRANCH)" step "گرفتنِ کدِ تازه ($BRANCH)"
git fetch origin "$BRANCH" || fail "به gitea وصل نشد." # DEPLOY_BUNDLE=/مسیر/فایل.bundle: کد از یک git bundle، نه از gitea (مثلِ deploy.shِ بک‌اند): برای وقتی که سرور
# به gitea نمی‌رسد یا اعتبارنامه ندارد. فقط وقتی صریحاً داده شود؛ بی‌آن نرسیدن به gitea دیپلو را می‌ایستاند.
if [ -n "${DEPLOY_BUNDLE:-}" ]; then
[ -f "$DEPLOY_BUNDLE" ] || fail "DEPLOY_BUNDLE=$DEPLOY_BUNDLE وجود ندارد."
git bundle verify "$DEPLOY_BUNDLE" >/dev/null 2>&1 || fail "bundle معتبر نیست یا کامیتِ پایه‌اش این‌جا نیست."
git fetch "$DEPLOY_BUNDLE" "$BRANCH:refs/remotes/origin/$BRANCH" || fail "گرفتن از bundle نشد."
else
git fetch origin "$BRANCH" || fail "به gitea وصل نشد (اگر فقط از این سرور: DEPLOY_BUNDLE، بالای همین خط)."
fi
printf ' origin/%s = %s\n' "$BRANCH" "$(git rev-parse --short "origin/$BRANCH")"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
mkdir -p .deploy-backups mkdir -p .deploy-backups
PATCH=".deploy-backups/local-changes-$(date +%Y%m%d-%H%M%S).patch" PATCH=".deploy-backups/local-changes-$(date +%Y%m%d-%H%M%S).patch"
@@ -41,6 +50,13 @@ if [ -f dist/config.js ] && ! cmp -s dist/config.js public/config.js; then
printf ' config.jsِ ویرایش‌شده‌ی سرور نگه داشته شد.\n' printf ' config.jsِ ویرایش‌شده‌ی سرور نگه داشته شد.\n'
fi fi
# فایل‌های buildهای قبلی (نامشان hash دارد و با هم برخورد نمی‌کنند) تا ۳۰ روز می‌مانند: گوشی‌ای که اپ از قبل
# در آن باز است هنوز index.htmlِ قبلی را دارد و با باز کردنِ صفحه‌ای که بار نشده همان نام‌ها را می‌خواهد؛ بی‌این
# ۴۰۴ و صفحه‌ی سفید می‌گرفت. cp -p زمانِ فایل را نگه می‌دارد، پس هر فایل ۳۰ روز بعد از buildِ خودش کنار می‌رود.
if [ -d dist/assets ]; then
find dist/assets -type f -mtime -30 -exec cp -p --update=none {} dist.next/assets/ \;
fi
step "جابه‌جایی" step "جابه‌جایی"
rm -rf dist.prev rm -rf dist.prev
[ -d dist ] && mv dist dist.prev [ -d dist ] && mv dist dist.prev

View File

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

View File

@@ -4,13 +4,26 @@
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است. * هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
*/ */
import { resolveApiBaseUrl } from '@/config'; import { resolveApiBaseUrl } from '@/config';
import { ApiError, createHttpClient, newIdempotencyKey } from './http'; import { homeSnapshot } from '@/state/homeSnapshot';
import { ApiError, createHttpClient, localTokenStorage, newIdempotencyKey, type TokenStorage } from './http';
import type * as T from './types'; import type * as T from './types';
export { ApiError }; export { ApiError };
export type * from './types'; export type * from './types';
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() }); /**
* پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل در http.signOut) عکسِ خانه را هم پاک می‌کند؛ هر سه از همین
* `set(null)` می‌گذرند، پس راهی برای ماندنِ خانه‌ی کاربرِ قبلی روی گوشیِ مشترک نمی‌ماند.
*/
const tokenStorage: TokenStorage = {
get: () => localTokenStorage.get(),
set(tokens) {
if (!tokens) homeSnapshot.clear();
localTokenStorage.set(tokens);
},
};
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl(), storage: tokenStorage });
const q = (params: Record<string, string | number | undefined>) => { const q = (params: Record<string, string | number | undefined>) => {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
@@ -18,6 +31,8 @@ const q = (params: Record<string, string | number | undefined>) => {
}; };
function saveTokens(data: T.AuthResponse) { function saveTokens(data: T.AuthResponse) {
// ورودِ تازه ممکن است کاربرِ دیگری باشد؛ عکسِ خانه فقط از پاسخِ سرورِ همین نشست ساخته می‌شود.
homeSnapshot.clear();
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' }); http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
return data; return data;
} }
@@ -56,9 +71,14 @@ export const api = {
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */ /** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
async logout() { async logout() {
// بدنه تابع است تا سرِ هر ارسال خوانده شود: اگر access token منقضی بوده، ۴۰۱ ← refresh ← تکرار، و
// refresh token عوض شده است؛ فرستادنِ نسخه‌ی قبلی نشستِ تازه را ۳۰ روز روی سرور باطل‌نشده می‌گذاشت.
const body = () => {
const refreshToken = http.storage.get()?.refreshToken; const refreshToken = http.storage.get()?.refreshToken;
return refreshToken ? { refreshToken } : {};
};
try { try {
await http.post('/auth/logout', refreshToken ? { refreshToken } : {}); await http.post('/auth/logout', body);
} finally { } finally {
http.storage.set(null); http.storage.set(null);
} }

View File

@@ -1,12 +1,14 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, MotionConfig, motion } from 'motion/react'; import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api'; import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import { TOKENS_STORAGE_KEY } from '@/api/http';
import type { Prefetch } from '@/state/AppData'; import type { Prefetch } from '@/state/AppData';
import { AppShell } from '@/components/AppShell'; import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast'; import { ToastProvider } from '@/components/Toast';
import { ErrorState } from '@/components/ui'; import { ErrorState } from '@/components/ui';
import { KEYS, store } from '@/lib/storage'; import { KEYS, store } from '@/lib/storage';
import { askStore } from '@/state/askStore'; import { askStore } from '@/state/askStore';
import { homeSnapshot } from '@/state/homeSnapshot';
import { fetchHome, subjectStore } from '@/state/subject'; import { fetchHome, subjectStore } from '@/state/subject';
import { clearAsyncCache } from '@/lib/useAsync'; import { clearAsyncCache } from '@/lib/useAsync';
import { LoginScreen } from '@/screens/LoginScreen'; import { LoginScreen } from '@/screens/LoginScreen';
@@ -22,14 +24,18 @@ type Flow =
| { kind: 'onboarding' } | { kind: 'onboarding' }
| { kind: 'login' } | { kind: 'login' }
| { kind: 'setup'; profile: UserProfile; grades: Grade[] } | { kind: 'setup'; profile: UserProfile; grades: Grade[] }
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch }; | { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch; stale?: boolean };
/** /**
* جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ. * جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
* خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است. * کاربرِ واردشده‌ای که عکسِ خانه روی گوشی دارد (state/homeSnapshot.ts) بی‌انتظار به خانه می‌رود و
* داده‌ها پشتِ صحنه تازه می‌شوند. خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است.
*/ */
export function App() { export function App() {
const [flow, setFlow] = useState<Flow>({ kind: 'boot' }); const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
// با هر rebootFresh یکی بالا می‌رود و جزوِ کلیدِ صفحه است. بی‌آن «main → boot → main» برای AnimatePresence
// همان کلید است و MainAppِ در حالِ خروج (با داده‌ی عکسِ غلط) به‌جای MainAppِ تازه سرِ جایش می‌ماند.
const [generation, setGeneration] = useState(0);
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */ /** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => { const enter = useCallback(async () => {
@@ -46,6 +52,18 @@ export function App() {
setFlow({ kind: 'boot' }); setFlow({ kind: 'boot' });
let next: Flow | null = null; let next: Flow | null = null;
if (api.auth.isSignedIn()) { if (api.auth.isSignedIn()) {
const snap = homeSnapshot.read();
if (snap) {
setFlow({
kind: 'main',
profile: snap.profile,
grades: snap.grades,
prefetch: { home: snap.home, summary: snap.summary },
stale: true,
});
void trackReferral();
return;
}
try { try {
await enter(); await enter();
} catch (error) { } catch (error) {
@@ -83,6 +101,34 @@ export function App() {
[], [],
); );
// عکسِ خانه مالِ کسِ دیگری بود یا پروفایل حالا ناقص است: عکس کنار می‌رود و بوتِ معمولی تصمیم می‌گیرد.
const rebootFresh = useCallback(() => {
homeSnapshot.clear();
askStore.clear();
clearAsyncCache();
setGeneration((g) => g + 1);
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 () => { const afterLogin = async () => {
try { try {
await enter(); await enter();
@@ -98,7 +144,7 @@ export function App() {
<ToastProvider> <ToastProvider>
<AnimatePresence mode="wait" initial={false}> <AnimatePresence mode="wait" initial={false}>
<motion.div <motion.div
key={flow.kind} key={`${flow.kind}:${generation}`}
className="flex h-full flex-col" className="flex h-full flex-col"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -133,6 +179,8 @@ export function App() {
profile={flow.profile} profile={flow.profile}
grades={flow.grades} grades={flow.grades}
prefetch={flow.prefetch} prefetch={flow.prefetch}
stale={flow.stale}
onIdentityChanged={rebootFresh}
payment={launchPayment} payment={launchPayment}
onSignedOut={() => { onSignedOut={() => {
clearAsyncCache(); clearAsyncCache();

View File

@@ -52,15 +52,24 @@ interface Props {
profile: UserProfile; profile: UserProfile;
grades: Grade[]; grades: Grade[];
prefetch?: Prefetch; prefetch?: Prefetch;
/** داده‌ها از عکسِ خانه روی گوشی آمده‌اند و پشتِ صحنه تازه می‌شوند (AppData). */
stale?: boolean;
onIdentityChanged?: () => void;
payment: PaymentReturn | null; payment: PaymentReturn | null;
onSignedOut: () => void; onSignedOut: () => void;
} }
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) { export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []); useEffect(preloadScreens, []);
return ( return (
<AppDataProvider initialProfile={profile} grades={grades} prefetch={prefetch}> <AppDataProvider
initialProfile={profile}
grades={grades}
prefetch={prefetch}
stale={stale}
onIdentityChanged={onIdentityChanged}
>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}> <NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} /> <Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />} {payment && <PaymentResult payment={payment} />}
@@ -303,20 +312,45 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSig
* نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر * نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
* لینکِ ساختگی می‌تواند «paid» بگذارد. * لینکِ ساختگی می‌تواند «paid» بگذارد.
*/ */
/** سرور سفارشِ pending را دقیقه‌ای یک بار از درگاه می‌پرسد؛ ۴ بار با فاصله‌ی ۲۰ ثانیه حدودِ یک دقیقه است. */
const PENDING_CHECKS = 4;
const PENDING_CHECK_MS = 20_000;
function PaymentResult({ payment }: { payment: PaymentReturn }) { function PaymentResult({ payment }: { payment: PaymentReturn }) {
const { refreshSummary } = useAppData(); const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking'); const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
useEffect(() => { useEffect(() => {
// pending یعنی تأییدِ درگاه هنوز نرسیده (مثلاً verify هنگامِ برگشت جواب نداد)، نه «پرداخت نشد»: سرور با هر
// خواندنِ وضعیت دوباره از زرین‌پال می‌پرسد (حداکثر دقیقه‌ای یک بار)، پس چند بار با فاصله می‌پرسیم.
let alive = true;
let timer: ReturnType<typeof setTimeout> | undefined;
const check = (attempt: number) => {
api.subscription api.subscription
.order(payment.orderId) .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) => { .catch((err: unknown) => {
console.warn('payment result lookup failed', err); console.warn('payment result lookup failed', err);
setState('unknown'); if (alive) setState('unknown');
}); });
};
check(1);
void refreshSummary(); void refreshSummary();
return () => {
alive = false;
clearTimeout(timer);
};
}, [payment.orderId, refreshSummary]); }, [payment.orderId, refreshSummary]);
if (state === 'checking') { if (state === 'checking') {

View File

@@ -47,6 +47,9 @@ function whenLabel(iso: string, now: number): string {
return `ساعت ${time} ${day}`; return `ساعت ${time} ${day}`;
} }
/** سرور هر بار regen را خودش حساب می‌کند؛ پرسیدنِ هر ۱۵ ثانیه برای یک صفحه‌ی منتظر بار نیست. */
const DUE_RECHECK_MS = 15_000;
export function NoHeartsScreen({ export function NoHeartsScreen({
nextRefillAt, nextRefillAt,
fullRefillAt, fullRefillAt,
@@ -81,10 +84,14 @@ export function NoHeartsScreen({
setNext(h.nextRefillAt); setNext(h.nextRefillAt);
setFull(h.fullRefillAt); setFull(h.fullRefillAt);
} }
}).catch(() => undefined); }).catch((err: unknown) => console.warn('hearts check failed', err));
void check(); void check();
// ساعتِ گوشیِ جلوتر از سرور: سرور هنوز «صفر» و همان nextRefillAt را می‌دهد و `due` عوض نمی‌شود؛ بی‌تکرار،
// صفحه روی شمارشِ صفر می‌ماند تا کاربر بیرون برود. پس تا برگشتنِ قلب با فاصله دوباره می‌پرسیم.
const timer = setInterval(() => void check(), DUE_RECHECK_MS);
return () => { return () => {
alive = false; alive = false;
clearInterval(timer);
}; };
}, [due, back]); }, [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 { AnimatePresence, motion, useDragControls } from 'motion/react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useBackStep } from '@/nav/Navigator'; import { useBackStep } from '@/nav/Navigator';
@@ -19,14 +19,23 @@ export function Sheet({
children: ReactNode; children: ReactNode;
}) { }) {
const drag = useDragControls(); const drag = useDragControls();
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را. const titleId = useId();
useBackStep(open, onClose); const panel = useRef<HTMLDivElement>(null);
// فوکوس با باز شدن به برگه می‌رود (صفحه‌خوان و صفحه‌کلید از همان‌جا ادامه می‌دهند) و با بسته شدن به دکمه‌ای
// که بازش کرد برمی‌گردد. ورودی‌ای که خودش autoFocus دارد فوکوسش را نگه می‌دارد.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
window.addEventListener('keydown', onKey); const t = setTimeout(() => {
return () => window.removeEventListener('keydown', onKey); if (!panel.current?.contains(document.activeElement)) panel.current?.focus({ preventScroll: true });
}, [open, onClose]); }, 0);
return () => {
clearTimeout(t);
if (opener?.isConnected) opener.focus({ preventScroll: true });
};
}, [open]);
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را.
useBackStep(open, onClose);
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -40,9 +49,20 @@ export function Sheet({
onClick={onClose} onClick={onClose}
/> />
<motion.div <motion.div
ref={panel}
role="dialog" role="dialog"
aria-modal="true" 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))' }} style={{ paddingBottom: 'calc(2rem + env(safe-area-inset-bottom))' }}
initial={{ y: '100%' }} initial={{ y: '100%' }}
animate={{ y: 0 }} animate={{ y: 0 }}
@@ -62,7 +82,7 @@ export function Sheet({
</div> </div>
{title && ( {title && (
<div className="mb-4 flex touch-none items-center justify-between gap-3" onPointerDown={(e) => drag.start(e)}> <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"> <button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
<X size={16} /> <X size={16} />
</button> </button>

View File

@@ -31,4 +31,6 @@ export const KEYS = {
companion: 'tadrisino.companion', companion: 'tadrisino.companion',
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */ /** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
subjects: 'tadrisino.subjects', subjects: 'tadrisino.subjects',
/** عکسِ صفحه‌ی خانه برای بازشدنِ فوری (state/homeSnapshot.ts). */
homeSnapshot: 'tadrisino.homeSnapshot',
} as const; } as const;

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 { createRoot } from 'react-dom/client';
import { App } from './app/App'; import { App } from './app/App';
import './styles/index.css'; import './styles/index.css';
@@ -8,6 +8,58 @@ import { startLineFit } from './lib/lineFit';
unlockAudioOnFirstTouch(); unlockAudioOnFirstTouch();
startLineFit(); 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 ثابت است). // گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است).
const DiagramGallery = import.meta.env.DEV const DiagramGallery = import.meta.env.DEV
? lazy(() => import('./dev/DiagramGallery').then((m) => ({ default: m.DiagramGallery }))) ? 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<RootBoundary>
{showGallery && DiagramGallery ? ( {showGallery && DiagramGallery ? (
<Suspense fallback={null}><DiagramGallery /></Suspense> <Suspense fallback={null}><DiagramGallery /></Suspense>
) : ( ) : (
<App /> <App />
)} )}
</RootBoundary>
</StrictMode>, </StrictMode>,
); );

View File

@@ -92,6 +92,22 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
return () => window.removeEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop);
}, [stepBack]); }, [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); const desired = steps.length + (stack.length - 1) + (stack[0].name !== 'home' ? 1 : 0);
useEffect(() => { useEffect(() => {
const diff = desired - entries.current; 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 })), .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 وابسته نیست. // فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
}, []); }, []);
@@ -112,10 +117,10 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
setQuota(done.remainingQuota); setQuota(done.remainingQuota);
} catch (err) { } catch (err) {
// سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است. // سقفِ روزانه (نه throttleِ چندثانیه‌ای): «دوباره» تا فردا بی‌فایده است.
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال'); const quotaReached = err instanceof ApiError && err.code === 'ai_daily_quota';
askStore.update(replyId, { askStore.update(replyId, {
text: friendlyError(err, 'جوابی نرسید'), text: friendlyError(err, 'جوابی نرسید'),
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }), ...(quotaReached ? { quotaReached: true } : { failedQuestion: question, failedQuestionId: forQuestionId }),
}); });
if (quotaReached) setQuota(0); if (quotaReached) setQuota(0);
} finally { } finally {
@@ -130,7 +135,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
const question = all[index - 1]; const question = all[index - 1];
askStore.remove(message.localId); askStore.remove(message.localId);
if (question?.role === 'user') askStore.remove(question.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; const empty = chat.messages.length === 0;

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Crown, Flame, Star } from 'lucide-react'; import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react';
import type { HomeNode } from '@/api'; import { ApiError, type HomeNode } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum'; import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
@@ -20,7 +20,7 @@ import { SubjectBar } from '@/components/SubjectBar';
* دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد. * دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد.
*/ */
export function HomeScreen() { export function HomeScreen() {
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome } = useAppData(); const { gradeTitle, summary, home, homeError, unreadCount, refreshHome, refreshSummary } = useAppData();
const nav = useNav(); const nav = useNav();
// فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند. // فصلِ غیرِ اول بی‌اشتراک: زدنِ جزیره یک‌راست فهرستِ اشتراک‌ها را باز می‌کند.
const paywall = () => nav.push({ name: 'subscription' }); const paywall = () => nav.push({ name: 'subscription' });
@@ -91,6 +91,23 @@ export function HomeScreen() {
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" /> <EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
) : ( ) : (
<div className="flex flex-col gap-2 px-4 pb-10 pt-4"> <div className="flex flex-col gap-2 px-4 pb-10 pt-4">
{homeError != null && (
// نقشه هست ولی تازه نشد (معمولاً عکسِ ذخیره‌شده روی گوشی و اینترنتِ قطع): بچه باید بداند
// عددها و قفل‌ها ممکن است مالِ دفعه‌ی قبل باشند.
<div role="status" className="flex items-center gap-2 rounded-xl bg-soft px-3 py-2 text-xs leading-6 text-body">
<span className="flex-1">
{homeError instanceof ApiError && homeError.isNetwork ? 'اینترنت وصل نیست یا کنده' : 'اطلاعات تازه نشد'}؛
آخرین اطلاعات ذخیره‌شده رو می‌بینی.
</span>
<button
type="button"
onClick={() => void Promise.all([refreshHome(), refreshSummary()])}
className="flex shrink-0 items-center gap-1 rounded-full bg-white px-3 py-1 font-bold text-coral ring-1 ring-line"
>
<RotateCw size={12} /> دوباره
</button>
</div>
)}
{home.units.map((unit, i) => { {home.units.map((unit, i) => {
const color = unitColor(i); const color = unitColor(i);
const p = unitProgress(unit); const p = unitProgress(unit);

View File

@@ -28,7 +28,9 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
const otpInput = useRef<HTMLInputElement>(null); const otpInput = useRef<HTMLInputElement>(null);
const phoneValid = /^09\d{9}$/.test(phone); 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(() => { useEffect(() => {
if (resendIn <= 0) return; if (resendIn <= 0) return;
@@ -75,7 +77,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
} }
async function verify(code = otp) { 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(''); setError('');
setLoading('login'); setLoading('login');
try { 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> <span className="mb-2 block text-sm font-bold text-ink">شماره موبایل</span>
<input <input
value={phone} value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))} onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹" placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr" dir="ltr"
inputMode="numeric" inputMode="numeric"
@@ -194,3 +196,16 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
</div> </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 = () => const askWhy = () =>
nav.push({ nav.push({
name: 'ask', name: 'ask',
@@ -245,7 +264,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
); );
} }
if (phase.kind === 'noHearts') { 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 === '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') { if (phase.kind === 'finishing' || phase.kind === 'finishError') {

View File

@@ -216,6 +216,15 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
useEffect(() => { useEffect(() => {
if (plan) setMethod(firstUsable); if (plan) setMethod(firstUsable);
}, [plan, 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; const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined;
/** /**

View File

@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api'; import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar'; import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
import { companionStore, useCompanion } from '@/state/companion'; import { companionStore, useCompanion } from '@/state/companion';
import { homeSnapshot } from '@/state/homeSnapshot';
import { fetchHome, subjectStore } from '@/state/subject'; import { fetchHome, subjectStore } from '@/state/subject';
/** /**
@@ -42,16 +43,26 @@ export interface Prefetch {
export function AppDataProvider({ export function AppDataProvider({
initialProfile, initialProfile,
grades, grades: initialGrades,
prefetch, prefetch,
stale = false,
onIdentityChanged,
children, children,
}: { }: {
initialProfile: UserProfile; initialProfile: UserProfile;
grades: Grade[]; grades: Grade[];
prefetch?: Prefetch; prefetch?: Prefetch;
/**
* داده‌ی اولیه عکسِ ذخیره‌شده روی گوشی است (state/homeSnapshot.ts)، نه پاسخِ همین حالا: همه‌چیز پشتِ صحنه
* دوباره گرفته می‌شود. همان چهار درخواستِ بوت، پس بارِ سرور بیشتر نمی‌شود؛ فقط کاربر منتظرشان نمی‌ماند.
*/
stale?: boolean;
/** پروفایلِ تازه با عکس نمی‌خواند (کاربرِ دیگر، یا بی‌نام/بی‌پایه که باید به تکمیلِ پروفایل برود). */
onIdentityChanged?: () => void;
children: ReactNode; children: ReactNode;
}) { }) {
const [profile, setProfile] = useState(initialProfile); const [profile, setProfile] = useState(initialProfile);
const [grades, setGrades] = useState(initialGrades);
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null); const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null); const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
const [homeError, setHomeError] = useState<unknown>(null); const [homeError, setHomeError] = useState<unknown>(null);
@@ -63,16 +74,21 @@ export function AppDataProvider({
const gradeId = profile.gradeId; const gradeId = profile.gradeId;
// نقشه‌ی کتاب‌هایی که دیده شده؛ جابه‌جایی به کتابِ دیده‌شده فوری است و پشت‌صحنه تازه می‌شود. // نقشه‌ی کتاب‌هایی که دیده شده؛ جابه‌جایی به کتابِ دیده‌شده فوری است و پشت‌صحنه تازه می‌شود.
const homeCache = useRef(new Map<string, HomeResponse>()); const homeCache = useRef(new Map<string, HomeResponse>());
// فقط پاسخِ آخرین درخواست روی صفحه می‌نشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازه‌ی
// پشتِ صحنه پایه‌ی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشه‌ی تازه را بپوشاند.
const homeSeq = useRef(0);
const refreshHome = useCallback(async (forGrade?: number) => { const refreshHome = useCallback(async (forGrade?: number) => {
const target = forGrade ?? gradeId; const target = forGrade ?? gradeId;
const seq = ++homeSeq.current;
try { try {
const fresh = await fetchHome(target); const fresh = await fetchHome(target);
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh); if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
if (seq !== homeSeq.current) return;
setHome(fresh); setHome(fresh);
setHomeError(null); setHomeError(null);
} catch (err) { } catch (err) {
setHomeError(err); if (seq === homeSeq.current) setHomeError(err);
} }
}, [gradeId]); }, [gradeId]);
@@ -106,10 +122,16 @@ export function AppDataProvider({
} }
}, []); }, []);
// بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه می‌سازد و نباید همه‌ی این‌ها دوباره گرفته شوند.
const started = useRef(false);
// عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمی‌نویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط).
const identityLost = useRef(false);
useEffect(() => { useEffect(() => {
// آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها. if (started.current) return;
if (!prefetch?.home) void refreshHome(); started.current = true;
if (!prefetch?.summary) void refreshSummary(); // اعلان‌ها و همراه برای صفحه‌ی اول لازم نیستند.
const loadExtras = () => {
if (!api.auth.isSignedIn()) return;
void refreshUnread(); void refreshUnread();
api.characters api.characters
.list() .list()
@@ -118,10 +140,58 @@ export function AppDataProvider({
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma'); setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
}) })
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err)); .catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
// فقط بارِ اول؛ prefetch بعد از mount عوض نمی‌شود. };
if (stale) {
api.profile
.get()
.then((fresh) => {
if (fresh.userId !== initialProfile.userId || !fresh.name || fresh.gradeId == null) {
identityLost.current = true;
onIdentityChanged?.();
return false;
}
setProfile(fresh);
// پایه در دستگاهِ دیگری عوض شده؛ نقشه‌ای که هم‌زمان با پایه‌ی عکس گرفته شد به کار نمی‌آید.
if (fresh.gradeId !== initialProfile.gradeId) void refreshHome(fresh.gradeId);
return true;
})
// ۴۰۱ را http خودش به خروج می‌برد. بقیه (اینترنتِ قطع، ۵xx): پروفایلِ عکس می‌ماند و نوارِ «اطلاعاتِ
// ذخیره‌شده» در خانه (homeError) خبر می‌دهد؛ نقشه هم‌زمان همین درخواست‌ها را می‌زند و همان‌جا شکست می‌خورد.
.catch((err: unknown) => {
console.warn('profile refresh failed; showing the stored one', err);
return true;
})
// بعد از پروفایل، نه هم‌زمان: با توکنِ یک‌ساعته‌ی منقضی هر درخواستِ هم‌زمان یک ۴۰۱ و یک تکرار می‌شود؛
// این‌طور تعدادِ درخواست‌ها همان بوتِ قبلی است (۴ + refresh + ۴ تکرار + ۲).
.then((stays) => stays && loadExtras());
api
.grades()
.then(setGrades)
.catch((err: unknown) => console.warn('grades refresh failed; keeping the stored list', err));
void refreshHome();
void refreshSummary();
} else {
// آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها.
if (!prefetch?.home) void refreshHome();
if (!prefetch?.summary) void refreshSummary();
loadExtras();
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshHome, refreshSummary, refreshUnread]); }, [refreshHome, refreshSummary, refreshUnread]);
// هر داده‌ی تازه عکسِ خانه را به‌روز می‌کند. بارِ اولِ حالتِ stale همان عکسِ خوانده‌شده است و نوشته نمی‌شود،
// تا savedAt فقط با پاسخِ سرور جلو برود.
const skipSnapshotWrite = useRef(stale);
useEffect(() => {
if (skipSnapshotWrite.current) {
skipSnapshotWrite.current = false;
return;
}
// پاسخی که بعد از خروج رسید (توکن پاک شده) نباید عکسِ تازه بسازد.
if (!home || !summary || identityLost.current || !api.auth.isSignedIn()) return;
homeSnapshot.save({ profile, grades, home, summary });
}, [profile, grades, home, summary]);
const value = useMemo<AppData>( const value = useMemo<AppData>(
() => ({ () => ({
profile, profile,

View File

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

63
src/state/homeSnapshot.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { Grade, HomeResponse, UserProfile, UserSummary } from '@/api';
import { KEYS, store } from '@/lib/storage';
/**
* عکسِ صفحه‌ی خانه روی گوشی: آخرین پروفایل، پایه‌ها، نقشه و آمار. اپ با آن فوراً خانه را نشان می‌دهد و
* همان درخواست‌های همیشگی را پشتِ صحنه می‌زند (AppData، `stale`)؛ بدونِ آن اسپلش تا رسیدنِ هر چهار پاسخ
* می‌ماند، که روی اینترنتِ ضعیف و با توکنِ یک‌ساعته‌ی منقضی (۴۰۱ ← refresh ← تکرار) چند رفت‌وبرگشتِ پشتِ هم است.
*
* درستی را سرور نگه می‌دارد، نه این عکس: جزیره‌ی قفل (۴۰۳)، اشتراکِ تمام‌شده (۴۰۲) و قلبِ صفر (۴۰۳) همان‌جا
* رد می‌شوند؛ عکسِ قدیمی فقط ممکن است یکی دو ثانیه عددِ کهنه نشان دهد.
*
* با پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل) و با ورودِ تازه پاک می‌شود (api/index.ts)، تا خانه‌ی یک
* کاربر روی گوشیِ مشترک به کاربرِ بعدی نرسد.
*/
export interface HomeSnapshot {
build: string;
savedAt: number;
profile: UserProfile;
grades: Grade[];
home: HomeResponse;
summary: UserSummary;
}
/**
* عکسِ کهنه‌تر از این کنار گذاشته می‌شود و اپ مثلِ قبل منتظرِ سرور می‌ماند. انتخابِ محصولی: کسی که یک هفته
* نیامده، آمدنِ یک‌باره‌ی نقشه‌ی تازه را به دیدنِ نقشه‌ی یک هفته پیش ترجیح می‌دهد.
*/
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export const homeSnapshot = {
/** عکسِ قابلِ استفاده، یا null: نبود، مالِ بیلدِ دیگری بود (شکلِ داده ممکن است عوض شده باشد)، کهنه یا ناقص بود. */
read(): HomeSnapshot | null {
const raw = store.get(KEYS.homeSnapshot);
if (!raw) return null;
let snap: Partial<HomeSnapshot>;
try {
snap = JSON.parse(raw) as Partial<HomeSnapshot>;
} catch (err) {
console.warn('home snapshot unreadable; dropped', err);
store.remove(KEYS.homeSnapshot);
return null;
}
const usable =
snap.build === __APP_BUILD__ &&
typeof snap.savedAt === 'number' &&
Date.now() - snap.savedAt < MAX_AGE_MS &&
// بی‌نام یا بی‌پایه یعنی کاربر باید به «تکمیلِ پروفایل» برود؛ آن تصمیم با پاسخِ سرور است.
Boolean(snap.profile?.name) &&
snap.profile?.gradeId != null &&
Array.isArray(snap.grades) &&
Array.isArray(snap.home?.units) &&
snap.summary != null;
return usable ? (snap as HomeSnapshot) : null;
},
save(data: Omit<HomeSnapshot, 'build' | 'savedAt'>) {
store.set(KEYS.homeSnapshot, JSON.stringify({ ...data, build: __APP_BUILD__, savedAt: Date.now() }));
},
clear() {
store.remove(KEYS.homeSnapshot);
},
};

3
src/vite-env.d.ts vendored
View File

@@ -1,5 +1,8 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/** شناسه‌ی هر بیلد (vite.config.ts)؛ عکسِ خانه‌ی بیلدِ دیگر خوانده نمی‌شود. */
declare const __APP_BUILD__: string;
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string; readonly VITE_API_BASE_URL?: string;
} }

View File

@@ -9,6 +9,9 @@ export default defineConfig(({ mode }) => {
return { return {
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
// عکسِ خانه (src/state/homeSnapshot.ts) فقط با همان بیلدی خوانده می‌شود که نوشته‌اش؛ بعد از هر دیپلوی
// بارِ اول مثلِ قبل از سرور می‌آید، تا داده‌ای با شکلِ قدیمی به کدِ تازه نرسد.
define: { __APP_BUILD__: JSON.stringify(Date.now().toString(36)) },
resolve: { resolve: {
alias: { '@': path.resolve(__dirname, './src') }, alias: { '@': path.resolve(__dirname, './src') },
}, },