From 23d8f62c2b8eca288497b1eabdc103129ff2ca4f Mon Sep 17 00:00:00 2001 From: Reino Muhl <10620585+StaberindeZA@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:08:24 -0500 Subject: [PATCH] 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 --- .../subscriptions/payments/paypal/page.tsx | 2 - .../client/components/CheckoutForm/index.tsx | 161 ++++++++++++------ .../components/PaypalManagement/index.tsx | 111 ++++++++---- libs/payments/ui/src/lib/client/en.ftl | 2 + 4 files changed, 185 insertions(+), 91 deletions(-) diff --git a/apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.tsx b/apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.tsx index 60dc1c2a98..254a95d78f 100644 --- a/apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.tsx +++ b/apps/payments/next/app/[locale]/subscriptions/payments/paypal/page.tsx @@ -20,10 +20,8 @@ import errorIcon from '@fxa/shared/assets/images/error.svg'; export default async function PaypalPaymentManagementPage({ params, - searchParams, }: { params: ManageParams; - searchParams: Record | undefined; }) { const acceptLanguage = headers().get('accept-language'); const l10n = getApp().getL10n(acceptLanguage); diff --git a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx index 9dab210276..ce5c902b33 100644 --- a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx +++ b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx @@ -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 && ( {showPayPalButton ? ( - 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); - }} + ) : ( @@ -423,6 +392,94 @@ export function CheckoutForm({ )} - + ); } + +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 ( + + ) + } + + if (isRejected) { + Sentry.captureMessage('PayPal script failed to load'); + return ( + +
PayPal is currently unavailable. Please use another payment option or try again later.
+
+ ) + } + + return ( + 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} + /> + ) +} + diff --git a/libs/payments/ui/src/lib/client/components/PaypalManagement/index.tsx b/libs/payments/ui/src/lib/client/components/PaypalManagement/index.tsx index 5ba71534f3..4bfa016656 100644 --- a/libs/payments/ui/src/lib/client/components/PaypalManagement/index.tsx +++ b/libs/payments/ui/src/lib/client/components/PaypalManagement/index.tsx @@ -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(true); return (
- {isLoading && ( - - )} - - 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)} +
); } + +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 ( + + ) + } + + if (isRejected) { + Sentry.captureMessage('PayPal script failed to load'); + return ( + +
PayPal is currently unavailable. Please use another payment option or try again later.
+
+ ) + } + + return ( + + getPayPalCheckoutToken(currency.toLowerCase()) + } + onApprove={async (data: { orderID: string }) => { + await createPayPalBillingAgreementId(sessionUid, data.orderID); + router.push(`/${locale}/subscriptions/manage` + queryParamString); + }} + onError={(error) => { + throw error; + }} + /> + ) +} diff --git a/libs/payments/ui/src/lib/client/en.ftl b/libs/payments/ui/src/lib/client/en.ftl index 4a8ba50c4c..eb1422a2d8 100644 --- a/libs/payments/ui/src/lib/client/en.ftl +++ b/libs/payments/ui/src/lib/client/en.ftl @@ -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.