From eafee76a93719aba2c73390df2f58235d8caee68 Mon Sep 17 00:00:00 2001 From: AliReza Date: Sat, 10 Oct 2026 17:55:47 +0330 Subject: [PATCH] Smooth screen transitions: new screen waits hidden until its data arrives, no half-built flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Navigator stage: the next screen mounts hidden under the current one and is revealed only when its first data has arrived (ScreenGate held by useAsync/useGateHold), at most 450 ms; a thin top bar shows after 120 ms of waiting. Push slides from the left, back from the right, tabs only fade. - Session cache (stale-while-revalidate) for screen GETs: revisited screens draw instantly. - Home map and stats are fetched during the splash, so the first screen arrives complete. - Entrance pop-ins (mascot, islands, lesson cards) run on first view only, not on every revisit. - Shop status card shows placeholders instead of a wrong «نداری» while loading. - Re-tapping the open tab scrolls to top instead of reloading; sheets close with a swipe down. - Quiz: «۱ از ۴» instead of «۱/۴» (read as a fraction), varied praise, «N تا پشتِ هم درست», growth-mindset copy after a weak round, zero-reward tiles hidden. - Streak: a concrete next step when the streak is 0 or today is still empty. Checked in headless Chrome against the local backend (frame captures at 150 ms and 700 ms latency, rapid tab tapping, full quiz run). Not checked on a real phone or iOS Safari. Co-Authored-By: Claude Opus 5.5 --- src/app/App.tsx | 26 ++- src/app/MainApp.tsx | 235 ++++++++++++++++++++++++---- src/components/IslandPath.tsx | 4 +- src/components/Sheet.tsx | 22 ++- src/lib/firstVisit.ts | 16 ++ src/lib/useAsync.ts | 81 +++++++++- src/mascots/SeyarMascot.tsx | 11 +- src/nav/Navigator.tsx | 34 +++- src/screens/AskScreen.tsx | 10 +- src/screens/CharactersScreen.tsx | 2 +- src/screens/InviteScreen.tsx | 2 +- src/screens/InvoicesScreen.tsx | 2 +- src/screens/LeaderboardScreen.tsx | 2 +- src/screens/LessonScreen.tsx | 2 +- src/screens/LessonsScreen.tsx | 4 +- src/screens/NotificationsScreen.tsx | 2 +- src/screens/ProfileScreen.tsx | 2 +- src/screens/QuizScreen.tsx | 34 +++- src/screens/ReportScreen.tsx | 2 +- src/screens/StreakScreen.tsx | 14 +- src/screens/SubscriptionScreen.tsx | 20 ++- src/screens/SupportScreen.tsx | 2 +- src/state/AppData.tsx | 19 ++- src/styles/index.css | 4 + 24 files changed, 463 insertions(+), 89 deletions(-) create mode 100644 src/lib/firstVisit.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 07ec43f..58667d0 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useState } from 'react'; import { AnimatePresence, MotionConfig, motion } from 'motion/react'; import { api, ApiError, http, type Grade, type UserProfile } from '@/api'; +import type { Prefetch } from '@/state/AppData'; import { AppShell } from '@/components/AppShell'; import { ToastProvider } from '@/components/Toast'; import { ErrorState } from '@/components/ui'; import { KEYS, store } from '@/lib/storage'; import { askStore } from '@/state/askStore'; +import { clearAsyncCache } from '@/lib/useAsync'; import { LoginScreen } from '@/screens/LoginScreen'; import { OnboardingScreen } from '@/screens/OnboardingScreen'; import { SetupScreen } from '@/screens/SetupScreen'; @@ -19,7 +21,7 @@ type Flow = | { kind: 'onboarding' } | { kind: 'login' } | { kind: 'setup'; profile: UserProfile; grades: Grade[] } - | { kind: 'main'; profile: UserProfile; grades: Grade[] }; + | { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch }; /** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */ const SPLASH_MIN_MS = 700; @@ -33,9 +35,11 @@ export function App() { /** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */ const enter = useCallback(async () => { - const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]); + // نقشه و آمار هم‌زمان با پروفایل گرفته می‌شوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر. + // شکستشان ورود را نمی‌خواباند: AppData همان‌ها را دوباره می‌گیرد و خطای نقشه را روی خانه نشان می‌دهد. + const [profile, grades, prefetch] = await Promise.all([api.profile.get(), api.grades(), prefetchMain()]); await trackReferral(); - if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades }); + if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch }); else setFlow({ kind: 'setup', profile, grades }); }, []); @@ -69,6 +73,7 @@ export function App() { () => http.onSignedOut(() => { askStore.clear(); + clearAsyncCache(); setFlow({ kind: 'login' }); }), [], @@ -122,8 +127,12 @@ export function App() { setFlow({ kind: 'login' })} + onSignedOut={() => { + clearAsyncCache(); + setFlow({ kind: 'login' }); + }} /> )} @@ -134,6 +143,15 @@ export function App() { ); } +/** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید null می‌ماند و AppData خودش دوباره می‌گیرد. */ +async function prefetchMain(): Promise { + const [home, summary] = await Promise.allSettled([api.home(), api.summary()]); + return { + home: home.status === 'fulfilled' ? home.value : null, + summary: summary.status === 'fulfilled' ? summary.value : null, + }; +} + /** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */ async function trackReferral() { const code = store.get(KEYS.referralCode); diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 311dfea..3e4ed05 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -1,9 +1,10 @@ -import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react'; -import { AnimatePresence, motion } from 'motion/react'; +import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentType } from 'react'; +import { motion } from 'motion/react'; import { CheckCircle2, XCircle } from 'lucide-react'; import { api, type Grade, type UserProfile } from '@/api'; -import { AppDataProvider, useAppData } from '@/state/AppData'; -import { NavigatorProvider, useNav } from '@/nav/Navigator'; +import { AppDataProvider, useAppData, type Prefetch } from '@/state/AppData'; +import { NavigatorProvider, useNav, type NavDirection } from '@/nav/Navigator'; +import { ScreenGateContext, type ScreenGate } from '@/lib/useAsync'; import { isTab, type Route } from '@/nav/routes'; import { BottomNav } from '@/components/BottomNav'; import { Sheet } from '@/components/Sheet'; @@ -50,15 +51,16 @@ function preloadScreens() { interface Props { profile: UserProfile; grades: Grade[]; + prefetch?: Prefetch; payment: PaymentReturn | null; onSignedOut: () => void; } /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ -export function MainApp({ profile, grades, payment, onSignedOut }: Props) { +export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) { useEffect(preloadScreens, []); return ( - + {payment && } @@ -67,44 +69,215 @@ export function MainApp({ profile, grades, payment, onSignedOut }: Props) { ); } +/** + * صحنه‌ی صفحه‌ها. صفحه‌ی تازه اول پنهان زیرِ صفحه‌ی فعلی ساخته می‌شود و فقط وقتی داده‌ی اولش رسید نشان داده + * می‌شود (ScreenGate در useAsync)، با یک حرکتِ کوتاه. قبلاً صفحه همان لحظه نیمه‌کاره می‌آمد (کارتِ خالی، «نداری»، + * عددِ صفر) و چند صد میلی‌ثانیه بعد داده‌ی واقعی جایش می‌پرید؛ همان «چشمک»ی که روی گوشی دیده می‌شد. + * + * اگر جواب کند باشد، بعد از HOLD_MAX_MS صفحه با لودرِ خودش می‌آید تا لمسِ کاربر بی‌جواب نماند؛ در این فاصله + * نوارِ باریکِ بالا نشان می‌دهد که کاری در جریان است. عددها از راهنمای رایجِ پاسخ‌گویی آمده‌اند (زیرِ ~۱۰۰ms + * «آنی»، تا ~۴۰۰ms هنوز پیوسته)؛ روی گوشیِ واقعی با شبکه‌ی ضعیف سنجیده نشده‌اند. + */ +const HOLD_MAX_MS = 450; +const HOLD_BAR_AFTER_MS = 120; + +interface Layer { + id: number; + route: Route; + direction: NavDirection; + /** ریشه‌ی پشته است (تب)، نه صفحه‌ای که روی آن باز شده؛ نوارِ پایین فقط آن‌جا هست. */ + root: boolean; + shown: boolean; +} + function Screens({ onSignedOut }: Pick) { const nav = useNav(); - const { route } = nav; - const showNav = isTab(route.name) && nav.tab === route.name; - // صفحه‌ی اولِ بعد از ورود بی‌انیمیشنِ ورود می‌آید، ولی این را روی خودِ motion.div می‌گذاریم نه - // ``: آن یکی به همه‌ی فرزندهای motionِ صفحه‌ی اول هم سرایت می‌کند و - // انیمیشن‌های تکرارشونده‌شان (ماسکوتِ بالا‌پایین، حلقه‌ی جزیره، دکمه‌ی «شروع») تا اولین تعویضِ تب شروع نمی‌شدند. - const firstRender = useRef(true); + const { route, direction, canGoBack } = nav; + const nextId = useRef(1); + // صفحه‌ی اولِ بعد از ورود بی‌انتظار و بی‌حرکت می‌آید (داده‌ی خانه پیش از آن در اسپلش گرفته شده). + const [layers, setLayers] = useState(() => [{ id: 0, route, direction, root: true, shown: true }]); + useEffect(() => { - firstRender.current = false; + setLayers((prev) => { + if (prev[prev.length - 1].route === route) return prev; + // صفحه‌ای که هنوز نیامده و کاربر از آن گذشته، همان‌جا کنار می‌رود. + const visible = prev.filter((l) => l.shown); + return [...visible, { id: nextId.current++, route, direction, root: !canGoBack, shown: false }]; + }); + }, [route, direction, canGoBack]); + + const reveal = useCallback((id: number) => { + setLayers((prev) => prev.map((l) => (l.id === id ? { ...l, shown: true } : l))); }, []); + const settle = useCallback((id: number) => { + // صفحه‌ی تازه کامل آمد؛ هرچه زیرش است برداشته شود. + setLayers((prev) => { + const i = prev.findIndex((l) => l.id === id); + return i > 0 ? prev.slice(i) : prev; + }); + }, []); + + const top = [...layers].reverse().find((l) => l.shown) ?? layers[0]; + + // زدنِ دوباره‌ی تبِ باز: بخشِ اسکرول‌شونده‌ی صفحه‌ی دیده‌شده نرم به بالا می‌رود. + const stage = useRef(null); + useEffect(() => { + if (!nav.reselect) return; + const layer = stage.current?.querySelector(`[data-layer="${top.id}"]`); + layer?.querySelectorAll('.overflow-y-auto').forEach((el) => el.scrollTo({ top: 0, behavior: 'smooth' })); + // فقط با زدنِ دوباره، نه با عوض شدنِ لایه. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nav.reselect]); + const waiting = !layers[layers.length - 1].shown; + // نوارِ پایین از صفحه‌ای که دیده می‌شود پیروی می‌کند، نه از صفحه‌ای که هنوز پنهان است؛ وگرنه ارتفاعِ صفحه‌ی + // فعلی پیش از رفتن عوض می‌شد و می‌پرید. تبِ روشن ولی همان لحظه‌ی لمس عوض می‌شود (بازخوردِ فوری). + const showNav = isTab(top.route.name) && top.root; return ( <> -
- - - }> - - - - +
+ {layers.map((layer) => ( + + ))} +
{showNav && } ); } +const ENTER: Record = { + // در راست‌به‌چپ صفحه‌ی بعدی از چپ می‌آید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپ‌های بومی. + push: { opacity: 0, x: -28 }, + replace: { opacity: 0, x: -28 }, + back: { opacity: 0, x: 28 }, + tab: { opacity: 0, x: 0 }, +}; + +function ScreenLayer({ layer, onReady, onSettled, onSignedOut }: { + layer: Layer; + onReady: (id: number) => void; + onSettled: (id: number) => void; +} & Pick) { + const { id, shown } = layer; + const initiallyShown = useRef(shown).current; + const gate = useGate(initiallyShown ? null : () => onReady(id)); + // اگر onAnimationComplete نرسید (مثلاً «کاهشِ حرکت» یا قطعِ انیمیشن)، صفحه‌های زیرین باز هم برداشته شوند. + useEffect(() => { + if (!shown || initiallyShown) return; + const t = setTimeout(() => onSettled(id), 700); + return () => clearTimeout(t); + }, [shown, initiallyShown, id, onSettled]); + + return ( + + shown && onSettled(id)} + data-layer={id} + style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }} + aria-hidden={!shown} + // صفحه‌ی پنهان نباید با Tab صفحه‌کلید فوکوس بگیرد. (React 18 «inert» را فقط به‌صورتِ رشته رد می‌کند.) + {...((!shown ? { inert: '' } : {}) as object)} + > + }> + + + + + ); +} + +/** + * شمارنده‌ی holdهای صفحه. وقتی بعد از mount هیچ holdی باز نماند، یا HOLD_MAX_MS گذشت، onReady یک بار صدا + * زده می‌شود. React افکت‌های فرزند را پیش از پدر اجرا می‌کند، پس تا افکتِ این‌جا همه‌ی useAsyncهای صفحه + * hold گرفته‌اند. + */ +function useGate(onReady: (() => void) | null): ScreenGate | null { + const readyRef = useRef(onReady); + readyRef.current = onReady; + const state = useRef({ count: 0, mounted: false, done: onReady === null }); + const fire = useCallback(() => { + const s = state.current; + if (s.done) return; + s.done = true; + readyRef.current?.(); + }, []); + const gate = useMemo( + () => + onReady === null + ? null + : { + hold() { + const s = state.current; + if (s.done) return () => {}; + s.count++; + let released = false; + return () => { + if (released) return; + released = true; + s.count--; + // یک microtask صبر: در StrictMode (و جابه‌جاییِ Suspense) افکت‌ها پاک و دوباره اجرا می‌شوند و + // hold تازه بلافاصله گرفته می‌شود؛ بی‌این صبر دروازه پیش از رسیدنِ داده باز می‌شد. + queueMicrotask(() => { + if (s.mounted && s.count === 0) fire(); + }); + }; + }, + }, + // فقط بارِ اول؛ لایه‌ای که یک بار نشان داده شد دیگر دروازه ندارد. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + useEffect(() => { + const s = state.current; + if (s.done) return; + s.mounted = true; + if (s.count === 0) { + fire(); + return; + } + const t = setTimeout(fire, HOLD_MAX_MS); + return () => { + s.mounted = false; + clearTimeout(t); + }; + }, [fire]); + return gate; +} + +/** تا فایلِ صفحه از شبکه نرسیده، دروازه بسته می‌ماند (معمولاً از قبل در پس‌زمینه گرفته شده). */ +function SuspenseHold() { + const gate = useContext(ScreenGateContext); + useEffect(() => { + const release = gate?.hold(); + // آزاد کردن خودش یک microtask صبر می‌کند (useGate)، پس useAsyncهای صفحه‌ی تازه پیش از آن hold گرفته‌اند. + return () => release?.(); + }, [gate]); + return ; +} + +/** نوارِ باریکِ بالای صفحه وقتی صفحه‌ی بعدی بیش از HOLD_BAR_AFTER_MS منتظرِ داده است. */ +function HoldBar({ active }: { active: boolean }) { + const [visible, setVisible] = useState(false); + useEffect(() => { + if (!active) { + setVisible(false); + return; + } + const t = setTimeout(() => setVisible(true), HOLD_BAR_AFTER_MS); + return () => clearTimeout(t); + }, [active]); + return ( +
+ {visible &&
} +
+ ); +} + function RouteView({ route, onSignedOut }: { route: Route } & Pick) { switch (route.name) { case 'home': return ; diff --git a/src/components/IslandPath.tsx b/src/components/IslandPath.tsx index 4c19d16..bd28417 100644 --- a/src/components/IslandPath.tsx +++ b/src/components/IslandPath.tsx @@ -6,6 +6,7 @@ import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath'; import { iconFor } from '@/components/NodeIcon'; import { shade } from '@/lib/color'; import { MathText } from '@/lib/MathText'; +import { useFirstVisit } from '@/lib/firstVisit'; const NODE_SIZE = 76; const SPACING = 128; @@ -26,6 +27,7 @@ export const IslandPath = forwardRef void; }>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) { const points = zigzagPositions(nodes.length, SPACING, TOP); + const intro = useFirstVisit(`islands:${nodes[0]?.id ?? ''}`); const height = points.length ? points[points.length - 1].y + 80 : 120; return ( @@ -40,7 +42,7 @@ export const IslandPath = forwardRef { if (!open) return; const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); @@ -41,10 +45,20 @@ export function Sheet({ animate={{ y: 0 }} exit={{ y: '100%' }} transition={{ type: 'spring', stiffness: 320, damping: 32 }} + drag="y" + dragControls={drag} + dragListener={false} + dragConstraints={{ top: 0, bottom: 0 }} + dragElastic={{ top: 0, bottom: 0.9 }} + onDragEnd={(_, info) => { + if (info.offset.y > 90 || info.velocity.y > 600) onClose(); + }} > -
+
drag.start(e)}> +
+
{title && ( -
+
drag.start(e)}>

{title}

- +
@@ -96,11 +96,19 @@ export function StreakScreen() { /** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */ const MILESTONES = [3, 7, 14, 30, 60, 100]; -function StreakGoal({ current }: { current: number }) { +function StreakGoal({ current, doneToday }: { current: number; doneToday: boolean }) { const next = MILESTONES.find((m) => m > current); const reached = MILESTONES.filter((m) => m <= current).pop(); + // زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود. + // زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد. + const today = current === 0 + ? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.' + : !doneToday + ? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره‌ی ${toFa(current)} روزه‌ت نسوزه.` + : null; return (
+ {today &&

{today}

}

{reached ? <>{toFa(current)} روزه‌ای! : null} {next ? `${toFa(next - current)} روز دیگه می‌شه زنجیره‌ی ${toFa(next)} روزه.` : 'از همه‌ی نقطه‌عطف‌ها رد شدی، افسانه‌ای!'} diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index 2da451c..7129e68 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -26,9 +26,9 @@ type View = 'subscription' | 'pack'; * یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`). */ export function SubscriptionScreen({ initialView }: { initialView?: View }) { - const plans = useAsync(() => api.subscription.plans(), []); - const status = useAsync(() => api.subscription.status(), []); - const quota = useAsync(() => api.ai.quota(), []); + const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' }); + const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' }); + const quota = useAsync(() => api.ai.quota(), [], { key: 'ai-quota' }); const [view, setView] = useState(initialView ?? null); const [buying, setBuying] = useState(null); // داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ محصولی که @@ -53,7 +53,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) { />

- + {view === null ? (
@@ -132,8 +132,11 @@ function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNod } /** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */ -function StatusCard({ status, credits, heartsUntil, quota }: { - status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null; +/** تا وضعیت نرسیده جای خالیِ هم‌اندازه نشان داده می‌شود؛ قبلاً «نداری» می‌آمد و برای مشترک غلط بود. */ +const Placeholder = ({ className }: { className: string }) => ; + +function StatusCard({ status, statusLoading, credits, heartsUntil, quota, quotaLoading }: { + status: SubscriptionStatus | null; statusLoading: boolean; credits: number; heartsUntil: string | null; quota: TutorQuota | null; quotaLoading: boolean; }) { const days = status?.active ? daysLeft(status.expiresAt) : null; return ( @@ -143,9 +146,10 @@ function StatusCard({ status, credits, heartsUntil, quota }: {
اشتراکِ تو
- {status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && · {toFa(days)} روز مونده} : 'نداری'} + {statusLoading ? : status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && · {toFa(days)} روز مونده} : 'نداری'}
+ {quotaLoading && } {quota && (
سوالِ امروز
@@ -154,7 +158,7 @@ function StatusCard({ status, credits, heartsUntil, quota }: { )}
- اعتبار: {toFa(credits)} سوال + اعتبار: {statusLoading && quotaLoading ? : <>{toFa(credits)} سوال} {(status?.active || heartsUntil) && ( قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}} diff --git a/src/screens/SupportScreen.tsx b/src/screens/SupportScreen.tsx index 4289bdd..d8b8320 100644 --- a/src/screens/SupportScreen.tsx +++ b/src/screens/SupportScreen.tsx @@ -31,7 +31,7 @@ export function SupportScreen() { const [sending, setSending] = useState(false); const [error, setError] = useState(''); // فهرست از سرور می‌آید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده می‌شود. - const tickets = useAsync(() => api.support.tickets(), []); + const tickets = useAsync(() => api.support.tickets(), [], { key: 'support-tickets' }); async function submit() { if (!subject.trim() || !text.trim()) return; diff --git a/src/state/AppData.tsx b/src/state/AppData.tsx index 7b0161f..cc6150b 100644 --- a/src/state/AppData.tsx +++ b/src/state/AppData.tsx @@ -27,18 +27,26 @@ interface AppData { const AppDataContext = createContext(null); +/** آنچه در اسپلش پیش از ورود به اپ گرفته شد (App.prefetchMain). */ +export interface Prefetch { + home: HomeResponse | null; + summary: UserSummary | null; +} + export function AppDataProvider({ initialProfile, grades, + prefetch, children, }: { initialProfile: UserProfile; grades: Grade[]; + prefetch?: Prefetch; children: ReactNode; }) { const [profile, setProfile] = useState(initialProfile); - const [summary, setSummary] = useState(null); - const [home, setHome] = useState(null); + const [summary, setSummary] = useState(prefetch?.summary ?? null); + const [home, setHome] = useState(prefetch?.home ?? null); const [homeError, setHomeError] = useState(null); const [companion, setCompanionChar] = useState('sigma'); const [unreadCount, setUnreadCount] = useState(0); @@ -69,8 +77,9 @@ export function AppDataProvider({ }, []); useEffect(() => { - void refreshHome(); - void refreshSummary(); + // آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها. + if (!prefetch?.home) void refreshHome(); + if (!prefetch?.summary) void refreshSummary(); void refreshUnread(); api.characters .list() @@ -79,6 +88,8 @@ export function AppDataProvider({ if (selected) setCompanionChar(selected); }) .catch(() => {}); + // فقط بارِ اول؛ prefetch بعد از mount عوض نمی‌شود. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshHome, refreshSummary, refreshUnread]); const value = useMemo( diff --git a/src/styles/index.css b/src/styles/index.css index bd084e8..9d45de7 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -119,3 +119,7 @@ .spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } @keyframes rise { 0% { transform: translateY(16px); opacity: 0; } 15%, 85% { opacity: .55; } 100% { transform: translateY(-110px); opacity: 0; } } .rise { animation: rise 7s linear infinite; will-change: transform, opacity; } + +/* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */ +@keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } } +.hold-bar { animation: hold-bar 0.9s cubic-bezier(.4,0,.2,1) infinite; will-change: transform; }