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 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:42:14 +03:30
parent befaaeb7bb
commit 33f5f8d009
5 changed files with 245 additions and 28 deletions

View File

@@ -17,6 +17,8 @@ const STATUS: Record<Invoice['status'], { label: string; className: string }> =
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<string, string> = { gateway: 'درگاهِ زرین‌پال', bazaar: 'کافه‌بازار' };
@@ -127,7 +129,7 @@ function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose:
</span>
</button>
) : (
invoice.status !== 'paid' && (
(invoice.status === 'pending' || invoice.status === 'failed') && (
<p className="text-xs leading-6 text-muted">
اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره‌ی فاکتور رو به پشتیبانی بده.
</p>

View File

@@ -381,7 +381,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
<PrimaryButton
color="#ef4444"
loading={busy}
disabled={needsCode ? value.length < 4 : value.trim() !== 'حذف'}
disabled={needsCode ? value.length < 6 : value.trim() !== 'حذف'}
onClick={() => void remove()}
>
حذفِ همیشگی

View File

@@ -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
</Screen>
);
}
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
if (phase.kind === 'noHearts') {
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
}
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />;
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 (
<Screen>
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<SeyarMascot char="pi" mood="depressed" size={110} />
<h2 className="text-xl font-black text-ink">قلب‌هات تموم شد</h2>
<p className="text-sm leading-7 text-muted">
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر می‌شه.</> : 'کمی صبر کن تا قلب‌هات دوباره پر بشن.'}
<br />
هر بسته‌ی سوال چند روز قلبِ نامحدود هم هدیه داره.
</p>
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ بسته‌ها</PrimaryButton>
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
</div>
</Screen>
);
}
function Result({
result, total, correct, lessonId, nodeId, color,
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {