forked from Tadrisino/MathApp_frontend
- Text: kasra/fatha/damma/shadda/sukun/hamza-above and «هی» ezafe removed from every user-visible string (comments untouched); tanwin kept. Stale copy fixed (question packs are no longer sold), awkward onboarding sentences rewritten, سؤال → سوال. plainPersian() in MathText cleans the live tutor stream. - Home: the mascot + speech bubble header is gone; the selected companion hops beside the current island and sits at every third bend (CSS transform animations only). - Checkout: order summary, payment-method rows with Zarinpal/Bazaar icons and radio, amount on the pay button, lock trust line, darker overlay. paymentMethods in config.js per channel (web: zarinpal, app: zarinpal+bazaar by default). - Profile: «ورود با حساب دیگر» removed (it was logout); new «حساب کاربری» sheet shows phone/ID and holds «حذف حساب کاربری»; logout is a red row. Delete sheet tells Bazaar subscribers to cancel renewal in Bazaar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
13 KiB
TypeScript
257 lines
13 KiB
TypeScript
import { useState } from 'react';
|
||
import { motion } from 'motion/react';
|
||
import { Brain, Check, ChevronDown, Crown, Lock, PartyPopper, Play, Sparkles } from 'lucide-react';
|
||
import { api, type HomeNode, type HomeUnit } from '@/api';
|
||
import { useAppData } from '@/state/AppData';
|
||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||
import { onWhite } from '@/lib/color';
|
||
import { useNav } from '@/nav/Navigator';
|
||
import { useAsync } from '@/lib/useAsync';
|
||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||
import { iconFor } from '@/components/NodeIcon';
|
||
import { toFa } from '@/lib/format';
|
||
import { MathText } from '@/lib/MathText';
|
||
import { useFirstVisit } from '@/lib/firstVisit';
|
||
import { SubjectBar } from '@/components/SubjectBar';
|
||
|
||
/**
|
||
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
||
* بازبودنِ فصل/جزیره در متغیرِ ماژول نگه داشته میشود، چون با رفتن به درس و برگشتن صفحه از نو ساخته
|
||
* میشود (کلیدِ مسیر در MainApp) و کاربر جایش را گم میکرد. با بستنِ اپ پاک میشود، عمداً.
|
||
*/
|
||
const remembered: { unit: string | null; node: string | null } = { unit: null, node: null };
|
||
|
||
export function LessonsScreen() {
|
||
const { home, homeError, refreshHome, gradeTitle } = useAppData();
|
||
const nav = useNav();
|
||
const units = home?.units ?? [];
|
||
const current = findContinue(home);
|
||
const [openUnit, setOpenUnitState] = useState<string | null>(remembered.unit);
|
||
const [openNode, setOpenNodeState] = useState<string | null>(remembered.node);
|
||
const setOpenUnit = (id: string | null) => { remembered.unit = id; setOpenUnitState(id); };
|
||
const setOpenNode = (id: string | null) => { remembered.node = id; setOpenNodeState(id); };
|
||
|
||
const totalNodes = units.reduce((n, u) => n + u.nodes.length, 0);
|
||
const doneNodes = units.reduce((n, u) => n + unitProgress(u).done, 0);
|
||
|
||
return (
|
||
<Screen className="bg-page">
|
||
<ScreenHeader
|
||
title="درسها"
|
||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمون جزیره رو بده</>}
|
||
color="#2FB36B"
|
||
exact
|
||
mascot={{ mood: 'happy' }}
|
||
>
|
||
{home && totalNodes > 0 && (
|
||
<div className="mt-4">
|
||
<div className="mb-1.5 flex justify-between text-[11px] text-white/85">
|
||
<span>جزیرههایی که تموم کردی</span>
|
||
<span>{toFa(doneNodes)} از {toFa(totalNodes)}</span>
|
||
</div>
|
||
<ProgressBar percent={Math.round((doneNodes / totalNodes) * 100)} color="#fff" track="rgba(255,255,255,.25)" height={10} />
|
||
</div>
|
||
)}
|
||
</ScreenHeader>
|
||
|
||
<SubjectBar />
|
||
|
||
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
|
||
{!home ? (
|
||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درسها رو میارم…" />
|
||
) : units.length === 0 ? (
|
||
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{current && current.node.nextLessonId != null && (
|
||
<button
|
||
type="button"
|
||
onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })}
|
||
className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white"
|
||
style={{ backgroundColor: onWhite(unitColor(current.unitIndex)), boxShadow: `0 4px 0 ${onWhite(unitColor(current.unitIndex))}99` }}
|
||
>
|
||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25">
|
||
<Play size={20} fill="white" />
|
||
</span>
|
||
<span className="flex-1">
|
||
<span className="block text-[11px] font-bold text-white/85">ادامه بده از همینجا</span>
|
||
<span className="block text-sm font-black leading-6"><MathText text={current.node.title} /></span>
|
||
</span>
|
||
</button>
|
||
)}
|
||
|
||
{units.map((unit, i) => (
|
||
<UnitCard
|
||
key={unit.id}
|
||
index={i}
|
||
unit={unit}
|
||
color={unitColor(i)}
|
||
currentNodeId={current?.node.id ?? null}
|
||
open={openUnit === unit.id}
|
||
onToggle={() => setOpenUnit(openUnit === unit.id ? null : unit.id)}
|
||
openNode={openNode}
|
||
onToggleNode={(id) => setOpenNode(openNode === id ? null : id)}
|
||
/>
|
||
))}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => nav.switchTab('ask')}
|
||
className="mt-1 flex items-center gap-3 rounded-2xl border border-line bg-white px-4 py-3 text-right"
|
||
>
|
||
<Brain size={22} className="flex-shrink-0 text-violet" />
|
||
<span className="text-sm leading-6 text-body">یه جایی از درس رو نفهمیدی؟ <b className="text-ink">از دستیار بپرس</b></span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
function UnitCard({
|
||
index, unit, color, currentNodeId, open, onToggle, openNode, onToggleNode,
|
||
}: {
|
||
index: number; unit: HomeUnit; color: string; currentNodeId: string | null; open: boolean; onToggle: () => void;
|
||
openNode: string | null; onToggleNode: (id: string) => void;
|
||
}) {
|
||
const p = unitProgress(unit);
|
||
const complete = p.total > 0 && p.done === p.total;
|
||
const untouched = unit.nodes.every((n) => n.status === 'locked');
|
||
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
||
const nav = useNav();
|
||
const premium = unit.requiresSubscription;
|
||
const intro = useFirstVisit(`unit-card:${unit.id}`);
|
||
return (
|
||
<motion.div
|
||
initial={intro ? { opacity: 0, y: 12 } : { opacity: 1, y: 0 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: Math.min(index, 8) * 0.04 }}
|
||
className="overflow-hidden rounded-2xl bg-white"
|
||
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
|
||
>
|
||
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
||
<span
|
||
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
|
||
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
|
||
>
|
||
{complete ? <Check size={26} strokeWidth={3.5} /> : untouched ? <Lock size={22} /> : <Icon size={26} strokeWidth={2.2} />}
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-[11px] font-bold" style={{ color }}>
|
||
{complete ? <><PartyPopper size={12} className="mb-0.5 ml-1 inline" />تموم شد</> : `${toFa(p.total)} جزیره`}
|
||
{premium && <span className="mr-1.5 inline-flex items-center gap-0.5 rounded-full bg-[#FFF4D6] px-1.5 text-[10px] text-[#A86A00]"><Crown size={10} /> با اشتراک</span>}
|
||
</span>
|
||
<span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span>
|
||
<span className="mt-1 flex items-center gap-2">
|
||
<span className="flex-1"><ProgressBar percent={p.percent} color={color} height={6} /></span>
|
||
<span className="text-[11px] text-muted">{toFa(p.done)}/{toFa(p.total)}</span>
|
||
</span>
|
||
</span>
|
||
{premium ? <Crown size={18} className="flex-shrink-0 text-[#E0A200]" /> : <ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||
</button>
|
||
{open && !premium && (
|
||
<ul className="flex flex-col gap-1.5 border-t border-line bg-[#fbfaff] px-3 py-3">
|
||
{unit.nodes.map((node, ni) => (
|
||
<NodeRow
|
||
key={node.id}
|
||
num={ni + 1}
|
||
node={node}
|
||
color={color}
|
||
isCurrent={node.id === currentNodeId}
|
||
open={openNode === node.id}
|
||
onToggle={() => onToggleNode(node.id)}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function NodeRow({
|
||
num, node, color, isCurrent, open, onToggle,
|
||
}: { num: number; node: HomeNode; color: string; isCurrent: boolean; open: boolean; onToggle: () => void }) {
|
||
const nav = useNav();
|
||
const locked = node.status === 'locked';
|
||
const done = node.status === 'completed';
|
||
const lessons = useAsync(() => (open && !locked ? api.nodeLessons(node.id) : Promise.resolve(null)), [node.id, open, locked]);
|
||
const playable = new Set(node.lessonIds);
|
||
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||
const loaded = !lessons.loading && !lessons.error && lessons.data !== null && lessons.data !== undefined;
|
||
|
||
return (
|
||
<li className="rounded-xl border bg-white" style={{ borderColor: isCurrent ? color : '#e9e6f7' }}>
|
||
<button
|
||
type="button"
|
||
disabled={locked}
|
||
onClick={onToggle}
|
||
aria-expanded={open}
|
||
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-right disabled:opacity-60"
|
||
>
|
||
<span
|
||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black"
|
||
style={done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }}
|
||
>
|
||
{done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)}
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span>
|
||
<span className="block text-[11px] text-muted">
|
||
{locked ? 'جزیره قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||
</span>
|
||
</span>
|
||
{isCurrent && !open && (
|
||
<span className="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-black text-white" style={{ backgroundColor: color }}>
|
||
بعدی
|
||
</span>
|
||
)}
|
||
{!locked && <ChevronDown size={16} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||
</button>
|
||
|
||
{open && !locked && (
|
||
<div className="px-3 pb-3">
|
||
{lessons.error ? (
|
||
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||
) : !loaded ? (
|
||
<div className="flex flex-col gap-1.5" aria-busy="true" aria-label="دارم درسها رو میارم">
|
||
{[0, 1, 2].map((k) => <div key={k} className="h-10 animate-pulse rounded-xl bg-soft" />)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ul className="flex flex-col gap-1.5">
|
||
{list.map((l, li) => (
|
||
<li key={l.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => nav.push({ name: 'lesson', lessonId: Number(l.id), nodeId: node.id })}
|
||
className="flex w-full items-center gap-2.5 rounded-xl bg-[#f7f8ff] px-3 py-2 text-right text-sm active:scale-[.99]"
|
||
>
|
||
<span
|
||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black"
|
||
style={l.done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }}
|
||
>
|
||
{l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)}
|
||
</span>
|
||
<span className={`flex-1 ${l.done ? 'text-muted' : 'text-ink'}`}><MathText text={l.title} /></span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button
|
||
type="button"
|
||
onClick={() => nav.push({ name: 'quiz', nodeId: node.id })}
|
||
className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]"
|
||
style={{ backgroundColor: onWhite(color), boxShadow: `0 3px 0 ${onWhite(color)}99` }}
|
||
>
|
||
{done ? <Sparkles size={15} /> : <Play size={15} fill="white" />}
|
||
{done ? 'دوباره تمرین کن' : 'درسها رو خوندی؟ برو آزمون'}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
}
|