No diacritics or written ezafe ی in the UI; map without the speech banner, companions cheering along the path; familiar payment sheet with gateway logos and per-channel methods; account deletion moved under «حساب کاربری»
- 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>
This commit is contained in:
@@ -15,7 +15,7 @@ import { toFa } from '@/lib/format';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
import { iAm, useCompanion } from '@/state/companion';
|
||||
|
||||
const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||||
const STARTERS = ['این فصل درباره چیه؟', 'یه مثال ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||||
|
||||
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایهی کاربر جواب میدهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
|
||||
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
|
||||
@@ -139,7 +139,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title={`از ${companion.name} بپرس`}
|
||||
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب میدم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
|
||||
subtitle={chat.lessonId ? 'از روی کتاب همین درس جواب میدم' : 'دستیار ریاضی، از روی کتاب خودت'}
|
||||
color={companion.color}
|
||||
back={lessonId !== undefined || prompt !== undefined}
|
||||
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
|
||||
@@ -161,7 +161,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
|
||||
<Bubble role="assistant">
|
||||
سلام {profile.name || 'دوستِ من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدمبهقدم با هم حلش میکنیم.
|
||||
سلام {profile.name || 'دوست من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضی کتابت داری بپرس؛ قدمبهقدم با هم حلش میکنیم.
|
||||
</Bubble>
|
||||
|
||||
{empty && chat.historyLoaded && (
|
||||
@@ -252,7 +252,7 @@ const THINKING_STEPS: [number, string][] = [
|
||||
[0, '{name} داره تو کتابت میگرده'],
|
||||
[4, '{name} داره حلش میکنه'],
|
||||
[15, 'داره قدمها رو مرتب مینویسه'],
|
||||
[28, 'جوابِ کامل یه کم طول میکشه، الان میرسه'],
|
||||
[28, 'جواب کامل یه کم طول میکشه، الان میرسه'],
|
||||
];
|
||||
|
||||
function Thinking() {
|
||||
@@ -363,11 +363,11 @@ function QuotaPills({ free }: { free: number }) {
|
||||
return (
|
||||
<>
|
||||
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
|
||||
{free > 0 ? <>{toFa(free)} سوالِ امروز</> : 'سوالهای امروز تموم شد'}
|
||||
{free > 0 ? <>{toFa(free)} سوال امروز</> : 'سوالهای امروز تموم شد'}
|
||||
</span>
|
||||
{free === 0 && (
|
||||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
|
||||
سوالِ بیشتر با اشتراک
|
||||
سوال بیشتر با اشتراک
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CharactersScreen() {
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="همراهت رو انتخاب کن"
|
||||
subtitle={<>امتیازِ تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیازِ بیشتر همراههای بیشتری باز میشن</>}
|
||||
subtitle={<>امتیاز تو: {toFa(summary?.xp ?? 0)}<Sep />با امتیاز بیشتر همراههای بیشتری باز میشن</>}
|
||||
color="#9B7BFF"
|
||||
back
|
||||
/>
|
||||
@@ -94,7 +94,7 @@ export function CharactersScreen() {
|
||||
</span>
|
||||
)}
|
||||
<span className="text-center text-[10px] leading-5 text-muted">
|
||||
{!c.unlocked ? `با ${toFa(c.unlockXp)} امتیاز باز میشه` : c.selected ? 'همراهِ فعلی' : 'بزن تا همراهت بشه'}
|
||||
{!c.unlocked ? `با ${toFa(c.unlockXp)} امتیاز باز میشه` : c.selected ? 'همراه فعلی' : 'بزن تا همراهت بشه'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, Star } from 'lucide-react';
|
||||
import { Bell, ChevronLeft, Coins, Crown, Flame, Star } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { IslandPath } from '@/components/IslandPath';
|
||||
import { IslandSheet } from '@/components/IslandSheet';
|
||||
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
|
||||
import { onWhite, shade } from '@/lib/color';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||
@@ -22,7 +20,7 @@ import { SubjectBar } from '@/components/SubjectBar';
|
||||
* دستیار، فصلها، چالش) و بچه نمیدانست کدام را بزند؛ حالا یک کارِ واضح دارد.
|
||||
*/
|
||||
export function HomeScreen() {
|
||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||
const { gradeTitle, summary, home, homeError, unreadCount, refreshHome } = useAppData();
|
||||
const nav = useNav();
|
||||
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||||
const paywall = () => nav.push({ name: 'subscription' });
|
||||
@@ -39,37 +37,18 @@ export function HomeScreen() {
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [home]);
|
||||
|
||||
const name = profile.name || 'دوستِ من';
|
||||
const flame = flameState(summary?.streak);
|
||||
const streak = summary?.streak;
|
||||
// زنجیرهی در خطر یا یخزده جلوتر از پیامِ معمول میآید: از دست دادن انگیزهی قویتری از بهدست آوردن است،
|
||||
// ولی لحن مهربان میماند و قدمِ بعدی را میگوید.
|
||||
const streakNote = cont && streak
|
||||
? flame === 'frozen'
|
||||
? <>{name}، زنجیرهی {toFa(streak.lost)} روزهت یخ زد <StreakFlame state="frozen" size={14} className="mb-0.5 inline" /> یه جزیره بزن تا دوباره آتیشش روشن بشه!</>
|
||||
: flame === 'risk'
|
||||
? <>{name}، امروز هنوز تمرین نکردی! زنجیرهی {toFa(streak.current)} روزهت منتظرته <StreakFlame state="lit" size={14} className="mb-0.5 inline" /></>
|
||||
: null
|
||||
: null;
|
||||
const bubble = !home
|
||||
? 'دارم مسیرت رو میارم…'
|
||||
: !home.units.length
|
||||
? 'هنوز درسی برای این پایه ثبت نشده.'
|
||||
: streakNote
|
||||
? streakNote
|
||||
: cont
|
||||
? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیرهی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمهی <b>شروع</b> رو بزن.</>
|
||||
: <>آفرین {name}! همهی جزیرههای این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
|
||||
|
||||
const stats = [
|
||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
||||
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روز پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
|
||||
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
|
||||
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
|
||||
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-3 pt-safe text-white shadow-md shadow-coral/20">
|
||||
<div className="flex items-center gap-2 pt-4">
|
||||
<BrandMark size={26} withName={false} />
|
||||
{gradeTitle && (
|
||||
@@ -101,12 +80,6 @@ export function HomeScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
|
||||
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
|
||||
{bubble}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<SubjectBar />
|
||||
@@ -132,7 +105,7 @@ export function HomeScreen() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-bold text-white/80">
|
||||
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||
فصل {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
|
||||
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function InviteScreen() {
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-2 rounded-3xl border border-line bg-white p-5 text-center">
|
||||
<Gift size={28} className="text-mint" />
|
||||
<span className="text-xs text-muted">کدِ معرفیِ تو</span>
|
||||
<span className="text-xs text-muted">کد معرفی تو</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard?.writeText(me.data!.referralCode).then(() => toast('کد کپی شد', 'success'))}
|
||||
@@ -71,11 +71,11 @@ export function InviteScreen() {
|
||||
|
||||
<p className="flex items-start gap-2 rounded-2xl bg-[#EAF8F0] p-3.5 text-xs leading-6 text-body">
|
||||
<Users size={16} className="mt-0.5 flex-shrink-0 text-mint" />
|
||||
دوستت باید با لینکِ تو وارد بشه و شمارهش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانتِ توئه.
|
||||
دوستت باید با لینک تو وارد بشه و شمارهش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانت توئه.
|
||||
</p>
|
||||
|
||||
<PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}>
|
||||
<Share2 size={17} /> فرستادنِ لینکِ دعوت
|
||||
<Share2 size={17} /> فرستادن لینک دعوت
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -15,13 +15,13 @@ import { formatJalaliDate, formatToman } from '@/lib/format';
|
||||
*/
|
||||
const STATUS: Record<Invoice['status'], { label: string; className: string }> = {
|
||||
paid: { label: 'پرداختشده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' },
|
||||
pending: { label: 'در انتظارِ پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
|
||||
pending: { label: 'در انتظار پرداخت', className: 'bg-[#FFF8E1] text-[#8A6100]' },
|
||||
failed: { label: 'ناموفق', className: 'bg-soft text-muted' },
|
||||
canceled: { label: 'لغوشده', className: 'bg-soft text-muted' },
|
||||
refunded: { label: 'بازپرداختشده', className: 'bg-[#EEF1FF] text-indigo' },
|
||||
};
|
||||
|
||||
const PROVIDER: Record<string, string> = { gateway: 'درگاهِ زرینپال', bazaar: 'کافهبازار' };
|
||||
const PROVIDER: Record<string, string> = { gateway: 'درگاه زرینپال', bazaar: 'کافهبازار' };
|
||||
|
||||
const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function InvoicesScreen() {
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="پرداختها و فاکتورها" subtitle="همهی خریدهات، با شمارهی فاکتور و کدِ پیگیری" color="#4C5FD5" back />
|
||||
<ScreenHeader title="پرداختها و فاکتورها" subtitle="همه خریدهات، با شماره فاکتور و کد پیگیری" color="#4C5FD5" back />
|
||||
<ScreenBody className="flex flex-col gap-3">
|
||||
{list.loading ? (
|
||||
<LoadingState />
|
||||
@@ -42,7 +42,7 @@ export function InvoicesScreen() {
|
||||
<div className="flex flex-col items-center gap-4 rounded-3xl border border-line bg-white px-5 py-8 text-center">
|
||||
<Receipt size={32} className="text-faint" />
|
||||
<p className="text-sm text-body">هنوز خریدی نکردی.</p>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراکها</PrimaryButton>
|
||||
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدن اشتراکها</PrimaryButton>
|
||||
</div>
|
||||
) : (
|
||||
list.data.map((invoice) => (
|
||||
@@ -88,17 +88,17 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
|
||||
const rows: [string, React.ReactNode][] = invoice
|
||||
? [
|
||||
['شمارهی فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
|
||||
['شماره فاکتور', <span dir="ltr">{invoice.invoiceNumber}</span>],
|
||||
['تاریخ', dateOf(invoice)],
|
||||
['وضعیت', STATUS[invoice.status].label],
|
||||
['اشتراک', invoice.planTitle || '—'],
|
||||
...(invoice.listPrice !== null ? [['قیمت', formatToman(invoice.listPrice)] as [string, React.ReactNode]] : []),
|
||||
...(invoice.discountAmount ? [[
|
||||
invoice.discountCode ? `تخفیف (کدِ ${invoice.discountCode})` : 'تخفیف',
|
||||
invoice.discountCode ? `تخفیف (کد ${invoice.discountCode})` : 'تخفیف',
|
||||
formatToman(invoice.discountAmount),
|
||||
] as [string, React.ReactNode]] : []),
|
||||
['مبلغِ پرداختی', <b>{formatToman(invoice.amount)}</b>],
|
||||
['روشِ پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
|
||||
['مبلغ پرداختی', <b>{formatToman(invoice.amount)}</b>],
|
||||
['روش پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -122,7 +122,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
onClick={() => void copy(invoice.refId)}
|
||||
className="flex items-center justify-between rounded-2xl bg-soft px-4 py-3 text-sm"
|
||||
>
|
||||
<span className="text-muted">کدِ پیگیری</span>
|
||||
<span className="text-muted">کد پیگیری</span>
|
||||
<span className="flex items-center gap-2 font-bold text-ink" dir="ltr">
|
||||
{/* کد همانطور که کپی میشود: با رقمِ فارسی صفرها نقطه دیده میشدند. */}
|
||||
{invoice.refId} <Copy size={14} />
|
||||
@@ -131,7 +131,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
|
||||
) : (
|
||||
(invoice.status === 'pending' || invoice.status === 'failed') && (
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
اگه پولی از حسابت کم شده ولی این خرید «پرداختشده» نیست، شمارهی فاکتور رو به پشتیبانی بده.
|
||||
اگه پولی از حسابت کم شده ولی این خرید «پرداختشده» نیست، شماره فاکتور رو به پشتیبانی بده.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -22,9 +22,9 @@ export function LeaderboardScreen() {
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="جدولِ امتیازها" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }}>
|
||||
<ScreenHeader title="جدول امتیازها" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }}>
|
||||
<div className="mt-4 flex gap-1 rounded-2xl bg-white/15 p-1" role="tablist">
|
||||
{([['weekly', 'این هفته'], ['all_time', 'همهی زمانها']] as const).map(([p, label]) => (
|
||||
{([['weekly', 'این هفته'], ['all_time', 'همه زمانها']] as const).map(([p, label]) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
|
||||
@@ -92,11 +92,11 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
<FeedbackBar kind="lesson" id={String(lessonId)} />
|
||||
{data.questionCount === 0 ? (
|
||||
<div className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted">
|
||||
تمرینِ این درس بهزودی اضافه میشه
|
||||
تمرین این درس بهزودی اضافه میشه
|
||||
</div>
|
||||
) : (
|
||||
<PrimaryButton color={color} onClick={() => nav.replace({ name: 'quiz', lessonId, nodeId: ref?.node.id ?? nodeId })}>
|
||||
<Play size={18} fill="white" /> شروعِ تمرین ({toFa(data.questionCount)} سوال)
|
||||
<Play size={18} fill="white" /> شروع تمرین ({toFa(data.questionCount)} سوال)
|
||||
</PrimaryButton>
|
||||
)}
|
||||
<button
|
||||
@@ -104,7 +104,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
onClick={() => nav.push({ name: 'ask', lessonId })}
|
||||
className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]"
|
||||
>
|
||||
<Brain size={16} /> دربارهی این درس از {companionName} بپرس
|
||||
<Brain size={16} /> درباره این درس از {companionName} بپرس
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function LessonsScreen() {
|
||||
<Screen className="bg-page">
|
||||
<ScreenHeader
|
||||
title="درسها"
|
||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
|
||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمون جزیره رو بده</>}
|
||||
color="#2FB36B"
|
||||
exact
|
||||
mascot={{ mood: 'happy' }}
|
||||
@@ -198,7 +198,7 @@ function NodeRow({
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span>
|
||||
<span className="block text-[11px] text-muted">
|
||||
{locked ? 'جزیرهی قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||||
{locked ? 'جزیره قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
|
||||
</span>
|
||||
</span>
|
||||
{isCurrent && !open && (
|
||||
|
||||
@@ -61,7 +61,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
setDevOtp(data.devOtp ?? null);
|
||||
} catch (err) {
|
||||
if (mine !== attempt.current) return;
|
||||
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
|
||||
setError(friendlyError(err, 'ارسال کد انجام نشد، دوباره امتحان کن'));
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
|
||||
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
|
||||
@@ -109,8 +109,8 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
<h1 className="text-xl font-black">ورود به تدریسینو</h1>
|
||||
<p className="mt-1 text-sm text-white/75">
|
||||
{step === 'phone'
|
||||
? 'شمارهی موبایلت رو وارد کن'
|
||||
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامکشده رو وارد کن`}
|
||||
? 'شماره موبایلت رو وارد کن'
|
||||
: `کد ${toFa(OTP_LENGTH)} رقمی پیامکشده رو وارد کن`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/15 p-2">
|
||||
@@ -146,7 +146,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
) : (
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-bold text-ink">کدِ تأیید</span>
|
||||
<span className="mb-2 block text-sm font-bold text-ink">کد تأیید</span>
|
||||
<input
|
||||
ref={otpInput}
|
||||
value={otp}
|
||||
@@ -165,7 +165,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
)}
|
||||
{devOtp && (
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
|
||||
کد آزمایشی حالت توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between text-xs font-bold">
|
||||
@@ -173,7 +173,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
|
||||
</button>
|
||||
<button type="button" disabled={resendIn > 0 || sending || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
|
||||
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
|
||||
{resendIn > 0 ? `ارسال دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسال دوباره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,7 +187,7 @@ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel
|
||||
loading={loading === 'login'}
|
||||
disabled={step === 'phone' ? !phoneValid : !otpValid}
|
||||
>
|
||||
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
|
||||
{step === 'phone' ? 'دریافت کد تأیید' : 'ورود'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,27 +7,27 @@ import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
|
||||
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
|
||||
{
|
||||
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
|
||||
sub: 'از پایهی سوم تا دوازدهم',
|
||||
sub: 'از پایه سوم تا دوازدهم',
|
||||
title: 'به تدریسینو خوش اومدی!',
|
||||
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو میبریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.',
|
||||
desc: 'من سیگمام. با هم ریاضی کتاب درسیت رو جزیره به جزیره جلو میبریم؛ هر جزیره چند درس کوتاه داره و کلی تمرین.',
|
||||
},
|
||||
{
|
||||
char: 'delta', mood: 'excited', bg: '#FF7A3D',
|
||||
sub: 'هر روز یه کم',
|
||||
title: 'امتیاز بگیر، زنجیره بساز',
|
||||
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه میگیری، روزهای پیاپیت شمرده میشه و تو جدولِ امتیازها بالا میری.',
|
||||
desc: 'منم دلتا! با هر جواب درست امتیاز و سکه میگیری، روزهایی که پشت هم تمرین میکنی شمرده میشه و تو جدول امتیازها بالا میری.',
|
||||
},
|
||||
{
|
||||
char: 'pi', mood: 'happy', bg: '#9B7BFF',
|
||||
sub: 'هر جا گیر کردی',
|
||||
title: 'از پی بپرس',
|
||||
desc: 'من پیام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدمبهقدم توضیح میدم.',
|
||||
desc: 'من پیام. هر جا سوالی داشتی یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتاب درسیت قدمبهقدم توضیح میدم.',
|
||||
},
|
||||
{
|
||||
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
||||
sub: 'یکی از ما مالِ توئه',
|
||||
sub: 'یکی از ما مال توئه',
|
||||
title: 'همراهت رو انتخاب کن',
|
||||
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازه باز میشن. از صفحهی «من» هر کدوممون رو خواستی کنارت میذاری.',
|
||||
desc: 'منم امگا! هر چی امتیازت بیشتر بشه، همراههای تازهتری باز میشن. از صفحه «من» هر کدوممون رو بخوای انتخاب میکنی.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
|
||||
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
|
||||
FileText, Pencil, Sparkles, Star, Trash2, Trophy, UserRound, Volume2, VolumeX,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
@@ -20,7 +20,7 @@ import { useAsync } from '@/lib/useAsync';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
|
||||
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
|
||||
type Editing = 'name' | 'grade' | 'birth' | 'account' | 'logout' | 'delete' | null;
|
||||
|
||||
/** «من»: پروفایل، تنظیمات و میانبُر به بخشهای فرعی. */
|
||||
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
@@ -46,7 +46,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
{profile.name || 'بینام'} <Pencil size={14} className="text-white/70" />
|
||||
</button>
|
||||
<div className="mt-0.5 text-xs text-white/75">
|
||||
شناسهی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
|
||||
شناسه کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
|
||||
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
|
||||
@@ -74,27 +74,27 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
|
||||
|
||||
<Menu>
|
||||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row icon={GraduationCap} label="پایه تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||
<Row
|
||||
icon={CalendarDays}
|
||||
label="تاریخِ تولد"
|
||||
label="تاریخ تولد"
|
||||
value={formatBirthDate(profile.birthDate) || (age ? `${toFa(age)} سال` : 'وارد نشده')}
|
||||
onClick={() => setEditing('birth')}
|
||||
/>
|
||||
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
|
||||
<Row icon={Sparkles} label="همراه من" onClick={go({ name: 'characters' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<Row icon={BarChart3} label="گزارشِ والدین" onClick={go({ name: 'report' })} />
|
||||
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
|
||||
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||||
<Row icon={BarChart3} label="گزارش والدین" onClick={go({ name: 'report' })} />
|
||||
<Row icon={Flame} label="تقویم زنجیره" onClick={go({ name: 'streak' })} />
|
||||
<Row icon={Trophy} label="جدول امتیازها" onClick={go({ name: 'leaderboard' })} />
|
||||
<Row icon={Bell} label="اعلانها" onClick={go({ name: 'notifications' })} />
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
|
||||
<Row icon={FileText} label="پرداختها و فاکتورها" onClick={go({ name: 'invoices' })} />
|
||||
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
|
||||
<Row icon={LifeBuoy} label="پشتیبانی و گزارش مشکل" onClick={go({ name: 'support' })} />
|
||||
<Row
|
||||
icon={sound ? Volume2 : VolumeX}
|
||||
label="صداها"
|
||||
@@ -106,13 +106,12 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
/>
|
||||
</Menu>
|
||||
|
||||
{/* حذفِ حساب یک لایه پایینتر، در «حساب کاربری»، مثلِ اپهای بزرگ (دیجیکالا، اسنپ، دولینگو): پیدا میشود
|
||||
(Google Play و App Store «در دسترس و پیداشدنی» میخواهند) ولی با یک لمسِ اشتباه زیرِ انگشت نیست. */}
|
||||
<Menu>
|
||||
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} />
|
||||
<Row icon={UserRound} label="حساب کاربری" value={profile.phone ? toFa(profile.phone) : undefined} onClick={() => setEditing('account')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" danger onClick={() => setEditing('logout')} />
|
||||
</Menu>
|
||||
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
|
||||
<Trash2 size={14} /> حذفِ حساب
|
||||
</button>
|
||||
<BrandFooter />
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +119,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||||
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
||||
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
|
||||
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<AccountSheet open={editing === 'account'} onClose={() => setEditing(null)} onDelete={() => setEditing('delete')} />
|
||||
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
</Screen>
|
||||
);
|
||||
@@ -148,7 +148,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span>
|
||||
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراک تدریسینو</span>
|
||||
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
|
||||
</span>
|
||||
<span className="text-left">
|
||||
@@ -166,7 +166,7 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={days ?? total}
|
||||
aria-label="روزهای ماندهٔ اشتراک"
|
||||
aria-label="روزهای مانده اشتراک"
|
||||
>
|
||||
{percent > 0 && (
|
||||
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
|
||||
@@ -178,11 +178,11 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}>
|
||||
{s.active ? (
|
||||
<>
|
||||
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
|
||||
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
|
||||
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقت تمدیده'}</span>}
|
||||
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلب نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
|
||||
</>
|
||||
) : (
|
||||
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همهی فصلها و قلبِ نامحدود باز میشه.</span>
|
||||
<span>فصل اول هر پایه رایگانه؛ با اشتراک همه فصلها و قلب نامحدود باز میشه.</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -193,11 +193,11 @@ function Menu({ children }: { children: ReactNode }) {
|
||||
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
|
||||
}
|
||||
|
||||
function Row({ icon: Icon, label, value, onClick }: { icon: typeof Bell; label: string; value?: string; onClick: () => void }) {
|
||||
function Row({ icon: Icon, label, value, danger = false, onClick }: { icon: typeof Bell; label: string; value?: string; danger?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft">
|
||||
<Icon size={18} className="flex-shrink-0 text-indigo" />
|
||||
<span className="flex-1 text-sm font-bold text-ink">{label}</span>
|
||||
<Icon size={18} className={`flex-shrink-0 ${danger ? 'text-red-500' : 'text-indigo'}`} />
|
||||
<span className={`flex-1 text-sm font-bold ${danger ? 'text-red-500' : 'text-ink'}`}>{label}</span>
|
||||
{value && <span className="text-xs text-muted">{value}</span>}
|
||||
<ChevronLeft size={16} className="text-faint" />
|
||||
</button>
|
||||
@@ -262,8 +262,8 @@ function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="پایهی تحصیلی">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته میشه؛ با برگشتن، سرِ جاشه.</p>
|
||||
<Sheet open={open} onClose={onClose} title="پایه تحصیلی">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">پیشرفت هر پایه جدا نگه داشته میشه؛ با برگشتن، سر جاشه.</p>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{grades.map((g) => {
|
||||
const on = g.id === profile.gradeId;
|
||||
@@ -307,8 +307,8 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="تاریخِ تولد">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سالِ تولدت به شمسی.</p>
|
||||
<Sheet open={open} onClose={onClose} title="تاریخ تولد">
|
||||
<p className="mb-3 text-xs leading-6 text-muted">روز، ماه و سال تولدت به شمسی.</p>
|
||||
<div className="mb-5">
|
||||
<BirthDatePicker value={profile.birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
@@ -318,11 +318,11 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی
|
||||
* یک گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ
|
||||
* دیگری میشد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر میکرد.
|
||||
* خروج. «ورود با حسابِ دیگر» (۲۰۲۶-۱۰-۱۴) برداشته شد: همان خروج بود با متنِ دیگر. عمداً چند حسابِ واردشده روی یک
|
||||
* گوشی نگه داشته نمیشود: گوشیِ خانوادگی دستبهدست میشود و هر کس بیکد واردِ حسابِ دیگری میشد، و چند refresh
|
||||
* tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر میکرد. خروج و ورودِ دوباره با پیامک همان کار را میکند.
|
||||
*/
|
||||
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function logout() {
|
||||
@@ -337,22 +337,39 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||||
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
||||
</p>
|
||||
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد میگیری 💛</p>
|
||||
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سر جاشه.</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
||||
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
|
||||
</PrimaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** «حساب کاربری»: شماره و شناسه، و پایینش حذفِ حساب (جای استانداردش؛ نه دمِ دستِ صفحهی اصلی). */
|
||||
function AccountSheet({ open, onClose, onDelete }: { open: boolean; onClose: () => void; onDelete: () => void }) {
|
||||
const { profile } = useAppData();
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حساب کاربری">
|
||||
<div className="mb-6 divide-y divide-line overflow-hidden rounded-2xl border border-line">
|
||||
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
|
||||
<span className="font-bold text-body">شماره موبایل</span>
|
||||
<span dir="ltr" className="font-bold text-ink">{profile.phone ? toFa(profile.phone) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3.5 text-sm">
|
||||
<span className="font-bold text-body">شناسه کاربری</span>
|
||||
<span dir="ltr" className="font-bold tracking-wide text-ink">{profile.publicId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-2 text-xs leading-6 text-muted">با حذف حساب، همه پیشرفت و اطلاعاتت برای همیشه پاک میشه.</p>
|
||||
<button type="button" onClick={onDelete} className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 py-3 text-sm font-bold text-red-500 active:bg-red-50">
|
||||
<Trash2 size={16} /> حذف حساب کاربری
|
||||
</button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* حذفِ همیشگیِ حساب (POST /user/delete). شمارهدار با کدِ تازهی پیامکی تأیید میکند تا گوشیِ
|
||||
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
|
||||
@@ -393,28 +410,28 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
|
||||
<Sheet open={open} onClose={onClose} title="حذف همیشگی حساب">
|
||||
<p className="mb-3 text-sm leading-7 text-body">
|
||||
امتیاز، زنجیره، پیشرفتِ جزیرهها، گفتگوها با دستیار، سوالهای خریدهشده و قلبِ نامحدودت پاک میشن و برنمیگردن.
|
||||
سوابقِ پرداخت فقط برای حسابداری میمونه.
|
||||
امتیاز، زنجیره، پیشرفت جزیرهها، گفتگوها با دستیار و اشتراکت پاک میشن و برنمیگردن.
|
||||
سوابق پرداخت فقط برای حسابداری میمونه. اگه اشتراکت رو از کافهبازار خریدی، تمدید خودکارش رو از بخش «اشتراکها»ی بازار لغو کن.
|
||||
</p>
|
||||
{sent ? (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
|
||||
placeholder="کدِ پیامکشده"
|
||||
placeholder="کد پیامکشده"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400"
|
||||
/>
|
||||
) : (
|
||||
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شمارهی خودت پیامک میشه.</p>
|
||||
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره خودت پیامک میشه.</p>
|
||||
)}
|
||||
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
|
||||
{!sent ? (
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادن کد</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
color="#ef4444"
|
||||
@@ -422,7 +439,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
|
||||
disabled={value.length < 6}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
حذفِ همیشگی
|
||||
حذف همیشگی
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -257,7 +257,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
<p className="text-sm text-muted">دارم نتیجهت رو ثبت میکنم…</p>
|
||||
) : (
|
||||
<>
|
||||
<InlineError>نتیجهی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
|
||||
<InlineError>نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
|
||||
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
|
||||
<SecondaryButton onClick={nav.back}>بیخیال، برگرد</SecondaryButton>
|
||||
</>
|
||||
@@ -281,9 +281,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
|
||||
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
|
||||
const bubble = !answer
|
||||
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||
? selected === null ? 'جواب درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||
: answer.correct
|
||||
? combo >= 2 ? `${toFa(combo)} تا پشتِ هم درست! داری عالی پیش میری` : PRAISE[index % PRAISE.length]
|
||||
? combo >= 2 ? `${toFa(combo)} تا پشت هم درست! داری عالی پیش میری` : PRAISE[index % PRAISE.length]
|
||||
: outOfHearts
|
||||
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
||||
: NUDGE[index % NUDGE.length];
|
||||
@@ -343,7 +343,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
|
||||
<div className="rounded-3xl border border-line bg-white p-5 shadow-sm">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted">سوالِ {toFa(index + 1)}</span>
|
||||
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
|
||||
</div>
|
||||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2>
|
||||
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null}
|
||||
@@ -389,7 +389,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
</button>
|
||||
)}
|
||||
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
||||
{isLast ? 'پایانِ تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||||
{isLast ? 'پایان تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
</>
|
||||
) : (
|
||||
@@ -417,7 +417,7 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
||||
if (hearts?.isUnlimited) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral">
|
||||
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلبِ نامحدود
|
||||
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلب نامحدود
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -495,7 +495,7 @@ function Result({
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جواب درست از {toFa(total)} سوال</p>
|
||||
{/* نمرهی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهشهای ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
|
||||
{result.stars <= 1 && (
|
||||
<p className="mt-2 text-xs leading-6 text-body">
|
||||
@@ -517,7 +517,7 @@ function Result({
|
||||
</div>
|
||||
</div>}
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
|
||||
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
{nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
{nodeMode && nextNode && (
|
||||
<button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||
@@ -534,13 +534,13 @@ function Result({
|
||||
* تلاش و راه ستایش میشود و قدمِ بعدی گفته میشود، نه اینکه «اشکالی نداره» تنها بماند.
|
||||
*/
|
||||
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
|
||||
const NUDGE = ['نزدیک بودی! جوابِ درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد میگیریم.', 'این یکی سخت بود؛ جوابِ درست سبز شده.'];
|
||||
const NUDGE = ['نزدیک بودی! جواب درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد میگیریم.', 'این یکی سخت بود؛ جواب درست سبز شده.'];
|
||||
|
||||
/** «راهنمای مهربون»ِ پی: نکتهی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */
|
||||
const PI_TIPS = [
|
||||
'صورتِ سوال رو یه بار دیگه آروم بخون؛ کلمهی «نیست» یا «بیشترین» زیاد جا میمونه.',
|
||||
'علامتِ منفیها رو یه بار دیگه چک کن.',
|
||||
'اول داخلِ پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
|
||||
'صورت سوال رو یه بار دیگه آروم بخون؛ کلمه «نیست» یا «بیشترین» زیاد جا میمونه.',
|
||||
'علامت منفیها رو یه بار دیگه چک کن.',
|
||||
'اول داخل پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
|
||||
'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.',
|
||||
];
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export function ReportScreen() {
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="گزارشِ والدین"
|
||||
subtitle={`پیشرفتِ ${profile.name || 'دانشآموز'} در ۷ روزِ اخیر`}
|
||||
title="گزارش والدین"
|
||||
subtitle={`پیشرفت ${profile.name || 'دانشآموز'} در ۷ روز اخیر`}
|
||||
color={THIS_WEEK}
|
||||
back
|
||||
mascot={{ mood: 'happy', size: 56 }}
|
||||
@@ -69,43 +69,43 @@ function Report({ data, name }: { data: WeeklyReport; name: string }) {
|
||||
<Verdict now={now} before={before} name={name} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<Stat icon={Clock} label="زمانِ تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} />
|
||||
<Stat icon={Clock} label="زمان تمرین" value={`حدود ${toFa(now.minutes)} دقیقه`} delta={change(now.minutes, before.minutes, 'دقیقه')} />
|
||||
<Stat icon={CalendarCheck} label="روزهای فعال" value={`${toFa(now.activeDays)} از ۷ روز`} delta={change(now.activeDays, before.activeDays, 'روز')} />
|
||||
<Stat icon={Target} label="سوالِ حلشده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} />
|
||||
<Stat icon={Target} label="سوال حلشده" value={toFa(now.questions)} delta={change(now.questions, before.questions, '')} />
|
||||
<Stat
|
||||
icon={CheckCircle2}
|
||||
label="درصدِ جوابِ درست"
|
||||
label="درصد جواب درست"
|
||||
value={now.questions ? `${toFa(percent(now.correct, now.questions))}٪` : '—'}
|
||||
delta={now.questions && before.questions ? change(percent(now.correct, now.questions), percent(before.correct, before.questions), '') : null}
|
||||
/>
|
||||
{/* آزمونِ جزیره و آزمونِ تکدرس هر دو «تمام کردن» اند؛ جدا شمردنشان برای والد فرقی نمیکند. */}
|
||||
<Stat icon={BookOpen} label="جزیره و درسِ تمامشده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} />
|
||||
<Stat icon={BookOpen} label="جزیره و درس تمامشده" value={toFa(now.islands + now.lessons)} delta={change(now.islands + now.lessons, before.islands + before.lessons, '')} />
|
||||
<Stat icon={MessageCircle} label="سوال از دستیار" value={toFa(now.aiQuestions)} delta={change(now.aiQuestions, before.aiQuestions, '')} />
|
||||
</div>
|
||||
|
||||
{data.streak.longest > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-[#FFF1E6] px-4 py-3 text-xs text-body">
|
||||
<Flame size={16} className="text-orange" fill="#FF7A3D" />
|
||||
زنجیرهی فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز
|
||||
زنجیره فعلی <b className="text-ink">{toFa(data.streak.current)}</b> روز؛ بیشترین <b className="text-ink">{toFa(data.streak.longest)}</b> روز
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BarChart
|
||||
title="زمانِ تمرین در هر روز"
|
||||
description="ستونِ آبی هر روزِ این هفته است و ستونِ نارنجیِ کنارش همان روز در هفتهی پیش؛ آبیِ بلندتر یعنی بیشتر از هفتهی پیش."
|
||||
title="زمان تمرین در هر روز"
|
||||
description="ستون آبی هر روز این هفته است و ستون نارنجی کنارش همان روز در هفته پیش؛ آبی بلندتر یعنی بیشتر از هفته پیش."
|
||||
unit="دقیقه"
|
||||
days={data.days}
|
||||
mode="grouped"
|
||||
series={[
|
||||
{ label: 'این هفته', color: THIS_WEEK, values: data.days.map((d) => d.minutes) },
|
||||
{ label: 'هفتهی پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) },
|
||||
{ label: 'هفته پیش', color: LAST_WEEK, values: data.previousDays.map((d) => d.minutes) },
|
||||
]}
|
||||
note="زمان تقریبی است: از فاصلهی جوابها و پرسشها حساب میشود، پس خواندنِ درس بدونِ جواب دادن در آن نیست."
|
||||
note="زمان تقریبی است: از فاصله جوابها و پرسشها حساب میشود، پس خواندن درس بدون جواب دادن در آن نیست."
|
||||
/>
|
||||
|
||||
<BarChart
|
||||
title="سوالهای حلشده در هر روز"
|
||||
description="کلِ ستون تعدادِ سوالهایی است که آن روز جواب داده؛ بخشِ سبز جوابهای درست است و بخشِ طلایی نادرست."
|
||||
description="کل ستون تعداد سوالهایی است که آن روز جواب داده؛ بخش سبز جوابهای درست است و بخش طلایی نادرست."
|
||||
unit="سوال"
|
||||
days={data.days}
|
||||
mode="stacked"
|
||||
@@ -128,18 +128,18 @@ function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood:
|
||||
const diff = now.minutes - before.minutes;
|
||||
const meaningful = Math.abs(diff) >= 3 && Math.abs(diff) >= before.minutes * 0.1;
|
||||
if (!now.minutes && !before.minutes) {
|
||||
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیرهی کوتاه شروعِ خوبیه!' };
|
||||
return { mood: 'idle', text: 'این هفته هنوز تمرینی ثبت نشده. یه جزیره کوتاه شروع خوبیه!' };
|
||||
}
|
||||
if (!before.minutes) {
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته شروع کرده و ${toFa(now.activeDays)} روز تمرین کرده.` };
|
||||
}
|
||||
if (meaningful && diff > 0) {
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته حدودِ ${toFa(diff)} دقیقه بیشتر از هفتهی پیش تمرین کرده.` };
|
||||
return { mood: 'excited', text: `آفرین! ${who}این هفته حدود ${toFa(diff)} دقیقه بیشتر از هفته پیش تمرین کرده.` };
|
||||
}
|
||||
if (meaningful && diff < 0) {
|
||||
return { mood: 'idle', text: `این هفته حدودِ ${toFa(-diff)} دقیقه کمتر از هفتهی پیش تمرین شده. یه تشویقِ کوچیک کمک میکنه!` };
|
||||
return { mood: 'idle', text: `این هفته حدود ${toFa(-diff)} دقیقه کمتر از هفته پیش تمرین شده. یه تشویق کوچیک کمک میکنه!` };
|
||||
}
|
||||
return { mood: 'happy', text: `${who}مثلِ هفتهی پیش منظم تمرین کرده؛ همینطور ادامه بده!` };
|
||||
return { mood: 'happy', text: `${who}مثل هفته پیش منظم تمرین کرده؛ همینطور ادامه بده!` };
|
||||
}
|
||||
|
||||
function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) {
|
||||
@@ -162,8 +162,8 @@ function change(now: number, before: number, unit: string): Delta {
|
||||
const u = unit ? ` ${unit}` : '';
|
||||
// صفر در برابرِ صفر خبری ندارد؛ زیرِ همهی کاشیهای حسابِ تازه «مثلِ هفتهی پیش» مینشست.
|
||||
if (!now && !before) return null;
|
||||
if (diff === 0) return { text: 'مثلِ هفتهی پیش', up: null };
|
||||
return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفتهی پیش`, up: diff > 0 };
|
||||
if (diff === 0) return { text: 'مثل هفته پیش', up: null };
|
||||
return { text: `${toFa(Math.abs(diff))}${u} ${diff > 0 ? 'بیشتر' : 'کمتر'} از هفته پیش`, up: diff > 0 };
|
||||
}
|
||||
|
||||
function Stat({ icon: Icon, label, value, delta }: { icon: LucideIcon; label: string; value: string; delta: Delta }) {
|
||||
@@ -326,7 +326,7 @@ function DayTable({ days, previous }: { days: ReportDay[]; previous: ReportDay[]
|
||||
<tr>
|
||||
<th className="py-1 text-right font-bold">روز</th>
|
||||
<th className="font-bold">دقیقه</th>
|
||||
<th className="font-bold">هفتهی پیش</th>
|
||||
<th className="font-bold">هفته پیش</th>
|
||||
<th className="font-bold">سوال</th>
|
||||
<th className="font-bold">درست</th>
|
||||
</tr>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SetupScreen({
|
||||
<div className="absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
|
||||
<div className="relative flex items-center justify-between pt-8">
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سؤالِ کوچیک</div>
|
||||
<div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سوال کوچیک</div>
|
||||
<h1 className="text-2xl font-black">بیا با هم آشنا بشیم!</h1>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white/15 p-2">
|
||||
@@ -84,14 +84,14 @@ export function SetupScreen({
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-black text-ink">
|
||||
تاریخِ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
|
||||
تاریخ تولدت <span className="text-xs font-medium text-muted">(به شمسی، اختیاری)</span>
|
||||
</span>
|
||||
<BirthDatePicker value={birthDate} fallbackYear={profile.birthYear} onChange={setBirthDate} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-3 block text-sm font-black text-ink">
|
||||
{adult ? 'ریاضیِ کدوم پایه رو میخوای یاد بگیری؟' : 'کدوم پایهای؟'}
|
||||
{adult ? 'ریاضی کدوم پایه رو میخوای یاد بگیری؟' : 'کدوم پایهای؟'}
|
||||
{adult && <span className="mt-1 block text-xs font-medium text-muted">هر وقت خواستی از پروفایل عوضش کن.</span>}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -129,7 +129,7 @@ export function SetupScreen({
|
||||
{error && <InlineError>{error}</InlineError>}
|
||||
|
||||
<PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto">
|
||||
شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} />
|
||||
شروع یادگیری <ChevronLeft size={19} strokeWidth={3} />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function StreakScreen() {
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScreenHeader title="زنجیرهی روزها" subtitle="هر روز حداقل یه درس، زنجیرهت رو زنده نگه میداره" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }} />
|
||||
<ScreenHeader title="زنجیره روزها" subtitle="هر روز حداقل یه درس، زنجیرهت رو زنده نگه میداره" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }} />
|
||||
<ScreenBody className="flex flex-col gap-4">
|
||||
{calendar.loading ? (
|
||||
<LoadingState />
|
||||
@@ -50,7 +50,7 @@ export function StreakScreen() {
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'زنجیرهی فعلی', value: calendar.data.currentStreak },
|
||||
{ label: 'زنجیره فعلی', value: calendar.data.currentStreak },
|
||||
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
|
||||
].map((s, i) => (
|
||||
<div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4">
|
||||
@@ -106,18 +106,18 @@ function StreakGoal({ current, doneToday, lost }: { current: number; doneToday:
|
||||
// زنجیرهی صفر: «۳ روز دیگه میشه زنجیرهی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته میشود.
|
||||
// زنجیرهی روشن و امروزِ خالی: یادآوریِ اینکه امروز چیزی مانده تا زنجیره نسوزد.
|
||||
const today = current === 0 && lost > 0
|
||||
? `زنجیرهی ${toFa(lost)} روزهت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
|
||||
? `زنجیره ${toFa(lost)} روزهت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
|
||||
: current === 0
|
||||
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیرهت روشن بشه.'
|
||||
: !doneToday
|
||||
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیرهی ${toFa(current)} روزهت نسوزه.`
|
||||
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره ${toFa(current)} روزهت نسوزه.`
|
||||
: null;
|
||||
return (
|
||||
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
|
||||
{today && <p className="mb-1 text-xs font-bold leading-6 text-[#B4501C]">{today}</p>}
|
||||
<p className="text-sm font-black leading-7 text-ink">
|
||||
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزهای! </> : null}
|
||||
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
||||
{next ? `${toFa(next - current)} روز دیگه میشه زنجیره ${toFa(next)} روزه.` : 'از همه نقطهعطفها رد شدی، افسانهای!'}
|
||||
</p>
|
||||
{next && (
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Check, Crown, CreditCard, ShieldCheck, ShoppingBag, Ticket } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, ChevronDown, Crown, Lock, Ticket } from 'lucide-react';
|
||||
import { api, type Plan, type SubscriptionStatus } from '@/api';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
@@ -10,6 +10,9 @@ import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Scree
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { formatNumber, formatToman, toFa } from '@/lib/format';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
import { enabledPaymentMethods, type PaymentMethod } from '@/lib/paymentMethods';
|
||||
import zarinpalLogo from '@/assets/pay/zarinpal.png';
|
||||
import bazaarLogo from '@/assets/pay/bazaar.png';
|
||||
|
||||
function durationLabel(days: number): string {
|
||||
if (days === 30 || days === 31) return 'یکماهه';
|
||||
@@ -62,8 +65,8 @@ export function SubscriptionScreen() {
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScreenHeader
|
||||
title={active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو'}
|
||||
subtitle="همهی فصلها، قلبِ نامحدود"
|
||||
title={active ? 'تمدید اشتراک' : 'اشتراک تدریسینو'}
|
||||
subtitle="همه فصلها، قلب نامحدود"
|
||||
color="#ED6956"
|
||||
exact
|
||||
back
|
||||
@@ -79,7 +82,7 @@ export function SubscriptionScreen() {
|
||||
) : !list.length ? (
|
||||
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
||||
) : (
|
||||
<div role="radiogroup" aria-label="مدتِ اشتراک" className="flex flex-col gap-3">
|
||||
<div role="radiogroup" aria-label="مدت اشتراک" className="flex flex-col gap-3">
|
||||
{list.map((plan) => (
|
||||
<PlanRow key={plan.id} plan={plan} assistant={look.name} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
|
||||
))}
|
||||
@@ -91,7 +94,7 @@ export function SubscriptionScreen() {
|
||||
{selected && (
|
||||
<div className="flex-shrink-0 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
|
||||
<PrimaryButton color="#FF7A3D" onClick={() => setBuying(selected)}>
|
||||
{`خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
|
||||
{`خرید اشتراک ${durationLabel(selected.durationDays) || selected.title}`}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -178,12 +181,10 @@ function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan:
|
||||
);
|
||||
}
|
||||
|
||||
type Method = 'gateway' | 'bazaar';
|
||||
|
||||
/** چرا بازار برای این پلن و اینجا ممکن نیست؛ null یعنی ممکن است. */
|
||||
function bazaarBlocker(plan: Plan | null): string | null {
|
||||
if (!plan?.bazaarSku) return 'این مورد در بازار نیست';
|
||||
if (!inAndroidApp()) return 'فقط در اپِ اندروید';
|
||||
if (!inAndroidApp()) return 'فقط در اپ اندروید';
|
||||
if (!canBuyFromBazaar()) return 'اپ رو از بازار بهروز کن';
|
||||
return null;
|
||||
}
|
||||
@@ -193,24 +194,28 @@ function bazaarBlocker(plan: Plan | null): string | null {
|
||||
* صفحهی خریدِ بازار میرود. window.open بعد از await را Safariِ آیفون مسدود میکند؛ برای همین درگاه
|
||||
* جابهجاییِ همین صفحه است.
|
||||
*
|
||||
* دو روشِ پرداخت همیشه کنارِ هم دیده میشوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار اینجا ممکن نیست، کاشیِ
|
||||
* آن غیرفعال است و دلیلش را زیرِ نامش میگوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). پیشفرض زرینپال
|
||||
* است؛ داخلِ اپ، زیرِ «کافهبازار» قیمتی میآید که خودِ بازار برای آن SKU دارد. ⚠️ قانونِ کافهبازار پرداختِ محتوای دیجیتال بیرون از پرداختِ
|
||||
* درونبرنامهای را محدود میکند؛ نگه داشتنِ زرینپال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده.
|
||||
* روشهای پرداخت به شکلِ آشنای برگهی پرداختِ اپها میآیند: هر روش یک ردیف با نشانِ خودِ درگاه، نام و یک
|
||||
* دایرهی انتخاب، و دکمهی پایین مبلغ را میگوید (خواستِ مالک، ۲۰۲۶-۱۰-۱۴: «حسِ اطمینان»). کدام روشها در مرورگر و
|
||||
* کدام داخلِ اپ دیده شوند از config.js میآید (src/lib/paymentMethods.ts)، تا نسخهی کافهبازار فقط بازار را نشان دهد
|
||||
* و سایت فقط زرینپال را، بی build. روشی که فهرست شده ولی اینجا ممکن نیست (APKِ قدیمی) غیرفعال با دلیل میآید.
|
||||
* ⚠️ قانونِ کافهبازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درونبرنامهای را محدود میکند؛ زرینپال داخلِ اپ
|
||||
* تصمیمِ مالک است و با بازار هماهنگ نشده.
|
||||
*/
|
||||
function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | null; storePrices: Record<string, string>; onClose: () => void; onPaid: () => void }) {
|
||||
const blocker = bazaarBlocker(plan);
|
||||
const [method, setMethod] = useState<Method>('gateway');
|
||||
const methods = useMemo(enabledPaymentMethods, []);
|
||||
const firstUsable = methods.find((m) => m !== 'bazaar' || !blocker) ?? methods[0];
|
||||
const [method, setMethod] = useState<PaymentMethod>(firstUsable);
|
||||
const [code, setCode] = useState('');
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
|
||||
|
||||
// هر بار که برگه برای پلنی باز میشود، زرینپال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آنطرفتر.
|
||||
// هر بار که برگه برای پلنی باز میشود، اولین روشِ ممکن انتخاب است (ترتیب از config.js؛ پیشفرض زرینپال اول).
|
||||
useEffect(() => {
|
||||
if (plan) setMethod('gateway');
|
||||
}, [plan]);
|
||||
if (plan) setMethod(firstUsable);
|
||||
}, [plan, firstUsable]);
|
||||
const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined;
|
||||
|
||||
/**
|
||||
@@ -269,8 +274,8 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'unavailable') {
|
||||
// کارِ کاربر است، نه خطای خرید: پولی جابهجا نشده.
|
||||
setError(err.message === 'old_bazaar'
|
||||
? 'کافهبازارِ گوشیت قدیمیه؛ از خودِ بازار بهروزش کن و دوباره بزن، یا با کارتِ بانکی پرداخت کن.'
|
||||
: 'کافهبازار روی گوشیت پیدا نشد یا وصل نشد. مطمئن شو نصبه و وارد حسابت شدی، بعد دوباره بزن؛ یا با کارتِ بانکی پرداخت کن.');
|
||||
? 'کافهبازار گوشیت قدیمیه؛ از خود بازار بهروزش کن و دوباره بزن، یا با کارت بانکی پرداخت کن.'
|
||||
: 'کافهبازار روی گوشیت پیدا نشد یا وصل نشد. مطمئن شو نصبه و وارد حسابت شدی، بعد دوباره بزن؛ یا با کارت بانکی پرداخت کن.');
|
||||
} else {
|
||||
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||||
}
|
||||
@@ -319,12 +324,12 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
};
|
||||
|
||||
const doneText =
|
||||
done === 'gateway' ? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش میکنه؛ لغوِ تمدید از بخشِ «اشتراکها»ی خودِ بازاره.';
|
||||
done === 'gateway' ? 'صفحه پرداخت در مرورگر گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کد تخفیفت کل مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخر هر دوره خودش تمدیدش میکنه؛ لغو تمدید از بخش «اشتراکها»ی خود بازاره.';
|
||||
|
||||
return (
|
||||
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}` : ''}>
|
||||
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? 'پرداخت' : ''}>
|
||||
{done ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
|
||||
@@ -332,20 +337,29 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
</>
|
||||
) : plan && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3.5">
|
||||
<span className="text-sm font-bold text-body">مبلغ</span>
|
||||
<span className="text-lg font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
|
||||
<div className="mb-5 flex items-center gap-3 rounded-2xl bg-soft px-4 py-3.5">
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-[#FFF4D6] text-[#E0A200]"><Crown size={20} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-black text-ink">اشتراک {durationLabel(plan.durationDays) || plan.title}</span>
|
||||
<span className="block text-xs text-muted">همه فصلها، قلب نامحدود</span>
|
||||
</span>
|
||||
<span className="text-left text-base font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4">
|
||||
<span className="mb-2 block text-sm font-black text-ink">با چی پرداخت میکنی؟</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MethodTile selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={22} />} title="کارتِ بانکی" note="زرینپال" />
|
||||
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافهبازار" note={blocker ?? storePrice ?? 'با حسابِ بازارت'} disabled={!!blocker} />
|
||||
<div role="radiogroup" aria-label="روش پرداخت" className="mb-4">
|
||||
<span className="mb-2 block text-sm font-black text-ink">روش پرداخت</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{methods.map((m) =>
|
||||
m === 'zarinpal' ? (
|
||||
<MethodRow key={m} selected={method === m} onSelect={() => setMethod(m)} logo={zarinpalLogo} title="زرینپال" note="کارت بانکی، همه بانکهای عضو شتاب" />
|
||||
) : (
|
||||
<MethodRow key={m} selected={method === m} onSelect={() => setMethod(m)} logo={bazaarLogo} title="کافهبازار" note={blocker ?? (storePrice ? `${storePrice} · با حساب بازارت` : 'با حساب بازارت')} disabled={!!blocker} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showCode ? (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span>
|
||||
<span className="mb-2 block text-sm font-bold text-body">کد تخفیف</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
|
||||
@@ -356,19 +370,19 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
|
||||
<Ticket size={15} /> کدِ تخفیف دارم
|
||||
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex w-full items-center gap-2 rounded-2xl border border-dashed border-line px-4 py-3 text-sm font-bold text-body active:bg-soft">
|
||||
<Ticket size={16} className="text-coral" /> <span className="flex-1 text-right">کد تخفیف دارم</span> <ChevronDown size={16} className="text-faint" />
|
||||
</button>
|
||||
)}
|
||||
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
|
||||
<PrimaryButton loading={busy} onClick={() => void pay()}>
|
||||
{method === 'bazaar' ? 'پرداخت با کافهبازار' : 'پرداخت با کارتِ بانکی'}
|
||||
<PrimaryButton loading={busy} disabled={method === 'bazaar' && !!blocker} onClick={() => void pay()}>
|
||||
{`پرداخت ${method === 'bazaar' && storePrice ? storePrice : price(plan.price)}`}
|
||||
</PrimaryButton>
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||
<ShieldCheck size={14} />
|
||||
<Lock size={13} />
|
||||
{method === 'bazaar'
|
||||
? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید میشه.'
|
||||
: 'پرداخت در درگاهِ امنِ زرینپال انجام میشه.'}
|
||||
? 'پرداخت امن در خود کافهبازار؛ آخر هر دوره خودکار تمدید میشه.'
|
||||
: 'پرداخت امن در درگاه زرینپال'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -376,7 +390,8 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
);
|
||||
}
|
||||
|
||||
function MethodTile({ selected, onSelect, icon, title, note, disabled = false }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string; disabled?: boolean }) {
|
||||
/** یک روشِ پرداخت به شکلِ ردیفِ آشنای برگههای پرداخت: نشانِ درگاه، نام و توضیح، و دایرهی انتخاب. */
|
||||
function MethodRow({ selected, onSelect, logo, title, note, disabled = false }: { selected: boolean; onSelect: () => void; logo: string; title: string; note: string; disabled?: boolean }) {
|
||||
const on = selected && !disabled;
|
||||
return (
|
||||
<button
|
||||
@@ -386,18 +401,23 @@ function MethodTile({ selected, onSelect, icon, title, note, disabled = false }:
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={`relative flex flex-col items-center gap-1 rounded-2xl border-2 border-b-4 px-2 py-3 text-center transition-colors ${
|
||||
disabled ? 'cursor-not-allowed border-line bg-soft opacity-70' : on ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white active:translate-y-0.5'
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border-2 px-3 py-3 text-right transition-colors ${
|
||||
disabled ? 'cursor-not-allowed border-line bg-soft opacity-60' : on ? 'border-coral bg-[#fff6f4]' : 'border-line bg-white active:bg-soft'
|
||||
}`}
|
||||
>
|
||||
{on && (
|
||||
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-coral text-white">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</span>
|
||||
)}
|
||||
<span className={on ? 'text-coral' : 'text-muted'}>{icon}</span>
|
||||
<span className="text-[15px] font-black text-ink">{title}</span>
|
||||
<span className="text-xs leading-5 text-muted">{note}</span>
|
||||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-line bg-white">
|
||||
<img src={logo} alt="" width={30} height={30} className={disabled ? 'grayscale' : ''} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[15px] font-black text-ink">{title}</span>
|
||||
<span className="block truncate text-xs text-muted">{note}</span>
|
||||
</span>
|
||||
<span
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
|
||||
style={{ borderColor: on ? '#ED6956' : '#d6d3ea', backgroundColor: on ? '#ED6956' : '#fff' }}
|
||||
>
|
||||
{on && <Check size={14} strokeWidth={3.5} className="text-white" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ const CATEGORIES = [
|
||||
{ id: 'learning', label: 'درس و سوالها' },
|
||||
{ id: 'payment', label: 'پرداخت و اشتراک' },
|
||||
{ id: 'account', label: 'حساب و ورود' },
|
||||
{ id: 'other', label: 'چیزِ دیگه' },
|
||||
{ id: 'other', label: 'چیز دیگه' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
open: 'باز',
|
||||
in_progress: 'در حالِ بررسی',
|
||||
in_progress: 'در حال بررسی',
|
||||
answered: 'پاسخ داده شد',
|
||||
closed: 'بسته',
|
||||
};
|
||||
@@ -61,7 +61,7 @@ export function SupportScreen() {
|
||||
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ mood: 'happy', size: 56 }} />
|
||||
<ScreenBody className="flex flex-col gap-4">
|
||||
<div className="flex gap-1 rounded-2xl bg-soft p-1">
|
||||
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارشِ خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
|
||||
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارش خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -108,7 +108,7 @@ export function SupportScreen() {
|
||||
|
||||
{!!tickets.data?.length && (
|
||||
<section>
|
||||
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیامهای قبلیِ تو</h2>
|
||||
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیامهای قبلی تو</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex justify-between text-[10px] text-white/75">
|
||||
<span>پیشرفتِ تو</span>
|
||||
<span>پیشرفت تو</span>
|
||||
<span>{toFa(progress.percent)}٪</span>
|
||||
</div>
|
||||
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />
|
||||
|
||||
Reference in New Issue
Block a user