A signed-in user with a snapshot goes straight to home; the same boot requests run in the background and replace it. Snapshot is cleared whenever tokens are cleared and on a new login, ignored from another build or when older than 7 days, and dropped when the fresh profile is a different user (reboot remounts MainApp via a generation key; AnimatePresence otherwise kept the exiting one). Notifications/characters wait for the profile answer so an expired access token costs the same requests as before. refreshHome applies only the latest answer. Home shows a "saved data" strip when the map could not be refreshed. Measured locally (old vs new build, same backend, Chrome throttling, median of 3, time to map): Fast 3G 2.46s -> 1.80s (expired token 3.63s -> 1.80s); Slow 3G 8.52s -> 6.14s (expired 12.6s -> 6.13s); API requests per open unchanged (6 valid, 11 expired). 12/12 behaviour checks passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
8.7 KiB
TypeScript
155 lines
8.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { Bell, ChevronLeft, Coins, Crown, Flame, RotateCw, Star } from 'lucide-react';
|
||
import { ApiError, type HomeNode } from '@/api';
|
||
import { useAppData } from '@/state/AppData';
|
||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||
import { useNav } from '@/nav/Navigator';
|
||
import { IslandPath } from '@/components/IslandPath';
|
||
import { IslandSheet } from '@/components/IslandSheet';
|
||
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||
import { onWhite, shade } from '@/lib/color';
|
||
import { toFa } from '@/lib/format';
|
||
import { BrandFooter } from '@/components/BrandFooter';
|
||
import { BrandMark } from '@/components/BrandMark';
|
||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||
import { SubjectBar } from '@/components/SubjectBar';
|
||
|
||
/**
|
||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||
* یک دکمهی «شروع» روی سرش. قبلاً خانه داشبوردی از شش کارت بود (ادامه، نقشه،
|
||
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||
*/
|
||
export function HomeScreen() {
|
||
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome, refreshSummary } = useAppData();
|
||
const nav = useNav();
|
||
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||
const paywall = () => nav.push({ name: 'subscription' });
|
||
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
||
const currentRef = useRef<HTMLDivElement>(null);
|
||
const scrolled = useRef(false);
|
||
const cont = findContinue(home);
|
||
|
||
// بارِ اول تا جزیرهی فعلی اسکرول کن، نه هر بار که نقشه تازه میشود.
|
||
useEffect(() => {
|
||
if (!home || scrolled.current) return;
|
||
scrolled.current = true;
|
||
const frame = requestAnimationFrame(() => currentRef.current?.scrollIntoView({ block: 'center' }));
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [home]);
|
||
|
||
const flame = flameState(summary?.streak);
|
||
const streak = summary?.streak;
|
||
|
||
const stats = [
|
||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روز پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
|
||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
|
||
];
|
||
|
||
return (
|
||
<Screen>
|
||
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-3 pt-safe text-white shadow-md shadow-coral/20">
|
||
<div className="flex items-center gap-2 pt-4">
|
||
<BrandMark size={26} withName={false} />
|
||
{gradeTitle && (
|
||
<button type="button" onClick={() => nav.switchTab('profile')} className="hit rounded-full bg-white/20 px-3 py-1.5 text-xs font-black active:scale-95">
|
||
{gradeTitle}
|
||
</button>
|
||
)}
|
||
<div className="mr-auto flex items-center gap-1.5">
|
||
{stats.map(({ icon: Icon, value, label, color, fill, to }) => (
|
||
<button
|
||
key={label}
|
||
type="button"
|
||
aria-label={`${label}: ${toFa(value)}`}
|
||
onClick={() => nav.push({ name: to })}
|
||
className={`hit flex items-center gap-1 rounded-full px-2.5 py-1.5 text-xs font-black shadow-sm active:scale-95 ${to === 'streak' && flame === 'frozen' ? 'bg-[#E6F6FF] text-[#2B7FB8]' : 'bg-white text-ink'}`}
|
||
>
|
||
{to === 'streak' ? <StreakFlame state={flame} size={13} /> : <Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />}
|
||
{toFa(to === 'streak' && flame === 'frozen' ? streak?.lost ?? 0 : value)}
|
||
</button>
|
||
))}
|
||
<button type="button" aria-label="اعلانها" onClick={() => nav.push({ name: 'notifications' })} className="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95">
|
||
<Bell size={16} />
|
||
{unreadCount > 0 && (
|
||
<span className="absolute -left-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
|
||
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</header>
|
||
|
||
<SubjectBar />
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
{!home ? (
|
||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
|
||
) : home.units.length === 0 ? (
|
||
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||
) : (
|
||
<div className="flex flex-col gap-2 px-4 pb-10 pt-4">
|
||
{homeError != null && (
|
||
// نقشه هست ولی تازه نشد (معمولاً عکسِ ذخیرهشده روی گوشی و اینترنتِ قطع): بچه باید بداند
|
||
// عددها و قفلها ممکن است مالِ دفعهی قبل باشند.
|
||
<div role="status" className="flex items-center gap-2 rounded-xl bg-soft px-3 py-2 text-xs leading-6 text-body">
|
||
<span className="flex-1">
|
||
{homeError instanceof ApiError && homeError.isNetwork ? 'اینترنت وصل نیست یا کنده' : 'اطلاعات تازه نشد'}؛
|
||
آخرین اطلاعات ذخیرهشده رو میبینی.
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => void Promise.all([refreshHome(), refreshSummary()])}
|
||
className="flex shrink-0 items-center gap-1 rounded-full bg-white px-3 py-1 font-bold text-coral ring-1 ring-line"
|
||
>
|
||
<RotateCw size={12} /> دوباره
|
||
</button>
|
||
</div>
|
||
)}
|
||
{home.units.map((unit, i) => {
|
||
const color = unitColor(i);
|
||
const p = unitProgress(unit);
|
||
const isCurrentUnit = cont?.unit.id === unit.id;
|
||
return (
|
||
<section key={unit.id} aria-label={unit.title}>
|
||
<button
|
||
type="button"
|
||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||
className="sticky top-2 z-10 flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
|
||
style={{ backgroundColor: onWhite(color), boxShadow: `0 4px 0 ${shade(onWhite(color), -0.25)}` }}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-[11px] font-bold text-white/80">
|
||
فصل {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
|
||
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
|
||
</div>
|
||
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
|
||
</div>
|
||
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
|
||
</button>
|
||
<div className="pt-12">
|
||
<IslandPath
|
||
ref={isCurrentUnit ? currentRef : undefined}
|
||
nodes={unit.nodes}
|
||
color={color}
|
||
currentId={isCurrentUnit ? cont!.node.id : null}
|
||
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode({ node, color }))}
|
||
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
|
||
/>
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
<BrandFooter />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<IslandSheet node={openNode?.node ?? null} color={openNode?.color ?? '#ED6956'} onClose={() => setOpenNode(null)} />
|
||
</Screen>
|
||
);
|
||
}
|