forked from Tadrisino/MathApp_frontend
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:
66
src/components/AgePicker.tsx
Normal file
66
src/components/AgePicker.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { currentJalaliYear, toFa } from '@/lib/format';
|
||||
import { sounds } from '@/lib/sound';
|
||||
|
||||
const MIN_AGE = 7;
|
||||
const MAX_AGE = 19;
|
||||
const DEFAULT_AGE = 13;
|
||||
|
||||
/** سرور سالِ تولدِ شمسی میخواهد، نه سن. */
|
||||
export const birthYearFromAge = (age: number) => currentJalaliYear() - age;
|
||||
|
||||
export function ageFromBirthYear(year: number | null | undefined): number | null {
|
||||
if (!year) return null;
|
||||
const age = currentJalaliYear() - year;
|
||||
return age >= MIN_AGE && age <= MAX_AGE ? age : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابِ سن با دو دکمهی − و +. جای «چرخِ» قبلی نشست که عددِ وسطش با مقدارِ
|
||||
* واقعی نمیخواند و کلیک روی عددهایش کار نمیکرد.
|
||||
*/
|
||||
export function AgePicker({ value, onChange }: { value: number | null; onChange: (age: number) => void }) {
|
||||
const current = value ?? DEFAULT_AGE;
|
||||
const step = (delta: number) => {
|
||||
const next = Math.max(MIN_AGE, Math.min(MAX_AGE, current + delta));
|
||||
if (next !== current || value === null) {
|
||||
sounds.tick();
|
||||
onChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-2xl border-2 border-line bg-white p-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="کمتر"
|
||||
onClick={() => step(-1)}
|
||||
disabled={value !== null && current <= MIN_AGE}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
|
||||
>
|
||||
<Minus size={20} strokeWidth={3} />
|
||||
</button>
|
||||
<div className="text-center" aria-live="polite">
|
||||
{value === null ? (
|
||||
<button type="button" onClick={() => onChange(DEFAULT_AGE)} className="text-sm font-bold text-muted">
|
||||
بزن تا انتخاب کنی
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-black text-coral">{toFa(value)}</span>
|
||||
<span className="mr-1 text-sm font-bold text-muted">سال</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بیشتر"
|
||||
onClick={() => step(1)}
|
||||
disabled={value !== null && current >= MAX_AGE}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
|
||||
>
|
||||
<Plus size={20} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/AppShell.tsx
Normal file
21
src/components/AppShell.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* قابِ اپ. روی گوشی تمامصفحه است؛ روی صفحهی بزرگ (دسکتاپ) وسطِ صفحه با
|
||||
* عرضِ گوشی نشان داده میشود تا چیدمانِ موبایلی به هم نریزد.
|
||||
*
|
||||
* قبلاً یک «قابِ گوشی» ثابتِ ۳۹۰×۸۴۴ با ساعتِ ساختگی بود که روی گوشیِ واقعی
|
||||
* داخلِ صفحه یک گوشیِ نقاشیشده نشان میداد.
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-[100dvh] w-full items-stretch justify-center sm:items-center sm:py-6">
|
||||
<div
|
||||
dir="rtl"
|
||||
className="relative flex h-full w-full max-w-[440px] flex-col overflow-hidden bg-page sm:h-[min(880px,100%)] sm:rounded-[36px] sm:shadow-2xl sm:ring-8 sm:ring-[#22223a]"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/BottomNav.tsx
Normal file
34
src/components/BottomNav.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Brain, Crown, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import type { TabName } from '@/nav/routes';
|
||||
|
||||
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
||||
{ id: 'home', icon: Home, label: 'خانه' },
|
||||
{ id: 'map', icon: Map, label: 'نقشه' },
|
||||
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
||||
{ id: 'subscription', icon: Crown, label: 'اشتراک' },
|
||||
{ id: 'profile', icon: UserRound, label: 'من' },
|
||||
];
|
||||
|
||||
export function BottomNav({ active, onChange }: { active: TabName | null; onChange: (tab: TabName) => void }) {
|
||||
return (
|
||||
<nav className="flex flex-shrink-0 items-center justify-around border-t border-line bg-white px-2 pb-safe pt-1.5">
|
||||
{ITEMS.map(({ id, icon: Icon, label }) => {
|
||||
const on = active === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-current={on ? 'page' : undefined}
|
||||
className={`flex flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
|
||||
>
|
||||
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
|
||||
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
|
||||
</span>
|
||||
<span className="text-[10px] font-bold">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
18
src/components/NodeIcon.tsx
Normal file
18
src/components/NodeIcon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
BookOpen, Brain, Compass, Grid3x3, LineChart, Play, Repeat, Sigma, Sparkles, TrendingUp,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* آیکنِ جزیره. سرور نامِ آیکنِ lucide را میدهد (مثلاً "Grid3x3")؛ فقط این
|
||||
* مجموعهی کوچک در باندل است تا کلِ کتابخانهی آیکن بار نشود.
|
||||
*/
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play,
|
||||
};
|
||||
|
||||
const FALLBACK: LucideIcon[] = [Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain];
|
||||
|
||||
export function iconFor(name: string | undefined, index = 0): LucideIcon {
|
||||
return (name && ICONS[name]) || FALLBACK[index % FALLBACK.length];
|
||||
}
|
||||
60
src/components/Sheet.tsx
Normal file
60
src/components/Sheet.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
/** برگهای که از پایین بالا میآید؛ با زدنِ پسزمینه یا Escape بسته میشود. */
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col justify-end">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[#14142a]/45"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3"
|
||||
style={{ paddingBottom: 'calc(2rem + env(safe-area-inset-bottom))' }}
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
|
||||
>
|
||||
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-soft" />
|
||||
{title && (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-black text-ink">{title}</h2>
|
||||
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
45
src/components/Toast.tsx
Normal file
45
src/components/Toast.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
type Tone = 'info' | 'success' | 'error';
|
||||
interface ToastItem { id: number; text: string; tone: Tone }
|
||||
|
||||
const ToastContext = createContext<(text: string, tone?: Tone) => void>(() => {});
|
||||
|
||||
/** پیامِ کوتاهِ بالای صفحه که خودش بعد از چند ثانیه میرود. */
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
const nextId = useRef(1);
|
||||
|
||||
const show = useCallback((text: string, tone: Tone = 'info') => {
|
||||
const id = nextId.current++;
|
||||
setItems((list) => [...list.slice(-2), { id, text, tone }]);
|
||||
setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), 3500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={show}>
|
||||
{children}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-[60] flex flex-col items-center gap-2 px-4 pt-safe">
|
||||
<AnimatePresence>
|
||||
{items.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
role="status"
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
className={`mt-3 max-w-full rounded-2xl px-4 py-2.5 text-center text-xs font-bold leading-6 shadow-lg ${
|
||||
t.tone === 'error' ? 'bg-red-500 text-white' : t.tone === 'success' ? 'bg-mint text-white' : 'bg-ink text-white'
|
||||
}`}
|
||||
>
|
||||
{t.text}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useToast = () => useContext(ToastContext);
|
||||
34
src/components/ZigzagPath.tsx
Normal file
34
src/components/ZigzagPath.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/** مسیرِ منحنیِ بینِ خانههای نقشه (مختصاتِ x به درصد، y به پیکسل). */
|
||||
export interface Point { x: number; y: number }
|
||||
|
||||
export function zigzagPositions(count: number, spacingY: number, startY = 80): Point[] {
|
||||
const xs = [50, 74, 46, 26, 54, 76, 48, 28];
|
||||
return Array.from({ length: count }, (_, i) => ({ x: xs[i % xs.length], y: startY + i * spacingY }));
|
||||
}
|
||||
|
||||
export function ZigzagPath({
|
||||
points,
|
||||
height,
|
||||
colorOf,
|
||||
}: {
|
||||
points: Point[];
|
||||
height: number;
|
||||
/** رنگِ تکهی i (از نقطهی i به i+1)؛ null یعنی خاکستری. */
|
||||
colorOf: (i: number) => string | null;
|
||||
}) {
|
||||
const segments = points.slice(0, -1).map((p1, i) => {
|
||||
const p2 = points[i + 1];
|
||||
const midY = (p1.y + p2.y) / 2;
|
||||
return { d: `M ${p1.x} ${p1.y} C ${p1.x} ${midY}, ${p2.x} ${midY}, ${p2.x} ${p2.y}`, color: colorOf(i) };
|
||||
});
|
||||
return (
|
||||
<svg className="pointer-events-none absolute inset-0" width="100%" height={height} viewBox={`0 0 100 ${height}`} preserveAspectRatio="none" aria-hidden>
|
||||
{segments.map((s, i) => (
|
||||
<path key={`s${i}`} d={s.d} fill="none" stroke="#d7d5e8" strokeWidth="10" strokeLinecap="round" opacity="0.4" vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{segments.map((s, i) => (
|
||||
<path key={`p${i}`} d={s.d} fill="none" stroke={s.color ?? '#dedced'} strokeWidth="7" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
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