Files
MathApp_frontend/src/screens/InvoicesScreen.tsx
AliReza 33f5f8d009 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>
2026-10-02 22:42:14 +03:30

143 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { ChevronLeft, Copy, Receipt } from 'lucide-react';
import { api, type Invoice } from '@/api';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { Sheet } from '@/components/Sheet';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
import { formatJalaliDate, formatToman } from '@/lib/format';
/**
* «پرداخت‌ها و فاکتورها» (۱۴۰۵-۰۷): همه‌ی سفارش‌های کاربر، با فاکتورِ هر خرید.
* سفارشِ «در انتظار» و «ناموفق» هم نشان داده می‌شود تا کسی که پولش کم شده ولی
* اشتراکش فعال نشده، شماره‌ی فاکتور را به پشتیبانی بدهد. عددها از GET /payment/invoices.
*/
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: 'کافه‌بازار' };
const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.createdAt);
export function InvoicesScreen() {
const nav = useNav();
const list = useAsync(() => api.subscription.invoices(), []);
const [open, setOpen] = useState<Invoice | null>(null);
return (
<Screen>
<ScreenHeader title="پرداخت‌ها و فاکتورها" subtitle="همه‌ی خریدهات، با شماره‌ی فاکتور و کدِ پیگیری" color="#4C5FD5" back />
<ScreenBody className="flex flex-col gap-3">
{list.loading ? (
<LoadingState />
) : list.error || !list.data ? (
<ErrorState error={list.error} onRetry={list.reload} />
) : list.data.length === 0 ? (
<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>
</div>
) : (
list.data.map((invoice) => (
<button
key={invoice.orderId}
type="button"
onClick={() => setOpen(invoice)}
className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4 text-right active:scale-[0.99]"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-black text-ink">{invoice.planTitle || 'اشتراک'}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${STATUS[invoice.status].className}`}>
{STATUS[invoice.status].label}
</span>
</div>
<div className="mt-1 text-xs text-muted">
{dateOf(invoice)}<Sep /><span dir="ltr">{invoice.invoiceNumber}</span>
</div>
</div>
<span className="text-sm font-black text-ink">{formatToman(invoice.amount)}</span>
<ChevronLeft size={16} className="text-faint" />
</button>
))
)}
</ScreenBody>
<InvoiceSheet invoice={open} onClose={() => setOpen(null)} />
</Screen>
);
}
function InvoiceSheet({ invoice, onClose }: { invoice: Invoice | null; onClose: () => void }) {
const toast = useToast();
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
toast('کپی شد', 'success');
} catch {
toast(text, 'success');
}
}
const rows: [string, React.ReactNode][] = invoice
? [
['شماره‌ی فاکتور', <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})` : 'تخفیف',
formatToman(invoice.discountAmount),
] as [string, React.ReactNode]] : []),
['مبلغِ پرداختی', <b>{formatToman(invoice.amount)}</b>],
['روشِ پرداخت', PROVIDER[invoice.provider] ?? invoice.provider],
]
: [];
return (
<Sheet open={invoice !== null} onClose={onClose} title="فاکتور">
{invoice && (
<div className="flex flex-col gap-4">
<table className="w-full text-sm">
<tbody>
{rows.map(([label, value]) => (
<tr key={label} className="border-b border-line last:border-0">
<td className="py-2.5 text-muted">{label}</td>
<td className="py-2.5 text-left text-ink">{value}</td>
</tr>
))}
</tbody>
</table>
{invoice.refId ? (
<button
type="button"
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="flex items-center gap-2 font-bold text-ink" dir="ltr">
{/* کد همان‌طور که کپی می‌شود: با رقمِ فارسی صفرها نقطه دیده می‌شدند. */}
{invoice.refId} <Copy size={14} />
</span>
</button>
) : (
(invoice.status === 'pending' || invoice.status === 'failed') && (
<p className="text-xs leading-6 text-muted">
اگه پولی از حسابت کم شده ولی این خرید «پرداخت‌شده» نیست، شماره‌ی فاکتور رو به پشتیبانی بده.
</p>
)
)}
</div>
)}
</Sheet>
);
}