feat: add checkout action 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 13:35:43 +01:00
parent 02363792c8
commit cceed361b7
6 changed files with 58 additions and 4 deletions

View File

@@ -239,10 +239,13 @@ class PackageController extends Controller
} }
} }
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
return response()->json([ return response()->json([
'status' => $session->status, 'status' => $session->status,
'completed_at' => optional($session->completed_at)->toIso8601String(), 'completed_at' => optional($session->completed_at)->toIso8601String(),
'reason' => $reason, 'reason' => $reason,
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
]); ]);
} }

View File

@@ -2476,9 +2476,9 @@ export async function createTenantPaddleCheckout(
export async function getTenantPackageCheckoutStatus( export async function getTenantPackageCheckoutStatus(
checkoutSessionId: string, checkoutSessionId: string,
): Promise<{ status: string; completed_at?: string | null; reason?: string | null }> { ): Promise<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }> {
const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`); const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`);
return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null }>( return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }>(
response, response,
'Failed to load checkout status' 'Failed to load checkout status'
); );

View File

@@ -47,6 +47,10 @@
"checkoutFailedBadge": "Fehlgeschlagen", "checkoutFailedBadge": "Fehlgeschlagen",
"checkoutFailedRetry": "Erneut versuchen", "checkoutFailedRetry": "Erneut versuchen",
"checkoutFailedDismiss": "Ausblenden", "checkoutFailedDismiss": "Ausblenden",
"checkoutActionTitle": "Aktion erforderlich",
"checkoutActionBody": "Schließe die Zahlung ab, um das Paket zu aktivieren.",
"checkoutActionBadge": "Aktion nötig",
"checkoutActionButton": "Checkout fortsetzen",
"checkoutFailureReasons": { "checkoutFailureReasons": {
"paddle_failed": "Die Zahlung wurde abgelehnt.", "paddle_failed": "Die Zahlung wurde abgelehnt.",
"paddle_cancelled": "Der Checkout wurde abgebrochen." "paddle_cancelled": "Der Checkout wurde abgebrochen."

View File

@@ -47,6 +47,10 @@
"checkoutFailedBadge": "Failed", "checkoutFailedBadge": "Failed",
"checkoutFailedRetry": "Try again", "checkoutFailedRetry": "Try again",
"checkoutFailedDismiss": "Dismiss", "checkoutFailedDismiss": "Dismiss",
"checkoutActionTitle": "Action required",
"checkoutActionBody": "Complete your payment to activate the package.",
"checkoutActionBadge": "Action needed",
"checkoutActionButton": "Continue checkout",
"checkoutFailureReasons": { "checkoutFailureReasons": {
"paddle_failed": "The payment was declined.", "paddle_failed": "The payment was declined.",
"paddle_cancelled": "The checkout was cancelled." "paddle_cancelled": "The checkout was cancelled."

View File

@@ -52,6 +52,7 @@ export default function MobileBillingPage() {
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout()); const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null); const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null); const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null); const lastCheckoutStatusRef = React.useRef<string | null>(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);
@@ -179,6 +180,7 @@ export default function MobileBillingPage() {
if (!pendingCheckout?.checkoutSessionId) { if (!pendingCheckout?.checkoutSessionId) {
setCheckoutStatus(null); setCheckoutStatus(null);
setCheckoutStatusReason(null); setCheckoutStatusReason(null);
setCheckoutActionUrl(null);
lastCheckoutStatusRef.current = null; lastCheckoutStatusRef.current = null;
return; return;
} }
@@ -194,6 +196,7 @@ export default function MobileBillingPage() {
} }
setCheckoutStatus(result.status); setCheckoutStatus(result.status);
setCheckoutStatusReason(result.reason ?? null); setCheckoutStatusReason(result.reason ?? null);
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
const lastStatus = lastCheckoutStatusRef.current; const lastStatus = lastCheckoutStatusRef.current;
lastCheckoutStatusRef.current = result.status; lastCheckoutStatusRef.current = result.status;
@@ -290,7 +293,43 @@ export default function MobileBillingPage() {
</XStack> </XStack>
</MobileCard> </MobileCard>
) : null} ) : null}
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' ? ( {pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
<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.checkoutActionTitle', 'Action required')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
</Text>
</YStack>
<PillBadge tone="warning">
{t('billing.checkoutActionBadge', 'Action needed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutActionButton', 'Continue checkout')}
onPress={() => {
if (checkoutActionUrl && typeof window !== 'undefined') {
window.open(checkoutActionUrl, '_blank', 'noopener');
return;
}
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' && checkoutStatus !== 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2"> <MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}> <YStack space="$0.5" flex={1}>

View File

@@ -21,6 +21,9 @@ class TenantCheckoutSessionStatusTest extends TenantTestCase
'package_id' => $package->id, 'package_id' => $package->id,
'status' => CheckoutSession::STATUS_FAILED, 'status' => CheckoutSession::STATUS_FAILED,
'provider' => CheckoutSession::PROVIDER_PADDLE, 'provider' => CheckoutSession::PROVIDER_PADDLE,
'provider_metadata' => [
'paddle_checkout_url' => 'https://checkout.paddle.test/checkout/123',
],
'status_history' => [ 'status_history' => [
[ [
'status' => CheckoutSession::STATUS_FAILED, 'status' => CheckoutSession::STATUS_FAILED,
@@ -37,6 +40,7 @@ class TenantCheckoutSessionStatusTest extends TenantTestCase
$response->assertOk() $response->assertOk()
->assertJsonPath('status', CheckoutSession::STATUS_FAILED) ->assertJsonPath('status', CheckoutSession::STATUS_FAILED)
->assertJsonPath('reason', 'paddle_failed'); ->assertJsonPath('reason', 'paddle_failed')
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
} }
} }