diff --git a/assets/js/base/components/radio-control/index.js b/assets/js/base/components/radio-control/index.js index dae70204a58..f72c5d52f7c 100644 --- a/assets/js/base/components/radio-control/index.js +++ b/assets/js/base/components/radio-control/index.js @@ -11,11 +11,11 @@ import RadioControlOption from './option'; import './style.scss'; const RadioControl = ( { - className, + className = '', instanceId, id, selected, - onChange, + onChange = () => {}, options = [], } ) => { const radioControlId = id || instanceId; diff --git a/assets/js/base/context/providers/cart-checkout/payment-methods/actions.ts b/assets/js/base/context/providers/cart-checkout/payment-methods/actions.ts index ddd9d2fea5d..84f24110c8f 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-methods/actions.ts +++ b/assets/js/base/context/providers/cart-checkout/payment-methods/actions.ts @@ -14,8 +14,9 @@ import { ACTION, STATUS } from './constants'; export interface ActionType { type: ACTION | STATUS; errorMessage?: string; - paymentMethodData?: Record< string, unknown >; + paymentMethodData?: Record< string, unknown > | undefined; paymentMethods?: PaymentMethods | ExpressPaymentMethods; + paymentMethod?: string; shouldSavePaymentMethod?: boolean; } @@ -23,63 +24,58 @@ export interface ActionType { * All the actions that can be dispatched for payment methods. */ export const actions = { - statusOnly: ( type: STATUS ): { type: STATUS } => ( { type } as const ), - error: ( errorMessage: string ): ActionType => - ( { - type: STATUS.ERROR, - errorMessage, - } as const ), + statusOnly: ( type: STATUS ): ActionType => ( { + type, + } ), + error: ( errorMessage: string ): ActionType => ( { + type: STATUS.ERROR, + errorMessage, + } ), failed: ( { errorMessage, paymentMethodData, }: { errorMessage: string; paymentMethodData: Record< string, unknown >; - } ): ActionType => - ( { - type: STATUS.FAILED, - errorMessage, - paymentMethodData, - } as const ), + } ): ActionType => ( { + type: STATUS.FAILED, + errorMessage, + paymentMethodData, + } ), success: ( { paymentMethodData, }: { paymentMethodData?: Record< string, unknown >; - } ): ActionType => - ( { - type: STATUS.SUCCESS, - paymentMethodData, - } as const ), - started: ( { + } ): ActionType => ( { + type: STATUS.SUCCESS, paymentMethodData, - }: { - paymentMethodData?: Record< string, unknown >; - } ): ActionType => - ( { - type: STATUS.STARTED, - paymentMethodData, - } as const ), + } ), setRegisteredPaymentMethods: ( paymentMethods: PaymentMethods - ): ActionType => - ( { - type: ACTION.SET_REGISTERED_PAYMENT_METHODS, - paymentMethods, - } as const ), + ): ActionType => ( { + type: ACTION.SET_REGISTERED_PAYMENT_METHODS, + paymentMethods, + } ), setRegisteredExpressPaymentMethods: ( paymentMethods: ExpressPaymentMethods - ): ActionType => - ( { - type: ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS, - paymentMethods, - } as const ), + ): ActionType => ( { + type: ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS, + paymentMethods, + } ), setShouldSavePaymentMethod: ( shouldSavePaymentMethod: boolean - ): ActionType => - ( { - type: ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD, - shouldSavePaymentMethod, - } as const ), + ): ActionType => ( { + type: ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD, + shouldSavePaymentMethod, + } ), + setActivePaymentMethod: ( + paymentMethod: string, + paymentMethodData: Record< string, unknown > + ): ActionType => ( { + type: ACTION.SET_ACTIVE_PAYMENT_METHOD, + paymentMethod, + paymentMethodData, + } ), }; export default actions; diff --git a/assets/js/base/context/providers/cart-checkout/payment-methods/constants.ts b/assets/js/base/context/providers/cart-checkout/payment-methods/constants.ts index f8c9bd7dd89..a5f9528b507 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-methods/constants.ts +++ b/assets/js/base/context/providers/cart-checkout/payment-methods/constants.ts @@ -20,16 +20,17 @@ export enum ACTION { SET_REGISTERED_PAYMENT_METHODS = 'set_registered_payment_methods', SET_REGISTERED_EXPRESS_PAYMENT_METHODS = 'set_registered_express_payment_methods', SET_SHOULD_SAVE_PAYMENT_METHOD = 'set_should_save_payment_method', + SET_ACTIVE_PAYMENT_METHOD = 'set_active_payment_method', } // Note - if fields are added/shape is changed, you may want to update PRISTINE reducer clause to preserve your new field. export const DEFAULT_PAYMENT_DATA_CONTEXT_STATE: PaymentMethodDataContextState = { currentStatus: STATUS.PRISTINE, shouldSavePaymentMethod: false, + activePaymentMethod: '', paymentMethodData: { payment_method: '', }, - hasSavedToken: false, errorMessage: '', paymentMethods: {}, expressPaymentMethods: {}, @@ -61,9 +62,8 @@ export const DEFAULT_PAYMENT_METHOD_DATA: PaymentMethodDataContextType = { paymentMethodData: {}, errorMessage: '', activePaymentMethod: '', - setActivePaymentMethod: () => void null, activeSavedToken: '', - setActiveSavedToken: () => void null, + setActivePaymentMethod: () => void null, customerPaymentMethods: {}, paymentMethods: {}, expressPaymentMethods: {}, diff --git a/assets/js/base/context/providers/cart-checkout/payment-methods/payment-method-data-context.tsx b/assets/js/base/context/providers/cart-checkout/payment-methods/payment-method-data-context.tsx index c8c6da01254..3fe19ab4f60 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-methods/payment-method-data-context.tsx +++ b/assets/js/base/context/providers/cart-checkout/payment-methods/payment-method-data-context.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, } from '@wordpress/element'; +import { objectHasProp } from '@woocommerce/types'; /** * Internal dependencies @@ -29,7 +30,6 @@ import { useExpressPaymentMethods, } from './use-payment-method-registration'; import { usePaymentMethodDataDispatchers } from './use-payment-method-dispatchers'; -import { useActivePaymentMethod } from './use-active-payment-method'; import { useCheckoutContext } from '../checkout-state'; import { useEditorContext } from '../../editor-context'; import { @@ -90,6 +90,7 @@ export const PaymentMethodDataProvider = ( { reducer, DEFAULT_PAYMENT_DATA_CONTEXT_STATE ); + const { dispatchActions, setPaymentStatus, @@ -103,13 +104,6 @@ export const PaymentMethodDataProvider = ( { dispatchActions.setRegisteredExpressPaymentMethods ); - const { - activePaymentMethod, - activeSavedToken, - setActivePaymentMethod, - setActiveSavedToken, - } = useActivePaymentMethod(); - const customerPaymentMethods = useMemo( (): CustomerPaymentMethods => { if ( isEditor ) { return getPreviewData( @@ -145,7 +139,7 @@ export const PaymentMethodDataProvider = ( { const isExpressPaymentMethodActive = Object.keys( paymentData.expressPaymentMethods - ).includes( activePaymentMethod ); + ).includes( paymentData.activePaymentMethod ); const currentStatus = useMemo( () => ( { @@ -167,38 +161,64 @@ export const PaymentMethodDataProvider = ( { [ paymentData.currentStatus, isExpressPaymentMethodActive ] ); - // Update the active (selected) payment method when it is empty, or invalid. + /** + * Active Gateway Selection + * + * Updates the active (selected) payment method when it is empty, or invalid. This uses the first saved payment + * method found (if applicable), or the first standard gateway. + */ useEffect( () => { const paymentMethodKeys = Object.keys( paymentData.paymentMethods ); + + if ( ! paymentMethodsInitialized || ! paymentMethodKeys.length ) { + return; + } + const allPaymentMethodKeys = [ ...paymentMethodKeys, ...Object.keys( paymentData.expressPaymentMethods ), ]; - if ( ! paymentMethodsInitialized || ! paymentMethodKeys.length ) { + + // Return if current method is valid. + if ( + paymentData.activePaymentMethod && + allPaymentMethodKeys.includes( paymentData.activePaymentMethod ) + ) { return; } - setActivePaymentMethod( ( currentActivePaymentMethod ) => { - // If there's no active payment method, or the active payment method has - // been removed (e.g. COD vs shipping methods), set one as active. - // Note: It's possible that the active payment method might be an - // express payment method. So registered express payment methods are - // included in the check here. - if ( - ! currentActivePaymentMethod || - ! allPaymentMethodKeys.includes( currentActivePaymentMethod ) - ) { - setPaymentStatus().pristine(); - return Object.keys( paymentData.paymentMethods )[ 0 ]; - } - return currentActivePaymentMethod; - } ); + setPaymentStatus().pristine(); + + const customerPaymentMethod = + Object.keys( customerPaymentMethods ).flatMap( + ( type ) => customerPaymentMethods[ type ] + )[ 0 ] || undefined; + + if ( customerPaymentMethod ) { + const token = customerPaymentMethod.tokenId; + const paymentMethodSlug = customerPaymentMethod.method.gateway; + const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`; + + dispatchActions.setActivePaymentMethod( paymentMethodSlug, { + token, + payment_method: paymentMethodSlug, + [ savedTokenKey ]: token.toString(), + isSavedToken: true, + } ); + return; + } + + dispatchActions.setActivePaymentMethod( + Object.keys( paymentData.paymentMethods )[ 0 ] + ); }, [ paymentMethodsInitialized, paymentData.paymentMethods, paymentData.expressPaymentMethods, - setActivePaymentMethod, + dispatchActions, setPaymentStatus, + paymentData.activePaymentMethod, + customerPaymentMethods, ] ); // flip payment to processing if checkout processing is complete, there are no errors, and payment status is started. @@ -226,21 +246,12 @@ export const PaymentMethodDataProvider = ( { } }, [ checkoutIsIdle, currentStatus.isSuccessful, setPaymentStatus ] ); - // if checkout has an error and payment is not being made with a saved token and payment status is success, then let's sync payment status back to pristine. + // if checkout has an error sync payment status back to pristine. useEffect( () => { - if ( - checkoutHasError && - currentStatus.isSuccessful && - ! paymentData.hasSavedToken - ) { + if ( checkoutHasError && currentStatus.isSuccessful ) { setPaymentStatus().pristine(); } - }, [ - checkoutHasError, - currentStatus.isSuccessful, - paymentData.hasSavedToken, - setPaymentStatus, - ] ); + }, [ checkoutHasError, currentStatus.isSuccessful, setPaymentStatus ] ); useEffect( () => { // Note: the nature of this event emitter is that it will bail on any @@ -325,16 +336,21 @@ export const PaymentMethodDataProvider = ( { addErrorNotice, ] ); + const activeSavedToken = + typeof paymentData.paymentMethodData === 'object' && + objectHasProp( paymentData.paymentMethodData, 'token' ) + ? paymentData.paymentMethodData.token + '' + : ''; + const paymentContextData: PaymentMethodDataContextType = { setPaymentStatus, currentStatus, paymentStatuses: STATUS, paymentMethodData: paymentData.paymentMethodData, errorMessage: paymentData.errorMessage, - activePaymentMethod, - setActivePaymentMethod, + activePaymentMethod: paymentData.activePaymentMethod, activeSavedToken, - setActiveSavedToken, + setActivePaymentMethod: dispatchActions.setActivePaymentMethod, onPaymentProcessing, customerPaymentMethods, paymentMethods: paymentData.paymentMethods, diff --git a/assets/js/base/context/providers/cart-checkout/payment-methods/reducer.ts b/assets/js/base/context/providers/cart-checkout/payment-methods/reducer.ts index eb58e0584e3..a943f5fd217 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-methods/reducer.ts +++ b/assets/js/base/context/providers/cart-checkout/payment-methods/reducer.ts @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { PaymentMethods } from '@woocommerce/type-defs/payments'; + /** * Internal dependencies */ @@ -9,14 +14,6 @@ import { import type { PaymentMethodDataContextState } from './types'; import type { ActionType } from './actions'; -const hasSavedPaymentToken = ( - paymentMethodData: Record< string, unknown > | undefined -): boolean => { - return !! ( - typeof paymentMethodData === 'object' && paymentMethodData.isSavedToken - ); -}; - /** * Reducer for payment data state */ @@ -28,17 +25,23 @@ const reducer = ( shouldSavePaymentMethod = false, errorMessage = '', paymentMethods = {}, + paymentMethod = '', }: ActionType ): PaymentMethodDataContextState => { switch ( type ) { + case STATUS.PRISTINE: + return { + // This keeps payment method registration state and any set data. This effectively just resets the + // status and any error messages. + ...DEFAULT_PAYMENT_DATA_CONTEXT_STATE, + ...state, + errorMessage: '', + currentStatus: STATUS.PRISTINE, + }; case STATUS.STARTED: return { ...state, currentStatus: STATUS.STARTED, - paymentMethodData: paymentMethodData || state.paymentMethodData, - hasSavedToken: hasSavedPaymentToken( - paymentMethodData || state.paymentMethodData - ), }; case STATUS.ERROR: return state.currentStatus !== STATUS.ERROR @@ -65,9 +68,6 @@ const reducer = ( currentStatus: STATUS.SUCCESS, paymentMethodData: paymentMethodData || state.paymentMethodData, - hasSavedToken: hasSavedPaymentToken( - paymentMethodData || state.paymentMethodData - ), } : state; case STATUS.PROCESSING: @@ -85,24 +85,10 @@ const reducer = ( currentStatus: STATUS.COMPLETE, } : state; - - case STATUS.PRISTINE: - return { - ...DEFAULT_PAYMENT_DATA_CONTEXT_STATE, - currentStatus: STATUS.PRISTINE, - // keep payment method registration state - paymentMethods: { - ...state.paymentMethods, - }, - expressPaymentMethods: { - ...state.expressPaymentMethods, - }, - shouldSavePaymentMethod: state.shouldSavePaymentMethod, - }; case ACTION.SET_REGISTERED_PAYMENT_METHODS: return { ...state, - paymentMethods, + paymentMethods: paymentMethods as PaymentMethods, }; case ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS: return { @@ -114,6 +100,12 @@ const reducer = ( ...state, shouldSavePaymentMethod, }; + case ACTION.SET_ACTIVE_PAYMENT_METHOD: + return { + ...state, + activePaymentMethod: paymentMethod, + paymentMethodData: paymentMethodData || state.paymentMethodData, + }; } }; diff --git a/assets/js/base/context/providers/cart-checkout/payment-methods/test/payment-method-data-context.js b/assets/js/base/context/providers/cart-checkout/payment-methods/test/payment-method-data-context.js index cfac18672c1..f2e145fb1bb 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-methods/test/payment-method-data-context.js +++ b/assets/js/base/context/providers/cart-checkout/payment-methods/test/payment-method-data-context.js @@ -60,7 +60,7 @@ jest.mock( '@woocommerce/settings', () => { }; } ); -const registerMockPaymentMethods = () => { +const registerMockPaymentMethods = ( savedCards = true ) => { [ 'cheque', 'bacs' ].forEach( ( name ) => { registerPaymentMethod( { name, @@ -84,7 +84,7 @@ const registerMockPaymentMethods = () => { icons: null, canMakePayment: () => true, supports: { - showSavedCards: true, + showSavedCards: savedCards, showSaveOption: true, features: [ 'products' ], }, @@ -132,7 +132,7 @@ const resetMockPaymentMethods = () => { describe( 'Testing Payment Method Data Context Provider', () => { beforeEach( () => { act( () => { - registerMockPaymentMethods(); + registerMockPaymentMethods( false ); fetchMock.mockResponse( ( req ) => { if ( req.url.match( /wc\/store\/cart/ ) ) { @@ -146,12 +146,14 @@ describe( 'Testing Payment Method Data Context Provider', () => { dispatch( storeKey ).receiveCart( defaultCartState.cartData ); } ); } ); + afterEach( async () => { act( () => { resetMockPaymentMethods(); fetchMock.resetMocks(); } ); } ); + it( 'toggles active payment method correctly for express payment activation and close', async () => { const TriggerActiveExpressPaymentMethod = () => { const { activePaymentMethod } = usePaymentMethodDataContext(); @@ -215,6 +217,32 @@ describe( 'Testing Payment Method Data Context Provider', () => { // ["`select` control in `@wordpress/data-controls` is deprecated. Please use built-in `resolveSelect` control in `@wordpress/data` instead."] expect( console ).toHaveWarned(); } ); +} ); + +describe( 'Testing Payment Method Data Context Provider with saved cards turned on', () => { + beforeEach( () => { + act( () => { + registerMockPaymentMethods( true ); + + fetchMock.mockResponse( ( req ) => { + if ( req.url.match( /wc\/store\/cart/ ) ) { + return Promise.resolve( JSON.stringify( previewCart ) ); + } + return Promise.resolve( '' ); + } ); + + // need to clear the store resolution state between tests. + dispatch( storeKey ).invalidateResolutionForStore(); + dispatch( storeKey ).receiveCart( defaultCartState.cartData ); + } ); + } ); + + afterEach( async () => { + act( () => { + resetMockPaymentMethods(); + fetchMock.resetMocks(); + } ); + } ); it( 'resets saved payment method data after starting and closing an express payment method', async () => { const TriggerActiveExpressPaymentMethod = () => { diff --git a/assets/js/base/context/providers/cart-checkout/payment-methods/types.ts b/assets/js/base/context/providers/cart-checkout/payment-methods/types.ts index 70928412111..7f3b4d1bebc 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-methods/types.ts +++ b/assets/js/base/context/providers/cart-checkout/payment-methods/types.ts @@ -27,17 +27,21 @@ export type CustomerPaymentMethods = | Record< string, CustomerPaymentMethod > | EmptyObjectType; -export type PaymentMethodDispatchers = { +export interface PaymentMethodDispatchers { setRegisteredPaymentMethods: ( paymentMethods: PaymentMethods ) => void; setRegisteredExpressPaymentMethods: ( paymentMethods: ExpressPaymentMethods ) => void; setShouldSavePayment: ( shouldSave: boolean ) => void; -}; + setActivePaymentMethod: ( + paymentMethod: string, + paymentMethodData?: ObjectType | EmptyObjectType + ) => void; +} export interface PaymentStatusDispatchers { pristine: () => void; - started: ( paymentMethodData?: ObjectType | EmptyObjectType ) => void; + started: () => void; processing: () => void; completed: () => void; error: ( error: string ) => void; @@ -56,8 +60,8 @@ export interface PaymentStatusDispatchers { export interface PaymentMethodDataContextState { currentStatus: STATUS; shouldSavePaymentMethod: boolean; + activePaymentMethod: string; paymentMethodData: ObjectType | EmptyObjectType; - hasSavedToken: boolean; errorMessage: string; paymentMethods: PaymentMethods; expressPaymentMethods: ExpressPaymentMethods; @@ -95,12 +99,10 @@ export type PaymentMethodDataContextType = { errorMessage: string; // The active payment method slug. activePaymentMethod: string; - // A function for setting the active payment method. - setActivePaymentMethod: ( paymentMethod: string ) => void; // Current active token. activeSavedToken: string; - // A function for setting the active payment method token. - setActiveSavedToken: ( activeSavedToken: string ) => void; + // A function for setting the active payment method. + setActivePaymentMethod: PaymentMethodDispatchers[ 'setActivePaymentMethod' ]; // Returns the customer payment for the customer if it exists. customerPaymentMethods: | Record< string, CustomerPaymentMethod > @@ -120,7 +122,7 @@ export type PaymentMethodDataContextType = { // True if an express payment method is active. isExpressPaymentMethodActive: boolean; // A function used to set the shouldSavePayment value. - setShouldSavePayment: ( shouldSavePayment: boolean ) => void; + setShouldSavePayment: PaymentMethodDispatchers[ 'setShouldSavePayment' ]; // True means that the configured payment method option is saved for the customer. shouldSavePayment: boolean; }; diff --git a/assets/js/base/context/providers/cart-checkout/payment-methods/use-active-payment-method.ts b/assets/js/base/context/providers/cart-checkout/payment-methods/use-active-payment-method.ts deleted file mode 100644 index cf01e64b88d..00000000000 --- a/assets/js/base/context/providers/cart-checkout/payment-methods/use-active-payment-method.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * External dependencies - */ -import { useState, useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { useStoreEvents } from '../../../hooks/use-store-events'; - -export const useActivePaymentMethod = (): { - activePaymentMethod: string; - activeSavedToken: string; - setActivePaymentMethod: React.Dispatch< React.SetStateAction< string > >; - setActiveSavedToken: ( token: string ) => void; -} => { - const { dispatchCheckoutEvent } = useStoreEvents(); - - // The active payment method - e.g. Stripe CC or BACS. - const [ activePaymentMethod, setActivePaymentMethod ] = useState( '' ); - - // If a previously saved payment method is active, the token for that method. For example, a for a Stripe CC card saved to user account. - const [ activeSavedToken, setActiveSavedToken ] = useState( '' ); - - // Trigger event on change. - useEffect( () => { - dispatchCheckoutEvent( 'set-active-payment-method', { - activePaymentMethod, - } ); - }, [ dispatchCheckoutEvent, activePaymentMethod ] ); - - return { - activePaymentMethod, - activeSavedToken, - setActivePaymentMethod, - setActiveSavedToken, - }; -}; diff --git a/assets/js/base/context/providers/cart-checkout/payment-methods/use-payment-method-dispatchers.ts b/assets/js/base/context/providers/cart-checkout/payment-methods/use-payment-method-dispatchers.ts index 06d2eaa5d1c..26cecb2a62b 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-methods/use-payment-method-dispatchers.ts +++ b/assets/js/base/context/providers/cart-checkout/payment-methods/use-payment-method-dispatchers.ts @@ -38,6 +38,13 @@ export const usePaymentMethodDataDispatchers = ( void dispatch( actions.setShouldSavePaymentMethod( shouldSave ) ), + setActivePaymentMethod: ( paymentMethod, paymentMethodData = {} ) => + void dispatch( + actions.setActivePaymentMethod( + paymentMethod, + paymentMethodData + ) + ), } ), [ dispatch ] ); @@ -45,13 +52,7 @@ export const usePaymentMethodDataDispatchers = ( const setPaymentStatus = useCallback( (): PaymentStatusDispatchers => ( { pristine: () => dispatch( actions.statusOnly( STATUS.PRISTINE ) ), - started: ( paymentMethodData ) => { - dispatch( - actions.started( { - paymentMethodData, - } ) - ); - }, + started: () => dispatch( actions.statusOnly( STATUS.STARTED ) ), processing: () => dispatch( actions.statusOnly( STATUS.PROCESSING ) ), completed: () => dispatch( actions.statusOnly( STATUS.COMPLETE ) ), diff --git a/assets/js/blocks/cart-checkout/payment-methods/express-payment-methods.js b/assets/js/blocks/cart-checkout/payment-methods/express-payment-methods.js index 58dd3e2c5f7..c173cc399bd 100644 --- a/assets/js/blocks/cart-checkout/payment-methods/express-payment-methods.js +++ b/assets/js/blocks/cart-checkout/payment-methods/express-payment-methods.js @@ -40,13 +40,14 @@ const ExpressPaymentMethods = () => { /** * onExpressPaymentClick should be triggered when the express payment button is clicked. * - * This will store the previous active payment method, set the express method as active, and set the payment status to started. + * This will store the previous active payment method, set the express method as active, and set the payment status + * to started. */ const onExpressPaymentClick = useCallback( ( paymentMethodId ) => () => { previousActivePaymentMethod.current = activePaymentMethod; previousPaymentMethodData.current = paymentMethodData; - setPaymentStatus().started( {} ); + setPaymentStatus().started(); setActivePaymentMethod( paymentMethodId ); }, [ @@ -64,10 +65,10 @@ const ExpressPaymentMethods = () => { */ const onExpressPaymentClose = useCallback( () => { setPaymentStatus().pristine(); - setActivePaymentMethod( previousActivePaymentMethod.current ); - if ( previousPaymentMethodData.current.isSavedToken ) { - setPaymentStatus().started( previousPaymentMethodData.current ); - } + setActivePaymentMethod( + previousActivePaymentMethod.current, + previousPaymentMethodData.current + ); }, [ setActivePaymentMethod, setPaymentStatus ] ); /** @@ -79,10 +80,10 @@ const ExpressPaymentMethods = () => { ( errorMessage ) => { setPaymentStatus().error( errorMessage ); setExpressPaymentError( errorMessage ); - setActivePaymentMethod( previousActivePaymentMethod.current ); - if ( previousPaymentMethodData.current.isSavedToken ) { - setPaymentStatus().started( previousPaymentMethodData.current ); - } + setActivePaymentMethod( + previousActivePaymentMethod.current, + previousPaymentMethodData.current + ); }, [ setActivePaymentMethod, setPaymentStatus, setExpressPaymentError ] ); diff --git a/assets/js/blocks/cart-checkout/payment-methods/payment-method-options.js b/assets/js/blocks/cart-checkout/payment-methods/payment-method-options.js index 9c77c459419..f63808fdfc4 100644 --- a/assets/js/blocks/cart-checkout/payment-methods/payment-method-options.js +++ b/assets/js/blocks/cart-checkout/payment-methods/payment-method-options.js @@ -6,8 +6,9 @@ import { usePaymentMethodInterface, useEmitResponse, useStoreNotices, + useStoreEvents, } from '@woocommerce/base-context/hooks'; -import { cloneElement } from '@wordpress/element'; +import { cloneElement, useCallback } from '@wordpress/element'; import { useEditorContext, usePaymentMethodDataContext, @@ -29,7 +30,6 @@ const PaymentMethodOptions = () => { const { setActivePaymentMethod, activeSavedToken, - setActiveSavedToken, isExpressPaymentMethodActive, customerPaymentMethods, } = usePaymentMethodDataContext(); @@ -40,6 +40,7 @@ const PaymentMethodOptions = () => { } = usePaymentMethodInterface(); const { noticeContexts } = useEmitResponse(); const { removeNotice } = useStoreNotices(); + const { dispatchCheckoutEvent } = useStoreEvents(); const { isEditor } = useEditorContext(); const options = Object.keys( paymentMethods ).map( ( name ) => { @@ -65,11 +66,21 @@ const PaymentMethodOptions = () => { }; } ); - const updateToken = ( value ) => { - setActivePaymentMethod( value ); - setActiveSavedToken( '' ); - removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS ); - }; + const onChange = useCallback( + ( value ) => { + setActivePaymentMethod( value ); + removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS ); + dispatchCheckoutEvent( 'set-active-payment-method', { + value, + } ); + }, + [ + dispatchCheckoutEvent, + noticeContexts.PAYMENTS, + removeNotice, + setActivePaymentMethod, + ] + ); const isSinglePaymentMethod = Object.keys( customerPaymentMethods ).length === 0 && @@ -84,7 +95,7 @@ const PaymentMethodOptions = () => { id={ 'wc-payment-method-options' } className={ singleOptionClass } selected={ activeSavedToken ? null : activePaymentMethod } - onChange={ updateToken } + onChange={ onChange } options={ options } /> ); diff --git a/assets/js/blocks/cart-checkout/payment-methods/saved-payment-method-options.js b/assets/js/blocks/cart-checkout/payment-methods/saved-payment-method-options.js index b0c76fe5a39..8ebff3e3cca 100644 --- a/assets/js/blocks/cart-checkout/payment-methods/saved-payment-method-options.js +++ b/assets/js/blocks/cart-checkout/payment-methods/saved-payment-method-options.js @@ -1,20 +1,17 @@ /** * External dependencies */ -import { - useEffect, - useRef, - useCallback, - cloneElement, -} from '@wordpress/element'; +import { useMemo, cloneElement } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { usePaymentMethodDataContext } from '@woocommerce/base-context'; import RadioControl from '@woocommerce/base-components/radio-control'; import { usePaymentMethodInterface, usePaymentMethods, + useStoreNotices, + useStoreEvents, + useEmitResponse, } from '@woocommerce/base-context/hooks'; -import { getPaymentMethods } from '@woocommerce/blocks-registry'; /** * @typedef {import('@woocommerce/type-defs/contexts').CustomerPaymentMethod} CustomerPaymentMethod @@ -25,139 +22,91 @@ import { getPaymentMethods } from '@woocommerce/blocks-registry'; * Returns the option object for a cc or echeck saved payment method token. * * @param {CustomerPaymentMethod} savedPaymentMethod - * @param {function(string):void} setActivePaymentMethod - * @param {PaymentStatusDispatch} setPaymentStatus - * @return {Object} An option objects to use for RadioControl. + * @return {string} label */ -const getCcOrEcheckPaymentMethodOption = ( - { method, expires, tokenId }, - setActivePaymentMethod, - setPaymentStatus -) => { - return { - value: tokenId + '', - label: sprintf( - /* translators: %1$s is referring to the payment method brand, %2$s is referring to the last 4 digits of the payment card, %3$s is referring to the expiry date. */ - __( - '%1$s ending in %2$s (expires %3$s)', - 'woo-gutenberg-products-block' - ), - method.brand, - method.last4, - expires +const getCcOrEcheckLabel = ( { method, expires } ) => { + return sprintf( + /* translators: %1$s is referring to the payment method brand, %2$s is referring to the last 4 digits of the payment card, %3$s is referring to the expiry date. */ + __( + '%1$s ending in %2$s (expires %3$s)', + 'woo-gutenberg-products-block' ), - name: `wc-saved-payment-method-token-${ tokenId }`, - onChange: ( token ) => { - const savedTokenKey = `wc-${ method.gateway }-payment-token`; - setActivePaymentMethod( method.gateway ); - setPaymentStatus().started( { - payment_method: method.gateway, - [ savedTokenKey ]: token + '', - isSavedToken: true, - } ); - }, - }; + method.brand, + method.last4, + expires + ); }; /** * Returns the option object for any non specific saved payment method. * * @param {CustomerPaymentMethod} savedPaymentMethod - * @param {function(string):void} setActivePaymentMethod - * @param {PaymentStatusDispatch} setPaymentStatus - * - * @return {Object} An option objects to use for RadioControl. + * @return {string} label */ -const getDefaultPaymentMethodOptions = ( - { method, tokenId }, - setActivePaymentMethod, - setPaymentStatus -) => { - return { - value: tokenId + '', - label: sprintf( - /* translators: %s is the name of the payment method gateway. */ - __( 'Saved token for %s', 'woo-gutenberg-products-block' ), - method.gateway - ), - name: `wc-saved-payment-method-token-${ tokenId }`, - onChange: ( token ) => { - const savedTokenKey = `wc-${ method.gateway }-payment-token`; - setActivePaymentMethod( method.gateway ); - setPaymentStatus().started( { - payment_method: method.gateway, - [ savedTokenKey ]: token + '', - isSavedToken: true, - } ); - }, - }; +const getDefaultLabel = ( { method } ) => { + return sprintf( + /* translators: %s is the name of the payment method gateway. */ + __( 'Saved token for %s', 'woo-gutenberg-products-block' ), + method.gateway + ); }; const SavedPaymentMethodOptions = () => { const { - setPaymentStatus, customerPaymentMethods, activePaymentMethod, setActivePaymentMethod, activeSavedToken, - setActiveSavedToken, } = usePaymentMethodDataContext(); - const standardMethods = getPaymentMethods(); const { paymentMethods } = usePaymentMethods(); const paymentMethodInterface = usePaymentMethodInterface(); + const { noticeContexts } = useEmitResponse(); + const { removeNotice } = useStoreNotices(); + const { dispatchCheckoutEvent } = useStoreEvents(); - /** - * @type {Object} Options - * @property {Array} current The current options on the type. - */ - const currentOptions = useRef( [] ); - - const updateToken = useCallback( - ( token ) => { - setActiveSavedToken( token ); - }, - [ setActiveSavedToken ] - ); - - useEffect( () => { + const options = useMemo( () => { const types = Object.keys( customerPaymentMethods ); - const options = types + return types .flatMap( ( type ) => { const typeMethods = customerPaymentMethods[ type ]; return typeMethods.map( ( paymentMethod ) => { - const option = - type === 'cc' || type === 'echeck' - ? getCcOrEcheckPaymentMethodOption( - paymentMethod, - setActivePaymentMethod, - setPaymentStatus - ) - : getDefaultPaymentMethodOptions( - paymentMethod, - setActivePaymentMethod, - setPaymentStatus - ); - if ( - ! activePaymentMethod && - paymentMethod.is_default && - activeSavedToken === '' - ) { - updateToken( paymentMethod.tokenId + '' ); - option.onChange( paymentMethod.tokenId ); - } - return option; + const isCC = type === 'cc' || type === 'echeck'; + const paymentMethodSlug = paymentMethod.method.gateway; + return { + name: `wc-saved-payment-method-token-${ paymentMethodSlug }`, + label: isCC + ? getCcOrEcheckLabel( paymentMethod ) + : getDefaultLabel( paymentMethod ), + value: paymentMethod.tokenId.toString(), + onChange: ( token ) => { + const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`; + setActivePaymentMethod( paymentMethodSlug, { + token, + payment_method: paymentMethodSlug, + [ savedTokenKey ]: token.toString(), + isSavedToken: true, + } ); + removeNotice( + 'wc-payment-error', + noticeContexts.PAYMENTS + ); + dispatchCheckoutEvent( + 'set-active-payment-method', + { + paymentMethodSlug, + } + ); + }, + }; } ); } ) .filter( Boolean ); - currentOptions.current = options; }, [ customerPaymentMethods, - updateToken, - activeSavedToken, - activePaymentMethod, setActivePaymentMethod, - setPaymentStatus, - standardMethods, + removeNotice, + noticeContexts.PAYMENTS, + dispatchCheckoutEvent, ] ); const savedPaymentMethodHandler = @@ -170,13 +119,12 @@ const SavedPaymentMethodOptions = () => { ) : null; - return currentOptions.current.length > 0 ? ( + return options.length > 0 ? ( <> { savedPaymentMethodHandler } diff --git a/docs/block-client-apis/checkout/checkout-api.md b/docs/block-client-apis/checkout/checkout-api.md index 206c3ddfe33..5dfb9462ca3 100644 --- a/docs/block-client-apis/checkout/checkout-api.md +++ b/docs/block-client-apis/checkout/checkout-api.md @@ -73,7 +73,7 @@ The payment method data context exposes the api interfaces for the following thi - `paymentMethodData`: This is the current extra data tracked in the context state. This is arbitrary data provided by the payment method extension after it has completed payment for checkout to include in the processing request. Typically this would contain things like payment `token` or `payment_method` name. - `errorMessage`: This exposes the current set error message provided by the active payment method (if present). - `activePaymentMethod`: This is the current active payment method in the checkout. -- `setActivePaymentMethod`: This is used to set the active payment method. +- `setActivePaymentMethod`: This is used to set the active payment method and any related payment method data. - `onPaymentProcessing`: This is an event subscriber that can be used to subscribe observers to be called when the status for the context is `PROCESSING`. - `customerPaymentMethods`: This is an object containing any saved payment method information for the current logged in user. It is provided via the server and used to generate the ui for the shopper to select a saved payment method from a previous purchase. - `paymentMethods`: This is an object containing all the _initialized_ registered payment methods. diff --git a/docs/extensibility/checkout-flow-and-events.md b/docs/extensibility/checkout-flow-and-events.md index 9231a2d47c0..1e6c352f85a 100644 --- a/docs/extensibility/checkout-flow-and-events.md +++ b/docs/extensibility/checkout-flow-and-events.md @@ -100,8 +100,8 @@ This context provider exposes everything related to payment method data and regi The possible _internal_ statuses that may be set are: -- `PRISTINE`: This is the status when checkout is initialized and there are payment methods are not doing anything. This status is also set whenever the checkout status is changed to `IDLE`. -- `STARTED`: This status is currently only used when an express payment method has been triggered by the user clicking it's button. +- `PRISTINE`: This is the status when checkout is initialized and there are payment methods that are not doing anything. This status is also set whenever the checkout status is changed to `IDLE`. +- `STARTED`: **Express Payment Methods Only** - This status is used when an express payment method has been triggered by the user clicking it's button. This flow happens before processing, usually in a modal window. - `PROCESSING`: This status is set when the checkout status is `PROCESSING`, checkout `hasError` is false, checkout is not calculating, and the current payment status is not `FINISHED`. When this status is set, it will trigger the payment processing event emitter. - `SUCCESS`: This status is set after all the observers hooked into the payment processing event have completed successfully. The `CheckoutProcessor` component uses this along with the checkout `PROCESSING` status to signal things are ready to send the order to the server with data for processing. - `FAILED`: This status is set after an observer hooked into the payment processing event returns a fail response. This in turn will end up causing the checkout `hasError` flag to be set to true.