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:
2026-10-10 17:55:47 +03:30
parent 19ca3d7de1
commit eafee76a93
24 changed files with 463 additions and 89 deletions

View File

@@ -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<View | null>(initialView ?? 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">
<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 ? (
<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;
return (
@@ -143,9 +146,10 @@ function StatusCard({ status, credits, heartsUntil, quota }: {
<div className="flex-1">
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
<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>
{quotaLoading && <Placeholder className="h-10 w-14" />}
{quota && (
<div className="text-left text-xs leading-6 text-muted">
سوالِ امروز<br />
@@ -154,7 +158,7 @@ function StatusCard({ status, credits, heartsUntil, quota }: {
)}
</div>
<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) && (
<span className="flex items-center gap-1.5 text-coral">
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}