Files
MathApp_frontend/src/screens/UnitScreen.tsx
AliReza 348bfecea6 rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused
packages, 45 unused UI components, no type-check). The live site could not
reach the backend, Persian-keyboard digits were erased from the phone field,
guests were signed out after an hour, "mark read" hit a method the server
does not accept (405), and iOS Safari blocked the payment window.

What changed:
- src/ split into api/ (one typed client: timeouts, GET retry, single-flight
  refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/.
- The API address is configuration only: public/config.js at runtime, then
  VITE_API_BASE_URL at build time, then same-origin /api. No domain in code.
- Responsive shell instead of a fixed 390x844 phone frame; self-hosted
  Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build`
  type-checks first.
- Everything on the handoff list: tutor answers keep their line breaks, have
  thumbs up/down, follow-ups, "from the book" badge, retry, history that
  survives tab switches and reloads, "why was I wrong" from a wrong answer
  with questionId; question reports; hearts with maxHearts/unlimited and a
  no-hearts screen with the refill time; result screen and a visible error
  when lesson completion fails; subscription status, badge, discount code
  before the gateway and the payment-return message; guest refresh token;
  6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH
  read-all; leaderboard refetched on open with "you" highlighted; selected
  companion shown across the app; logout that revokes the refresh token.
- New screens for endpoints that had none: profile, streak calendar,
  support tickets and bug reports, parents, invite-a-friend (with ?ref=
  tracking), guest-to-phone upgrade.
- Browser/phone back button walks the screen stack.

Checked with a headless-Chrome walkthrough against a local backend at
390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map,
island sheet, every tab and sub-screen, one real tutor question, reload,
logout): no console errors, no failed API calls. Real SMS login and a real
Zarinpal payment were not exercised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:35:48 +03:30

213 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { motion } from 'motion/react';
import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react';
import { api, type HomeNode } from '@/api';
import { useAppData } from '@/state/AppData';
import { findUnit, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
const NODE_SIZE = 76;
/** نقشه‌ی جزیره‌های یک فصل، مثلِ مسیرِ دولینگو. */
export function UnitScreen({ unitId }: { unitId: string }) {
const { home, homeError, refreshHome, companion } = useAppData();
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId);
if (!found) {
return (
<Screen>
<ScreenHeader title="…" color="#91ADC6" back />
{homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : home ? <EmptyState text="این فصل پیدا نشد" /> : <LoadingState />}
</Screen>
);
}
const { unit, unitIndex } = found;
const color = unitColor(unitIndex);
const progress = unitProgress(unit);
const points = zigzagPositions(unit.nodes.length, 125, 90);
const height = points.length ? points[points.length - 1].y + 150 : 400;
return (
<Screen className="bg-[#f7f8ff]">
<ScreenHeader
title={unit.title}
subtitle={unit.blurb || `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
color={color}
back
mascot={{ char: companion, mood: progress.percent >= 50 ? 'happy' : 'idle', size: 72 }}
>
<div className="mt-4">
<div className="mb-1.5 flex justify-between text-[10px] text-white/75">
<span>پیشرفتِ تو</span>
<span>{toFa(progress.percent)}٪</span>
</div>
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto">
{unit.nodes.length === 0 ? (
<EmptyState text="این فصل هنوز جزیره‌ای نداره" />
) : (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath points={points} height={height} colorOf={(i) => (unit.nodes[i].status === 'completed' ? color : null)} />
{unit.nodes.map((node, i) => {
const locked = node.status === 'locked';
const done = node.status === 'completed';
const current = !locked && !done;
const Icon = iconFor(node.icon, i);
return (
<motion.div
key={node.id}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
{current && (
<>
<div
className="absolute -top-14 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-2xl border-2 bg-white px-3 py-1.5 text-[11px] font-black shadow-lg"
style={{ color, borderColor: color }}
>
{node.title}
<span className="absolute -bottom-2 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 border-b-2 border-r-2 bg-white" style={{ borderColor: color }} />
</div>
<motion.div
animate={{ scale: [1, 1.18, 1], opacity: [0.25, 0.05, 0.25] }}
transition={{ duration: 1.8, repeat: Infinity }}
className="absolute inset-[-13px] rounded-full"
style={{ backgroundColor: color }}
/>
</>
)}
<button
type="button"
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمام‌شده)' : ''}`}
onClick={() => setOpenNode(node)}
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
style={{
width: NODE_SIZE,
height: NODE_SIZE,
backgroundColor: locked ? '#e5e4ec' : done ? color : '#fff',
border: current ? `4px solid ${color}` : 'none',
boxShadow: locked ? '0 6px 0 #cccbd6' : done ? `0 7px 0 ${color}99` : `0 7px 0 ${color}55, 0 0 0 5px white`,
}}
>
{locked ? (
<Lock size={24} strokeWidth={2.5} className="text-[#aaa9b8]" />
) : done ? (
<Check size={31} color="white" strokeWidth={3.5} />
) : (
<Icon size={28} style={{ color }} strokeWidth={2.5} />
)}
</button>
{!current && (
<div className="absolute left-1/2 top-full mt-2.5 w-28 -translate-x-1/2 truncate rounded-lg bg-[#f7f8ff]/90 text-center text-[10px] font-bold text-muted">
{node.title}
</div>
)}
</motion.div>
);
})}
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 70 }}>
<span className="mb-1 text-[10px] font-bold text-[#aaa9bd]">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
<Sparkles size={20} style={{ color }} />
</span>
</div>
</div>
)}
</div>
<NodeSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
</Screen>
);
}
/** برگه‌ی جزیره: درس‌هایش، و دکمه‌ی شروعِ درسِ بعدی. */
function NodeSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
const nav = useNav();
const lessons = useAsync(() => (node ? api.nodeLessons(node.id) : Promise.resolve(null)), [node?.id]);
const next = node ? nextLessonOf(node) : null;
const locked = node?.status === 'locked';
const open = (lessonId: number) => {
if (!node) return;
onClose();
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
};
// فقط درس‌هایی که سوال دارند در lessonIds هستند؛ بقیه هنوز آزمون ندارند.
const playable = new Set(node?.lessonIds ?? []);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
const doneCount = list.filter((l) => l.done).length;
return (
<Sheet open={node !== null} onClose={onClose} title={node?.title}>
{locked ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<Lock size={24} className="text-muted" />
</span>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.</p>
</div>
) : (
<>
{lessons.loading ? (
<div className="py-6 text-center text-sm text-muted">دارم درس‌ها رو میارم…</div>
) : lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} />
) : (
<>
<p className="mb-3 text-xs text-muted">
{toFa(doneCount)} از {toFa(list.length)} درس تموم شده
</p>
<ul className="mb-5 flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{list.map((lesson) => {
const isNext = Number(lesson.id) === next;
return (
<li key={lesson.id}>
<button
type="button"
onClick={() => open(Number(lesson.id))}
className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-2.5 text-right text-sm ${
isNext ? 'border-2 bg-white font-bold' : 'border-line bg-white'
}`}
style={isNext ? { borderColor: color } : undefined}
>
{lesson.done ? (
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
) : isNext ? (
<Play size={16} className="flex-shrink-0" style={{ color }} fill={color} />
) : (
<Circle size={18} className="flex-shrink-0 text-faint" />
)}
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
</button>
</li>
);
})}
</ul>
</>
)}
{next != null && (
<PrimaryButton color={color} onClick={() => open(next)}>
<Play size={18} fill="white" /> {node?.status === 'completed' ? 'مرور دوباره' : 'شروعِ درسِ بعدی'}
</PrimaryButton>
)}
</>
)}
</Sheet>
);
}