Fix PayPal billing flow and mobile admin UX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-05 10:19:29 +01:00
parent c43327af74
commit 0d7a861875
39 changed files with 1630 additions and 253 deletions

View File

@@ -10,12 +10,12 @@ import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { ContextHelpLink } from './components/ContextHelpLink';
import {
createTenantBillingPortalSession,
getTenantBillingTransactions,
getTenantPackagesOverview,
getTenantLemonSqueezyTransactions,
getTenantPackageCheckoutStatus,
TenantPackageSummary,
LemonSqueezyOrderSummary,
TenantBillingTransactionSummary,
downloadTenantBillingReceipt,
} from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
@@ -42,6 +42,7 @@ import {
shouldClearPendingCheckout,
storePendingCheckout,
} from './lib/billingCheckout';
import { triggerDownloadFromBlob } from './invite-layout/export-utils';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
@@ -52,11 +53,10 @@ export default function MobileBillingPage() {
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<LemonSqueezyOrderSummary[]>([]);
const [transactions, setTransactions] = React.useState<TenantBillingTransactionSummary[]>([]);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false);
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
@@ -78,7 +78,7 @@ export default function MobileBillingPage() {
try {
const [pkg, trx, addonHistory] = await Promise.all([
getTenantPackagesOverview({ force: true }),
getTenantLemonSqueezyTransactions().catch(() => ({ data: [] as LemonSqueezyOrderSummary[] })),
getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })),
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
]);
setPackages(pkg.packages ?? []);
@@ -104,30 +104,32 @@ export default function MobileBillingPage() {
}
}, [supportEmail]);
const openPortal = React.useCallback(async () => {
if (portalBusy) {
return;
}
setPortalBusy(true);
try {
const { url } = await createTenantBillingPortalSession();
if (typeof window !== 'undefined') {
window.open(url, '_blank', 'noopener');
}
} catch (err) {
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Lemon Squeezy-Portal nicht öffnen.'));
toast.error(message);
} finally {
setPortalBusy(false);
}
}, [portalBusy, t]);
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
setPendingCheckout(next);
storePendingCheckout(next);
}, []);
const handleReceiptDownload = React.useCallback(
async (transaction: TenantBillingTransactionSummary) => {
if (!transaction.receipt_url) {
return;
}
const transactionId = transaction.provider_id
?? (transaction.id !== null && transaction.id !== undefined ? String(transaction.id) : 'receipt');
try {
const blob = await downloadTenantBillingReceipt(transaction.receipt_url);
const filename = `fotospiel-receipt-${transactionId}.pdf`;
triggerDownloadFromBlob(blob, filename);
toast.success(t('billing.actions.receiptDownloaded', 'Receipt downloaded.'));
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.receipt', 'Receipt download failed.')));
}
},
[t],
);
React.useEffect(() => {
void load();
}, [load]);
@@ -387,11 +389,6 @@ export default function MobileBillingPage() {
<Text fontSize="$xs" color={muted}>
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
</Text>
<CTAButton
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Lemon Squeezy')}
onPress={openPortal}
disabled={portalBusy}
/>
{loading ? (
<Text fontSize="$sm" color={muted}>
{t('common.loading', 'Lädt...')}
@@ -439,38 +436,55 @@ export default function MobileBillingPage() {
</YStack>
) : (
<YStack gap="$1.5">
{transactions.slice(0, 8).map((trx) => (
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
<YStack>
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{trx.status ?? '—'}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDate(trx.created_at)}
</Text>
{trx.origin ? (
<Text fontSize="$xs" color={subtle}>
{trx.origin}
{transactions.slice(0, 8).map((trx) => {
const statusLabel = trx.status
? t(`billing.sections.transactions.status.${trx.status}`, trx.status)
: '—';
const providerLabel = trx.provider
? t(`billing.providers.${trx.provider}`, trx.provider)
: t('billing.providers.unknown', 'Unknown');
const transactionId = trx.provider_id ?? (trx.id !== null && trx.id !== undefined ? String(trx.id) : '—');
const packageName = trx.package_name ?? t('billing.sections.transactions.labels.packageFallback', 'Package');
return (
<XStack key={trx.id ?? transactionId} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
<YStack>
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{packageName}
</Text>
) : null}
</YStack>
<YStack alignItems="flex-end">
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{formatAmount(trx.amount, trx.currency)}
</Text>
{trx.tax ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
{statusLabel}
</Text>
) : null}
{trx.receipt_url ? (
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: primary }}>
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
</a>
) : null}
</YStack>
</XStack>
))}
<Text fontSize="$xs" color={subtle}>
{t('billing.sections.transactions.labels.provider', { provider: providerLabel })}
</Text>
<Text fontSize="$xs" color={subtle}>
{t('billing.sections.transactions.labels.transactionId', { id: transactionId })}
</Text>
</YStack>
<YStack alignItems="flex-end">
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{formatAmount(trx.amount, trx.currency)}
</Text>
{trx.tax ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
</Text>
) : null}
<Text fontSize="$xs" color={muted}>
{formatDate(trx.purchased_at)}
</Text>
{trx.receipt_url ? (
<Pressable onPress={() => void handleReceiptDownload(trx)}>
<Text fontSize="$xs" color={primary}>
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
</Text>
</Pressable>
) : null}
</YStack>
</XStack>
);
})}
</YStack>
)}
</MobileCard>