fix(payments-ui): improve paypal sdk error handling

Because:

- If the PayPal SDK fails to load, the page crashes and navigates the
  user to an error page.

This commit:

- More gracefully handle PayPal SDK load failure, by showing the
  customer a message letting them know PayPal isn't currently available
  and to try a different payment method or try again later.

Closes #PAY-3323
This commit is contained in:
Reino Muhl 2025-12-16 11:08:24 -05:00
parent bfa1ef30e4
commit 23d8f62c2b
No known key found for this signature in database
4 changed files with 185 additions and 91 deletions

View File

@ -20,10 +20,8 @@ import errorIcon from '@fxa/shared/assets/images/error.svg';
export default async function PaypalPaymentManagementPage({
params,
searchParams,
}: {
params: ManageParams;
searchParams: Record<string, string | string[]> | undefined;
}) {
const acceptLanguage = headers().get('accept-language');
const l10n = getApp().getL10n(acceptLanguage);

View File

@ -3,8 +3,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use client';
import { Localized, } from '@fluent/react';
import { PayPalButtons } from '@paypal/react-paypal-js';
import { Localized } from '@fluent/react';
import { PayPalButtons, usePayPalScriptReducer } from '@paypal/react-paypal-js';
import * as Form from '@radix-ui/react-form';
import Stripe from 'stripe';
import {
@ -41,6 +41,8 @@ import { CartErrorReasonId } from '@fxa/shared/db/mysql/account/kysely-types';
import { PaymentProvidersType } from '@fxa/payments/cart';
import PaypalIcon from '@fxa/shared/assets/images/payment-methods/paypal.svg';
import spinnerWhiteImage from '@fxa/shared/assets/images/spinnerwhite.svg';
import spinnerImage from '@fxa/shared/assets/images/spinner.svg';
import * as Sentry from '@sentry/nextjs';
const getAttributionParams = (searchParams: ReadonlyURLSearchParams) => {
const paramsRecord = Object.fromEntries(searchParams);
@ -75,10 +77,10 @@ interface CheckoutFormProps {
};
paymentInfo?: {
type:
| Stripe.PaymentMethod.Type
| 'google_iap'
| 'apple_iap'
| 'external_paypal';
| Stripe.PaymentMethod.Type
| 'google_iap'
| 'apple_iap'
| 'external_paypal';
last4?: string;
brand?: string;
customerSessionClientSecret?: string;
@ -221,12 +223,12 @@ export function CheckoutForm({
const confirmationTokenParams: ConfirmationTokenCreateParams | undefined =
!isSavedPaymentMethod
? {
payment_method_data: {
billing_details: {
email: sessionEmail || undefined,
},
payment_method_data: {
billing_details: {
email: sessionEmail || undefined,
},
}
},
}
: undefined;
// Create the ConfirmationToken using the details collected by the Payment Element
@ -345,46 +347,13 @@ export function CheckoutForm({
{!isPaymentElementLoading && (
<Form.Submit asChild>
{showPayPalButton ? (
<PayPalButtons
style={{
layout: 'horizontal',
color: 'gold',
shape: 'rect',
label: 'paypal',
height: 48,
borderRadius: 6, // This should match 0.375rem
tagline: false,
}}
className="mt-6 flex justify-center w-full"
createOrder={async () => getPayPalCheckoutToken(cart.currency)}
onApprove={async (data: { orderID: string }) => {
await checkoutCartWithPaypal(
cart.id,
cart.version,
{
locale,
displayName: '',
},
getAttributionParams(searchParams),
sessionUid,
data.orderID
);
const queryParamString = searchParams.toString()
? `?${searchParams.toString()}`
: '';
router.push('./processing' + queryParamString);
}}
onError={async () => {
await finalizeCartWithError(
cart.id,
CartErrorReasonId.BASIC_ERROR
);
const queryParamString = searchParams.toString()
? `?${searchParams.toString()}`
: '';
router.push('./error' + queryParamString);
}}
<CheckoutPayPalButton
cartId={cart.id}
cartVersion={cart.version}
cartCurrency={cart.currency}
locale={locale}
sessionUid={sessionUid}
searchParams={searchParams}
disabled={loading || !formEnabled}
/>
) : (
@ -423,6 +392,94 @@ export function CheckoutForm({
</Form.Submit>
)}
</div>
</Form.Root>
</Form.Root >
);
}
interface CheckoutPayPalButtonProps {
cartId: string;
cartVersion: number;
cartCurrency: string;
locale: string;
sessionUid?: string;
searchParams: ReadonlyURLSearchParams;
disabled: boolean;
}
function CheckoutPayPalButton({
cartId,
cartVersion,
cartCurrency,
locale,
sessionUid,
searchParams,
disabled,
}: CheckoutPayPalButtonProps) {
const router = useRouter();
const [{ isPending, isRejected }] = usePayPalScriptReducer();
if (isPending) {
return (
<Image
src={spinnerImage}
alt=""
className="absolute animate-spin h-8 w-8"
/>
)
}
if (isRejected) {
Sentry.captureMessage('PayPal script failed to load');
return (
<Localized id="paypal-unavailable-error">
<div className="mt-6 flex justify-center w-full text-center text-sm">PayPal is currently unavailable. Please use another payment option or try again later.</div>
</Localized>
)
}
return (
<PayPalButtons
style={{
layout: 'horizontal',
color: 'gold',
shape: 'rect',
label: 'paypal',
height: 48,
borderRadius: 6, // This should match 0.375rem
tagline: false,
}}
className="mt-6 flex justify-center w-full"
createOrder={async () => getPayPalCheckoutToken(cartCurrency)}
onApprove={async (data: { orderID: string }) => {
await checkoutCartWithPaypal(
cartId,
cartVersion,
{
locale,
displayName: '',
},
getAttributionParams(searchParams),
sessionUid,
data.orderID
);
const queryParamString = searchParams.toString()
? `?${searchParams.toString()}`
: '';
router.push('./processing' + queryParamString);
}}
onError={async () => {
await finalizeCartWithError(
cartId,
CartErrorReasonId.BASIC_ERROR
);
const queryParamString = searchParams.toString()
? `?${searchParams.toString()}`
: '';
router.push('./error' + queryParamString);
}}
disabled={disabled}
/>
)
}

View File

@ -8,15 +8,17 @@ import {
PayPalButtons,
PayPalScriptProvider,
ReactPayPalScriptOptions,
usePayPalScriptReducer,
} from '@paypal/react-paypal-js';
import {
createPayPalBillingAgreementId,
getPayPalCheckoutToken,
} from '@fxa/payments/ui/actions';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useParams, useRouter, useSearchParams, type ReadonlyURLSearchParams } from 'next/navigation';
import Image from 'next/image';
import spinnerImage from '@fxa/shared/assets/images/spinner.svg';
import { Localized } from '@fluent/react';
import * as Sentry from '@sentry/nextjs';
const paypalInitialOptions: ReactPayPalScriptOptions = {
clientId: '',
@ -39,14 +41,8 @@ export function PaypalManagement({
sessionUid,
currency,
}: PaypalManagementProps) {
const router = useRouter();
const { locale } = useParams();
const searchParams = useSearchParams();
const queryParamString = searchParams.toString()
? `?${searchParams.toString()}`
: '';
const [isLoading, setLoading] = useState<boolean>(true);
return (
<PayPalScriptProvider
@ -59,37 +55,78 @@ export function PaypalManagement({
}}
>
<div className="flex justify-center items-center max-w-md w-full h-12">
{isLoading && (
<Image
src={spinnerImage}
alt=""
className="absolute animate-spin h-8 w-8"
/>
)}
<PayPalButtons
style={{
layout: 'horizontal',
color: 'gold',
shape: 'rect',
label: 'paypal',
height: 48,
borderRadius: 6,
tagline: false,
}}
className="flex justify-center w-full"
createOrder={async () =>
getPayPalCheckoutToken(currency.toLowerCase())
}
onApprove={async (data: { orderID: string }) => {
await createPayPalBillingAgreementId(sessionUid, data.orderID);
router.push(`/${locale}/subscriptions/manage` + queryParamString);
}}
onError={(error) => {
throw error;
}}
onInit={() => setLoading(false)}
<ManagementPayPalButton
currency={currency}
locale={Array.isArray(locale) ? locale[0] : locale}
sessionUid={sessionUid}
searchParams={searchParams}
/>
</div>
</PayPalScriptProvider>
);
}
interface ManagementPayPalButtonProps {
currency: string;
locale: string;
sessionUid: string;
searchParams: ReadonlyURLSearchParams;
}
function ManagementPayPalButton({
currency,
locale,
sessionUid,
searchParams,
}: ManagementPayPalButtonProps) {
const router = useRouter();
const [{ isPending, isRejected }] = usePayPalScriptReducer();
const queryParamString = searchParams.toString()
? `?${searchParams.toString()}`
: '';
if (isPending) {
return (
<Image
src={spinnerImage}
alt=""
className="absolute animate-spin h-8 w-8"
/>
)
}
if (isRejected) {
Sentry.captureMessage('PayPal script failed to load');
return (
<Localized id="paypal-unavailable-error">
<div className="mt-6 flex justify-center w-full text-center text-sm">PayPal is currently unavailable. Please use another payment option or try again later.</div>
</Localized>
)
}
return (
<PayPalButtons
style={{
layout: 'horizontal',
color: 'gold',
shape: 'rect',
label: 'paypal',
height: 48,
borderRadius: 6,
tagline: false,
}}
className="flex justify-center w-full"
createOrder={async () =>
getPayPalCheckoutToken(currency.toLowerCase())
}
onApprove={async (data: { orderID: string }) => {
await createPayPalBillingAgreementId(sessionUid, data.orderID);
router.push(`/${locale}/subscriptions/manage` + queryParamString);
}}
onError={(error) => {
throw error;
}}
/>
)
}

View File

@ -2,3 +2,5 @@ dialog-close = Close dialog
button-back-to-subscriptions = Back to subscriptions
subscription-content-cancel-action-error = An unexpected error occurred. Please try again.
paypal-unavailable-error = { -brand-paypal } is currently unavailable. Please use another payment option or try again later.