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>
This commit is contained in:
2026-09-26 23:35:48 +03:30
parent b411b636f4
commit 348bfecea6
120 changed files with 5840 additions and 12783 deletions

View File

@@ -0,0 +1,66 @@
import { Minus, Plus } from 'lucide-react';
import { currentJalaliYear, toFa } from '@/lib/format';
import { sounds } from '@/lib/sound';
const MIN_AGE = 7;
const MAX_AGE = 19;
const DEFAULT_AGE = 13;
/** سرور سالِ تولدِ شمسی می‌خواهد، نه سن. */
export const birthYearFromAge = (age: number) => currentJalaliYear() - age;
export function ageFromBirthYear(year: number | null | undefined): number | null {
if (!year) return null;
const age = currentJalaliYear() - year;
return age >= MIN_AGE && age <= MAX_AGE ? age : null;
}
/**
* انتخابِ سن با دو دکمه‌ی − و +. جای «چرخِ» قبلی نشست که عددِ وسطش با مقدارِ
* واقعی نمی‌خواند و کلیک روی عددهایش کار نمی‌کرد.
*/
export function AgePicker({ value, onChange }: { value: number | null; onChange: (age: number) => void }) {
const current = value ?? DEFAULT_AGE;
const step = (delta: number) => {
const next = Math.max(MIN_AGE, Math.min(MAX_AGE, current + delta));
if (next !== current || value === null) {
sounds.tick();
onChange(next);
}
};
return (
<div className="flex items-center justify-between rounded-2xl border-2 border-line bg-white p-2">
<button
type="button"
aria-label="کمتر"
onClick={() => step(-1)}
disabled={value !== null && current <= MIN_AGE}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
>
<Minus size={20} strokeWidth={3} />
</button>
<div className="text-center" aria-live="polite">
{value === null ? (
<button type="button" onClick={() => onChange(DEFAULT_AGE)} className="text-sm font-bold text-muted">
بزن تا انتخاب کنی
</button>
) : (
<>
<span className="text-3xl font-black text-coral">{toFa(value)}</span>
<span className="mr-1 text-sm font-bold text-muted">سال</span>
</>
)}
</div>
<button
type="button"
aria-label="بیشتر"
onClick={() => step(1)}
disabled={value !== null && current >= MAX_AGE}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
>
<Plus size={20} strokeWidth={3} />
</button>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
/**
* قابِ اپ. روی گوشی تمام‌صفحه است؛ روی صفحه‌ی بزرگ (دسکتاپ) وسطِ صفحه با
* عرضِ گوشی نشان داده می‌شود تا چیدمانِ موبایلی به هم نریزد.
*
* قبلاً یک «قابِ گوشی» ثابتِ ۳۹۰×۸۴۴ با ساعتِ ساختگی بود که روی گوشیِ واقعی
* داخلِ صفحه یک گوشیِ نقاشی‌شده نشان می‌داد.
*/
export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="flex h-[100dvh] w-full items-stretch justify-center sm:items-center sm:py-6">
<div
dir="rtl"
className="relative flex h-full w-full max-w-[440px] flex-col overflow-hidden bg-page sm:h-[min(880px,100%)] sm:rounded-[36px] sm:shadow-2xl sm:ring-8 sm:ring-[#22223a]"
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Brain, Crown, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
import type { TabName } from '@/nav/routes';
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
{ id: 'home', icon: Home, label: 'خانه' },
{ id: 'map', icon: Map, label: 'نقشه' },
{ id: 'ask', icon: Brain, label: 'دستیار' },
{ id: 'subscription', icon: Crown, label: 'اشتراک' },
{ id: 'profile', icon: UserRound, label: 'من' },
];
export function BottomNav({ active, onChange }: { active: TabName | null; onChange: (tab: TabName) => void }) {
return (
<nav className="flex flex-shrink-0 items-center justify-around border-t border-line bg-white px-2 pb-safe pt-1.5">
{ITEMS.map(({ id, icon: Icon, label }) => {
const on = active === id;
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-current={on ? 'page' : undefined}
className={`flex flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
>
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
</span>
<span className="text-[10px] font-bold">{label}</span>
</button>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,18 @@
import {
BookOpen, Brain, Compass, Grid3x3, LineChart, Play, Repeat, Sigma, Sparkles, TrendingUp,
type LucideIcon,
} from 'lucide-react';
/**
* آیکنِ جزیره. سرور نامِ آیکنِ lucide را می‌دهد (مثلاً "Grid3x3")؛ فقط این
* مجموعه‌ی کوچک در باندل است تا کلِ کتابخانه‌ی آیکن بار نشود.
*/
const ICONS: Record<string, LucideIcon> = {
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play,
};
const FALLBACK: LucideIcon[] = [Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain];
export function iconFor(name: string | undefined, index = 0): LucideIcon {
return (name && ICONS[name]) || FALLBACK[index % FALLBACK.length];
}

60
src/components/Sheet.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { useEffect, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { X } from 'lucide-react';
/** برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه یا Escape بسته می‌شود. */
export function Sheet({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title?: ReactNode;
children: ReactNode;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<div className="absolute inset-0 z-50 flex flex-col justify-end">
<motion.div
className="absolute inset-0 bg-[#14142a]/45"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
role="dialog"
aria-modal="true"
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3"
style={{ paddingBottom: 'calc(2rem + env(safe-area-inset-bottom))' }}
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
>
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-soft" />
{title && (
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-base font-black text-ink">{title}</h2>
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
<X size={16} />
</button>
</div>
)}
{children}
</motion.div>
</div>
)}
</AnimatePresence>
);
}

45
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
type Tone = 'info' | 'success' | 'error';
interface ToastItem { id: number; text: string; tone: Tone }
const ToastContext = createContext<(text: string, tone?: Tone) => void>(() => {});
/** پیامِ کوتاهِ بالای صفحه که خودش بعد از چند ثانیه می‌رود. */
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const nextId = useRef(1);
const show = useCallback((text: string, tone: Tone = 'info') => {
const id = nextId.current++;
setItems((list) => [...list.slice(-2), { id, text, tone }]);
setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), 3500);
}, []);
return (
<ToastContext.Provider value={show}>
{children}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[60] flex flex-col items-center gap-2 px-4 pt-safe">
<AnimatePresence>
{items.map((t) => (
<motion.div
key={t.id}
role="status"
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className={`mt-3 max-w-full rounded-2xl px-4 py-2.5 text-center text-xs font-bold leading-6 shadow-lg ${
t.tone === 'error' ? 'bg-red-500 text-white' : t.tone === 'success' ? 'bg-mint text-white' : 'bg-ink text-white'
}`}
>
{t.text}
</motion.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}
export const useToast = () => useContext(ToastContext);

View File

@@ -0,0 +1,34 @@
/** مسیرِ منحنیِ بینِ خانه‌های نقشه (مختصاتِ x به درصد، y به پیکسل). */
export interface Point { x: number; y: number }
export function zigzagPositions(count: number, spacingY: number, startY = 80): Point[] {
const xs = [50, 74, 46, 26, 54, 76, 48, 28];
return Array.from({ length: count }, (_, i) => ({ x: xs[i % xs.length], y: startY + i * spacingY }));
}
export function ZigzagPath({
points,
height,
colorOf,
}: {
points: Point[];
height: number;
/** رنگِ تکه‌ی i (از نقطه‌ی i به i+1)؛ null یعنی خاکستری. */
colorOf: (i: number) => string | null;
}) {
const segments = points.slice(0, -1).map((p1, i) => {
const p2 = points[i + 1];
const midY = (p1.y + p2.y) / 2;
return { d: `M ${p1.x} ${p1.y} C ${p1.x} ${midY}, ${p2.x} ${midY}, ${p2.x} ${p2.y}`, color: colorOf(i) };
});
return (
<svg className="pointer-events-none absolute inset-0" width="100%" height={height} viewBox={`0 0 100 ${height}`} preserveAspectRatio="none" aria-hidden>
{segments.map((s, i) => (
<path key={`s${i}`} d={s.d} fill="none" stroke="#d7d5e8" strokeWidth="10" strokeLinecap="round" opacity="0.4" vectorEffect="non-scaling-stroke" />
))}
{segments.map((s, i) => (
<path key={`p${i}`} d={s.d} fill="none" stroke={s.color ?? '#dedced'} strokeWidth="7" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
))}
</svg>
);
}

198
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,198 @@
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
import { ChevronRight, RotateCw } from 'lucide-react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
import { friendlyError } from '@/lib/errors';
import { useNav } from '@/nav/Navigator';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
export function ScreenHeader({
title,
subtitle,
color,
back = false,
mascot,
children,
}: {
title: ReactNode;
subtitle?: ReactNode;
color: string;
/** دکمه‌ی بازگشت؛ true یعنی صفحه‌ی قبل در پشته. */
back?: boolean | (() => void);
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
children?: ReactNode;
}) {
const nav = useNav();
const onBack = typeof back === 'function' ? back : nav.back;
return (
<header
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
style={{ backgroundColor: color }}
>
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
<div className="relative z-10 pt-6">
{back && (
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
<ChevronRight size={18} strokeWidth={2.5} />
</IconButton>
)}
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-xl font-black leading-snug">{title}</h1>
{subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>}
</div>
{mascot && <SeyarMascot char={mascot.char} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
</div>
{children}
</div>
</header>
);
}
/* ─── دکمه‌ها ──────────────────────────────────────────────────────────────── */
export function IconButton({
label,
className = '',
children,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return (
<button
type="button"
aria-label={label}
title={label}
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full transition-transform active:scale-90 ${className}`}
{...rest}
>
{children}
</button>
);
}
export function PrimaryButton({
color = '#ED6956',
loading = false,
className = '',
style,
children,
disabled,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { color?: string; loading?: boolean }) {
return (
<button
type="button"
disabled={disabled || loading}
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 ${className}`}
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }}
{...rest}
>
{loading ? <Spinner /> : children}
</button>
);
}
export function SecondaryButton({ className = '', children, ...rest }: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:scale-[0.98] disabled:opacity-50 ${className}`}
{...rest}
>
{children}
</button>
);
}
export function Spinner({ size = 20, className = '' }: { size?: number; className?: string }) {
return (
<span
role="status"
aria-label="در حالِ بارگذاری"
className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
style={{ width: size, height: size }}
/>
);
}
/* ─── حالت‌های بارگذاری / خطا / خالی ───────────────────────────────────────── */
function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center">
<SeyarMascot char={char} mood={mood} size={72} />
<p className="text-sm leading-7 text-muted">{text}</p>
{children}
</div>
);
}
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar }) {
return <StateBox char={char} mood="idle" text={text} />;
}
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
return (
<StateBox char="pi" mood="sad" text={text ?? friendlyError(error)}>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="flex items-center gap-1.5 rounded-full bg-white px-4 py-2 text-xs font-bold text-coral shadow-sm ring-1 ring-line"
>
<RotateCw size={13} /> دوباره امتحان کن
</button>
)}
</StateBox>
);
}
export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; char?: SeyarChar }) {
return <StateBox char={char} mood="idle" text={text} />;
}
export function InlineError({ children }: { children: ReactNode }) {
return (
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs leading-6 text-red-600">
{children}
</div>
);
}
/* ─── چیزهای کوچک ──────────────────────────────────────────────────────────── */
export function ProgressBar({ percent, color, track = '#eae7ff', height = 8 }: { percent: number; color: string; track?: string; height?: number }) {
return (
<div className="w-full overflow-hidden rounded-full" style={{ height, backgroundColor: track }}>
<div
className="h-full rounded-full transition-[width] duration-700 ease-out"
style={{ width: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: color }}
/>
</div>
);
}
export function Card({ className = '', style, children, onClick }: { className?: string; style?: CSSProperties; children: ReactNode; onClick?: () => void }) {
const Tag = onClick ? 'button' : 'div';
return (
<Tag
type={onClick ? 'button' : undefined}
onClick={onClick}
style={style}
className={`rounded-3xl border border-line bg-white p-4 text-right shadow-sm ${onClick ? 'transition-transform active:scale-[0.98]' : ''} ${className}`}
>
{children}
</Tag>
);
}
/** بدنه‌ی اسکرول‌شونده‌ی صفحه، زیرِ سربرگ. */
export function ScreenBody({ className = '', children }: { className?: string; children: ReactNode }) {
return <div className={`flex-1 overflow-y-auto px-4 py-5 ${className}`}>{children}</div>;
}
export function Screen({ className = 'bg-page', children }: { className?: string; children: ReactNode }) {
return <div className={`flex h-full flex-col ${className}`}>{children}</div>;
}