forked from Tadrisino/MathApp_frontend
Compare commits
5 Commits
f0b7a5e28f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 06f80d181e | |||
| 3a03c5088a | |||
| 0a91ffc059 | |||
| b75127c237 | |||
| f7009b11e8 |
@@ -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`ِ
|
||||||
|
|||||||
18
deploy.sh
18
deploy.sh
@@ -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
|
||||||
|
|||||||
@@ -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; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
|
||||||
|
|||||||
@@ -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() {
|
||||||
const refreshToken = http.storage.get()?.refreshToken;
|
// بدنه تابع است تا سرِ هر ارسال خوانده شود: اگر access token منقضی بوده، ۴۰۱ ← refresh ← تکرار، و
|
||||||
|
// refresh token عوض شده است؛ فرستادنِ نسخهی قبلی نشستِ تازه را ۳۰ روز روی سرور باطلنشده میگذاشت.
|
||||||
|
const body = () => {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
api.subscription
|
// pending یعنی تأییدِ درگاه هنوز نرسیده (مثلاً verify هنگامِ برگشت جواب نداد)، نه «پرداخت نشد»: سرور با هر
|
||||||
.order(payment.orderId)
|
// خواندنِ وضعیت دوباره از زرینپال میپرسد (حداکثر دقیقهای یک بار)، پس چند بار با فاصله میپرسیم.
|
||||||
.then((order) => setState(order.status === 'paid' ? 'paid' : 'failed'))
|
let alive = true;
|
||||||
.catch((err: unknown) => {
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
console.warn('payment result lookup failed', err);
|
const check = (attempt: number) => {
|
||||||
setState('unknown');
|
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();
|
void refreshSummary();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
}, [payment.orderId, refreshSummary]);
|
}, [payment.orderId, refreshSummary]);
|
||||||
|
|
||||||
if (state === 'checking') {
|
if (state === 'checking') {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
66
src/main.tsx
66
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 { 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>
|
||||||
{showGallery && DiagramGallery ? (
|
<RootBoundary>
|
||||||
<Suspense fallback={null}><DiagramGallery /></Suspense>
|
{showGallery && DiagramGallery ? (
|
||||||
) : (
|
<Suspense fallback={null}><DiagramGallery /></Suspense>
|
||||||
<App />
|
) : (
|
||||||
)}
|
<App />
|
||||||
|
)}
|
||||||
|
</RootBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api, ApiError, type AnswerResult, type Hearts, type Question } from '@/
|
|||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { fetchHome } from '@/state/subject';
|
import { fetchHome } from '@/state/subject';
|
||||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||||
import { useNav } from '@/nav/Navigator';
|
import { useBackStep, useNav } from '@/nav/Navigator';
|
||||||
import { newIdempotencyKey } from '@/api/http';
|
import { newIdempotencyKey } from '@/api/http';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
import type { SeyarMood } from '@/mascots/seyar';
|
import type { SeyarMood } from '@/mascots/seyar';
|
||||||
@@ -74,6 +74,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
|
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
|
||||||
const [combo, setCombo] = useState(0);
|
const [combo, setCombo] = useState(0);
|
||||||
useGateHold(phase.kind === 'loading');
|
useGateHold(phase.kind === 'loading');
|
||||||
|
// دکمهی برگشتِ گوشی وسطِ تمرین همان «X» است: اول میپرسد، بیصدا بیرون نمیاندازد. برگهی باز خودش قدمِ
|
||||||
|
// برگشتِ جداگانه دارد، پس برگشتِ دوم فقط برگه را میبندد.
|
||||||
|
useBackStep(phase.kind === 'playing' && !confirmExit, () => setConfirmExit(true));
|
||||||
|
|
||||||
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمیشود.
|
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمیشود.
|
||||||
const answerKeys = useRef(new Map<string, string>());
|
const answerKeys = useRef(new Map<string, string>());
|
||||||
@@ -223,6 +226,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 +267,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') {
|
||||||
@@ -421,8 +443,10 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const max = hearts?.maxHearts ?? 5;
|
// وضعیتِ قلب نرسید: پنج قلبِ پُرِ ساختگی نشان نمیدهیم؛ سرور با هر جواب عددِ واقعی را برمیگرداند.
|
||||||
const current = hearts?.currentHearts ?? max;
|
if (!hearts) return null;
|
||||||
|
const max = hearts.maxHearts;
|
||||||
|
const current = hearts.currentHearts;
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 flex items-center justify-center gap-2" aria-label={`${toFa(current)} قلب از ${toFa(max)}`}>
|
<div className="mt-3 flex items-center justify-center gap-2" aria-label={`${toFa(current)} قلب از ${toFa(max)}`}>
|
||||||
{Array.from({ length: max }, (_, i) => (
|
{Array.from({ length: max }, (_, i) => (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,22 +122,76 @@ 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();
|
// اعلانها و همراه برای صفحهی اول لازم نیستند.
|
||||||
void refreshUnread();
|
const loadExtras = () => {
|
||||||
api.characters
|
if (!api.auth.isSignedIn()) return;
|
||||||
.list()
|
void refreshUnread();
|
||||||
.then((list) => {
|
api.characters
|
||||||
// بیانتخاب یعنی پیشفرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
|
.list()
|
||||||
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
|
.then((list) => {
|
||||||
})
|
// بیانتخاب یعنی پیشفرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
|
||||||
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
|
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
|
||||||
// فقط بارِ اول؛ prefetch بعد از mount عوض نمیشود.
|
})
|
||||||
|
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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
63
src/state/homeSnapshot.ts
Normal 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
3
src/vite-env.d.ts
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user