/** * External dependencies */ import { createContext, useContext, useReducer, useMemo, useEffect, } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useShallowEqual } from '@woocommerce/base-hooks'; import { productIsPurchasable, productSupportsAddToCartForm, } from '@woocommerce/base-utils'; /** * Internal dependencies */ import { actions } from './actions'; import { reducer } from './reducer'; import { DEFAULT_STATE, STATUS } from './constants'; import { EMIT_TYPES, emitterObservers, emitEvent, emitEventWithAbort, reducer as emitReducer, } from './event-emit'; import { useValidationContext } from '../../validation'; import { useStoreNotices } from '../../../hooks/use-store-notices'; import { useEmitResponse } from '../../../hooks/use-emit-response'; /** * @typedef {import('@woocommerce/type-defs/add-to-cart-form').AddToCartFormDispatchActions} AddToCartFormDispatchActions * @typedef {import('@woocommerce/type-defs/add-to-cart-form').AddToCartFormEventRegistration} AddToCartFormEventRegistration * @typedef {import('@woocommerce/type-defs/contexts').AddToCartFormContext} AddToCartFormContext */ const AddToCartFormContext = createContext( { product: {}, productType: 'simple', productIsPurchasable: true, productHasOptions: false, supportsFormElements: true, showFormElements: false, quantity: 0, minQuantity: 1, maxQuantity: 99, requestParams: {}, isIdle: false, isDisabled: false, isProcessing: false, isBeforeProcessing: false, isAfterProcessing: false, hasError: false, eventRegistration: { onAddToCartAfterProcessingWithSuccess: ( callback ) => void callback, onAddToCartAfterProcessingWithError: ( callback ) => void callback, onAddToCartBeforeProcessing: ( callback ) => void callback, }, dispatchActions: { resetForm: () => void null, submitForm: () => void null, setQuantity: ( quantity ) => void quantity, setHasError: ( hasError ) => void hasError, setAfterProcessing: ( response ) => void response, setRequestParams: ( data ) => void data, }, } ); /** * @return {AddToCartFormContext} Returns the add to cart form data context value */ export const useAddToCartFormContext = () => { // @ts-ignore return useContext( AddToCartFormContext ); }; /** * Add to cart form state provider. * * This provides provides an api interface exposing add to cart form state. * * @param {Object} props Incoming props for the provider. * @param {Object} props.children The children being wrapped. * @param {Object} [props.product] The product for which the form belongs to. * @param {boolean} [props.showFormElements] Should form elements be shown. */ export const AddToCartFormStateContextProvider = ( { children, product, showFormElements, } ) => { const [ addToCartFormState, dispatch ] = useReducer( reducer, DEFAULT_STATE ); const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); const currentObservers = useShallowEqual( observers ); const { addErrorNotice, removeNotices } = useStoreNotices(); const { setValidationErrors } = useValidationContext(); const { isSuccessResponse, isErrorResponse, isFailResponse, } = useEmitResponse(); /** * @type {AddToCartFormEventRegistration} */ const eventRegistration = useMemo( () => ( { onAddToCartAfterProcessingWithSuccess: emitterObservers( observerDispatch ).onAddToCartAfterProcessingWithSuccess, onAddToCartAfterProcessingWithError: emitterObservers( observerDispatch ).onAddToCartAfterProcessingWithError, onAddToCartBeforeProcessing: emitterObservers( observerDispatch ) .onAddToCartBeforeProcessing, } ), [ observerDispatch ] ); /** * @type {AddToCartFormDispatchActions} */ const dispatchActions = useMemo( () => ( { resetForm: () => void dispatch( actions.setPristine() ), submitForm: () => void dispatch( actions.setBeforeProcessing() ), setQuantity: ( quantity ) => void dispatch( actions.setQuantity( quantity ) ), setHasError: ( hasError ) => void dispatch( actions.setHasError( hasError ) ), setRequestParams: ( data ) => void dispatch( actions.setRequestParams( data ) ), setAfterProcessing: ( response ) => { dispatch( actions.setProcessingResponse( response ) ); void dispatch( actions.setAfterProcessing() ); }, } ), [] ); /** * This Effect is responsible for disabling or enabling the form based on the provided product. */ useEffect( () => { const status = addToCartFormState.status; const willBeDisabled = ! product.id || ! productIsPurchasable( product ); if ( status === STATUS.DISABLED && ! willBeDisabled ) { dispatch( actions.setIdle() ); } else if ( status !== STATUS.DISABLED && willBeDisabled ) { dispatch( actions.setDisabled() ); } }, [ addToCartFormState.status, product, dispatch ] ); /** * This Effect performs events before processing starts. */ useEffect( () => { const status = addToCartFormState.status; if ( status === STATUS.BEFORE_PROCESSING ) { removeNotices( 'error' ); emitEvent( currentObservers, EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING, {} ).then( ( response ) => { if ( response !== true ) { if ( Array.isArray( response ) ) { response.forEach( ( { errorMessage, validationErrors } ) => { if ( errorMessage ) { addErrorNotice( errorMessage ); } if ( validationErrors ) { setValidationErrors( validationErrors ); } } ); } dispatch( actions.setIdle() ); } else { dispatch( actions.setProcessing() ); } } ); } }, [ addToCartFormState.status, setValidationErrors, addErrorNotice, removeNotices, dispatch, currentObservers, ] ); /** * This Effect performs events after processing is complete. */ useEffect( () => { if ( addToCartFormState.status === STATUS.AFTER_PROCESSING ) { // @todo: This data package differs from what is passed through in // the checkout state context. Should we introduce a "context" // property in the data package for this emitted event so that // observers are able to know what context the event is firing in? const data = { processingResponse: addToCartFormState.processingResponse, }; const handleErrorResponse = ( observerResponses ) => { let handled = false; observerResponses.forEach( ( response ) => { const { message, messageContext } = response; if ( ( isErrorResponse( response ) || isFailResponse( response ) ) && message ) { const errorOptions = messageContext ? { context: messageContext } : undefined; handled = true; addErrorNotice( message, errorOptions ); } } ); return handled; }; if ( addToCartFormState.hasError ) { // allow things to customize the error with a fallback if nothing customizes it. emitEventWithAbort( currentObservers, EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR, data ).then( ( observerResponses ) => { if ( ! handleErrorResponse( observerResponses ) ) { // 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 to get assistance.', 'woocommerce' ); addErrorNotice( message, { id: 'add-to-cart', } ); } dispatch( actions.setIdle() ); } ); return; } emitEventWithAbort( currentObservers, EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS, data ).then( ( observerResponses ) => { if ( handleErrorResponse( observerResponses ) ) { // this will set an error which will end up // triggering the onAddToCartAfterProcessingWithError emitter. // and then setting to IDLE state. dispatch( actions.setHasError( true ) ); } else { dispatch( actions.setIdle() ); } } ); } }, [ addToCartFormState.status, addToCartFormState.hasError, addToCartFormState.processingResponse, dispatchActions, addErrorNotice, isErrorResponse, isFailResponse, isSuccessResponse, currentObservers, ] ); const supportsFormElements = productSupportsAddToCartForm( product ); /** * @type {AddToCartFormContext} */ const contextData = { product, productType: product.type || 'simple', productIsPurchasable: productIsPurchasable( product ), productHasOptions: product.has_options || false, supportsFormElements, showFormElements: showFormElements && supportsFormElements, quantity: addToCartFormState.quantity, minQuantity: 1, maxQuantity: product.quantity_limit || 99, requestParams: addToCartFormState.requestParams, isIdle: addToCartFormState.status === STATUS.IDLE, isDisabled: addToCartFormState.status === STATUS.DISABLED, isProcessing: addToCartFormState.status === STATUS.PROCESSING, isBeforeProcessing: addToCartFormState.status === STATUS.BEFORE_PROCESSING, isAfterProcessing: addToCartFormState.status === STATUS.AFTER_PROCESSING, hasError: addToCartFormState.hasError, eventRegistration, dispatchActions, }; return ( <AddToCartFormContext.Provider // @ts-ignore value={ contextData } > { children } </AddToCartFormContext.Provider> ); };