feat: poll checkout status and show failures

This commit is contained in:
Codex Agent
2026-01-12 13:31:30 +01:00
parent f452c486d4
commit 749cb04cec
11 changed files with 345 additions and 48 deletions

View File

@@ -12,6 +12,7 @@ import {
createTenantBillingPortalSession,
getTenantPackagesOverview,
getTenantPaddleTransactions,
getTenantPackageCheckoutStatus,
TenantPackageSummary,
PaddleTransactionSummary,
} from '../api';
@@ -27,9 +28,14 @@ import {
getPackageFeatureLabel,
getPackageLimitEntries,
} from './lib/packageSummary';
import { PendingCheckout, PENDING_CHECKOUT_TTL_MS, shouldClearPendingCheckout } from './lib/billingCheckout';
import {
PendingCheckout,
loadPendingCheckout,
shouldClearPendingCheckout,
storePendingCheckout,
} from './lib/billingCheckout';
const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
export default function MobileBillingPage() {
const { t } = useTranslation('management');
@@ -43,29 +49,10 @@ export default function MobileBillingPage() {
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>(() => {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PendingCheckout;
if (typeof parsed?.startedAt !== 'number') {
return null;
}
const packageId =
typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId)
? parsed.packageId
: null;
const isExpired = Date.now() - parsed.startedAt > PENDING_CHECKOUT_TTL_MS;
return isExpired ? null : { packageId, startedAt: parsed.startedAt };
} catch {
return null;
}
});
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
@@ -123,18 +110,7 @@ export default function MobileBillingPage() {
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
setPendingCheckout(next);
if (typeof window === 'undefined') {
return;
}
try {
if (!next) {
window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY);
} else {
window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next));
}
} catch {
// Ignore storage errors.
}
storePendingCheckout(next);
}, []);
React.useEffect(() => {
@@ -164,8 +140,10 @@ export default function MobileBillingPage() {
if (checkout === 'success') {
const packageIdNumber = packageId ? Number(packageId) : null;
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
const pendingEntry = {
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
checkoutSessionId: existingSessionId,
startedAt: Date.now(),
};
persistPendingCheckout(pendingEntry);
@@ -185,7 +163,7 @@ export default function MobileBillingPage() {
},
{ replace: true },
);
}, [location.hash, location.pathname, location.search, navigate, persistPendingCheckout, t]);
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
React.useEffect(() => {
if (!pendingCheckout) {
@@ -197,6 +175,64 @@ export default function MobileBillingPage() {
}
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
React.useEffect(() => {
if (!pendingCheckout?.checkoutSessionId) {
setCheckoutStatus(null);
setCheckoutStatusReason(null);
lastCheckoutStatusRef.current = null;
return;
}
let active = true;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = async () => {
try {
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
if (!active) {
return;
}
setCheckoutStatus(result.status);
setCheckoutStatusReason(result.reason ?? null);
const lastStatus = lastCheckoutStatusRef.current;
lastCheckoutStatusRef.current = result.status;
if (result.status === 'completed') {
persistPendingCheckout(null);
if (lastStatus !== 'completed') {
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
}
await load();
if (intervalId) {
clearInterval(intervalId);
}
return;
}
if (result.status === 'failed' || result.status === 'cancelled') {
if (intervalId) {
clearInterval(intervalId);
}
}
} catch {
if (!active) {
return;
}
}
};
void poll();
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
return () => {
active = false;
if (intervalId) {
clearInterval(intervalId);
}
};
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
return (
<MobileShell
activeTab="profile"
@@ -216,7 +252,45 @@ export default function MobileBillingPage() {
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard>
) : null}
{pendingCheckout ? (
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={danger}>
{t('billing.checkoutFailedTitle', 'Checkout failed')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutFailedBody',
'The payment did not complete. You can try again or contact support.'
)}
</Text>
{checkoutStatusReason ? (
<Text fontSize="$xs" color={muted}>
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
</Text>
) : null}
</YStack>
<PillBadge tone="danger">
{t('billing.checkoutFailedBadge', 'Failed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
fullWidth={false}
/>
<CTAButton
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>