247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
import { client } from "@/api/rpcclient";
|
|
import DownloadIcon from "@/components/icons/DownloadIcon.vue";
|
|
import ListIcon from "@/components/icons/ListIcon.vue";
|
|
import BaseTable from "@/components/ui/BaseTable.vue";
|
|
import { useAppToast } from "@/composables/useAppToast";
|
|
import { getApiErrorMessage, getStatusStyles } from "@/lib/utils";
|
|
import { PaymentHistoryItem } from "@/server/gen/proto/app/v1/common";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { useQuery } from "@pinia/colada";
|
|
import { useTranslation } from "i18next-vue";
|
|
import type { ColumnDef } from "@tanstack/vue-table";
|
|
|
|
const normalizeHistoryStatus = (status?: string) => {
|
|
switch ((status || '').toLowerCase()) {
|
|
case 'success':
|
|
case 'succeeded':
|
|
case 'paid':
|
|
return 'success';
|
|
case 'failed':
|
|
case 'error':
|
|
case 'canceled':
|
|
case 'cancelled':
|
|
return 'failed';
|
|
case 'pending':
|
|
case 'processing':
|
|
default:
|
|
return 'pending';
|
|
}
|
|
};
|
|
|
|
const PaymentHistory = defineComponent({
|
|
name: 'PaymentHistory',
|
|
setup() {
|
|
const auth = useAuthStore();
|
|
const { t } = useTranslation();
|
|
const toast = useAppToast();
|
|
const downloadingInvoiceId = ref<string | null>(null);
|
|
|
|
const formatTermLabel = (months: number) => t('settings.billing.termOption', { months });
|
|
const formatPaymentMethodLabel = (value?: string) => {
|
|
switch ((value || '').toLowerCase()) {
|
|
case 'topup':
|
|
return t('settings.billing.paymentMethod.topup');
|
|
case 'wallet':
|
|
default:
|
|
return t('settings.billing.paymentMethod.wallet');
|
|
}
|
|
};
|
|
const mapHistoryItem = (item: PaymentHistoryItem) => {
|
|
const details: string[] = [];
|
|
|
|
if (item.kind !== 'wallet_topup' && item.termMonths) {
|
|
details.push(formatTermLabel(item.termMonths));
|
|
}
|
|
if (item.kind !== 'wallet_topup' && item.paymentMethod) {
|
|
details.push(formatPaymentMethodLabel(item.paymentMethod));
|
|
}
|
|
if (item.kind !== 'wallet_topup' && item.expiresAt) {
|
|
details.push(t('settings.billing.history.validUntil', { date: auth.formatHistoryDate(item.expiresAt) }));
|
|
}
|
|
|
|
return {
|
|
id: item.id || '',
|
|
date: auth.formatHistoryDate(item.createdAt),
|
|
amount: item.amount || 0,
|
|
plan: item.kind === 'wallet_topup'
|
|
? t('settings.billing.walletTopup')
|
|
: (item.planName || t('settings.billing.unknownPlan')),
|
|
status: normalizeHistoryStatus(item.status),
|
|
invoiceId: item.invoiceId || '-',
|
|
currency: item.currency || 'USD',
|
|
kind: item.kind || 'subscription',
|
|
details,
|
|
};
|
|
};
|
|
const { data, isLoading } = useQuery({
|
|
key: ['paymentHistory'],
|
|
query: () => client.listPaymentHistory().then(res => (res.payments || []).map(mapHistoryItem)),
|
|
});
|
|
|
|
const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
|
|
if (!item.id) return;
|
|
|
|
downloadingInvoiceId.value = item.id;
|
|
toast.add({
|
|
severity: 'info',
|
|
summary: t('settings.billing.toast.downloadingSummary'),
|
|
detail: t('settings.billing.toast.downloadingDetail', { invoiceId: item.invoiceId }),
|
|
life: 2000,
|
|
});
|
|
|
|
try {
|
|
const response = await client.downloadInvoice({ id: item.id });
|
|
const content = response.content || '';
|
|
const contentType = response.contentType || 'text/plain;charset=utf-8';
|
|
const filename = response.filename || `${item.invoiceId}.txt`;
|
|
const blob = new Blob([content], { type: contentType });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
document.body.removeChild(anchor);
|
|
URL.revokeObjectURL(url);
|
|
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: t('settings.billing.toast.downloadedSummary'),
|
|
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
|
|
life: 3000,
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: t('settings.billing.toast.downloadFailedSummary'),
|
|
detail: getApiErrorMessage(error, t('settings.billing.toast.downloadFailedDetail')),
|
|
life: 5000,
|
|
});
|
|
} finally {
|
|
downloadingInvoiceId.value = null;
|
|
}
|
|
};
|
|
|
|
const columns: ColumnDef<ReturnType<typeof mapHistoryItem>>[] = [
|
|
{
|
|
accessorKey: 'date',
|
|
header: t('settings.billing.table.date'),
|
|
cell: ({ getValue }) => (
|
|
<p class="text-sm font-medium text-foreground">{getValue<string>()}</p>
|
|
),
|
|
meta: {
|
|
headerClass: 'col-span-3',
|
|
cellClass: 'col-span-3',
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'amount',
|
|
header: t('settings.billing.table.amount'),
|
|
cell: ({ row }) => (
|
|
<p class="text-sm text-foreground">{auth.formatMoney(row.original.amount)}</p>
|
|
),
|
|
meta: {
|
|
headerClass: 'col-span-2',
|
|
cellClass: 'col-span-2',
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'plan',
|
|
header: t('settings.billing.table.plan'),
|
|
cell: ({ row }) => (
|
|
<>
|
|
<p class="text-sm text-foreground">{row.original.plan}</p>
|
|
{row.original.details?.length ? (
|
|
<p class="mt-1 text-xs text-foreground/60">
|
|
{row.original.details.join(' · ')}
|
|
</p>
|
|
) : null}
|
|
</>
|
|
),
|
|
meta: {
|
|
headerClass: 'col-span-3',
|
|
cellClass: 'col-span-3',
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'status',
|
|
header: t('settings.billing.table.status'),
|
|
cell: ({ row }) => (
|
|
<span class={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(row.original.status)}`}>
|
|
{t('settings.billing.status.' + row.original.status)}
|
|
</span>
|
|
),
|
|
meta: {
|
|
headerClass: 'col-span-2',
|
|
cellClass: 'col-span-2',
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'invoice',
|
|
enableSorting: false,
|
|
header: t('settings.billing.table.invoice'),
|
|
cell: ({ row }) => (
|
|
<button
|
|
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all disabled:opacity-60 disabled:cursor-wait"
|
|
disabled={downloadingInvoiceId.value === row.original.id}
|
|
onClick={() => handleDownloadInvoice(row.original as PaymentHistoryItem)}
|
|
>
|
|
<DownloadIcon class="w-4 h-4" />
|
|
<span>{downloadingInvoiceId.value === row.original.id ? '...' : t('settings.billing.download')}</span>
|
|
</button>
|
|
),
|
|
meta: {
|
|
headerClass: 'col-span-2 flex justify-center',
|
|
cellClass: 'col-span-2 justify-center',
|
|
},
|
|
},
|
|
];
|
|
|
|
return () => (
|
|
<div class="px-6 py-4">
|
|
<div class="flex items-center gap-4 mb-4">
|
|
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
|
|
<ListIcon class="w-6 h-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-foreground">{t('settings.billing.paymentHistory')}</p>
|
|
<p class="text-xs text-foreground/60 mt-0.5">{t('settings.billing.paymentHistorySubtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<BaseTable
|
|
data={data.value || []}
|
|
columns={columns}
|
|
loading={isLoading.value}
|
|
emptyText={t('settings.billing.noPaymentHistory')}
|
|
>
|
|
{{
|
|
loading: () => (
|
|
<div class="px-4 py-6 space-y-3">
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<div key={index} class="grid grid-cols-12 gap-4 items-center animate-pulse">
|
|
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
|
<div class="col-span-2 h-4 rounded bg-muted/50" />
|
|
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
|
<div class="col-span-2 h-6 rounded bg-muted/50" />
|
|
<div class="col-span-2 h-8 rounded bg-muted/50" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
),
|
|
empty: () => (
|
|
<div class="text-center py-12 text-foreground/60">
|
|
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
|
<DownloadIcon class="w-8 h-8 text-foreground/40" />
|
|
</div>
|
|
<p>{t('settings.billing.noPaymentHistory')}</p>
|
|
</div>
|
|
)
|
|
}}
|
|
</BaseTable>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
export default PaymentHistory; |