forked from Tadrisino/MathApp_frontend
Smooth screen transitions: new screen waits hidden until its data arrives, no half-built flash
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
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 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 { clearAsyncCache } from '@/lib/useAsync';
|
||||||
import { LoginScreen } from '@/screens/LoginScreen';
|
import { LoginScreen } from '@/screens/LoginScreen';
|
||||||
import { OnboardingScreen } from '@/screens/OnboardingScreen';
|
import { OnboardingScreen } from '@/screens/OnboardingScreen';
|
||||||
import { SetupScreen } from '@/screens/SetupScreen';
|
import { SetupScreen } from '@/screens/SetupScreen';
|
||||||
@@ -19,7 +21,7 @@ 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[] };
|
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch };
|
||||||
|
|
||||||
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
|
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
|
||||||
const SPLASH_MIN_MS = 700;
|
const SPLASH_MIN_MS = 700;
|
||||||
@@ -33,9 +35,11 @@ export function App() {
|
|||||||
|
|
||||||
/** پروفایل و پایهها را میگیرد و تصمیم میگیرد کاربر کجا برود. */
|
/** پروفایل و پایهها را میگیرد و تصمیم میگیرد کاربر کجا برود. */
|
||||||
const enter = useCallback(async () => {
|
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();
|
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 });
|
else setFlow({ kind: 'setup', profile, grades });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -69,6 +73,7 @@ export function App() {
|
|||||||
() =>
|
() =>
|
||||||
http.onSignedOut(() => {
|
http.onSignedOut(() => {
|
||||||
askStore.clear();
|
askStore.clear();
|
||||||
|
clearAsyncCache();
|
||||||
setFlow({ kind: 'login' });
|
setFlow({ kind: 'login' });
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
@@ -122,8 +127,12 @@ export function App() {
|
|||||||
<MainApp
|
<MainApp
|
||||||
profile={flow.profile}
|
profile={flow.profile}
|
||||||
grades={flow.grades}
|
grades={flow.grades}
|
||||||
|
prefetch={flow.prefetch}
|
||||||
payment={launchPayment}
|
payment={launchPayment}
|
||||||
onSignedOut={() => setFlow({ kind: 'login' })}
|
onSignedOut={() => {
|
||||||
|
clearAsyncCache();
|
||||||
|
setFlow({ kind: 'login' });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -134,6 +143,15 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** دادهی صفحهی اول؛ هر کدام که نرسید null میماند و AppData خودش دوباره میگیرد. */
|
||||||
|
async function prefetchMain(): Promise<Prefetch> {
|
||||||
|
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() {
|
async function trackReferral() {
|
||||||
const code = store.get(KEYS.referralCode);
|
const code = store.get(KEYS.referralCode);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react';
|
import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||||
import { api, type Grade, type UserProfile } from '@/api';
|
import { api, type Grade, type UserProfile } from '@/api';
|
||||||
import { AppDataProvider, useAppData } from '@/state/AppData';
|
import { AppDataProvider, useAppData, type Prefetch } from '@/state/AppData';
|
||||||
import { NavigatorProvider, useNav } from '@/nav/Navigator';
|
import { NavigatorProvider, useNav, type NavDirection } from '@/nav/Navigator';
|
||||||
|
import { ScreenGateContext, type ScreenGate } from '@/lib/useAsync';
|
||||||
import { isTab, type Route } from '@/nav/routes';
|
import { isTab, type Route } from '@/nav/routes';
|
||||||
import { BottomNav } from '@/components/BottomNav';
|
import { BottomNav } from '@/components/BottomNav';
|
||||||
import { Sheet } from '@/components/Sheet';
|
import { Sheet } from '@/components/Sheet';
|
||||||
@@ -50,15 +51,16 @@ function preloadScreens() {
|
|||||||
interface Props {
|
interface Props {
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
grades: Grade[];
|
grades: Grade[];
|
||||||
|
prefetch?: Prefetch;
|
||||||
payment: PaymentReturn | null;
|
payment: PaymentReturn | null;
|
||||||
onSignedOut: () => void;
|
onSignedOut: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
||||||
export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
|
export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) {
|
||||||
useEffect(preloadScreens, []);
|
useEffect(preloadScreens, []);
|
||||||
return (
|
return (
|
||||||
<AppDataProvider initialProfile={profile} grades={grades}>
|
<AppDataProvider initialProfile={profile} grades={grades} prefetch={prefetch}>
|
||||||
<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} />}
|
||||||
@@ -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<Props, 'onSignedOut'>) {
|
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const { route } = nav;
|
const { route, direction, canGoBack } = nav;
|
||||||
const showNav = isTab(route.name) && nav.tab === route.name;
|
const nextId = useRef(1);
|
||||||
// صفحهی اولِ بعد از ورود بیانیمیشنِ ورود میآید، ولی این را روی خودِ motion.div میگذاریم نه
|
// صفحهی اولِ بعد از ورود بیانتظار و بیحرکت میآید (دادهی خانه پیش از آن در اسپلش گرفته شده).
|
||||||
// `<AnimatePresence initial={false}>`: آن یکی به همهی فرزندهای motionِ صفحهی اول هم سرایت میکند و
|
const [layers, setLayers] = useState<Layer[]>(() => [{ id: 0, route, direction, root: true, shown: true }]);
|
||||||
// انیمیشنهای تکرارشوندهشان (ماسکوتِ بالاپایین، حلقهی جزیره، دکمهی «شروع») تا اولین تعویضِ تب شروع نمیشدند.
|
|
||||||
const firstRender = useRef(true);
|
|
||||||
useEffect(() => {
|
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<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!nav.reselect) return;
|
||||||
|
const layer = stage.current?.querySelector<HTMLElement>(`[data-layer="${top.id}"]`);
|
||||||
|
layer?.querySelectorAll<HTMLElement>('.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex-1 overflow-hidden bg-page">
|
<div ref={stage} className="relative flex-1 overflow-hidden bg-page">
|
||||||
<AnimatePresence mode="popLayout">
|
{layers.map((layer) => (
|
||||||
<motion.div
|
<ScreenLayer key={layer.id} layer={layer} onReady={reveal} onSettled={settle} onSignedOut={onSignedOut} />
|
||||||
key={JSON.stringify(route)}
|
))}
|
||||||
className="absolute inset-0 flex flex-col bg-page"
|
<HoldBar active={waiting} />
|
||||||
initial={firstRender.current ? false : { opacity: 0, x: -14 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
// صفحهی قبلی نمیپرد و محو نمیشود، زیرِ صفحهی تازه میماند تا کامل بیاید. با محوشدنِ همزمانِ
|
|
||||||
// هر دو، پسزمینهی تیرهی body از وسط دیده میشد و تعویضِ تب «گلیچ» میزد (از روی کد؛ با
|
|
||||||
// فیلمِ دستگاهِ واقعی سنجیده نشده).
|
|
||||||
exit={{ opacity: 1, x: 0, transition: { duration: 0.16 } }}
|
|
||||||
transition={{ duration: 0.16, ease: 'easeOut' }}
|
|
||||||
>
|
|
||||||
<Suspense fallback={<LoadingState />}>
|
|
||||||
<RouteView route={route} onSignedOut={onSignedOut} />
|
|
||||||
</Suspense>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
|
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ENTER: Record<NavDirection, { opacity: number; x: number }> = {
|
||||||
|
// در راستبهچپ صفحهی بعدی از چپ میآید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپهای بومی.
|
||||||
|
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<Props, 'onSignedOut'>) {
|
||||||
|
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 (
|
||||||
|
<ScreenGateContext.Provider value={gate}>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 flex flex-col bg-page"
|
||||||
|
initial={initiallyShown ? false : ENTER[layer.direction]}
|
||||||
|
animate={shown ? { opacity: 1, x: 0 } : ENTER[layer.direction]}
|
||||||
|
transition={{ duration: layer.direction === 'tab' ? 0.14 : 0.2, ease: [0.2, 0.8, 0.2, 1] }}
|
||||||
|
onAnimationComplete={() => shown && onSettled(id)}
|
||||||
|
data-layer={id}
|
||||||
|
style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }}
|
||||||
|
aria-hidden={!shown}
|
||||||
|
// صفحهی پنهان نباید با Tab صفحهکلید فوکوس بگیرد. (React 18 «inert» را فقط بهصورتِ رشته رد میکند.)
|
||||||
|
{...((!shown ? { inert: '' } : {}) as object)}
|
||||||
|
>
|
||||||
|
<Suspense fallback={<SuspenseHold />}>
|
||||||
|
<RouteView route={layer.route} onSignedOut={onSignedOut} />
|
||||||
|
</Suspense>
|
||||||
|
</motion.div>
|
||||||
|
</ScreenGateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شمارندهی 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<ScreenGate | null>(
|
||||||
|
() =>
|
||||||
|
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 <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نوارِ باریکِ بالای صفحه وقتی صفحهی بعدی بیش از 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 (
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-[9999] h-[3px] overflow-hidden">
|
||||||
|
{visible && <div className="hold-bar h-full w-1/3 rounded-full bg-white/90 shadow-[0_0_6px_rgba(237,105,86,.8)]" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
||||||
switch (route.name) {
|
switch (route.name) {
|
||||||
case 'home': return <HomeScreen />;
|
case 'home': return <HomeScreen />;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
|||||||
import { iconFor } from '@/components/NodeIcon';
|
import { iconFor } from '@/components/NodeIcon';
|
||||||
import { shade } from '@/lib/color';
|
import { shade } from '@/lib/color';
|
||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
|
import { useFirstVisit } from '@/lib/firstVisit';
|
||||||
|
|
||||||
const NODE_SIZE = 76;
|
const NODE_SIZE = 76;
|
||||||
const SPACING = 128;
|
const SPACING = 128;
|
||||||
@@ -26,6 +27,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
|||||||
onStart: (node: HomeNode) => void;
|
onStart: (node: HomeNode) => void;
|
||||||
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
|
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
|
||||||
const points = zigzagPositions(nodes.length, SPACING, TOP);
|
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;
|
const height = points.length ? points[points.length - 1].y + 80 : 120;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,7 +42,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={node.id}
|
key={node.id}
|
||||||
ref={current ? currentRef : undefined}
|
ref={current ? currentRef : undefined}
|
||||||
initial={{ opacity: 0, scale: 0.7 }}
|
initial={intro ? { opacity: 0, scale: 0.7 } : { opacity: 1, scale: 1 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
||||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion, useDragControls } from 'motion/react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
/** برگهای که از پایین بالا میآید؛ با زدنِ پسزمینه یا Escape بسته میشود. */
|
/**
|
||||||
|
* برگهای که از پایین بالا میآید؛ با زدنِ پسزمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته میشود.
|
||||||
|
* کشیدن فقط از بالای برگه شروع میشود تا با اسکرولِ محتوای بلندِ برگه قاطی نشود.
|
||||||
|
*/
|
||||||
export function Sheet({
|
export function Sheet({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -14,6 +17,7 @@ export function Sheet({
|
|||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const drag = useDragControls();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||||
@@ -41,10 +45,20 @@ export function Sheet({
|
|||||||
animate={{ y: 0 }}
|
animate={{ y: 0 }}
|
||||||
exit={{ y: '100%' }}
|
exit={{ y: '100%' }}
|
||||||
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
|
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();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-soft" />
|
<div className="-mx-5 -mt-3 cursor-grab touch-none px-5 pb-3 pt-3" onPointerDown={(e) => drag.start(e)}>
|
||||||
|
<div className="mx-auto h-1.5 w-10 rounded-full bg-line" />
|
||||||
|
</div>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<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 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} />
|
||||||
|
|||||||
16
src/lib/firstVisit.ts
Normal file
16
src/lib/firstVisit.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* true فقط بارِ اولی که این بخش در این نشست دیده میشود. انیمیشنِ ورودِ کارتها و جزیرهها (یکییکی ظاهر
|
||||||
|
* شدن) بارِ اول جذاب است، ولی با هر برگشت به همان تب تکرار میشد و صفحه چشمک میزد.
|
||||||
|
*/
|
||||||
|
export function useFirstVisit(tag: string): boolean {
|
||||||
|
const [first] = useState(() => {
|
||||||
|
if (seen.has(tag)) return false;
|
||||||
|
seen.add(tag);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return first;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type DependencyList } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, type DependencyList } from 'react';
|
||||||
|
|
||||||
export interface AsyncState<T> {
|
export interface AsyncState<T> {
|
||||||
data: T | undefined;
|
data: T | undefined;
|
||||||
@@ -8,39 +8,104 @@ export interface AsyncState<T> {
|
|||||||
setData: (update: T | ((prev: T | undefined) => T | undefined)) => void;
|
setData: (update: T | ((prev: T | undefined) => T | undefined)) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کشِ همین نشست برای پاسخهای GET، با کلیدی که صفحه میدهد. صفحهای که دوباره باز میشود (برگشت، تعویضِ تب)
|
||||||
|
* فوراً با دادهی قبلی رسم میشود و همان لحظه بیصدا از سرور تازه میشود (stale-while-revalidate). قبلاً هر بار
|
||||||
|
* اول کارتِ خالی یا «نداری» دیده میشد و چند صد میلیثانیه بعد دادهی واقعی جایش میپرید.
|
||||||
|
* با خروج از حساب پاک میشود (`clearAsyncCache`)، تا دادهی یک کاربر به کاربرِ بعدی نرسد.
|
||||||
|
*/
|
||||||
|
const cache = new Map<string, unknown>();
|
||||||
|
|
||||||
|
export function clearAsyncCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «دروازه»ی صفحهی تازه: Navigator صفحهی بعدی را پنهان زیرِ صفحهی فعلی میسازد و تا وقتی دادهی اولش
|
||||||
|
* نرسیده (یا سقفِ انتظار نگذشته) نشانش نمیدهد. هر useAsyncی که بارِ اولش بیکش است یک hold میگیرد.
|
||||||
|
* بیرون از صفحهها (مثلاً جریانِ ورود) این context خالی است و کاری نمیکند.
|
||||||
|
*/
|
||||||
|
export interface ScreenGate {
|
||||||
|
hold: () => () => void;
|
||||||
|
}
|
||||||
|
export const ScreenGateContext = createContext<ScreenGate | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* برای صفحهای که دادهاش را بی useAsync میگیرد (آزمون، دستیار): تا `loading` درست است، صفحهی تازه پنهان
|
||||||
|
* میماند. همان قاعدهی useAsync: دروازهی بازشده hold را نادیده میگیرد.
|
||||||
|
*/
|
||||||
|
export function useGateHold(loading: boolean) {
|
||||||
|
const gate = useContext(ScreenGateContext);
|
||||||
|
useEffect(() => (loading && gate ? gate.hold() : undefined), [loading, gate]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* دادهای که از سرور میآید، با حالتهای بارگذاری/خطا و «دوباره».
|
* دادهای که از سرور میآید، با حالتهای بارگذاری/خطا و «دوباره».
|
||||||
* پاسخِ درخواستِ کهنه (بعد از عوض شدنِ deps) نادیده گرفته میشود.
|
* پاسخِ درخواستِ کهنه (بعد از عوض شدنِ deps) نادیده گرفته میشود.
|
||||||
|
* با `key`، پاسخ در کشِ نشست میماند و بارِ بعد فوراً نشان داده میشود (بالای همین فایل).
|
||||||
*/
|
*/
|
||||||
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList): AsyncState<T> {
|
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList, opts: { key?: string } = {}): AsyncState<T> {
|
||||||
const [data, setData] = useState<T | undefined>(undefined);
|
const { key } = opts;
|
||||||
|
const [data, setDataState] = useState<T | undefined>(() => (key !== undefined ? (cache.get(key) as T | undefined) : undefined));
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(() => key === undefined || !cache.has(key));
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
const loadRef = useRef(load);
|
const loadRef = useRef(load);
|
||||||
loadRef.current = load;
|
loadRef.current = load;
|
||||||
|
const gate = useContext(ScreenGateContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
const hit = key !== undefined && cache.has(key);
|
||||||
|
if (hit) {
|
||||||
|
// کلید عوض شده (مثلاً «هفتگی» ← «همیشه») و برای کلیدِ تازه کش هست: همان را نشان بده و بیلودر تازه کن.
|
||||||
|
setDataState(cache.get(key) as T);
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// دروازهای که یک بار باز شده hold را نادیده میگیرد؛ پس «دوباره» و تازهسازی صفحه را پنهان نمیکنند.
|
||||||
|
const release = !hit && gate ? gate.hold() : null;
|
||||||
loadRef.current().then(
|
loadRef.current().then(
|
||||||
(value) => {
|
(value) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData(value);
|
if (key !== undefined) cache.set(key, value);
|
||||||
|
setDataState(value);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
release?.();
|
||||||
},
|
},
|
||||||
(err: unknown) => {
|
(err: unknown) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setError(err);
|
if (hit) {
|
||||||
|
// دادهی کششده روی صفحه است؛ شکستِ تازهسازی آن را با صفحهی خطا عوض نمیکند، ولی دیده میشود.
|
||||||
|
console.warn(`refresh of ${key} failed; showing cached data`, err);
|
||||||
|
} else {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
release?.();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
release?.();
|
||||||
};
|
};
|
||||||
}, [...deps, nonce]);
|
}, [...deps, key, nonce]);
|
||||||
|
|
||||||
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
||||||
|
const setData = useCallback(
|
||||||
|
(update: T | ((prev: T | undefined) => T | undefined)) => {
|
||||||
|
setDataState((prev) => {
|
||||||
|
const next = typeof update === 'function' ? (update as (p: T | undefined) => T | undefined)(prev) : update;
|
||||||
|
if (key !== undefined) {
|
||||||
|
if (next === undefined) cache.delete(key);
|
||||||
|
else cache.set(key, next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[key],
|
||||||
|
);
|
||||||
return { data, error, loading, reload, setData };
|
return { data, error, loading, reload, setData };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { SEYAR_INFO, SEYAR_SVG, type SeyarChar, type SeyarMood } from './seyar';
|
import { SEYAR_INFO, SEYAR_SVG, type SeyarChar, type SeyarMood } from './seyar';
|
||||||
|
|
||||||
@@ -13,13 +13,20 @@ interface Props {
|
|||||||
|
|
||||||
function SeyarMascotBase({ char, mood = 'idle', size = 100, bob = false, className }: Props) {
|
function SeyarMascotBase({ char, mood = 'idle', size = 100, bob = false, className }: Props) {
|
||||||
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
|
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
|
||||||
|
// ظاهر شدن با فنر فقط وقتی حالت یا شخصیت عوض میشود؛ روی هر صفحهی تازه، ماسکوت دیر از صفر بزرگ
|
||||||
|
// میشد و بعد از رسیدنِ صفحه یک «پرش»ِ جدا میساخت.
|
||||||
|
const mounted = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={char + mood}
|
key={char + mood}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={SEYAR_INFO[char].name}
|
aria-label={SEYAR_INFO[char].name}
|
||||||
className={className}
|
className={className}
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
// نه `initial={false}`: آن انیمیشنِ تکرارشوندهی bob را هم از کار میانداخت (همان باگِ ۸۸۷۹b42).
|
||||||
|
initial={mounted.current ? { scale: 0.8, opacity: 0 } : { scale: 1, opacity: 1 }}
|
||||||
animate={{ scale: 1, opacity: 1, y: bob ? [0, -6, 0] : 0 }}
|
animate={{ scale: 1, opacity: 1, y: bob ? [0, -6, 0] : 0 }}
|
||||||
transition={
|
transition={
|
||||||
bob
|
bob
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import type { Route, TabName } from './routes';
|
import type { Route, TabName } from './routes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,11 +8,17 @@ import type { Route, TabName } from './routes';
|
|||||||
* یک صفحه عقب میرود بهجای بیرون انداختنِ کاربر از اپ. تعویضِ تب پشته را
|
* یک صفحه عقب میرود بهجای بیرون انداختنِ کاربر از اپ. تعویضِ تب پشته را
|
||||||
* از نو میسازد (مثلِ اپهای بومی).
|
* از نو میسازد (مثلِ اپهای بومی).
|
||||||
*/
|
*/
|
||||||
|
/** نوعِ آخرین جابهجایی؛ انیمیشنِ ورودِ صفحه از آن میآید (جلو از چپ، عقب از راست، تب فقط محو). */
|
||||||
|
export type NavDirection = 'push' | 'back' | 'tab' | 'replace';
|
||||||
|
|
||||||
interface Nav {
|
interface Nav {
|
||||||
route: Route;
|
route: Route;
|
||||||
|
direction: NavDirection;
|
||||||
/** تبی که ریشهی پشتهی فعلی است؛ برای روشن کردنِ نوارِ پایین. */
|
/** تبی که ریشهی پشتهی فعلی است؛ برای روشن کردنِ نوارِ پایین. */
|
||||||
tab: TabName | null;
|
tab: TabName | null;
|
||||||
canGoBack: boolean;
|
canGoBack: boolean;
|
||||||
|
/** هر بار که کاربر تبِ همینحالا باز را دوباره میزند یکی زیاد میشود (برای «برو بالا»). */
|
||||||
|
reselect: number;
|
||||||
push: (route: Route) => void;
|
push: (route: Route) => void;
|
||||||
/** صفحهی فعلی را عوض میکند بدونِ اضافه کردن به تاریخچه. */
|
/** صفحهی فعلی را عوض میکند بدونِ اضافه کردن به تاریخچه. */
|
||||||
replace: (route: Route) => void;
|
replace: (route: Route) => void;
|
||||||
@@ -23,21 +29,29 @@ interface Nav {
|
|||||||
const NavContext = createContext<Nav | null>(null);
|
const NavContext = createContext<Nav | null>(null);
|
||||||
|
|
||||||
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
|
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
|
||||||
const [stack, setStack] = useState<Route[]>([initial]);
|
const [{ stack, direction }, setState] = useState<{ stack: Route[]; direction: NavDirection }>({ stack: [initial], direction: 'tab' });
|
||||||
|
const [reselect, setReselect] = useState(0);
|
||||||
|
const stackRef = useRef(stack);
|
||||||
|
stackRef.current = stack;
|
||||||
|
const setStack = (update: (s: Route[]) => Route[], dir: NavDirection) =>
|
||||||
|
setState((prev) => {
|
||||||
|
const next = update(prev.stack);
|
||||||
|
return next === prev.stack ? prev : { stack: next, direction: dir };
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s), 'back');
|
||||||
window.addEventListener('popstate', onPop);
|
window.addEventListener('popstate', onPop);
|
||||||
return () => window.removeEventListener('popstate', onPop);
|
return () => window.removeEventListener('popstate', onPop);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const push = useCallback((route: Route) => {
|
const push = useCallback((route: Route) => {
|
||||||
setStack((s) => [...s, route]);
|
setStack((s) => [...s, route], 'push');
|
||||||
window.history.pushState({ tadrisino: true }, '');
|
window.history.pushState({ tadrisino: true }, '');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const replace = useCallback((route: Route) => {
|
const replace = useCallback((route: Route) => {
|
||||||
setStack((s) => [...s.slice(0, -1), route]);
|
setStack((s) => [...s.slice(0, -1), route], 'replace');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const back = useCallback(() => {
|
const back = useCallback(() => {
|
||||||
@@ -46,21 +60,27 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const switchTab = useCallback((tab: TabName) => {
|
const switchTab = useCallback((tab: TabName) => {
|
||||||
setStack([{ name: tab } as Route]);
|
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمیسازد (قبلاً دوباره بار میشد)؛ مثلِ اپهای بومی به
|
||||||
|
// بالای همان صفحه برمیگردد.
|
||||||
|
const current = stackRef.current;
|
||||||
|
if (current.length === 1 && current[0].name === tab) setReselect((n) => n + 1);
|
||||||
|
else setState({ stack: [{ name: tab } as Route], direction: 'tab' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<Nav>(() => {
|
const value = useMemo<Nav>(() => {
|
||||||
const root = stack[0].name;
|
const root = stack[0].name;
|
||||||
return {
|
return {
|
||||||
route: stack[stack.length - 1],
|
route: stack[stack.length - 1],
|
||||||
|
direction,
|
||||||
tab: ['home', 'lessons', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
tab: ['home', 'lessons', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
||||||
canGoBack: stack.length > 1,
|
canGoBack: stack.length > 1,
|
||||||
|
reselect,
|
||||||
push,
|
push,
|
||||||
replace,
|
replace,
|
||||||
back,
|
back,
|
||||||
switchTab,
|
switchTab,
|
||||||
};
|
};
|
||||||
}, [stack, push, replace, back, switchTab]);
|
}, [stack, direction, reselect, push, replace, back, switchTab]);
|
||||||
|
|
||||||
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Diagram } from '@/diagram';
|
|||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
|
import { useGateHold } from '@/lib/useAsync';
|
||||||
|
|
||||||
const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||||||
|
|
||||||
@@ -23,9 +24,16 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
// «X سوالِ رایگانِ امروز + Y سوالِ خریدهشده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
// «X سوالِ رایگانِ امروز + Y سوالِ خریدهشده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||||
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
|
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
|
||||||
|
const [quotaDone, setQuotaDone] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.ai.quota().then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })).catch(() => {});
|
api.ai
|
||||||
|
.quota()
|
||||||
|
.then((q) => setQuota({ free: q.remainingQuota, credits: q.credits }))
|
||||||
|
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
|
||||||
|
.finally(() => setQuotaDone(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
// صفحه وقتی میآید که تاریخچه و سهمیه رسیده باشند؛ وگرنه اول خوشآمد و بعد پیامهای قبلی رویش میپرید.
|
||||||
|
useGateHold(!chat.historyLoaded || !quotaDone);
|
||||||
const listEnd = useRef<HTMLDivElement>(null);
|
const listEnd = useRef<HTMLDivElement>(null);
|
||||||
const autoSent = useRef(false);
|
const autoSent = useRef(false);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { toFa } from '@/lib/format';
|
|||||||
export function CharactersScreen() {
|
export function CharactersScreen() {
|
||||||
const { summary, setCompanion } = useAppData();
|
const { summary, setCompanion } = useAppData();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const list = useAsync(() => api.characters.list(), []);
|
const list = useAsync(() => api.characters.list(), [], { key: 'characters' });
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
async function select(c: Character) {
|
async function select(c: Character) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function inviteLink(code: string): string {
|
|||||||
|
|
||||||
export function InviteScreen() {
|
export function InviteScreen() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const me = useAsync(() => api.affiliate.me(), []);
|
const me = useAsync(() => api.affiliate.me(), [], { key: 'affiliate' });
|
||||||
|
|
||||||
async function share(code: string) {
|
async function share(code: string) {
|
||||||
const url = inviteLink(code);
|
const url = inviteLink(code);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.
|
|||||||
|
|
||||||
export function InvoicesScreen() {
|
export function InvoicesScreen() {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const list = useAsync(() => api.subscription.invoices(), []);
|
const list = useAsync(() => api.subscription.invoices(), [], { key: 'invoices' });
|
||||||
const [open, setOpen] = useState<Invoice | null>(null);
|
const [open, setOpen] = useState<Invoice | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const MEDAL: Record<number, { bg: string; fg: string; h: number }> = {
|
|||||||
export function LeaderboardScreen() {
|
export function LeaderboardScreen() {
|
||||||
const { profile } = useAppData();
|
const { profile } = useAppData();
|
||||||
const [period, setPeriod] = useState<LeaderboardPeriod>('weekly');
|
const [period, setPeriod] = useState<LeaderboardPeriod>('weekly');
|
||||||
const board = useAsync(() => api.leaderboard(period), [period]);
|
const board = useAsync(() => api.leaderboard(period), [period], { key: `leaderboard:${period}` });
|
||||||
const entries = board.data ?? [];
|
const entries = board.data ?? [];
|
||||||
const podium = [entries[1], entries[0], entries[2]].filter(Boolean);
|
const podium = [entries[1], entries[0], entries[2]].filter(Boolean);
|
||||||
const isMe = (userId: string) => userId === profile.userId;
|
const isMe = (userId: string) => userId === profile.userId;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Diagram } from '@/diagram';
|
|||||||
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
||||||
const { home, companion } = useAppData();
|
const { home, companion } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId]);
|
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId], { key: `lesson-intro:${lessonId}` });
|
||||||
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||||
const data = intro.data;
|
const data = intro.data;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader
|
|||||||
import { iconFor } from '@/components/NodeIcon';
|
import { iconFor } from '@/components/NodeIcon';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
|
import { useFirstVisit } from '@/lib/firstVisit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
||||||
@@ -117,9 +118,10 @@ function UnitCard({
|
|||||||
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const premium = unit.requiresSubscription;
|
const premium = unit.requiresSubscription;
|
||||||
|
const intro = useFirstVisit(`unit-card:${unit.id}`);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={intro ? { opacity: 0, y: 12 } : { opacity: 1, y: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(index, 8) * 0.04 }}
|
transition={{ delay: Math.min(index, 8) * 0.04 }}
|
||||||
className="overflow-hidden rounded-2xl bg-white"
|
className="overflow-hidden rounded-2xl bg-white"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function NotificationsScreen() {
|
|||||||
const r = await api.notifications.list();
|
const r = await api.notifications.list();
|
||||||
setUnreadCount(r.unreadCount);
|
setUnreadCount(r.unreadCount);
|
||||||
return r.items;
|
return r.items;
|
||||||
}, []);
|
}, [], { key: 'notifications' });
|
||||||
const items = list.data ?? [];
|
const items = list.data ?? [];
|
||||||
const unread = items.filter((n) => !n.isRead).length;
|
const unread = items.filter((n) => !n.isRead).length;
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
* موعد روزها را روی هم میگذارد، پس نوار در ۱۰۰٪ بسته میشود. زدنِ کارت فروشگاه را باز میکند.
|
* موعد روزها را روی هم میگذارد، پس نوار در ۱۰۰٪ بسته میشود. زدنِ کارت فروشگاه را باز میکند.
|
||||||
*/
|
*/
|
||||||
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||||
const status = useAsync(() => api.subscription.status(), []);
|
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
|
||||||
const s = status.data;
|
const s = status.data;
|
||||||
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
|
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
|
||||||
const days = s.active ? daysLeft(s.expiresAt) : 0;
|
const days = s.active ? daysLeft(s.expiresAt) : 0;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { friendlyError } from '@/lib/errors';
|
|||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
import { sounds } from '@/lib/sound';
|
import { sounds } from '@/lib/sound';
|
||||||
|
import { useGateHold } from '@/lib/useAsync';
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
| { kind: 'loading' }
|
| { kind: 'loading' }
|
||||||
@@ -62,6 +63,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
const [confirmExit, setConfirmExit] = useState(false);
|
const [confirmExit, setConfirmExit] = useState(false);
|
||||||
const [reporting, setReporting] = useState(false);
|
const [reporting, setReporting] = useState(false);
|
||||||
const [reload, setReload] = useState(0);
|
const [reload, setReload] = useState(0);
|
||||||
|
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
|
||||||
|
const [combo, setCombo] = useState(0);
|
||||||
|
useGateHold(phase.kind === 'loading');
|
||||||
|
|
||||||
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمیشود.
|
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمیشود.
|
||||||
const answerKeys = useRef(new Map<string, string>());
|
const answerKeys = useRef(new Map<string, string>());
|
||||||
@@ -90,6 +94,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
setSelected(null);
|
setSelected(null);
|
||||||
setAnswer(null);
|
setAnswer(null);
|
||||||
setCorrectCount(0);
|
setCorrectCount(0);
|
||||||
|
setCombo(0);
|
||||||
answerKeys.current.clear();
|
answerKeys.current.clear();
|
||||||
completeKey.current = newIdempotencyKey();
|
completeKey.current = newIdempotencyKey();
|
||||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||||
@@ -127,8 +132,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
|
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
|
||||||
if (result.correct) {
|
if (result.correct) {
|
||||||
setCorrectCount((c) => c + 1);
|
setCorrectCount((c) => c + 1);
|
||||||
|
setCombo((c) => c + 1);
|
||||||
sounds.correct();
|
sounds.correct();
|
||||||
} else {
|
} else {
|
||||||
|
setCombo(0);
|
||||||
sounds.wrong();
|
sounds.wrong();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -252,10 +259,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
const bubble = !answer
|
const bubble = !answer
|
||||||
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||||
: answer.correct
|
: answer.correct
|
||||||
? 'آفرین! درست بود'
|
? combo >= 2 ? `${toFa(combo)} تا پشتِ هم درست! داری عالی پیش میری` : PRAISE[index % PRAISE.length]
|
||||||
: outOfHearts
|
: outOfHearts
|
||||||
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
||||||
: 'اشکالی نداره، جوابِ درست رو ببین.';
|
: NUDGE[index % NUDGE.length];
|
||||||
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
||||||
|
|
||||||
const optionClass = (id: string) => {
|
const optionClass = (id: string) => {
|
||||||
@@ -275,8 +282,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
|
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
|
||||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
|
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="w-10 flex-shrink-0 text-left text-xs font-bold text-muted">
|
<span className="flex-shrink-0 text-left text-xs font-bold text-muted">
|
||||||
{toFa(index + 1)}/{toFa(questions.length)}
|
{/* «۱/۴» در اپِ ریاضی کسر خوانده میشد. */}
|
||||||
|
{toFa(index + 1)} از {toFa(questions.length)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<HeartsRow hearts={hearts} />
|
<HeartsRow hearts={hearts} />
|
||||||
@@ -451,8 +459,15 @@ function Result({
|
|||||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||||
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
|
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
|
||||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||||
|
{/* نمرهی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهشهای ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
|
||||||
|
{result.stars <= 1 && (
|
||||||
|
<p className="mt-2 text-xs leading-6 text-body">
|
||||||
|
اشتباهها همون جاهاییان که مغزت داره یاد میگیره. هر وقت خواستی از «درسها» دوباره مرورش کن.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full gap-3">
|
{/* «+۰ امتیاز، +۰ سکه» بعد از یک دورِ بد فقط نمک روی زخم بود؛ پاداشِ صفر نشان داده نمیشود. */}
|
||||||
|
{(result.xpEarned > 0 || result.coinsEarned > 0) && <div className="flex w-full gap-3">
|
||||||
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
|
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
|
||||||
<Star size={18} className="text-gold" />
|
<Star size={18} className="text-gold" />
|
||||||
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
|
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
|
||||||
@@ -463,7 +478,7 @@ function Result({
|
|||||||
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
|
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
|
||||||
<span className="text-[10px] text-muted">سکه</span>
|
<span className="text-[10px] text-muted">سکه</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
||||||
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
@@ -477,6 +492,13 @@ function Result({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پیامِ بعد از جواب. یک جملهی ثابت بعد از چند بار دیگر خوانده نمیشود؛ چند تا به نوبت. برای جوابِ غلط
|
||||||
|
* تلاش و راه ستایش میشود و قدمِ بعدی گفته میشود، نه اینکه «اشکالی نداره» تنها بماند.
|
||||||
|
*/
|
||||||
|
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
|
||||||
|
const NUDGE = ['نزدیک بودی! جوابِ درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد میگیریم.', 'این یکی سخت بود؛ جوابِ درست سبز شده.'];
|
||||||
|
|
||||||
const REASONS: { id: ReportReason; label: string }[] = [
|
const REASONS: { id: ReportReason; label: string }[] = [
|
||||||
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
|
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
|
||||||
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
|
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const percent = (part: number, whole: number) => (whole ? Math.round((part / who
|
|||||||
|
|
||||||
export function ReportScreen() {
|
export function ReportScreen() {
|
||||||
const { profile } = useAppData();
|
const { profile } = useAppData();
|
||||||
const report = useAsync(() => api.weeklyReport(), []);
|
const report = useAsync(() => api.weeklyReport(), [], { key: 'weekly-report' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function recentWeeks(today = new Date()): Date[][] {
|
|||||||
const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric' }).format(d);
|
const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric' }).format(d);
|
||||||
|
|
||||||
export function StreakScreen() {
|
export function StreakScreen() {
|
||||||
const calendar = useAsync(() => api.streakCalendar(), []);
|
const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' });
|
||||||
const active = new Set(calendar.data?.activeDays ?? []);
|
const active = new Set(calendar.data?.activeDays ?? []);
|
||||||
const today = isoDay(new Date());
|
const today = isoDay(new Date());
|
||||||
const weeks = recentWeeks();
|
const weeks = recentWeeks();
|
||||||
@@ -57,7 +57,7 @@ export function StreakScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StreakGoal current={calendar.data.currentStreak} />
|
<StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} />
|
||||||
|
|
||||||
<div className="rounded-3xl border border-line bg-white p-4">
|
<div className="rounded-3xl border border-line bg-white p-4">
|
||||||
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
|
||||||
@@ -96,11 +96,19 @@ export function StreakScreen() {
|
|||||||
/** نقطهعطفهای زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعیای پشتش نیست (پاداش تصمیمِ محصولی است). */
|
/** نقطهعطفهای زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعیای پشتش نیست (پاداش تصمیمِ محصولی است). */
|
||||||
const MILESTONES = [3, 7, 14, 30, 60, 100];
|
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 next = MILESTONES.find((m) => m > current);
|
||||||
const reached = MILESTONES.filter((m) => m <= current).pop();
|
const reached = MILESTONES.filter((m) => m <= current).pop();
|
||||||
|
// زنجیرهی صفر: «۳ روز دیگه میشه زنجیرهی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته میشود.
|
||||||
|
// زنجیرهی روشن و امروزِ خالی: یادآوریِ اینکه امروز چیزی مانده تا زنجیره نسوزد.
|
||||||
|
const today = current === 0
|
||||||
|
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیرهت روشن بشه.'
|
||||||
|
: !doneToday
|
||||||
|
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیرهی ${toFa(current)} روزهت نسوزه.`
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
|
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
|
||||||
|
{today && <p className="mb-1 text-xs font-bold leading-6 text-[#B4501C]">{today}</p>}
|
||||||
<p className="text-sm font-black leading-7 text-ink">
|
<p className="text-sm font-black leading-7 text-ink">
|
||||||
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزهای! </> : null}
|
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزهای! </> : null}
|
||||||
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ type View = 'subscription' | 'pack';
|
|||||||
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
||||||
*/
|
*/
|
||||||
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||||
const plans = useAsync(() => api.subscription.plans(), []);
|
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
|
||||||
const status = useAsync(() => api.subscription.status(), []);
|
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
|
||||||
const quota = useAsync(() => api.ai.quota(), []);
|
const quota = useAsync(() => api.ai.quota(), [], { key: 'ai-quota' });
|
||||||
const [view, setView] = useState<View | null>(initialView ?? null);
|
const [view, setView] = useState<View | null>(initialView ?? null);
|
||||||
const [buying, setBuying] = useState<Plan | null>(null);
|
const [buying, setBuying] = useState<Plan | null>(null);
|
||||||
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
|
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
|
||||||
@@ -53,7 +53,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
|
<StatusCard status={status.data ?? null} statusLoading={status.loading && !status.data} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} quotaLoading={quota.loading && !quota.data} />
|
||||||
|
|
||||||
{view === null ? (
|
{view === null ? (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -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 }) => <span className={`inline-block animate-pulse rounded-lg bg-soft align-middle ${className}`} />;
|
||||||
|
|
||||||
|
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;
|
const days = status?.active ? daysLeft(status.expiresAt) : null;
|
||||||
return (
|
return (
|
||||||
@@ -143,9 +146,10 @@ function StatusCard({ status, credits, heartsUntil, quota }: {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
||||||
<div className="text-base font-black text-ink">
|
<div className="text-base font-black text-ink">
|
||||||
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
{statusLoading ? <Placeholder className="h-5 w-20" /> : status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{quotaLoading && <Placeholder className="h-10 w-14" />}
|
||||||
{quota && (
|
{quota && (
|
||||||
<div className="text-left text-xs leading-6 text-muted">
|
<div className="text-left text-xs leading-6 text-muted">
|
||||||
سوالِ امروز<br />
|
سوالِ امروز<br />
|
||||||
@@ -154,7 +158,7 @@ function StatusCard({ status, credits, heartsUntil, quota }: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
|
||||||
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {toFa(credits)} سوال</span>
|
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {statusLoading && quotaLoading ? <Placeholder className="h-3.5 w-10" /> : <>{toFa(credits)} سوال</>}</span>
|
||||||
{(status?.active || heartsUntil) && (
|
{(status?.active || heartsUntil) && (
|
||||||
<span className="flex items-center gap-1.5 text-coral">
|
<span className="flex items-center gap-1.5 text-coral">
|
||||||
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
|
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function SupportScreen() {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
// فهرست از سرور میآید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده میشود.
|
// فهرست از سرور میآید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده میشود.
|
||||||
const tickets = useAsync(() => api.support.tickets(), []);
|
const tickets = useAsync(() => api.support.tickets(), [], { key: 'support-tickets' });
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!subject.trim() || !text.trim()) return;
|
if (!subject.trim() || !text.trim()) return;
|
||||||
|
|||||||
@@ -27,18 +27,26 @@ interface AppData {
|
|||||||
|
|
||||||
const AppDataContext = createContext<AppData | null>(null);
|
const AppDataContext = createContext<AppData | null>(null);
|
||||||
|
|
||||||
|
/** آنچه در اسپلش پیش از ورود به اپ گرفته شد (App.prefetchMain). */
|
||||||
|
export interface Prefetch {
|
||||||
|
home: HomeResponse | null;
|
||||||
|
summary: UserSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function AppDataProvider({
|
export function AppDataProvider({
|
||||||
initialProfile,
|
initialProfile,
|
||||||
grades,
|
grades,
|
||||||
|
prefetch,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
initialProfile: UserProfile;
|
initialProfile: UserProfile;
|
||||||
grades: Grade[];
|
grades: Grade[];
|
||||||
|
prefetch?: Prefetch;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [profile, setProfile] = useState(initialProfile);
|
const [profile, setProfile] = useState(initialProfile);
|
||||||
const [summary, setSummary] = useState<UserSummary | null>(null);
|
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
|
||||||
const [home, setHome] = useState<HomeResponse | null>(null);
|
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
|
||||||
const [homeError, setHomeError] = useState<unknown>(null);
|
const [homeError, setHomeError] = useState<unknown>(null);
|
||||||
const [companion, setCompanionChar] = useState<SeyarChar>('sigma');
|
const [companion, setCompanionChar] = useState<SeyarChar>('sigma');
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
@@ -69,8 +77,9 @@ export function AppDataProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshHome();
|
// آنچه در اسپلش رسیده دوباره گرفته نمیشود؛ فقط کمها.
|
||||||
void refreshSummary();
|
if (!prefetch?.home) void refreshHome();
|
||||||
|
if (!prefetch?.summary) void refreshSummary();
|
||||||
void refreshUnread();
|
void refreshUnread();
|
||||||
api.characters
|
api.characters
|
||||||
.list()
|
.list()
|
||||||
@@ -79,6 +88,8 @@ export function AppDataProvider({
|
|||||||
if (selected) setCompanionChar(selected);
|
if (selected) setCompanionChar(selected);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
// فقط بارِ اول؛ prefetch بعد از mount عوض نمیشود.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [refreshHome, refreshSummary, refreshUnread]);
|
}, [refreshHome, refreshSummary, refreshUnread]);
|
||||||
|
|
||||||
const value = useMemo<AppData>(
|
const value = useMemo<AppData>(
|
||||||
|
|||||||
@@ -119,3 +119,7 @@
|
|||||||
.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
.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; } }
|
@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; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user