- Shop: subscription perks shown once (each card repeated them, «۳ سوال در روز» twice), plans as selectable rows with one big buy button, prices as «۷۹۸ هزار تومان», a single clear status card for subscribers. Checkout inside the Bazaar app offers «کارتِ بانکی» (default) or «کافهبازار» for packs with a SKU; «بهزودی در بازار» is gone. The gateway opens in the phone browser; status refreshes on return. - Back button: every back-able level (open sheet, pushed screen, non-home tab, shop list) owns exactly one history entry created right after the tap; back closes the sheet, then the screen, then returns to Home, then leaves the app. (A re-pushed trap is skipped by Chrome's history intervention.) - Sound: AudioContext unlocked on the first touch (it was created after a network wait, which WebView/Safari keep muted); softer notes, option tick, finish fanfare, streak whoosh. Haptics through the new TadrisinoAndroid.vibrate bridge when the APK has it, navigator.vibrate otherwise. - Streak: animated flame with lit / at-risk (grey) / frozen (ice) states on Home and the streak screen, a companion reminder when the streak is at risk or frozen, and a full-screen «روزِ پیاپی» celebration after the first lesson of the day (needs backend 9024c6b: streak.activeToday / lost). - Lessons: intro/example text with inline «۱. … ۲. …» renders as a numbered list (2,250 of 4,469 texts). - Quiz: duplicate React key on the answer bubble fixed. - android/MainActivity.kt: vibrate bridge and mediaPlaybackRequiresUserGesture=false. Not built; needs the next APK from the signing owner. Checked in headless Chrome against the local backend (shop as a subscriber with the Bazaar bridge stubbed, back sequences, quiz → streak celebration, frozen state). Not checked on a real phone or in the APK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
9.4 KiB
TypeScript
162 lines
9.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, Star } from 'lucide-react';
|
||
import type { HomeNode } from '@/api';
|
||
import { useAppData } from '@/state/AppData';
|
||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||
import { useNav } from '@/nav/Navigator';
|
||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||
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 { MathText } from '@/lib/MathText';
|
||
import { BrandFooter } from '@/components/BrandFooter';
|
||
import { BrandMark } from '@/components/BrandMark';
|
||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||
|
||
/**
|
||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||
* یک دکمهی «شروع» روی سرش. قبلاً خانه داشبوردی از شش کارت بود (ادامه، نقشه،
|
||
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||
*/
|
||
export function HomeScreen() {
|
||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||
const nav = useNav();
|
||
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||
const paywall = () => nav.push({ name: 'subscription', view: '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 name = profile.name || 'دوستِ من';
|
||
const flame = flameState(summary?.streak);
|
||
const streak = summary?.streak;
|
||
// زنجیرهی در خطر یا یخزده جلوتر از پیامِ معمول میآید: از دست دادن انگیزهی قویتری از بهدست آوردن است،
|
||
// ولی لحن مهربان میماند و قدمِ بعدی را میگوید.
|
||
const streakNote = cont && streak
|
||
? flame === 'frozen'
|
||
? <>{name}، زنجیرهی {toFa(streak.lost)} روزهت یخ زد <StreakFlame state="frozen" size={14} className="mb-0.5 inline" /> یه جزیره بزن تا دوباره آتیشش روشن بشه!</>
|
||
: flame === 'risk'
|
||
? <>{name}، امروز هنوز تمرین نکردی! زنجیرهی {toFa(streak.current)} روزهت منتظرته <StreakFlame state="lit" size={14} className="mb-0.5 inline" /></>
|
||
: null
|
||
: null;
|
||
const bubble = !home
|
||
? 'دارم مسیرت رو میارم…'
|
||
: !home.units.length
|
||
? 'هنوز درسی برای این پایه ثبت نشده.'
|
||
: streakNote
|
||
? streakNote
|
||
: cont
|
||
? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیرهی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمهی <b>شروع</b> رو بزن.</>
|
||
: <>آفرین {name}! همهی جزیرههای این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
|
||
|
||
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-4 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>
|
||
|
||
<div className="mt-3 flex items-end gap-2">
|
||
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
|
||
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
|
||
{bubble}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<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">
|
||
{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>
|
||
);
|
||
}
|