diff --git a/assets/js/base/context/providers/cart-checkout/checkout-state/actions.ts b/assets/js/base/context/providers/cart-checkout/checkout-state/actions.ts new file mode 100644 index 00000000000..f1b8f97c1e0 --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/checkout-state/actions.ts @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import { PaymentResult } from '@woocommerce/types'; +/** + * Internal dependencies + */ +import type { CheckoutStateContextState } from './types'; + +export enum ACTION { + SET_IDLE = 'set_idle', + SET_PRISTINE = 'set_pristine', + SET_REDIRECT_URL = 'set_redirect_url', + SET_COMPLETE = 'set_checkout_complete', + SET_BEFORE_PROCESSING = 'set_before_processing', + SET_AFTER_PROCESSING = 'set_after_processing', + SET_PROCESSING_RESPONSE = 'set_processing_response', + SET_PROCESSING = 'set_checkout_is_processing', + SET_HAS_ERROR = 'set_checkout_has_error', + SET_NO_ERROR = 'set_checkout_no_error', + SET_CUSTOMER_ID = 'set_checkout_customer_id', + SET_ORDER_ID = 'set_checkout_order_id', + SET_ORDER_NOTES = 'set_checkout_order_notes', + INCREMENT_CALCULATING = 'increment_calculating', + DECREMENT_CALCULATING = 'decrement_calculating', + SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS = 'set_shipping_address_as_billing_address', + SET_SHOULD_CREATE_ACCOUNT = 'set_should_create_account', + SET_EXTENSION_DATA = 'set_extension_data', +} + +export interface ActionType extends Partial< CheckoutStateContextState > { + type: ACTION; + data?: Record< string, unknown > | Record< string, never > | PaymentResult; +} + +/** + * All the actions that can be dispatched for the checkout. + */ +export const actions = { + setPristine: () => + ( { + type: ACTION.SET_PRISTINE, + } as const ), + setIdle: () => + ( { + type: ACTION.SET_IDLE, + } as const ), + setProcessing: () => + ( { + type: ACTION.SET_PROCESSING, + } as const ), + setRedirectUrl: ( redirectUrl: string ) => + ( { + type: ACTION.SET_REDIRECT_URL, + redirectUrl, + } as const ), + setProcessingResponse: ( data: PaymentResult ) => + ( { + type: ACTION.SET_PROCESSING_RESPONSE, + data, + } as const ), + setComplete: ( data: Record< string, unknown > = {} ) => + ( { + type: ACTION.SET_COMPLETE, + data, + } as const ), + setBeforeProcessing: () => + ( { + type: ACTION.SET_BEFORE_PROCESSING, + } as const ), + setAfterProcessing: () => + ( { + type: ACTION.SET_AFTER_PROCESSING, + } as const ), + setHasError: ( hasError = true ) => + ( { + type: hasError ? ACTION.SET_HAS_ERROR : ACTION.SET_NO_ERROR, + } as const ), + incrementCalculating: () => + ( { + type: ACTION.INCREMENT_CALCULATING, + } as const ), + decrementCalculating: () => + ( { + type: ACTION.DECREMENT_CALCULATING, + } as const ), + setCustomerId: ( customerId: number ) => + ( { + type: ACTION.SET_CUSTOMER_ID, + customerId, + } as const ), + setOrderId: ( orderId: number ) => + ( { + type: ACTION.SET_ORDER_ID, + orderId, + } as const ), + setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => + ( { + type: ACTION.SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS, + useShippingAsBilling, + } as const ), + setShouldCreateAccount: ( shouldCreateAccount: boolean ) => + ( { + type: ACTION.SET_SHOULD_CREATE_ACCOUNT, + shouldCreateAccount, + } as const ), + setOrderNotes: ( orderNotes: string ) => + ( { + type: ACTION.SET_ORDER_NOTES, + orderNotes, + } as const ), + setExtensionData: ( + extensionData: Record< string, Record< string, unknown > > + ) => + ( { + type: ACTION.SET_EXTENSION_DATA, + extensionData, + } as const ), +}; diff --git a/assets/js/base/context/providers/cart-checkout/checkout-state/constants.ts b/assets/js/base/context/providers/cart-checkout/checkout-state/constants.ts new file mode 100644 index 00000000000..8ab0ff575f4 --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/checkout-state/constants.ts @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { getSetting, EnteredAddress } from '@woocommerce/settings'; +import { isSameAddress } from '@woocommerce/base-utils'; + +/** + * Internal dependencies + */ +import type { + CheckoutStateContextType, + CheckoutStateContextState, +} from './types'; + +export enum STATUS { + // Checkout is in it's initialized state. + PRISTINE = 'pristine', + // When checkout state has changed but there is no activity happening. + IDLE = 'idle', + // After BEFORE_PROCESSING status emitters have finished successfully. Payment processing is started on this checkout status. + PROCESSING = 'processing', + // After the AFTER_PROCESSING event emitters have completed. This status triggers the checkout redirect. + COMPLETE = 'complete', + // This is the state before checkout processing begins after the checkout button has been pressed/submitted. + BEFORE_PROCESSING = 'before_processing', + // After server side checkout processing is completed this status is set + AFTER_PROCESSING = 'after_processing', +} + +const preloadedCheckoutData = getSetting( 'checkoutData', {} ) as Record< + string, + unknown +>; + +const checkoutData = { + order_id: 0, + customer_id: 0, + billing_address: {} as EnteredAddress, + shipping_address: {} as EnteredAddress, + ...( preloadedCheckoutData || {} ), +}; + +export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = { + onSubmit: () => void null, + onCheckoutAfterProcessingWithSuccess: () => () => void null, + onCheckoutAfterProcessingWithError: () => () => void null, + onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing + onCheckoutValidationBeforeProcessing: () => () => void null, +}; + +export const DEFAULT_STATE: CheckoutStateContextState = { + redirectUrl: '', + status: STATUS.PRISTINE, + hasError: false, + calculatingCount: 0, + orderId: checkoutData.order_id, + orderNotes: '', + customerId: checkoutData.customer_id, + useShippingAsBilling: isSameAddress( + checkoutData.billing_address, + checkoutData.shipping_address + ), + shouldCreateAccount: false, + processingResponse: null, + extensionData: {}, +}; diff --git a/assets/js/base/context/providers/cart-checkout/checkout-state/index.tsx b/assets/js/base/context/providers/cart-checkout/checkout-state/index.tsx new file mode 100644 index 00000000000..6b77cd5f3c1 --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/checkout-state/index.tsx @@ -0,0 +1,337 @@ +/** + * External dependencies + */ + +import { + createContext, + useContext, + useReducer, + useRef, + useMemo, + useEffect, + useCallback, +} from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { usePrevious } from '@woocommerce/base-hooks'; +import deprecated from '@wordpress/deprecated'; +import { isObject, isString } from '@woocommerce/types'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; + +/** + * Internal dependencies + */ +import { STATUS, DEFAULT_CHECKOUT_STATE_DATA } from './constants'; +import type { CheckoutStateContextType } from './types'; +import { + EMIT_TYPES, + useEventEmitters, + emitEvent, + emitEventWithAbort, + reducer as emitReducer, +} from './event-emit'; +import { useValidationContext } from '../../validation'; +import { useStoreEvents } from '../../../hooks/use-store-events'; +import { useCheckoutNotices } from '../../../hooks/use-checkout-notices'; +import { useEmitResponse } from '../../../hooks/use-emit-response'; +import { removeNoticesByStatus } from '../../../../../utils/notices'; +import { CheckoutState } from '../../../../../data/checkout/default-state'; + +const CheckoutContext = createContext( DEFAULT_CHECKOUT_STATE_DATA ); + +export const useCheckoutContext = (): CheckoutStateContextType => { + return useContext( CheckoutContext ); +}; + +/** + * Checkout state provider + * This provides an API interface exposing checkout state for use with cart or checkout blocks. + * + * @param {Object} props Incoming props for the provider. + * @param {Object} props.children The children being wrapped. + * @param {string} props.redirectUrl Initialize what the checkout will redirect to after successful submit. + */ +export const CheckoutStateProvider = ( { + children, + redirectUrl, +}: { + children: React.ReactChildren; + redirectUrl: string; +} ): JSX.Element => { + const checkoutActions = useDispatch( CHECKOUT_STORE_KEY ); + const checkoutState: CheckoutState = useSelect( ( select ) => + select( CHECKOUT_STORE_KEY ).getCheckoutState() + ); + + if ( redirectUrl && redirectUrl !== checkoutState.redirectUrl ) { + checkoutActions.setRedirectUrl( redirectUrl ); + } + + const { setValidationErrors } = useValidationContext(); + const { createErrorNotice } = useDispatch( 'core/notices' ); + + const { dispatchCheckoutEvent } = useStoreEvents(); + const { isSuccessResponse, isErrorResponse, isFailResponse, shouldRetry } = + useEmitResponse(); + const { checkoutNotices, paymentNotices, expressPaymentNotices } = + useCheckoutNotices(); + + const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); + const currentObservers = useRef( observers ); + const { + onCheckoutAfterProcessingWithSuccess, + onCheckoutAfterProcessingWithError, + onCheckoutValidationBeforeProcessing, + } = useEventEmitters( observerDispatch ); + + // set observers on ref so it's always current. + useEffect( () => { + currentObservers.current = observers; + }, [ observers ] ); + + /** + * @deprecated use onCheckoutValidationBeforeProcessing instead + * + * To prevent the deprecation message being shown at render time + * we need an extra function between useMemo and event emitters + * so that the deprecated message gets shown only at invocation time. + * (useMemo calls the passed function at render time) + * See: https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4039/commits/a502d1be8828848270993264c64220731b0ae181 + */ + const onCheckoutBeforeProcessing = useMemo( () => { + return function ( + ...args: Parameters< typeof onCheckoutValidationBeforeProcessing > + ) { + deprecated( 'onCheckoutBeforeProcessing', { + alternative: 'onCheckoutValidationBeforeProcessing', + plugin: 'WooCommerce Blocks', + } ); + return onCheckoutValidationBeforeProcessing( ...args ); + }; + }, [ onCheckoutValidationBeforeProcessing ] ); + + // emit events. + useEffect( () => { + const status = checkoutState.status; + if ( status === STATUS.BEFORE_PROCESSING ) { + removeNoticesByStatus( 'error' ); + emitEvent( + currentObservers.current, + EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING, + {} + ).then( ( response ) => { + if ( response !== true ) { + if ( Array.isArray( response ) ) { + response.forEach( + ( { errorMessage, validationErrors } ) => { + createErrorNotice( errorMessage, { + context: 'wc/checkout', + } ); + setValidationErrors( validationErrors ); + } + ); + } + checkoutActions.setIdle(); + checkoutActions.setHasError(); + } else { + checkoutActions.setProcessing(); + } + } ); + } + }, [ + checkoutState.status, + setValidationErrors, + createErrorNotice, + checkoutActions, + ] ); + + const previousStatus = usePrevious( checkoutState.status ); + const previousHasError = usePrevious( checkoutState.hasError ); + + useEffect( () => { + if ( + checkoutState.status === previousStatus && + checkoutState.hasError === previousHasError + ) { + return; + } + + const handleErrorResponse = ( observerResponses: unknown[] ) => { + let errorResponse = null; + observerResponses.forEach( ( response ) => { + if ( + isErrorResponse( response ) || + isFailResponse( response ) + ) { + if ( response.message && isString( response.message ) ) { + const errorOptions = + response.messageContext && + isString( response.messageContent ) + ? // The `as string` is OK here because of the type guard above. + { context: response.messageContext as string } + : undefined; + errorResponse = response; + createErrorNotice( response.message, errorOptions ); + } + } + } ); + return errorResponse; + }; + + if ( checkoutState.status === STATUS.AFTER_PROCESSING ) { + const data = { + redirectUrl: checkoutState.redirectUrl, + orderId: checkoutState.orderId, + customerId: checkoutState.customerId, + orderNotes: checkoutState.orderNotes, + processingResponse: checkoutState.processingResponse, + }; + if ( checkoutState.hasError ) { + // allow payment methods or other things to customize the error + // with a fallback if nothing customizes it. + emitEventWithAbort( + currentObservers.current, + EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR, + data + ).then( ( observerResponses ) => { + const errorResponse = + handleErrorResponse( observerResponses ); + if ( errorResponse !== null ) { + // irrecoverable error so set complete + if ( ! shouldRetry( errorResponse ) ) { + checkoutActions.setComplete( errorResponse ); + } else { + checkoutActions.setIdle(); + } + } else { + const hasErrorNotices = + checkoutNotices.some( + ( notice: { status: string } ) => + notice.status === 'error' + ) || + expressPaymentNotices.some( + ( notice: { status: string } ) => + notice.status === 'error' + ) || + paymentNotices.some( + ( notice: { status: string } ) => + notice.status === 'error' + ); + if ( ! hasErrorNotices ) { + // no error handling in place by anything so let's fall + // back to default + const message = + data.processingResponse?.message || + __( + 'Something went wrong. Please contact us for assistance.', + 'woo-gutenberg-products-block' + ); + createErrorNotice( message, { + id: 'checkout', + context: 'wc/checkout', + } ); + } + + checkoutActions.setIdle(); + } + } ); + } else { + emitEventWithAbort( + currentObservers.current, + EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS, + data + ).then( ( observerResponses: unknown[] ) => { + let successResponse = null as null | Record< + string, + unknown + >; + let errorResponse = null as null | Record< + string, + unknown + >; + + observerResponses.forEach( ( response ) => { + if ( isSuccessResponse( response ) ) { + // the last observer response always "wins" for success. + successResponse = response; + } + + if ( + isErrorResponse( response ) || + isFailResponse( response ) + ) { + errorResponse = response; + } + } ); + + if ( successResponse && ! errorResponse ) { + checkoutActions.setComplete( successResponse ); + } else if ( isObject( errorResponse ) ) { + if ( + errorResponse.message && + isString( errorResponse.message ) + ) { + const errorOptions = + errorResponse.messageContext && + isString( errorResponse.messageContext ) + ? { context: errorResponse.messageContext } + : undefined; + createErrorNotice( + errorResponse.message, + errorOptions + ); + } + if ( ! shouldRetry( errorResponse ) ) { + checkoutActions.setComplete( errorResponse ); + } else { + // this will set an error which will end up + // triggering the onCheckoutAfterProcessingWithError emitter. + // and then setting checkout to IDLE state. + checkoutActions.setHasError( true ); + } + } else { + // nothing hooked in had any response type so let's just consider successful. + checkoutActions.setComplete(); + } + } ); + } + } + }, [ + checkoutState.status, + checkoutState.hasError, + checkoutState.redirectUrl, + checkoutState.orderId, + checkoutState.customerId, + checkoutState.orderNotes, + checkoutState.processingResponse, + previousStatus, + previousHasError, + createErrorNotice, + isErrorResponse, + isFailResponse, + isSuccessResponse, + shouldRetry, + checkoutNotices, + expressPaymentNotices, + paymentNotices, + checkoutActions, + ] ); + + const onSubmit = useCallback( () => { + dispatchCheckoutEvent( 'submit' ); + checkoutActions.setBeforeProcessing(); + }, [ dispatchCheckoutEvent, checkoutActions ] ); + + const checkoutData: CheckoutStateContextType = { + onSubmit, + onCheckoutBeforeProcessing, + onCheckoutValidationBeforeProcessing, + onCheckoutAfterProcessingWithSuccess, + onCheckoutAfterProcessingWithError, + }; + return ( + + { children } + + ); +}; diff --git a/assets/js/base/context/providers/cart-checkout/checkout-state/reducer.ts b/assets/js/base/context/providers/cart-checkout/checkout-state/reducer.ts new file mode 100644 index 00000000000..b447105dfb0 --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/checkout-state/reducer.ts @@ -0,0 +1,213 @@ +/** + * External dependencies + */ +import type { PaymentResult } from '@woocommerce/types'; + +/** + * Internal dependencies + */ +import { DEFAULT_STATE, STATUS } from './constants'; +import { ActionType, ACTION } from './actions'; +import type { CheckoutStateContextState } from './types'; + +/** + * Reducer for the checkout state + */ +export const reducer = ( + state = DEFAULT_STATE, + { + redirectUrl, + type, + customerId, + orderId, + orderNotes, + extensionData, + useShippingAsBilling, + shouldCreateAccount, + data, + }: ActionType +): CheckoutStateContextState => { + let newState = state; + switch ( type ) { + case ACTION.SET_PRISTINE: + newState = DEFAULT_STATE; + break; + case ACTION.SET_IDLE: + newState = + state.status !== STATUS.IDLE + ? { + ...state, + status: STATUS.IDLE, + } + : state; + break; + case ACTION.SET_REDIRECT_URL: + newState = + redirectUrl !== undefined && redirectUrl !== state.redirectUrl + ? { + ...state, + redirectUrl, + } + : state; + break; + case ACTION.SET_PROCESSING_RESPONSE: + newState = { + ...state, + processingResponse: data as PaymentResult, + }; + break; + + case ACTION.SET_COMPLETE: + newState = + state.status !== STATUS.COMPLETE + ? { + ...state, + status: STATUS.COMPLETE, + redirectUrl: + typeof data?.redirectUrl === 'string' + ? data.redirectUrl + : state.redirectUrl, + } + : state; + break; + case ACTION.SET_PROCESSING: + newState = + state.status !== STATUS.PROCESSING + ? { + ...state, + status: STATUS.PROCESSING, + hasError: false, + } + : state; + // clear any error state. + newState = + newState.hasError === false + ? newState + : { ...newState, hasError: false }; + break; + case ACTION.SET_BEFORE_PROCESSING: + newState = + state.status !== STATUS.BEFORE_PROCESSING + ? { + ...state, + status: STATUS.BEFORE_PROCESSING, + hasError: false, + } + : state; + break; + case ACTION.SET_AFTER_PROCESSING: + newState = + state.status !== STATUS.AFTER_PROCESSING + ? { + ...state, + status: STATUS.AFTER_PROCESSING, + } + : state; + break; + case ACTION.SET_HAS_ERROR: + newState = state.hasError + ? state + : { + ...state, + hasError: true, + }; + newState = + state.status === STATUS.PROCESSING || + state.status === STATUS.BEFORE_PROCESSING + ? { + ...newState, + status: STATUS.IDLE, + } + : newState; + break; + case ACTION.SET_NO_ERROR: + newState = state.hasError + ? { + ...state, + hasError: false, + } + : state; + break; + case ACTION.INCREMENT_CALCULATING: + newState = { + ...state, + calculatingCount: state.calculatingCount + 1, + }; + break; + case ACTION.DECREMENT_CALCULATING: + newState = { + ...state, + calculatingCount: Math.max( 0, state.calculatingCount - 1 ), + }; + break; + case ACTION.SET_CUSTOMER_ID: + newState = + customerId !== undefined + ? { + ...state, + customerId, + } + : state; + break; + case ACTION.SET_ORDER_ID: + newState = + orderId !== undefined + ? { + ...state, + orderId, + } + : state; + break; + case ACTION.SET_SHIPPING_ADDRESS_AS_BILLING_ADDRESS: + if ( + useShippingAsBilling !== undefined && + useShippingAsBilling !== state.useShippingAsBilling + ) { + newState = { + ...state, + useShippingAsBilling, + }; + } + break; + case ACTION.SET_SHOULD_CREATE_ACCOUNT: + if ( + shouldCreateAccount !== undefined && + shouldCreateAccount !== state.shouldCreateAccount + ) { + newState = { + ...state, + shouldCreateAccount, + }; + } + break; + case ACTION.SET_ORDER_NOTES: + if ( orderNotes !== undefined && state.orderNotes !== orderNotes ) { + newState = { + ...state, + orderNotes, + }; + } + break; + case ACTION.SET_EXTENSION_DATA: + if ( + extensionData !== undefined && + state.extensionData !== extensionData + ) { + newState = { + ...state, + extensionData, + }; + } + break; + } + // automatically update state to idle from pristine as soon as it + // initially changes. + if ( + newState !== state && + type !== ACTION.SET_PRISTINE && + newState.status === STATUS.PRISTINE + ) { + newState.status = STATUS.IDLE; + } + return newState; +}; diff --git a/assets/js/base/context/providers/cart-checkout/checkout-state/types.ts b/assets/js/base/context/providers/cart-checkout/checkout-state/types.ts new file mode 100644 index 00000000000..a00f57277cd --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/checkout-state/types.ts @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { PaymentResult } from '@woocommerce/types'; +/** + * Internal dependencies + */ +import { STATUS } from './constants'; +import type { emitterCallback } from '../../../event-emit'; + +export interface CheckoutResponseError { + code: string; + message: string; + data: { + status: number; + }; +} + +export interface CheckoutResponseSuccess { + // eslint-disable-next-line camelcase + payment_result: { + // eslint-disable-next-line camelcase + payment_status: 'success' | 'failure' | 'pending' | 'error'; + // eslint-disable-next-line camelcase + payment_details: Record< string, string > | Record< string, never >; + // eslint-disable-next-line camelcase + redirect_url: string; + }; +} + +export type CheckoutResponse = CheckoutResponseSuccess | CheckoutResponseError; + +type extensionDataNamespace = string; +type extensionDataItem = Record< string, unknown >; +export type extensionData = Record< extensionDataNamespace, extensionDataItem >; + +export interface CheckoutStateContextState { + redirectUrl: string; + status: STATUS; + hasError: boolean; + calculatingCount: number; + orderId: number; + orderNotes: string; + customerId: number; + useShippingAsBilling: boolean; + shouldCreateAccount: boolean; + processingResponse: PaymentResult | null; + extensionData: extensionData; +} + +export type CheckoutStateDispatchActions = { + resetCheckout: () => void; + setRedirectUrl: ( url: string ) => void; + setHasError: ( hasError: boolean ) => void; + setAfterProcessing: ( response: CheckoutResponse ) => void; + incrementCalculating: () => void; + decrementCalculating: () => void; + setCustomerId: ( id: number ) => void; + setOrderId: ( id: number ) => void; + setOrderNotes: ( orderNotes: string ) => void; + setExtensionData: ( extensionData: extensionData ) => void; +}; + +export type CheckoutStateContextType = { + // Submits the checkout and begins processing. + onSubmit: () => void; + // Used to register a callback that will fire after checkout has been processed and there are no errors. + onCheckoutAfterProcessingWithSuccess: ReturnType< typeof emitterCallback >; + // Used to register a callback that will fire when the checkout has been processed and has an error. + onCheckoutAfterProcessingWithError: ReturnType< typeof emitterCallback >; + // Deprecated in favour of onCheckoutValidationBeforeProcessing. + onCheckoutBeforeProcessing: ReturnType< typeof emitterCallback >; + // Used to register a callback that will fire when the checkout has been submitted before being sent off to the server. + onCheckoutValidationBeforeProcessing: ReturnType< typeof emitterCallback >; +}; diff --git a/assets/js/base/context/providers/cart-checkout/checkout-state/utils.ts b/assets/js/base/context/providers/cart-checkout/checkout-state/utils.ts new file mode 100644 index 00000000000..e579b166e51 --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/checkout-state/utils.ts @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; +import type { PaymentResult, CheckoutResponse } from '@woocommerce/types'; + +/** + * Prepares the payment_result data from the server checkout endpoint response. + */ +export const getPaymentResultFromCheckoutResponse = ( + response: CheckoutResponse +): PaymentResult => { + const paymentResult = { + message: '', + paymentStatus: '', + redirectUrl: '', + paymentDetails: {}, + } as PaymentResult; + + // payment_result is present in successful responses. + if ( 'payment_result' in response ) { + paymentResult.paymentStatus = response.payment_result.payment_status; + paymentResult.redirectUrl = response.payment_result.redirect_url; + + if ( + response.payment_result.hasOwnProperty( 'payment_details' ) && + Array.isArray( response.payment_result.payment_details ) + ) { + response.payment_result.payment_details.forEach( + ( { key, value }: { key: string; value: string } ) => { + paymentResult.paymentDetails[ key ] = + decodeEntities( value ); + } + ); + } + } + + // message is present in error responses. + if ( 'message' in response ) { + paymentResult.message = decodeEntities( response.message ); + } + + // If there was an error code but no message, set a default message. + if ( + ! paymentResult.message && + 'data' in response && + 'status' in response.data && + response.data.status > 299 + ) { + paymentResult.message = __( + 'Something went wrong. Please contact us for assistance.', + 'woo-gutenberg-products-block' + ); + } + + return paymentResult; +}; 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 new file mode 100644 index 00000000000..11c334872e4 --- /dev/null +++ b/assets/js/base/context/providers/cart-checkout/payment-methods/payment-method-data-context.tsx @@ -0,0 +1,378 @@ +/** + * External dependencies + */ +import { + createContext, + useContext, + useReducer, + useCallback, + useRef, + useEffect, + useMemo, +} from '@wordpress/element'; +import { objectHasProp } from '@woocommerce/types'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; + +/** + * Internal dependencies + */ +import type { + CustomerPaymentMethods, + PaymentMethodDataContextType, +} from './types'; +import { + STATUS, + DEFAULT_PAYMENT_DATA_CONTEXT_STATE, + DEFAULT_PAYMENT_METHOD_DATA, +} from './constants'; +import reducer from './reducer'; +import { + usePaymentMethods, + useExpressPaymentMethods, +} from './use-payment-method-registration'; +import { usePaymentMethodDataDispatchers } from './use-payment-method-dispatchers'; +import { useEditorContext } from '../../editor-context'; +import { + EMIT_TYPES, + useEventEmitters, + emitEventWithAbort, + reducer as emitReducer, +} from './event-emit'; +import { useValidationContext } from '../../validation'; +import { useEmitResponse } from '../../../hooks/use-emit-response'; +import { getCustomerPaymentMethods } from './utils'; + +const PaymentMethodDataContext = createContext( DEFAULT_PAYMENT_METHOD_DATA ); + +export const usePaymentMethodDataContext = (): PaymentMethodDataContextType => { + return useContext( PaymentMethodDataContext ); +}; + +/** + * PaymentMethodDataProvider is automatically included in the CheckoutDataProvider. + * + * This provides the api interface (via the context hook) for payment method status and data. + * + * @param {Object} props Incoming props for provider + * @param {Object} props.children The wrapped components in this provider. + */ +export const PaymentMethodDataProvider = ( { + children, +}: { + children: React.ReactNode; +} ): JSX.Element => { + const { + isProcessing: checkoutIsProcessing, + isIdle: checkoutIsIdle, + isCalculating: checkoutIsCalculating, + hasError: checkoutHasError, + } = useSelect( ( select ) => { + const store = select( CHECKOUT_STORE_KEY ); + return { + isProcessing: store.isProcessing(), + isIdle: store.isIdle(), + hasError: store.hasError(), + isCalculating: store.isCalculating(), + }; + } ); + const { isEditor, getPreviewData } = useEditorContext(); + const { setValidationErrors } = useValidationContext(); + const { createErrorNotice: addErrorNotice, removeNotice } = + useDispatch( 'core/notices' ); + const { + isSuccessResponse, + isErrorResponse, + isFailResponse, + noticeContexts, + } = useEmitResponse(); + const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); + const { onPaymentProcessing } = useEventEmitters( observerDispatch ); + const currentObservers = useRef( observers ); + + // ensure observers are always current. + useEffect( () => { + currentObservers.current = observers; + }, [ observers ] ); + + const [ paymentData, dispatch ] = useReducer( + reducer, + DEFAULT_PAYMENT_DATA_CONTEXT_STATE + ); + + const { dispatchActions, setPaymentStatus } = + usePaymentMethodDataDispatchers( dispatch ); + + const paymentMethodsInitialized = usePaymentMethods( + dispatchActions.setRegisteredPaymentMethods + ); + + const expressPaymentMethodsInitialized = useExpressPaymentMethods( + dispatchActions.setRegisteredExpressPaymentMethods + ); + + const customerPaymentMethods = useMemo( (): CustomerPaymentMethods => { + if ( isEditor ) { + return getPreviewData( + 'previewSavedPaymentMethods' + ) as CustomerPaymentMethods; + } + return paymentMethodsInitialized + ? getCustomerPaymentMethods( paymentData.paymentMethods ) + : {}; + }, [ + isEditor, + getPreviewData, + paymentMethodsInitialized, + paymentData.paymentMethods, + ] ); + + const setExpressPaymentError = useCallback( + ( message ) => { + if ( message ) { + addErrorNotice( message, { + id: 'wc-express-payment-error', + context: noticeContexts.EXPRESS_PAYMENTS, + } ); + } else { + removeNotice( + 'wc-express-payment-error', + noticeContexts.EXPRESS_PAYMENTS + ); + } + }, + [ addErrorNotice, noticeContexts.EXPRESS_PAYMENTS, removeNotice ] + ); + + const isExpressPaymentMethodActive = Object.keys( + paymentData.expressPaymentMethods + ).includes( paymentData.activePaymentMethod ); + + const currentStatus = useMemo( + () => ( { + isPristine: paymentData.currentStatus === STATUS.PRISTINE, + isStarted: paymentData.currentStatus === STATUS.STARTED, + isProcessing: paymentData.currentStatus === STATUS.PROCESSING, + isFinished: [ + STATUS.ERROR, + STATUS.FAILED, + STATUS.SUCCESS, + ].includes( paymentData.currentStatus ), + hasError: paymentData.currentStatus === STATUS.ERROR, + hasFailed: paymentData.currentStatus === STATUS.FAILED, + isSuccessful: paymentData.currentStatus === STATUS.SUCCESS, + isDoingExpressPayment: + paymentData.currentStatus !== STATUS.PRISTINE && + isExpressPaymentMethodActive, + } ), + [ paymentData.currentStatus, isExpressPaymentMethodActive ] + ); + + /** + * 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 ), + ]; + + // Return if current method is valid. + if ( + paymentData.activePaymentMethod && + allPaymentMethodKeys.includes( paymentData.activePaymentMethod ) + ) { + return; + } + + setPaymentStatus().pristine(); + + const customerPaymentMethod = + Object.keys( customerPaymentMethods ).flatMap( + ( type ) => customerPaymentMethods[ type ] + )[ 0 ] || undefined; + + if ( customerPaymentMethod ) { + const token = customerPaymentMethod.tokenId.toString(); + const paymentMethodSlug = customerPaymentMethod.method.gateway; + const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`; + + dispatchActions.setActivePaymentMethod( paymentMethodSlug, { + token, + payment_method: paymentMethodSlug, + [ savedTokenKey ]: token, + isSavedToken: true, + } ); + return; + } + + dispatchActions.setActivePaymentMethod( + Object.keys( paymentData.paymentMethods )[ 0 ] + ); + }, [ + paymentMethodsInitialized, + paymentData.paymentMethods, + paymentData.expressPaymentMethods, + dispatchActions, + setPaymentStatus, + paymentData.activePaymentMethod, + customerPaymentMethods, + ] ); + + // flip payment to processing if checkout processing is complete, there are no errors, and payment status is started. + useEffect( () => { + if ( + checkoutIsProcessing && + ! checkoutHasError && + ! checkoutIsCalculating && + ! currentStatus.isFinished + ) { + setPaymentStatus().processing(); + } + }, [ + checkoutIsProcessing, + checkoutHasError, + checkoutIsCalculating, + currentStatus.isFinished, + setPaymentStatus, + ] ); + + // When checkout is returned to idle, set payment status to pristine but only if payment status is already not finished. + useEffect( () => { + if ( checkoutIsIdle && ! currentStatus.isSuccessful ) { + setPaymentStatus().pristine(); + } + }, [ checkoutIsIdle, currentStatus.isSuccessful, setPaymentStatus ] ); + + // if checkout has an error sync payment status back to pristine. + useEffect( () => { + if ( checkoutHasError && currentStatus.isSuccessful ) { + setPaymentStatus().pristine(); + } + }, [ checkoutHasError, currentStatus.isSuccessful, setPaymentStatus ] ); + + useEffect( () => { + // Note: the nature of this event emitter is that it will bail on any + // observer that returns a response that !== true. However, this still + // allows for other observers that return true for continuing through + // to the next observer (or bailing if there's a problem). + if ( currentStatus.isProcessing ) { + removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS ); + emitEventWithAbort( + currentObservers.current, + EMIT_TYPES.PAYMENT_PROCESSING, + {} + ).then( ( observerResponses ) => { + let successResponse, errorResponse; + observerResponses.forEach( ( response ) => { + if ( isSuccessResponse( response ) ) { + // the last observer response always "wins" for success. + successResponse = response; + } + if ( + isErrorResponse( response ) || + isFailResponse( response ) + ) { + errorResponse = response; + } + } ); + if ( successResponse && ! errorResponse ) { + setPaymentStatus().success( + successResponse?.meta?.paymentMethodData, + successResponse?.meta?.billingAddress, + successResponse?.meta?.shippingData + ); + } else if ( errorResponse && isFailResponse( errorResponse ) ) { + if ( + errorResponse.message && + errorResponse.message.length + ) { + addErrorNotice( errorResponse.message, { + id: 'wc-payment-error', + isDismissible: false, + context: + errorResponse?.messageContext || + noticeContexts.PAYMENTS, + } ); + } + setPaymentStatus().failed( + errorResponse?.message, + errorResponse?.meta?.paymentMethodData, + errorResponse?.meta?.billingAddress + ); + } else if ( errorResponse ) { + if ( + errorResponse.message && + errorResponse.message.length + ) { + addErrorNotice( errorResponse.message, { + id: 'wc-payment-error', + isDismissible: false, + context: + errorResponse?.messageContext || + noticeContexts.PAYMENTS, + } ); + } + setPaymentStatus().error( errorResponse.message ); + setValidationErrors( errorResponse?.validationErrors ); + } else { + // otherwise there are no payment methods doing anything so + // just consider success + setPaymentStatus().success(); + } + } ); + } + }, [ + currentStatus.isProcessing, + setValidationErrors, + setPaymentStatus, + removeNotice, + noticeContexts.PAYMENTS, + isSuccessResponse, + isFailResponse, + isErrorResponse, + 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: paymentData.activePaymentMethod, + activeSavedToken, + setActivePaymentMethod: dispatchActions.setActivePaymentMethod, + onPaymentProcessing, + customerPaymentMethods, + paymentMethods: paymentData.paymentMethods, + expressPaymentMethods: paymentData.expressPaymentMethods, + paymentMethodsInitialized, + expressPaymentMethodsInitialized, + setExpressPaymentError, + isExpressPaymentMethodActive, + shouldSavePayment: paymentData.shouldSavePaymentMethod, + setShouldSavePayment: dispatchActions.setShouldSavePayment, + }; + + return ( + + { children } + + ); +}; diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx index fa520db4809..cf26f401c4e 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx @@ -23,6 +23,7 @@ const Block = ( { allowCreateAccount: boolean; phoneAsPrimary: boolean; } ): JSX.Element => { +<<<<<<< HEAD <<<<<<< HEAD const { customerId, shouldCreateAccount } = useSelect( ( select ) => select( CHECKOUT_STORE_KEY ).getCheckoutState() @@ -35,6 +36,18 @@ const Block = ( { const onChangeEmail = ( value: string ) => { +======= + const { customerId, shouldCreateAccount, setShouldCreateAccount } = + useSelect( ( select ) => + select( CHECKOUT_STORE_KEY ).getCheckoutState() + ); + const { setShouldCreateAccount } = useDispatch( CHECKOUT_STORE_KEY ); + const { billingAddress, setEmail, setBillingPhone, setShippingPhone } = + useCheckoutAddress(); + const { dispatchCheckoutEvent } = useStoreEvents(); + + const onChangeEmail = ( value: string ) => { +>>>>>>> 3e5b82cad (Convert checkout context to data store - part 1 (#6232)) setEmail( value ); dispatchCheckoutEvent( 'set-email-address' ); }; diff --git a/assets/js/data/tsconfig.json b/assets/js/data/tsconfig.json new file mode 100644 index 00000000000..64a7b9178d0 --- /dev/null +++ b/assets/js/data/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": {}, + "include": [ + ".", + "../types/type-defs/**.ts", + "../mapped-types.ts", + "../settings/shared/index.js", + "../settings/blocks/index.js", + "../base/context/providers/cart-checkout/checkout-state/utils.ts" + ], + "exclude": [ "**/test/**" ] +}