switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.
This commit is contained in:
@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function BillingPage() {
|
||||
@@ -21,6 +21,10 @@ export default function BillingPage() {
|
||||
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
|
||||
const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null);
|
||||
const [transactionsHasMore, setTransactionsHasMore] = React.useState(false);
|
||||
const [transactionsLoading, setTransactionsLoading] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
@@ -57,9 +61,18 @@ export default function BillingPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const packagesResult = await getTenantPackagesOverview();
|
||||
const [packagesResult, paddleTransactions] = await Promise.all([
|
||||
getTenantPackagesOverview(),
|
||||
getTenantPaddleTransactions().catch((err) => {
|
||||
console.warn('Failed to load Paddle transactions', err);
|
||||
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
|
||||
}),
|
||||
]);
|
||||
setPackages(packagesResult.packages);
|
||||
setActivePackage(packagesResult.activePackage);
|
||||
setTransactions(paddleTransactions.data);
|
||||
setTransactionCursor(paddleTransactions.nextCursor);
|
||||
setTransactionsHasMore(paddleTransactions.hasMore);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('billing.errors.load'));
|
||||
@@ -69,6 +82,25 @@ export default function BillingPage() {
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const loadMoreTransactions = React.useCallback(async () => {
|
||||
if (!transactionsHasMore || transactionsLoading || !transactionCursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTransactionsLoading(true);
|
||||
try {
|
||||
const result = await getTenantPaddleTransactions(transactionCursor);
|
||||
setTransactions((current) => [...current, ...result.data]);
|
||||
setTransactionCursor(result.nextCursor);
|
||||
setTransactionsHasMore(result.hasMore && Boolean(result.nextCursor));
|
||||
} catch (error) {
|
||||
console.warn('Failed to load additional Paddle transactions', error);
|
||||
setTransactionsHasMore(false);
|
||||
} finally {
|
||||
setTransactionsLoading(false);
|
||||
}
|
||||
}, [transactionCursor, transactionsHasMore, transactionsLoading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadAll();
|
||||
}, [loadAll]);
|
||||
@@ -176,11 +208,134 @@ export default function BillingPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-sky-500" />
|
||||
{t('billing.sections.transactions.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('billing.sections.transactions.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{transactions.length === 0 ? (
|
||||
<EmptyState message={t('billing.sections.transactions.empty')} />
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{transactions.map((transaction) => (
|
||||
<TransactionCard
|
||||
key={transaction.id ?? Math.random().toString(36).slice(2)}
|
||||
transaction={transaction}
|
||||
formatCurrency={formatCurrency}
|
||||
formatDate={formatDate}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{transactionsHasMore && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void loadMoreTransactions()}
|
||||
disabled={transactionsLoading}
|
||||
>
|
||||
{transactionsLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('billing.sections.transactions.loadingMore')}
|
||||
</>
|
||||
) : (
|
||||
t('billing.sections.transactions.loadMore')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionCard({
|
||||
transaction,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
transaction: PaddleTransactionSummary;
|
||||
formatCurrency: (value: number | null | undefined, currency?: string) => string;
|
||||
formatDate: (value: string | null | undefined) => string;
|
||||
locale: string;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const amount = transaction.grand_total ?? transaction.amount ?? null;
|
||||
const currency = transaction.currency ?? 'EUR';
|
||||
const createdAtIso = transaction.created_at ?? null;
|
||||
const createdAt = createdAtIso ? new Date(createdAtIso) : null;
|
||||
const createdLabel = createdAt
|
||||
? createdAt.toLocaleString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: formatDate(createdAtIso);
|
||||
const statusKey = transaction.status ? `billing.sections.transactions.status.${transaction.status}` : 'billing.sections.transactions.status.unknown';
|
||||
const statusText = t(statusKey, {
|
||||
defaultValue: (transaction.status ?? 'unknown').replace(/_/g, ' '),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{createdLabel}</p>
|
||||
{transaction.checkout_id && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
|
||||
</p>
|
||||
)}
|
||||
{transaction.origin && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t('billing.sections.transactions.labels.origin', { origin: transaction.origin })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 text-sm font-medium text-slate-700 md:flex-row md:items-center md:gap-4">
|
||||
<Badge className="bg-sky-100 text-sky-700">
|
||||
{statusText}
|
||||
</Badge>
|
||||
<div className="text-base font-semibold text-slate-900">
|
||||
{formatCurrency(amount, currency)}
|
||||
</div>
|
||||
{transaction.tax !== undefined && transaction.tax !== null && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })}
|
||||
</span>
|
||||
)}
|
||||
{transaction.receipt_url && (
|
||||
<a
|
||||
href={transaction.receipt_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-medium text-sky-600 hover:text-sky-700"
|
||||
>
|
||||
{t('billing.sections.transactions.labels.receipt')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
label,
|
||||
value,
|
||||
|
||||
Reference in New Issue
Block a user