Files
MathApp_frontend/src/components/ui.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

199 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 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>;
}