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:
198
src/components/ui.tsx
Normal file
198
src/components/ui.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user