From 33f5f8d009ac742a2d9a97a0c756b51065159845 Mon Sep 17 00:00:00 2001 From: AliReza Date: Fri, 2 Oct 2026 22:42:14 +0330 Subject: [PATCH] no-hearts screen: live countdown, filling heart, auto-continue; refunded/canceled invoices - The wait screen counts down to the next heart second by second, fills the first heart as time passes, shows when all five are back, and asks the server when the time comes; 'continue' reloads the quiz. Packs and 'ask Pi meanwhile' (asking costs no hearts) are offered as ways on. - The server now also refuses to finish a lesson/island without hearts and answers (403 no_hearts); the quiz shows the same screen then. - Invoices know the two admin-set statuses, canceled and refunded. - Account deletion waits for all six digits of the SMS code. Co-Authored-By: Claude Opus 5.5 --- src/api/types.ts | 3 +- src/components/NoHeartsScreen.tsx | 213 ++++++++++++++++++++++++++++++ src/screens/InvoicesScreen.tsx | 4 +- src/screens/ProfileScreen.tsx | 2 +- src/screens/QuizScreen.tsx | 51 +++---- 5 files changed, 245 insertions(+), 28 deletions(-) create mode 100644 src/components/NoHeartsScreen.tsx 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 }) {