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 && (
+ <>
+
nav.switchTab('subscription')}
+ className="relative w-full overflow-hidden rounded-3xl bg-gradient-to-l from-coral to-orange p-5 text-right text-white shadow-lg shadow-coral/30 active:scale-[0.99]"
+ >
+
+
+ نمیخوای منتظر بمونی؟
+
+
+ هر بستهی سوال چند روز قلبِ نامحدود هدیه داره؛ هر چقدر بخوای تمرین کن.
+
+
+ دیدنِ بستهها
+
+
+
+
nav.switchTab('ask')}
+ className="flex w-full items-center gap-3 rounded-3xl border border-line bg-white p-4 text-right active:scale-[0.99]"
+ >
+
+
+
+
+ تا اون موقع از «پی» بپرس
+ جایی که گیر کردی رو با هم مرور کنیم؛ قلب خرج نمیکنه.
+
+
+
+
+ باشه، بعداً
+
+ >
+ )}
+
+
+ );
+}
+
+/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر میشود. */
+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 }) {