diff --git a/src/api/types.ts b/src/api/types.ts index 68ce253..dedbb2e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -324,7 +324,8 @@ export interface Ticket { export interface Invoice { orderId: string; invoiceNumber: string; - status: 'pending' | 'paid' | 'failed'; + /** «canceled» و «refunded» را فقط ادمین می‌گذارد (لغو و بازپرداخت، از ۱۴۰۵-۰۷). */ + status: 'pending' | 'paid' | 'failed' | 'canceled' | 'refunded'; provider: 'gateway' | 'bazaar' | string; planTitle: string; /** قیمتِ پایه پیش از تخفیف؛ null برای سفارش‌های قدیمی‌تر از ثبتِ فاکتور. */ diff --git a/src/components/NoHeartsScreen.tsx b/src/components/NoHeartsScreen.tsx new file mode 100644 index 0000000..9cc2cc9 --- /dev/null +++ b/src/components/NoHeartsScreen.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from 'react'; +import { motion } from 'motion/react'; +import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, ShoppingBag, X } from 'lucide-react'; +import { api } from '@/api'; +import { useAppData } from '@/state/AppData'; +import { useNav } from '@/nav/Navigator'; +import { SeyarMascot } from '@/mascots/SeyarMascot'; +import { PrimaryButton, Screen } from '@/components/ui'; +import { toFa } from '@/lib/format'; + +/** + * «قلب‌هات تموم شد»: با قلبِ صفر سرور هیچ جوابی نمی‌پذیرد (۴۰۳ با code=no_hearts) و پایانِ درس یا + * جزیره را هم بی‌جواب نمی‌پذیرد. این صفحه شمارشِ معکوسِ قلبِ بعدی را زنده نشان می‌دهد و وقتی رسید، + * خودش از سرور می‌پرسد و دکمه‌ی «ادامه بده» را می‌آورد. + */ + +/** همان HEART_REFILL_INTERVALِ سرور (progress/models.py)؛ فقط برای پرشدنِ نوار و قلبِ اول. */ +const REFILL_MS = 5 * 60 * 60 * 1000; +const MAX_HEARTS = 5; + +function useNow(): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + return now; +} + +/** «۴:۰۷:۳۲» */ +function clock(ms: number): string { + const s = Math.max(0, Math.ceil(ms / 1000)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const pad = (n: number) => String(n).padStart(2, '0'); + return toFa(`${h}:${pad(m)}:${pad(s % 60)}`); +} + +/** «ساعتِ ۱۸:۳۰ امروز» / «… فردا» / «… شنبه» */ +function whenLabel(iso: string, now: number): string { + const at = new Date(iso); + const time = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit', hour12: false }).format(at); + const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const days = Math.round((dayOf(at) - dayOf(new Date(now))) / 86_400_000); + const day = days <= 0 ? 'امروز' : days === 1 ? 'فردا' : new Intl.DateTimeFormat('fa-IR', { weekday: 'long' }).format(at); + return `ساعتِ ${time} ${day}`; +} + +export function NoHeartsScreen({ + nextRefillAt, + fullRefillAt, + onRefilled, +}: { + nextRefillAt: string | null; + fullRefillAt: string | null; + /** قلب برگشت و کاربر «ادامه بده» زد. */ + onRefilled: () => void; +}) { + const nav = useNav(); + const { companion } = useAppData(); + const now = useNow(); + const [next, setNext] = useState(nextRefillAt); + const [full, setFull] = useState(fullRefillAt); + const [back, setBack] = useState(false); + + const left = next ? Date.parse(next) - now : null; + const progress = left === null ? 0 : Math.min(1, Math.max(0, 1 - left / REFILL_MS)); + const due = left !== null && left <= 0; + + // زمانِ قلبِ بعدی رسید: از سرور می‌پرسیم (ساعتِ گوشی ممکن است جلو باشد). + useEffect(() => { + if (!due || back) return; + let alive = true; + const check = () => + api.hearts().then((h) => { + if (!alive) return; + if (h.isUnlimited || h.currentHearts > 0) setBack(true); + else { + setNext(h.nextRefillAt); + setFull(h.fullRefillAt); + } + }).catch(() => undefined); + void check(); + return () => { + alive = false; + }; + }, [due, back]); + + return ( + +
+ +
+ +
+ +
+ +
+ + {back ? : } + +
+ +
+

{back ? 'یه قلب برگشت!' : 'قلب‌هات تموم شد'}

+

+ {back ? 'بریم ادامه بدیم؛ این بار با دقتِ بیشتر 💪' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'} +

+
+ + + + {back ? ( +
+ ادامه بده +
+ ) : ( +
+
قلبِ بعدی
+ {left !== null ? ( + <> +
{clock(left)}
+
+ +
+ {full && ( +

+ همه‌ی {toFa(MAX_HEARTS)} قلب: {whenLabel(full, now)} +

+ )} + + ) : ( +

کمی صبر کن تا قلب‌هات دوباره پر بشن.

+ )} +
+ )} + + {!back && ( + <> + + + + + + + )} +
+
+ ); +} + +/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر می‌شود. */ +function HeartSlots({ progress }: { progress: number }) { + return ( +
+ {Array.from({ length: MAX_HEARTS }, (_, i) => ( + + + {i === 0 && ( + + + + )} + + ))} +
+ ); +} diff --git a/src/screens/InvoicesScreen.tsx b/src/screens/InvoicesScreen.tsx index 1d1bc84..3ccee69 100644 --- a/src/screens/InvoicesScreen.tsx +++ b/src/screens/InvoicesScreen.tsx @@ -17,6 +17,8 @@ const STATUS: Record = paid: { label: 'پرداخت‌شده', className: 'bg-[#EAF8F0] text-[#2E7D5B]' }, 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 = { gateway: 'درگاهِ زرین‌پال', bazaar: 'کافه‌بازار' }; @@ -127,7 +129,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose: ) : ( - invoice.status !== 'paid' && ( + (invoice.status === 'pending' || invoice.status === 'failed') && (

اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره‌ی فاکتور رو به پشتیبانی بده.

diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index 1288595..827edd8 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -381,7 +381,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC void remove()} > حذفِ همیشگی diff --git a/src/screens/QuizScreen.tsx b/src/screens/QuizScreen.tsx index 7544237..477e47c 100644 --- a/src/screens/QuizScreen.tsx +++ b/src/screens/QuizScreen.tsx @@ -9,11 +9,12 @@ import { useNav } from '@/nav/Navigator'; import { newIdempotencyKey } from '@/api/http'; import { SeyarMascot } from '@/mascots/SeyarMascot'; import type { SeyarMood } from '@/mascots/seyar'; +import { NoHeartsScreen } from '@/components/NoHeartsScreen'; import { Sheet } from '@/components/Sheet'; import { useToast } from '@/components/Toast'; import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui'; import { friendlyError } from '@/lib/errors'; -import { timeUntil, toFa } from '@/lib/format'; +import { toFa } from '@/lib/format'; import { MathText } from '@/lib/MathText'; import { sounds } from '@/lib/sound'; @@ -21,7 +22,7 @@ type Phase = | { kind: 'loading' } | { kind: 'error'; error: unknown } | { kind: 'playing' } - | { kind: 'noHearts'; nextRefillAt: string | null } + | { kind: 'noHearts'; nextRefillAt: string | null; fullRefillAt: string | null } | { kind: 'finishing' } | { kind: 'finishError'; error: unknown } | { kind: 'result'; result: QuizResult }; @@ -92,7 +93,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s setCorrectCount(0); answerKeys.current.clear(); completeKey.current = newIdempotencyKey(); - if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt }); + if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt }); else setPhase({ kind: 'playing' }); }) .catch((error: unknown) => alive && setPhase({ kind: 'error', error })); @@ -123,7 +124,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s } catch (err) { setSelected(null); if (err instanceof ApiError && err.code === 'no_hearts') { - setPhase({ kind: 'noHearts', nextRefillAt: (err.body.nextRefillAt as string | null) ?? null }); + setPhase({ + kind: 'noHearts', + nextRefillAt: (err.body.nextRefillAt as string | null) ?? null, + fullRefillAt: (err.body.fullRefillAt as string | null) ?? null, + }); } else { toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error'); } @@ -135,7 +140,11 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s async function next() { if (outOfHearts && !isLast) { const h = await api.hearts().catch(() => null); - setPhase({ kind: 'noHearts', nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null }); + setPhase({ + kind: 'noHearts', + nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null, + fullRefillAt: h?.fullRefillAt ?? hearts?.fullRefillAt ?? null, + }); return; } if (!isLast) { @@ -160,6 +169,15 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s void refreshHome(); void refreshSummary(); } catch (error) { + // سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار. + if (error instanceof ApiError && error.code === 'no_hearts') { + setPhase({ + kind: 'noHearts', + nextRefillAt: (error.body.nextRefillAt as string | null) ?? null, + fullRefillAt: (error.body.fullRefillAt as string | null) ?? null, + }); + return; + } setPhase({ kind: 'finishError', error }); } } @@ -185,7 +203,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s ); } - if (phase.kind === 'noHearts') return ; + if (phase.kind === 'noHearts') { + return setReload((n) => n + 1)} />; + } if (phase.kind === 'result') return ; if (phase.kind === 'finishing' || phase.kind === 'finishError') { return ( @@ -354,25 +374,6 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) { ); } -function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) { - const nav = useNav(); - return ( - -
- -

قلب‌هات تموم شد

-

- {nextRefillAt ? <>قلبِ بعدی تا {timeUntil(nextRefillAt)} دیگه پر می‌شه. : 'کمی صبر کن تا قلب‌هات دوباره پر بشن.'} -
- هر بسته‌ی سوال چند روز قلبِ نامحدود هم هدیه داره. -

- nav.switchTab('subscription')}>دیدنِ بسته‌ها - باشه، بعداً -
-
- ); -} - function Result({ result, total, correct, lessonId, nodeId, color, }: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {