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/**" ]
+}