Files
MathApp_frontend/src/components/ui.tsx
AliReza 355bae7035 play whole islands, make home the path itself, draw tutor diagrams, add tedi
- 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>
2026-09-28 17:45:57 +03:30

200 lines
7.7 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';
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>;
}