diff --git a/assets/js/base/components/quantity-selector/index.tsx b/assets/js/base/components/quantity-selector/index.tsx
index 74ed741b28c..0b3c492c1f8 100644
--- a/assets/js/base/components/quantity-selector/index.tsx
+++ b/assets/js/base/components/quantity-selector/index.tsx
@@ -4,7 +4,12 @@
import { __, sprintf } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import classNames from 'classnames';
-import { useCallback, useLayoutEffect } from '@wordpress/element';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+} from '@wordpress/element';
import { DOWN, UP } from '@wordpress/keycodes';
import { useDebouncedCallback } from 'use-debounce';
import { ValidationInputError } from '@woocommerce/blocks-checkout';
@@ -16,6 +21,7 @@ import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
* Internal dependencies
*/
import './style.scss';
+import { useCartEventsContext } from '../../context/providers/cart-checkout/cart-events';
export interface QuantitySelectorProps {
/**
@@ -76,13 +82,17 @@ const QuantitySelector = ( {
}: QuantitySelectorProps ): JSX.Element => {
const errorId = `wc-block-components-quantity-selector-error-${ instanceId }`;
- const hasValidationErrors = useSelect( ( select ) => {
- return !! select( VALIDATION_STORE_KEY ).getValidationError( errorId );
+ const { hasValidationError, validationError } = useSelect( ( select ) => {
+ const store = select( VALIDATION_STORE_KEY );
+ return {
+ hasValidationError: !! store.getValidationError( errorId ),
+ validationError: store.getValidationError( errorId ),
+ };
} );
const classes = classNames(
'wc-block-components-quantity-selector',
- hasValidationErrors ? 'has-error' : '',
+ hasValidationError ? 'has-error' : '',
className
);
@@ -123,7 +133,7 @@ const QuantitySelector = ( {
onChange( value );
}
},
- [ hasMaximum, maximum, minimum, onChange, step ]
+ [ hasMaximum, maximum, minimum, onChange, step, strictLimits ]
);
/*
@@ -248,10 +258,37 @@ const QuantitySelector = ( {
*/
const stepToUse = ! strictLimits && quantity % step !== 0 ? 1 : step;
+ const { onProceedToCheckout } = useCartEventsContext();
+
+ const inputRef = useRef< HTMLInputElement >( null );
+
+ useEffect( () => {
+ // onProceedToCheckout returns an unsubscribe function. By returning it here, we ensure that the
+ // observer is removed when the component is unmounted/this effect reruns.
+ return onProceedToCheckout( () => {
+ if ( ! inputRef.current ) {
+ return;
+ }
+ const isValid = inputRef.current.checkValidity();
+
+ if ( ! isValid || hasValidationError ) {
+ inputRef.current.focus();
+ return {
+ type: 'error',
+ message:
+ inputRef.current.validationMessage ||
+ validationError?.message,
+ };
+ }
+ return true;
+ } );
+ }, [ errorId, hasValidationError, onProceedToCheckout, validationError ] );
+
return (
- { hasValidationErrors && ! strictLimits && (
+ { hasValidationError && ! strictLimits && (
diff --git a/assets/js/base/context/providers/cart-checkout/cart-events/event-emit.ts b/assets/js/base/context/providers/cart-checkout/cart-events/event-emit.ts
new file mode 100644
index 00000000000..ac6ed876084
--- /dev/null
+++ b/assets/js/base/context/providers/cart-checkout/cart-events/event-emit.ts
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { useMemo } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import {
+ emitterCallback,
+ reducer,
+ emitEvent,
+ emitEventWithAbort,
+ ActionType,
+} from '../../../event-emit';
+
+// These events are emitted when the Cart status is BEFORE_PROCESSING and AFTER_PROCESSING
+// to enable third parties to hook into the cart process
+const EVENTS = {
+ PROCEED_TO_CHECKOUT: 'cart_proceed_to_checkout',
+};
+
+type EventEmittersType = Record< string, ReturnType< typeof emitterCallback > >;
+
+/**
+ * Receives a reducer dispatcher and returns an object with the
+ * various event emitters for the payment processing events.
+ *
+ * Calling the event registration function with the callback will register it
+ * for the event emitter and will return a dispatcher for removing the
+ * registered callback (useful for implementation in `useEffect`).
+ *
+ * @param {Function} observerDispatch The emitter reducer dispatcher.
+ * @return {Object} An object with the various payment event emitter registration functions
+ */
+const useEventEmitters = (
+ observerDispatch: React.Dispatch< ActionType >
+): EventEmittersType => {
+ const eventEmitters = useMemo(
+ () => ( {
+ onProceedToCheckout: emitterCallback(
+ EVENTS.PROCEED_TO_CHECKOUT,
+ observerDispatch
+ ),
+ } ),
+ [ observerDispatch ]
+ );
+ return eventEmitters;
+};
+
+export { EVENTS, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
diff --git a/assets/js/base/context/providers/cart-checkout/cart-events/index.tsx b/assets/js/base/context/providers/cart-checkout/cart-events/index.tsx
new file mode 100644
index 00000000000..90a4998f94a
--- /dev/null
+++ b/assets/js/base/context/providers/cart-checkout/cart-events/index.tsx
@@ -0,0 +1,78 @@
+/**
+ * External dependencies
+ */
+
+import {
+ createContext,
+ useContext,
+ useReducer,
+ useRef,
+ useEffect,
+} from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import {
+ useEventEmitters,
+ reducer as emitReducer,
+ emitEventWithAbort,
+ EVENTS,
+} from './event-emit';
+import type { emitterCallback } from '../../../event-emit';
+
+type CartEventsContextType = {
+ // Used to register a callback that will fire when the cart has been processed and has an error.
+ onProceedToCheckout: ReturnType< typeof emitterCallback >;
+ // Used to register a callback that will fire when the cart has been processed and has an error.
+ dispatchOnProceedToCheckout: () => Promise< unknown[] >;
+};
+
+const CartEventsContext = createContext< CartEventsContextType >( {
+ onProceedToCheckout: () => () => void null,
+ dispatchOnProceedToCheckout: () => new Promise( () => void null ),
+} );
+
+export const useCartEventsContext = () => {
+ return useContext( CartEventsContext );
+};
+
+/**
+ * Checkout Events provider
+ * Emit Checkout events and provide access to Checkout event handlers
+ *
+ * @param {Object} props Incoming props for the provider.
+ * @param {Object} props.children The children being wrapped.
+ */
+export const CartEventsProvider = ( {
+ children,
+}: {
+ children: React.ReactNode;
+} ): JSX.Element => {
+ const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
+ const currentObservers = useRef( observers );
+ const { onProceedToCheckout } = useEventEmitters( observerDispatch );
+
+ // set observers on ref so it's always current.
+ useEffect( () => {
+ currentObservers.current = observers;
+ }, [ observers ] );
+
+ const dispatchOnProceedToCheckout = async () => {
+ return await emitEventWithAbort(
+ currentObservers.current,
+ EVENTS.PROCEED_TO_CHECKOUT,
+ null
+ );
+ };
+
+ const cartEvents = {
+ onProceedToCheckout,
+ dispatchOnProceedToCheckout,
+ };
+ return (
+
+ { children }
+
+ );
+};
diff --git a/assets/js/base/context/providers/cart-checkout/cart-events/test/index.tsx b/assets/js/base/context/providers/cart-checkout/cart-events/test/index.tsx
new file mode 100644
index 00000000000..cd74494123c
--- /dev/null
+++ b/assets/js/base/context/providers/cart-checkout/cart-events/test/index.tsx
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import { useCartEventsContext } from '@woocommerce/base-context';
+import { useEffect } from '@wordpress/element';
+import { render, screen, waitFor } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { CartEventsProvider } from '../index';
+import Block from '../../../../../../blocks/cart/inner-blocks/proceed-to-checkout-block/block';
+
+describe( 'CartEventsProvider', () => {
+ it( 'allows observers to unsubscribe', async () => {
+ const mockObserver = jest.fn();
+ const MockObserverComponent = () => {
+ const { onProceedToCheckout } = useCartEventsContext();
+ useEffect( () => {
+ const unsubscribe = onProceedToCheckout( () => {
+ mockObserver();
+ unsubscribe();
+ } );
+ }, [ onProceedToCheckout ] );
+ return
Mock observer
;
+ };
+
+ render(
+
+
+
+
+
+
+ );
+ expect( screen.getByText( 'Mock observer' ) ).toBeInTheDocument();
+ const button = screen.getByText( 'Proceed to Checkout' );
+ // Click twice. The observer should unsubscribe after the first click.
+ button.click();
+ button.click();
+ await waitFor( () => {
+ expect( mockObserver ).toHaveBeenCalledTimes( 1 );
+ } );
+ } );
+} );
diff --git a/assets/js/base/context/providers/cart-checkout/index.js b/assets/js/base/context/providers/cart-checkout/index.js
index 39f7e4f97ec..90fce40ddc0 100644
--- a/assets/js/base/context/providers/cart-checkout/index.js
+++ b/assets/js/base/context/providers/cart-checkout/index.js
@@ -2,6 +2,7 @@ export * from './payment-events';
export * from './shipping';
export * from './customer';
export * from './checkout-events';
+export * from './cart-events';
export * from './cart';
export * from './checkout-processor';
export * from './checkout-provider';
diff --git a/assets/js/blocks/cart/block.js b/assets/js/blocks/cart/block.js
index acb264e9d0d..ba4a79ed483 100644
--- a/assets/js/blocks/cart/block.js
+++ b/assets/js/blocks/cart/block.js
@@ -13,7 +13,10 @@ import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
import { translateJQueryEventToNative } from '@woocommerce/base-utils';
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
-import { CartProvider } from '@woocommerce/base-context/providers';
+import {
+ CartProvider,
+ CartEventsProvider,
+} from '@woocommerce/base-context/providers';
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
/**
@@ -87,8 +90,10 @@ const Block = ( { attributes, children, scrollToTop } ) => (
- { children }
-
+
+ { children }
+
+
diff --git a/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx b/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx
index 441fda5a41b..d0b8b907a8c 100644
--- a/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx
+++ b/assets/js/blocks/cart/inner-blocks/proceed-to-checkout-block/block.tsx
@@ -10,11 +10,13 @@ import { usePositionRelativeToViewport } from '@woocommerce/base-hooks';
import { getSetting } from '@woocommerce/settings';
import { useSelect } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
+import { isErrorResponse } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import './style.scss';
+import { useCartEventsContext } from '../../../../base/context/providers/cart-checkout/cart-events';
/**
* Checkout button rendered in the full cart page.
@@ -56,13 +58,25 @@ const Block = ( {
};
}, [] );
+ const { dispatchOnProceedToCheckout } = useCartEventsContext();
+
const submitContainerContents = (