- Text: kasra/fatha/damma/shadda/sukun/hamza-above and «هی» ezafe removed from every user-visible string (comments untouched); tanwin kept. Stale copy fixed (question packs are no longer sold), awkward onboarding sentences rewritten, سؤال → سوال. plainPersian() in MathText cleans the live tutor stream. - Home: the mascot + speech bubble header is gone; the selected companion hops beside the current island and sits at every third bend (CSS transform animations only). - Checkout: order summary, payment-method rows with Zarinpal/Bazaar icons and radio, amount on the pay button, lock trust line, darker overlay. paymentMethods in config.js per channel (web: zarinpal, app: zarinpal+bazaar by default). - Profile: «ورود با حساب دیگر» removed (it was logout); new «حساب کاربری» sheet shows phone/ID and holds «حذف حساب کاربری»; logout is a red row. Delete sheet tells Bazaar subscribers to cancel renewal in Bazaar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
import { useEffect, useState, type ButtonHTMLAttributes, type CSSProperties, type 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 { onWhite, shade } from '@/lib/color';
|
||
import { useCompanion } from '@/state/companion';
|
||
|
||
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
|
||
|
||
export function ScreenHeader({
|
||
title,
|
||
subtitle,
|
||
color,
|
||
back = false,
|
||
mascot,
|
||
exact = false,
|
||
children,
|
||
}: {
|
||
title: ReactNode;
|
||
subtitle?: ReactNode;
|
||
color: string;
|
||
/** دکمهی بازگشت؛ true یعنی صفحهی قبل در پشته. */
|
||
back?: boolean | (() => void);
|
||
/** بی `char` همراهِ انتخابیِ کاربر است؛ همهی صفحهها همین را میخواهند. */
|
||
mascot?: { char?: SeyarChar; mood?: SeyarMood; size?: number };
|
||
/** رنگ را همانطور که هست بگذار (بیتیرهکردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */
|
||
exact?: boolean;
|
||
children?: ReactNode;
|
||
}) {
|
||
const nav = useNav();
|
||
const companion = useCompanion().char;
|
||
const onBack = typeof back === 'function' ? back : nav.back;
|
||
const bg = exact ? color : onWhite(color);
|
||
return (
|
||
<header
|
||
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
|
||
style={{ backgroundColor: bg, backgroundImage: `linear-gradient(160deg, ${shade(bg, 0.12)} 0%, ${bg} 55%, ${shade(bg, -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" />
|
||
{/* نمادهای ریاضی که آرام شناورند؛ فقط transform/opacity و بیتصویر، پس ارزاناند. */}
|
||
<span aria-hidden className="glyph pointer-events-none absolute left-[42%] top-5 text-3xl font-black text-white/15" style={{ animationDelay: '0s' }}>π</span>
|
||
<span aria-hidden className="glyph pointer-events-none absolute right-[34%] bottom-3 text-2xl font-black text-white/15" style={{ animationDelay: '-2s' }}>∑</span>
|
||
<span aria-hidden className="glyph pointer-events-none absolute left-[26%] bottom-2 text-2xl font-black text-white/15" style={{ animationDelay: '-4s' }}>√</span>
|
||
<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 ?? companion} 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 }) {
|
||
const companion = useCompanion().char;
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center">
|
||
<SeyarMascot char={char ?? companion} mood={mood} size={72} />
|
||
<p className="text-sm leading-7 text-muted">{text}</p>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* لودر فقط بعد از `delayMs` دیده میشود. پاسخِ API معمولاً زیرِ ۳۰۰ms میرسد و نشان دادنِ لودر برای
|
||
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» میساخت؛ حالا تا آن لحظه جای خالی میماند. اگر پاسخ
|
||
* کند باشد کاربر باز هم لودر را میبیند. در مرورگر با شبکهی کند آزموده نشده.
|
||
*/
|
||
export function LoadingState({ text = 'یه لحظه صبر کن…', char, delayMs = 300 }: { text?: string; char?: SeyarChar; delayMs?: number }) {
|
||
const [show, setShow] = useState(delayMs <= 0);
|
||
useEffect(() => {
|
||
if (delayMs <= 0) return;
|
||
const t = setTimeout(() => setShow(true), delayMs);
|
||
return () => clearTimeout(t);
|
||
}, [delayMs]);
|
||
return show ? <StateBox char={char} mood="idle" text={text} /> : null;
|
||
}
|
||
|
||
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
|
||
return (
|
||
<StateBox 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 }: { text: string; char?: SeyarChar }) {
|
||
return <StateBox char={char} mood="idle" text={text} />;
|
||
}
|
||
|
||
/**
|
||
* جداکنندهی میانِ دو تکهی متن. «·» کنارِ صفرِ فارسی (که خودش یک نقطه است)
|
||
* خوانده نمیشد: «فصلِ ۱ · ۰ از ۴» شبیهِ «۱۰۰» دیده میشد.
|
||
*/
|
||
export function Sep() {
|
||
return <span aria-hidden className="mx-1.5 inline-block h-2.5 w-px bg-current align-middle opacity-40" />;
|
||
}
|
||
|
||
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>;
|
||
}
|