From b854e3feaaf8cca794b5e57728b5040058f204f1 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Jan 2026 12:07:37 +0100 Subject: [PATCH] Show billing activation banner --- .../js/admin/i18n/locales/de/management.json | 5 + .../js/admin/i18n/locales/en/management.json | 5 + resources/js/admin/mobile/BillingPage.tsx | 92 ++++++++++++++++++- resources/js/admin/mobile/PackageShopPage.tsx | 2 + .../mobile/__tests__/billingCheckout.test.ts | 20 ++++ .../js/admin/mobile/lib/billingCheckout.ts | 31 +++++++ 6 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 resources/js/admin/mobile/__tests__/billingCheckout.test.ts create mode 100644 resources/js/admin/mobile/lib/billingCheckout.ts diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 0d88185..f08f9ca 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -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", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 85533de..aad3549 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -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", diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index 798773e..9115261 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -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(null); const [portalBusy, setPortalBusy] = React.useState(false); + const [pendingCheckout, setPendingCheckout] = React.useState(() => { + 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(null); const invoicesRef = React.useRef(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 ( ) : null} + {pendingCheckout ? ( + + + + + {t('billing.checkoutPendingTitle', 'Activating your package')} + + + {t( + 'billing.checkoutPendingBody', + 'This can take a few minutes. We will update this screen once the package is active.' + )} + + + + {t('billing.checkoutPendingBadge', 'Pending')} + + + + + persistPendingCheckout(null)} + fullWidth={false} + /> + + + ) : null} diff --git a/resources/js/admin/mobile/PackageShopPage.tsx b/resources/js/admin/mobile/PackageShopPage.tsx index 5345dc6..9d38935 100644 --- a/resources/js/admin/mobile/PackageShopPage.tsx +++ b/resources/js/admin/mobile/PackageShopPage.tsx @@ -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(), diff --git a/resources/js/admin/mobile/__tests__/billingCheckout.test.ts b/resources/js/admin/mobile/__tests__/billingCheckout.test.ts new file mode 100644 index 0000000..68b7a47 --- /dev/null +++ b/resources/js/admin/mobile/__tests__/billingCheckout.test.ts @@ -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); + }); +}); diff --git a/resources/js/admin/mobile/lib/billingCheckout.ts b/resources/js/admin/mobile/lib/billingCheckout.ts new file mode 100644 index 0000000..cb54d7c --- /dev/null +++ b/resources/js/admin/mobile/lib/billingCheckout.ts @@ -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; +}