forked from Tadrisino/MathApp_frontend
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>
This commit is contained in:
@@ -70,6 +70,10 @@ pay.html · src/pay.ts صفحهی واسطِ پرداختِ زرینپا
|
||||
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` میفرستد و `\d` در JS آنها را نمیشناسد).
|
||||
- **رنگها** در `src/styles/index.css` (`@theme`) تعریف شدهاند: `bg-coral`، `text-ink`، …
|
||||
- **خطا:** پیامِ کاربر همیشه از `friendlyError` میآید، نه متنِ خامِ سرور.
|
||||
- **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی میبیند (`state/homeSnapshot.ts`: پروفایل، پایهها، نقشه،
|
||||
آمار) و همان درخواستهای بوت پشتِ صحنه تازهاش میکنند. عکس با پاک شدنِ توکن و ورودِ تازه پاک میشود، با بیلدِ دیگر
|
||||
یا کهنهتر از ۷ روز خوانده نمیشود، و درستی (قفل، اشتراک، قلب) همیشه با سرور است. دادهی تازهای که در خانه دیده
|
||||
میشود باید در `AppData` بماند تا در عکس هم برود.
|
||||
- **متنِ فارسی** بیاعراب و بی «ی»ِ اضافهی نوشتهشده («پایهی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») میماند. متنِ تازه
|
||||
را همینطور بنویسید. متنِ سرور را بکاند هنگامِ ساختنِ پاسخ پاک میکند و `plainPersian` در `MathText` همان کار را برای
|
||||
پاسخِ زندهی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده میکند و باید با `content/plain_math.py`ِ
|
||||
|
||||
@@ -4,13 +4,26 @@
|
||||
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
|
||||
*/
|
||||
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';
|
||||
|
||||
export { ApiError };
|
||||
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 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) {
|
||||
// ورودِ تازه ممکن است کاربرِ دیگری باشد؛ عکسِ خانه فقط از پاسخِ سرورِ همین نشست ساخته میشود.
|
||||
homeSnapshot.clear();
|
||||
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToastProvider } from '@/components/Toast';
|
||||
import { ErrorState } from '@/components/ui';
|
||||
import { KEYS, store } from '@/lib/storage';
|
||||
import { askStore } from '@/state/askStore';
|
||||
import { homeSnapshot } from '@/state/homeSnapshot';
|
||||
import { fetchHome, subjectStore } from '@/state/subject';
|
||||
import { clearAsyncCache } from '@/lib/useAsync';
|
||||
import { LoginScreen } from '@/screens/LoginScreen';
|
||||
@@ -22,14 +23,18 @@ type Flow =
|
||||
| { kind: 'onboarding' }
|
||||
| { kind: 'login' }
|
||||
| { 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() {
|
||||
const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
|
||||
// با هر rebootFresh یکی بالا میرود و جزوِ کلیدِ صفحه است. بیآن «main → boot → main» برای AnimatePresence
|
||||
// همان کلید است و MainAppِ در حالِ خروج (با دادهی عکسِ غلط) بهجای MainAppِ تازه سرِ جایش میماند.
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
/** پروفایل و پایهها را میگیرد و تصمیم میگیرد کاربر کجا برود. */
|
||||
const enter = useCallback(async () => {
|
||||
@@ -46,6 +51,18 @@ export function App() {
|
||||
setFlow({ kind: 'boot' });
|
||||
let next: Flow | null = null;
|
||||
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 {
|
||||
await enter();
|
||||
} catch (error) {
|
||||
@@ -83,6 +100,15 @@ export function App() {
|
||||
[],
|
||||
);
|
||||
|
||||
// عکسِ خانه مالِ کسِ دیگری بود یا پروفایل حالا ناقص است: عکس کنار میرود و بوتِ معمولی تصمیم میگیرد.
|
||||
const rebootFresh = useCallback(() => {
|
||||
homeSnapshot.clear();
|
||||
askStore.clear();
|
||||
clearAsyncCache();
|
||||
setGeneration((g) => g + 1);
|
||||
void boot();
|
||||
}, [boot]);
|
||||
|
||||
const afterLogin = async () => {
|
||||
try {
|
||||
await enter();
|
||||
@@ -98,7 +124,7 @@ export function App() {
|
||||
<ToastProvider>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={flow.kind}
|
||||
key={`${flow.kind}:${generation}`}
|
||||
className="flex h-full flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -133,6 +159,8 @@ export function App() {
|
||||
profile={flow.profile}
|
||||
grades={flow.grades}
|
||||
prefetch={flow.prefetch}
|
||||
stale={flow.stale}
|
||||
onIdentityChanged={rebootFresh}
|
||||
payment={launchPayment}
|
||||
onSignedOut={() => {
|
||||
clearAsyncCache();
|
||||
|
||||
@@ -52,15 +52,24 @@ interface Props {
|
||||
profile: UserProfile;
|
||||
grades: Grade[];
|
||||
prefetch?: Prefetch;
|
||||
/** دادهها از عکسِ خانه روی گوشی آمدهاند و پشتِ صحنه تازه میشوند (AppData). */
|
||||
stale?: boolean;
|
||||
onIdentityChanged?: () => void;
|
||||
payment: PaymentReturn | null;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
||||
export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) {
|
||||
export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
|
||||
useEffect(preloadScreens, []);
|
||||
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' }}>
|
||||
<Screens onSignedOut={onSignedOut} />
|
||||
{payment && <PaymentResult payment={payment} />}
|
||||
|
||||
@@ -31,4 +31,6 @@ export const KEYS = {
|
||||
companion: 'tadrisino.companion',
|
||||
/** {gradeId: subjectKey}؛ کتابی که کاربر در هر پایه آخر انتخاب کرده. */
|
||||
subjects: 'tadrisino.subjects',
|
||||
/** عکسِ صفحهی خانه برای بازشدنِ فوری (state/homeSnapshot.ts). */
|
||||
homeSnapshot: 'tadrisino.homeSnapshot',
|
||||
} as const;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, ChevronLeft, Coins, Crown, Flame, Star } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react';
|
||||
import { ApiError, type HomeNode } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
@@ -20,7 +20,7 @@ import { SubjectBar } from '@/components/SubjectBar';
|
||||
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||||
*/
|
||||
export function HomeScreen() {
|
||||
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome } = useAppData();
|
||||
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome, refreshSummary } = useAppData();
|
||||
const nav = useNav();
|
||||
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||||
const paywall = () => nav.push({ name: 'subscription' });
|
||||
@@ -91,6 +91,23 @@ export function HomeScreen() {
|
||||
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||||
) : (
|
||||
<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) => {
|
||||
const color = unitColor(i);
|
||||
const p = unitProgress(unit);
|
||||
|
||||
@@ -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 { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
|
||||
import { companionStore, useCompanion } from '@/state/companion';
|
||||
import { homeSnapshot } from '@/state/homeSnapshot';
|
||||
import { fetchHome, subjectStore } from '@/state/subject';
|
||||
|
||||
/**
|
||||
@@ -42,16 +43,26 @@ export interface Prefetch {
|
||||
|
||||
export function AppDataProvider({
|
||||
initialProfile,
|
||||
grades,
|
||||
grades: initialGrades,
|
||||
prefetch,
|
||||
stale = false,
|
||||
onIdentityChanged,
|
||||
children,
|
||||
}: {
|
||||
initialProfile: UserProfile;
|
||||
grades: Grade[];
|
||||
prefetch?: Prefetch;
|
||||
/**
|
||||
* دادهی اولیه عکسِ ذخیرهشده روی گوشی است (state/homeSnapshot.ts)، نه پاسخِ همین حالا: همهچیز پشتِ صحنه
|
||||
* دوباره گرفته میشود. همان چهار درخواستِ بوت، پس بارِ سرور بیشتر نمیشود؛ فقط کاربر منتظرشان نمیماند.
|
||||
*/
|
||||
stale?: boolean;
|
||||
/** پروفایلِ تازه با عکس نمیخواند (کاربرِ دیگر، یا بینام/بیپایه که باید به تکمیلِ پروفایل برود). */
|
||||
onIdentityChanged?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [profile, setProfile] = useState(initialProfile);
|
||||
const [grades, setGrades] = useState(initialGrades);
|
||||
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
|
||||
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
|
||||
const [homeError, setHomeError] = useState<unknown>(null);
|
||||
@@ -63,16 +74,21 @@ export function AppDataProvider({
|
||||
const gradeId = profile.gradeId;
|
||||
// نقشهی کتابهایی که دیده شده؛ جابهجایی به کتابِ دیدهشده فوری است و پشتصحنه تازه میشود.
|
||||
const homeCache = useRef(new Map<string, HomeResponse>());
|
||||
// فقط پاسخِ آخرین درخواست روی صفحه مینشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازهی
|
||||
// پشتِ صحنه پایهی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشهی تازه را بپوشاند.
|
||||
const homeSeq = useRef(0);
|
||||
|
||||
const refreshHome = useCallback(async (forGrade?: number) => {
|
||||
const target = forGrade ?? gradeId;
|
||||
const seq = ++homeSeq.current;
|
||||
try {
|
||||
const fresh = await fetchHome(target);
|
||||
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
|
||||
if (seq !== homeSeq.current) return;
|
||||
setHome(fresh);
|
||||
setHomeError(null);
|
||||
} catch (err) {
|
||||
setHomeError(err);
|
||||
if (seq === homeSeq.current) setHomeError(err);
|
||||
}
|
||||
}, [gradeId]);
|
||||
|
||||
@@ -106,22 +122,76 @@ export function AppDataProvider({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه میسازد و نباید همهی اینها دوباره گرفته شوند.
|
||||
const started = useRef(false);
|
||||
// عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمینویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط).
|
||||
const identityLost = useRef(false);
|
||||
useEffect(() => {
|
||||
// آنچه در اسپلش رسیده دوباره گرفته نمیشود؛ فقط کمها.
|
||||
if (!prefetch?.home) void refreshHome();
|
||||
if (!prefetch?.summary) void refreshSummary();
|
||||
void refreshUnread();
|
||||
api.characters
|
||||
.list()
|
||||
.then((list) => {
|
||||
// بیانتخاب یعنی پیشفرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
|
||||
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
|
||||
})
|
||||
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
|
||||
// فقط بارِ اول؛ prefetch بعد از mount عوض نمیشود.
|
||||
if (started.current) return;
|
||||
started.current = true;
|
||||
// اعلانها و همراه برای صفحهی اول لازم نیستند.
|
||||
const loadExtras = () => {
|
||||
if (!api.auth.isSignedIn()) return;
|
||||
void refreshUnread();
|
||||
api.characters
|
||||
.list()
|
||||
.then((list) => {
|
||||
// بیانتخاب یعنی پیشفرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
|
||||
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
|
||||
})
|
||||
.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
|
||||
}, [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>(
|
||||
() => ({
|
||||
profile,
|
||||
|
||||
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" />
|
||||
|
||||
/** شناسهی هر بیلد (vite.config.ts)؛ عکسِ خانهی بیلدِ دیگر خوانده نمیشود. */
|
||||
declare const __APP_BUILD__: string;
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
// عکسِ خانه (src/state/homeSnapshot.ts) فقط با همان بیلدی خوانده میشود که نوشتهاش؛ بعد از هر دیپلوی
|
||||
// بارِ اول مثلِ قبل از سرور میآید، تا دادهای با شکلِ قدیمی به کدِ تازه نرسد.
|
||||
define: { __APP_BUILD__: JSON.stringify(Date.now().toString(36)) },
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user