forked from Tadrisino/MathApp_frontend
- Home is now the island path of every unit with one clear Start button on the
current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
7.7 KiB
TypeScript
200 lines
7.7 KiB
TypeScript
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';
|
||
import { shade } from '@/lib/color';
|
||
|
||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||
|
||
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, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }}
|
||
>
|
||
<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 && (typeof back === 'function' || nav.canGoBack) && (
|
||
<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,box-shadow] active:translate-y-[3px] active:shadow-none disabled:opacity-50 disabled:active:translate-y-0 ${className}`}
|
||
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.22)}`, ...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-b-4 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:translate-y-[2px] active:border-b-2 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>;
|
||
}
|