Show billing activation banner
This commit is contained in:
@@ -36,6 +36,11 @@
|
|||||||
},
|
},
|
||||||
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||||
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
"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": {
|
"sections": {
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"title": "Rechnungen & Zahlungen",
|
"title": "Rechnungen & Zahlungen",
|
||||||
|
|||||||
@@ -36,6 +36,11 @@
|
|||||||
},
|
},
|
||||||
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||||
"checkoutCancelled": "Checkout was cancelled.",
|
"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": {
|
"sections": {
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"title": "Invoices & payments",
|
"title": "Invoices & payments",
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
getPackageFeatureLabel,
|
getPackageFeatureLabel,
|
||||||
getPackageLimitEntries,
|
getPackageLimitEntries,
|
||||||
} from './lib/packageSummary';
|
} 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() {
|
export default function MobileBillingPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -40,6 +43,29 @@ export default function MobileBillingPage() {
|
|||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
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 packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const supportEmail = 'support@fotospiel.de';
|
const supportEmail = 'support@fotospiel.de';
|
||||||
@@ -95,6 +121,22 @@ export default function MobileBillingPage() {
|
|||||||
}
|
}
|
||||||
}, [portalBusy, t]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -115,17 +157,26 @@ export default function MobileBillingPage() {
|
|||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const checkout = params.get('checkout');
|
const checkout = params.get('checkout');
|
||||||
|
const packageId = params.get('package_id');
|
||||||
if (!checkout) {
|
if (!checkout) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkout === 'success') {
|
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.'));
|
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
|
||||||
} else if (checkout === 'cancel') {
|
} else if (checkout === 'cancel') {
|
||||||
|
persistPendingCheckout(null);
|
||||||
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
|
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
params.delete('checkout');
|
params.delete('checkout');
|
||||||
|
params.delete('package_id');
|
||||||
navigate(
|
navigate(
|
||||||
{
|
{
|
||||||
pathname: location.pathname,
|
pathname: location.pathname,
|
||||||
@@ -134,7 +185,17 @@ export default function MobileBillingPage() {
|
|||||||
},
|
},
|
||||||
{ replace: true },
|
{ 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 (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
@@ -155,6 +216,35 @@ export default function MobileBillingPage() {
|
|||||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : 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}>
|
<MobileCard space="$2" ref={packagesRef as any}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
|
|||||||
@@ -256,8 +256,10 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
|
|||||||
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
|
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
|
||||||
const successUrl = new URL(billingUrl);
|
const successUrl = new URL(billingUrl);
|
||||||
successUrl.searchParams.set('checkout', 'success');
|
successUrl.searchParams.set('checkout', 'success');
|
||||||
|
successUrl.searchParams.set('package_id', String(pkg.id));
|
||||||
const cancelUrl = new URL(billingUrl);
|
const cancelUrl = new URL(billingUrl);
|
||||||
cancelUrl.searchParams.set('checkout', 'cancel');
|
cancelUrl.searchParams.set('checkout', 'cancel');
|
||||||
|
cancelUrl.searchParams.set('package_id', String(pkg.id));
|
||||||
|
|
||||||
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
|
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
|
||||||
success_url: successUrl.toString(),
|
success_url: successUrl.toString(),
|
||||||
|
|||||||
20
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
20
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
31
resources/js/admin/mobile/lib/billingCheckout.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user