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

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>;
}