Show billing activation banner
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-01-12 12:07:37 +01:00
parent 4bcaef53f7
commit b854e3feaa
6 changed files with 154 additions and 1 deletions

View File

@@ -36,6 +36,11 @@
},
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
"checkoutCancelled": "Checkout wurde abgebrochen.",
"checkoutPendingTitle": "Paket wird aktiviert",
"checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.",
"checkoutPendingBadge": "Ausstehend",
"checkoutPendingRefresh": "Aktualisieren",
"checkoutPendingDismiss": "Ausblenden",
"sections": {
"invoices": {
"title": "Rechnungen & Zahlungen",

View File

@@ -36,6 +36,11 @@
},
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
"checkoutCancelled": "Checkout was cancelled.",
"checkoutPendingTitle": "Activating your package",
"checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.",
"checkoutPendingBadge": "Pending",
"checkoutPendingRefresh": "Refresh",
"checkoutPendingDismiss": "Dismiss",
"sections": {
"invoices": {
"title": "Invoices & payments",

View File

@@ -27,6 +27,9 @@ import {
getPackageFeatureLabel,
getPackageLimitEntries,
} from './lib/packageSummary';
import { PendingCheckout, PENDING_CHECKOUT_TTL_MS, shouldClearPendingCheckout } from './lib/billingCheckout';
const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1';
export default function MobileBillingPage() {
const { t } = useTranslation('management');
@@ -40,6 +43,29 @@ 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 packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
@@ -95,6 +121,22 @@ export default function MobileBillingPage() {
}
}, [portalBusy, t]);
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.
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
@@ -115,17 +157,26 @@ export default function MobileBillingPage() {
const params = new URLSearchParams(location.search);
const checkout = params.get('checkout');
const packageId = params.get('package_id');
if (!checkout) {
return;
}
if (checkout === 'success') {
const packageIdNumber = packageId ? Number(packageId) : null;
const pendingEntry = {
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
startedAt: Date.now(),
};
persistPendingCheckout(pendingEntry);
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
} else if (checkout === 'cancel') {
persistPendingCheckout(null);
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
}
params.delete('checkout');
params.delete('package_id');
navigate(
{
pathname: location.pathname,
@@ -134,7 +185,17 @@ export default function MobileBillingPage() {
},
{ replace: true },
);
}, [location.hash, location.pathname, location.search, navigate, t]);
}, [location.hash, location.pathname, location.search, navigate, persistPendingCheckout, t]);
React.useEffect(() => {
if (!pendingCheckout) {
return;
}
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
persistPendingCheckout(null);
}
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
return (
<MobileShell
@@ -155,6 +216,35 @@ export default function MobileBillingPage() {
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard>
) : null}
{pendingCheckout ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutPendingTitle', 'Activating your package')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutPendingBody',
'This can take a few minutes. We will update this screen once the package is active.'
)}
</Text>
</YStack>
<PillBadge tone="warning">
{t('billing.checkoutPendingBadge', 'Pending')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
<CTAButton
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
<MobileCard space="$2" ref={packagesRef as any}>
<XStack alignItems="center" space="$2">

View File

@@ -256,8 +256,10 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
const successUrl = new URL(billingUrl);
successUrl.searchParams.set('checkout', 'success');
successUrl.searchParams.set('package_id', String(pkg.id));
const cancelUrl = new URL(billingUrl);
cancelUrl.searchParams.set('checkout', 'cancel');
cancelUrl.searchParams.set('package_id', String(pkg.id));
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
success_url: successUrl.toString(),

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { PENDING_CHECKOUT_TTL_MS, isCheckoutExpired, shouldClearPendingCheckout } from '../lib/billingCheckout';
describe('billingCheckout helpers', () => {
it('detects expired pending checkout', () => {
const pending = { packageId: 12, startedAt: 0 };
expect(isCheckoutExpired(pending, PENDING_CHECKOUT_TTL_MS + 1)).toBe(true);
});
it('keeps pending checkout when active package differs', () => {
const pending = { packageId: 12, startedAt: Date.now() };
expect(shouldClearPendingCheckout(pending, 18, pending.startedAt)).toBe(false);
});
it('clears pending checkout when active package matches', () => {
const now = Date.now();
const pending = { packageId: 12, startedAt: now };
expect(shouldClearPendingCheckout(pending, 12, now)).toBe(true);
});
});

View File

@@ -0,0 +1,31 @@
export type PendingCheckout = {
packageId: number | null;
startedAt: number;
};
export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30;
export function isCheckoutExpired(
pending: PendingCheckout,
now = Date.now(),
ttl = PENDING_CHECKOUT_TTL_MS,
): boolean {
return now - pending.startedAt > ttl;
}
export function shouldClearPendingCheckout(
pending: PendingCheckout,
activePackageId: number | null,
now = Date.now(),
ttl = PENDING_CHECKOUT_TTL_MS,
): boolean {
if (isCheckoutExpired(pending, now, ttl)) {
return true;
}
if (pending.packageId && activePackageId && pending.packageId === activePackageId) {
return true;
}
return false;
}