initial commit
This commit is contained in:
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES } from './constants';
|
||||
|
||||
const {
|
||||
SET_PRISTINE,
|
||||
SET_IDLE,
|
||||
SET_DISABLED,
|
||||
SET_PROCESSING,
|
||||
SET_BEFORE_PROCESSING,
|
||||
SET_AFTER_PROCESSING,
|
||||
SET_PROCESSING_RESPONSE,
|
||||
SET_HAS_ERROR,
|
||||
SET_NO_ERROR,
|
||||
SET_QUANTITY,
|
||||
SET_REQUEST_PARAMS,
|
||||
} = ACTION_TYPES;
|
||||
|
||||
/**
|
||||
* All the actions that can be dispatched for the checkout.
|
||||
*/
|
||||
export const actions = {
|
||||
setPristine: () => ( {
|
||||
type: SET_PRISTINE,
|
||||
} ),
|
||||
setIdle: () => ( {
|
||||
type: SET_IDLE,
|
||||
} ),
|
||||
setDisabled: () => ( {
|
||||
type: SET_DISABLED,
|
||||
} ),
|
||||
setProcessing: () => ( {
|
||||
type: SET_PROCESSING,
|
||||
} ),
|
||||
setBeforeProcessing: () => ( {
|
||||
type: SET_BEFORE_PROCESSING,
|
||||
} ),
|
||||
setAfterProcessing: () => ( {
|
||||
type: SET_AFTER_PROCESSING,
|
||||
} ),
|
||||
setProcessingResponse: ( data ) => ( {
|
||||
type: SET_PROCESSING_RESPONSE,
|
||||
data,
|
||||
} ),
|
||||
setHasError: ( hasError = true ) => {
|
||||
const type = hasError ? SET_HAS_ERROR : SET_NO_ERROR;
|
||||
return { type };
|
||||
},
|
||||
setQuantity: ( quantity ) => ( {
|
||||
type: SET_QUANTITY,
|
||||
quantity,
|
||||
} ),
|
||||
setRequestParams: ( data ) => ( {
|
||||
type: SET_REQUEST_PARAMS,
|
||||
data,
|
||||
} ),
|
||||
};
|
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @type {import("@woocommerce/type-defs/add-to-cart-form").AddToCartFormStatusConstants}
|
||||
*/
|
||||
export const STATUS = {
|
||||
PRISTINE: 'pristine',
|
||||
IDLE: 'idle',
|
||||
DISABLED: 'disabled',
|
||||
PROCESSING: 'processing',
|
||||
BEFORE_PROCESSING: 'before_processing',
|
||||
AFTER_PROCESSING: 'after_processing',
|
||||
};
|
||||
|
||||
export const DEFAULT_STATE = {
|
||||
status: STATUS.PRISTINE,
|
||||
hasError: false,
|
||||
quantity: 1,
|
||||
processingResponse: null,
|
||||
requestParams: {},
|
||||
};
|
||||
export const ACTION_TYPES = {
|
||||
SET_PRISTINE: 'set_pristine',
|
||||
SET_IDLE: 'set_idle',
|
||||
SET_DISABLED: 'set_disabled',
|
||||
SET_PROCESSING: 'set_processing',
|
||||
SET_BEFORE_PROCESSING: 'set_before_processing',
|
||||
SET_AFTER_PROCESSING: 'set_after_processing',
|
||||
SET_PROCESSING_RESPONSE: 'set_processing_response',
|
||||
SET_HAS_ERROR: 'set_has_error',
|
||||
SET_NO_ERROR: 'set_no_error',
|
||||
SET_QUANTITY: 'set_quantity',
|
||||
SET_REQUEST_PARAMS: 'set_request_params',
|
||||
};
|
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
emitterCallback,
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
} from '../../../event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
ADD_TO_CART_BEFORE_PROCESSING: 'add_to_cart_before_processing',
|
||||
ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS:
|
||||
'add_to_cart_after_processing_with_success',
|
||||
ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR:
|
||||
'add_to_cart_after_processing_with_error',
|
||||
};
|
||||
|
||||
/**
|
||||
* Receives a reducer dispatcher and returns an object with the callback registration function for
|
||||
* the add to cart emit 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} dispatcher The emitter reducer dispatcher.
|
||||
*
|
||||
* @return {Object} An object with the add to cart form emitter registration
|
||||
*/
|
||||
const emitterObservers = ( dispatcher ) => ( {
|
||||
onAddToCartAfterProcessingWithSuccess: emitterCallback(
|
||||
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_SUCCESS,
|
||||
dispatcher
|
||||
),
|
||||
onAddToCartProcessingWithError: emitterCallback(
|
||||
EMIT_TYPES.ADD_TO_CART_AFTER_PROCESSING_WITH_ERROR,
|
||||
dispatcher
|
||||
),
|
||||
onAddToCartBeforeProcessing: emitterCallback(
|
||||
EMIT_TYPES.ADD_TO_CART_BEFORE_PROCESSING,
|
||||
dispatcher
|
||||
),
|
||||
} );
|
||||
|
||||
export { EMIT_TYPES, emitterObservers, reducer, emitEvent, emitEventWithAbort };
|
@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION_TYPES, DEFAULT_STATE, STATUS } from './constants';
|
||||
|
||||
const {
|
||||
SET_PRISTINE,
|
||||
SET_IDLE,
|
||||
SET_DISABLED,
|
||||
SET_PROCESSING,
|
||||
SET_BEFORE_PROCESSING,
|
||||
SET_AFTER_PROCESSING,
|
||||
SET_PROCESSING_RESPONSE,
|
||||
SET_HAS_ERROR,
|
||||
SET_NO_ERROR,
|
||||
SET_QUANTITY,
|
||||
SET_REQUEST_PARAMS,
|
||||
} = ACTION_TYPES;
|
||||
|
||||
const {
|
||||
PRISTINE,
|
||||
IDLE,
|
||||
DISABLED,
|
||||
PROCESSING,
|
||||
BEFORE_PROCESSING,
|
||||
AFTER_PROCESSING,
|
||||
} = STATUS;
|
||||
|
||||
/**
|
||||
* Reducer for the checkout state
|
||||
*
|
||||
* @param {Object} state Current state.
|
||||
* @param {Object} action Incoming action object.
|
||||
* @param {number} action.quantity Incoming quantity.
|
||||
* @param {string} action.type Type of action.
|
||||
* @param {Object} action.data Incoming payload for action.
|
||||
*/
|
||||
export const reducer = ( state = DEFAULT_STATE, { quantity, type, data } ) => {
|
||||
let newState;
|
||||
switch ( type ) {
|
||||
case SET_PRISTINE:
|
||||
newState = DEFAULT_STATE;
|
||||
break;
|
||||
case SET_IDLE:
|
||||
newState =
|
||||
state.status !== IDLE
|
||||
? {
|
||||
...state,
|
||||
status: IDLE,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_DISABLED:
|
||||
newState =
|
||||
state.status !== DISABLED
|
||||
? {
|
||||
...state,
|
||||
status: DISABLED,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_QUANTITY:
|
||||
newState =
|
||||
quantity !== state.quantity
|
||||
? {
|
||||
...state,
|
||||
quantity,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_REQUEST_PARAMS:
|
||||
newState = {
|
||||
...state,
|
||||
requestParams: {
|
||||
...state.requestParams,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case SET_PROCESSING_RESPONSE:
|
||||
newState = {
|
||||
...state,
|
||||
processingResponse: data,
|
||||
};
|
||||
break;
|
||||
case SET_PROCESSING:
|
||||
newState =
|
||||
state.status !== PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: PROCESSING,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
// clear any error state.
|
||||
newState =
|
||||
newState.hasError === false
|
||||
? newState
|
||||
: { ...newState, hasError: false };
|
||||
break;
|
||||
case SET_BEFORE_PROCESSING:
|
||||
newState =
|
||||
state.status !== BEFORE_PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: BEFORE_PROCESSING,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_AFTER_PROCESSING:
|
||||
newState =
|
||||
state.status !== AFTER_PROCESSING
|
||||
? {
|
||||
...state,
|
||||
status: AFTER_PROCESSING,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
case SET_HAS_ERROR:
|
||||
newState = state.hasError
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
hasError: true,
|
||||
};
|
||||
newState =
|
||||
state.status === PROCESSING ||
|
||||
state.status === BEFORE_PROCESSING
|
||||
? {
|
||||
...newState,
|
||||
status: IDLE,
|
||||
}
|
||||
: newState;
|
||||
break;
|
||||
case SET_NO_ERROR:
|
||||
newState = state.hasError
|
||||
? {
|
||||
...state,
|
||||
hasError: false,
|
||||
}
|
||||
: state;
|
||||
break;
|
||||
}
|
||||
// automatically update state to idle from pristine as soon as it initially changes.
|
||||
if (
|
||||
newState !== state &&
|
||||
type !== SET_PRISTINE &&
|
||||
newState.status === PRISTINE
|
||||
) {
|
||||
newState.status = IDLE;
|
||||
}
|
||||
return newState;
|
||||
};
|
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { AddToCartFormStateContextProvider } from '../form-state';
|
||||
import { ValidationContextProvider } from '../../validation';
|
||||
import FormSubmit from './submit';
|
||||
|
||||
/**
|
||||
* Add to cart form provider.
|
||||
*
|
||||
* This wraps the add to cart form and provides an api interface for children via various hooks.
|
||||
*
|
||||
* @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 AddToCartFormContextProvider = ( {
|
||||
children,
|
||||
product,
|
||||
showFormElements,
|
||||
} ) => {
|
||||
return (
|
||||
<ValidationContextProvider>
|
||||
<AddToCartFormStateContextProvider
|
||||
product={ product }
|
||||
showFormElements={ showFormElements }
|
||||
>
|
||||
{ children }
|
||||
<FormSubmit />
|
||||
</AddToCartFormStateContextProvider>
|
||||
</ValidationContextProvider>
|
||||
);
|
||||
};
|
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import triggerFetch from '@wordpress/api-fetch';
|
||||
import { useEffect, useCallback, useState } from '@wordpress/element';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useAddToCartFormContext } from '../../form-state';
|
||||
import { useValidationContext } from '../../../validation';
|
||||
import { useStoreCart } from '../../../../hooks/cart/use-store-cart';
|
||||
import { useStoreNotices } from '../../../../hooks/use-store-notices';
|
||||
|
||||
/**
|
||||
* FormSubmit.
|
||||
*
|
||||
* Subscribes to add to cart form context and triggers processing via the API.
|
||||
*/
|
||||
const FormSubmit = () => {
|
||||
const {
|
||||
dispatchActions,
|
||||
product,
|
||||
quantity,
|
||||
eventRegistration,
|
||||
hasError,
|
||||
isProcessing,
|
||||
requestParams,
|
||||
} = useAddToCartFormContext();
|
||||
const {
|
||||
hasValidationErrors,
|
||||
showAllValidationErrors,
|
||||
} = useValidationContext();
|
||||
const { addErrorNotice, removeNotice } = useStoreNotices();
|
||||
const { receiveCart } = useStoreCart();
|
||||
const [ isSubmitting, setIsSubmitting ] = useState( false );
|
||||
const doSubmit = ! hasError && isProcessing;
|
||||
|
||||
const checkValidationContext = useCallback( () => {
|
||||
if ( hasValidationErrors ) {
|
||||
showAllValidationErrors();
|
||||
return {
|
||||
type: 'error',
|
||||
};
|
||||
}
|
||||
return true;
|
||||
}, [ hasValidationErrors, showAllValidationErrors ] );
|
||||
|
||||
// Subscribe to emitter before processing.
|
||||
useEffect( () => {
|
||||
const unsubscribeProcessing = eventRegistration.onAddToCartBeforeProcessing(
|
||||
checkValidationContext,
|
||||
0
|
||||
);
|
||||
return () => {
|
||||
unsubscribeProcessing();
|
||||
};
|
||||
}, [ eventRegistration, checkValidationContext ] );
|
||||
|
||||
// Triggers form submission to the API.
|
||||
const submitFormCallback = useCallback( () => {
|
||||
setIsSubmitting( true );
|
||||
removeNotice( 'add-to-cart' );
|
||||
|
||||
const fetchData = {
|
||||
id: product.id || 0,
|
||||
quantity,
|
||||
...requestParams,
|
||||
};
|
||||
|
||||
triggerFetch( {
|
||||
path: '/wc/store/cart/add-item',
|
||||
method: 'POST',
|
||||
data: fetchData,
|
||||
cache: 'no-store',
|
||||
parse: false,
|
||||
} )
|
||||
.then( ( fetchResponse ) => {
|
||||
// Update nonce.
|
||||
triggerFetch.setNonce( fetchResponse.headers );
|
||||
|
||||
// Handle response.
|
||||
fetchResponse.json().then( function ( response ) {
|
||||
if ( ! fetchResponse.ok ) {
|
||||
// We received an error response.
|
||||
if ( response.body && response.body.message ) {
|
||||
addErrorNotice(
|
||||
decodeEntities( response.body.message ),
|
||||
{
|
||||
id: 'add-to-cart',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
addErrorNotice(
|
||||
__(
|
||||
'Something went wrong. Please contact us to get assistance.',
|
||||
'woocommerce'
|
||||
),
|
||||
{
|
||||
id: 'add-to-cart',
|
||||
}
|
||||
);
|
||||
}
|
||||
dispatchActions.setHasError();
|
||||
} else {
|
||||
receiveCart( response );
|
||||
}
|
||||
dispatchActions.setAfterProcessing( response );
|
||||
setIsSubmitting( false );
|
||||
} );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
error.json().then( function ( response ) {
|
||||
// If updated cart state was returned, also update that.
|
||||
if ( response.data?.cart ) {
|
||||
receiveCart( response.data.cart );
|
||||
}
|
||||
dispatchActions.setHasError();
|
||||
dispatchActions.setAfterProcessing( response );
|
||||
setIsSubmitting( false );
|
||||
} );
|
||||
} );
|
||||
}, [
|
||||
product,
|
||||
addErrorNotice,
|
||||
removeNotice,
|
||||
receiveCart,
|
||||
dispatchActions,
|
||||
quantity,
|
||||
requestParams,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( doSubmit && ! isSubmitting ) {
|
||||
submitFormCallback();
|
||||
}
|
||||
}, [ doSubmit, submitFormCallback, isSubmitting ] );
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default FormSubmit;
|
@ -0,0 +1,2 @@
|
||||
export * from './form';
|
||||
export * from './form-state';
|
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CheckoutProvider } from '../checkout-provider';
|
||||
|
||||
/**
|
||||
* Cart provider
|
||||
* This wraps the Cart and provides an api interface for the Cart to
|
||||
* children via various hooks.
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} [props.children] The children being wrapped.
|
||||
* @param {string} [props.redirectUrl] Initialize what the cart will
|
||||
* redirect to after successful
|
||||
* submit.
|
||||
*/
|
||||
export const CartProvider = ( { children, redirectUrl } ) => {
|
||||
return (
|
||||
<CheckoutProvider isCart={ true } redirectUrl={ redirectUrl }>
|
||||
{ children }
|
||||
</CheckoutProvider>
|
||||
);
|
||||
};
|
@ -0,0 +1,269 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import triggerFetch from '@wordpress/api-fetch';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
useMemo,
|
||||
} from '@wordpress/element';
|
||||
import {
|
||||
emptyHiddenAddressFields,
|
||||
formatStoreApiErrorMessage,
|
||||
} from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { preparePaymentData, processCheckoutResponseHeaders } from './utils';
|
||||
import { useCheckoutContext } from './checkout-state';
|
||||
import { useShippingDataContext } from './shipping';
|
||||
import { useCustomerDataContext } from './customer';
|
||||
import { usePaymentMethodDataContext } from './payment-methods';
|
||||
import { useValidationContext } from '../validation';
|
||||
import { useStoreCart } from '../../hooks/cart/use-store-cart';
|
||||
import { useStoreNotices } from '../../hooks/use-store-notices';
|
||||
|
||||
/**
|
||||
* CheckoutProcessor component.
|
||||
*
|
||||
* Subscribes to checkout context and triggers processing via the API.
|
||||
*/
|
||||
const CheckoutProcessor = () => {
|
||||
const {
|
||||
hasError: checkoutHasError,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
dispatchActions,
|
||||
redirectUrl,
|
||||
isProcessing: checkoutIsProcessing,
|
||||
isBeforeProcessing: checkoutIsBeforeProcessing,
|
||||
isComplete: checkoutIsComplete,
|
||||
orderNotes,
|
||||
shouldCreateAccount,
|
||||
extensionData,
|
||||
} = useCheckoutContext();
|
||||
const { hasValidationErrors } = useValidationContext();
|
||||
const { shippingErrorStatus } = useShippingDataContext();
|
||||
const { billingData, shippingAddress } = useCustomerDataContext();
|
||||
const { cartNeedsPayment, receiveCart } = useStoreCart();
|
||||
const {
|
||||
activePaymentMethod,
|
||||
isExpressPaymentMethodActive,
|
||||
currentStatus: currentPaymentStatus,
|
||||
paymentMethodData,
|
||||
expressPaymentMethods,
|
||||
paymentMethods,
|
||||
shouldSavePayment,
|
||||
} = usePaymentMethodDataContext();
|
||||
const { addErrorNotice, removeNotice, setIsSuppressed } = useStoreNotices();
|
||||
const currentBillingData = useRef( billingData );
|
||||
const currentShippingAddress = useRef( shippingAddress );
|
||||
const currentRedirectUrl = useRef( redirectUrl );
|
||||
const [ isProcessingOrder, setIsProcessingOrder ] = useState( false );
|
||||
|
||||
const paymentMethodId = useMemo( () => {
|
||||
const merged = { ...expressPaymentMethods, ...paymentMethods };
|
||||
return merged?.[ activePaymentMethod ]?.paymentMethodId;
|
||||
}, [ activePaymentMethod, expressPaymentMethods, paymentMethods ] );
|
||||
|
||||
const checkoutWillHaveError =
|
||||
( hasValidationErrors && ! isExpressPaymentMethodActive ) ||
|
||||
currentPaymentStatus.hasError ||
|
||||
shippingErrorStatus.hasError;
|
||||
|
||||
const paidAndWithoutErrors =
|
||||
! checkoutHasError &&
|
||||
! checkoutWillHaveError &&
|
||||
( currentPaymentStatus.isSuccessful || ! cartNeedsPayment ) &&
|
||||
checkoutIsProcessing;
|
||||
|
||||
// If express payment method is active, let's suppress notices
|
||||
useEffect( () => {
|
||||
setIsSuppressed( isExpressPaymentMethodActive );
|
||||
}, [ isExpressPaymentMethodActive, setIsSuppressed ] );
|
||||
|
||||
// Determine if checkout has an error.
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutWillHaveError !== checkoutHasError &&
|
||||
( checkoutIsProcessing || checkoutIsBeforeProcessing ) &&
|
||||
! isExpressPaymentMethodActive
|
||||
) {
|
||||
dispatchActions.setHasError( checkoutWillHaveError );
|
||||
}
|
||||
}, [
|
||||
checkoutWillHaveError,
|
||||
checkoutHasError,
|
||||
checkoutIsProcessing,
|
||||
checkoutIsBeforeProcessing,
|
||||
isExpressPaymentMethodActive,
|
||||
dispatchActions,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
currentBillingData.current = billingData;
|
||||
currentShippingAddress.current = shippingAddress;
|
||||
currentRedirectUrl.current = redirectUrl;
|
||||
}, [ billingData, shippingAddress, redirectUrl ] );
|
||||
|
||||
const checkValidation = useCallback( () => {
|
||||
if ( hasValidationErrors ) {
|
||||
return false;
|
||||
}
|
||||
if ( currentPaymentStatus.hasError ) {
|
||||
return {
|
||||
errorMessage: __(
|
||||
'There was a problem with your payment option.',
|
||||
'woocommerce'
|
||||
),
|
||||
};
|
||||
}
|
||||
if ( shippingErrorStatus.hasError ) {
|
||||
return {
|
||||
errorMessage: __(
|
||||
'There was a problem with your shipping option.',
|
||||
'woocommerce'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [
|
||||
hasValidationErrors,
|
||||
currentPaymentStatus.hasError,
|
||||
shippingErrorStatus.hasError,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
let unsubscribeProcessing;
|
||||
if ( ! isExpressPaymentMethodActive ) {
|
||||
unsubscribeProcessing = onCheckoutValidationBeforeProcessing(
|
||||
checkValidation,
|
||||
0
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
if ( ! isExpressPaymentMethodActive ) {
|
||||
unsubscribeProcessing();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
checkValidation,
|
||||
isExpressPaymentMethodActive,
|
||||
] );
|
||||
|
||||
// redirect when checkout is complete and there is a redirect url.
|
||||
useEffect( () => {
|
||||
if ( currentRedirectUrl.current ) {
|
||||
window.location.href = currentRedirectUrl.current;
|
||||
}
|
||||
}, [ checkoutIsComplete ] );
|
||||
|
||||
const processOrder = useCallback( async () => {
|
||||
if ( isProcessingOrder ) {
|
||||
return;
|
||||
}
|
||||
setIsProcessingOrder( true );
|
||||
removeNotice( 'checkout' );
|
||||
|
||||
const paymentData = cartNeedsPayment
|
||||
? {
|
||||
payment_method: paymentMethodId,
|
||||
payment_data: preparePaymentData(
|
||||
paymentMethodData,
|
||||
shouldSavePayment,
|
||||
activePaymentMethod
|
||||
),
|
||||
}
|
||||
: {};
|
||||
|
||||
const data = {
|
||||
billing_address: emptyHiddenAddressFields(
|
||||
currentBillingData.current
|
||||
),
|
||||
shipping_address: emptyHiddenAddressFields(
|
||||
currentShippingAddress.current
|
||||
),
|
||||
customer_note: orderNotes,
|
||||
should_create_account: shouldCreateAccount,
|
||||
...paymentData,
|
||||
extensions: { ...extensionData },
|
||||
};
|
||||
|
||||
triggerFetch( {
|
||||
path: '/wc/store/checkout',
|
||||
method: 'POST',
|
||||
data,
|
||||
cache: 'no-store',
|
||||
parse: false,
|
||||
} )
|
||||
.then( ( response ) => {
|
||||
processCheckoutResponseHeaders(
|
||||
response.headers,
|
||||
dispatchActions
|
||||
);
|
||||
if ( ! response.ok ) {
|
||||
throw new Error( response );
|
||||
}
|
||||
return response.json();
|
||||
} )
|
||||
.then( ( response ) => {
|
||||
dispatchActions.setAfterProcessing( response );
|
||||
setIsProcessingOrder( false );
|
||||
} )
|
||||
.catch( ( fetchResponse ) => {
|
||||
processCheckoutResponseHeaders(
|
||||
fetchResponse.headers,
|
||||
dispatchActions
|
||||
);
|
||||
fetchResponse.json().then( ( response ) => {
|
||||
// If updated cart state was returned, update the store.
|
||||
if ( response.data?.cart ) {
|
||||
receiveCart( response.data.cart );
|
||||
}
|
||||
addErrorNotice( formatStoreApiErrorMessage( response ), {
|
||||
id: 'checkout',
|
||||
} );
|
||||
response.additional_errors?.forEach?.(
|
||||
( additionalError ) => {
|
||||
addErrorNotice( additionalError.message, {
|
||||
id: additionalError.error_code,
|
||||
} );
|
||||
}
|
||||
);
|
||||
dispatchActions.setHasError( true );
|
||||
dispatchActions.setAfterProcessing( response );
|
||||
setIsProcessingOrder( false );
|
||||
} );
|
||||
} );
|
||||
}, [
|
||||
isProcessingOrder,
|
||||
removeNotice,
|
||||
orderNotes,
|
||||
shouldCreateAccount,
|
||||
cartNeedsPayment,
|
||||
paymentMethodId,
|
||||
paymentMethodData,
|
||||
shouldSavePayment,
|
||||
activePaymentMethod,
|
||||
extensionData,
|
||||
dispatchActions,
|
||||
addErrorNotice,
|
||||
receiveCart,
|
||||
] );
|
||||
|
||||
// process order if conditions are good.
|
||||
useEffect( () => {
|
||||
if ( paidAndWithoutErrors && ! isProcessingOrder ) {
|
||||
processOrder();
|
||||
}
|
||||
}, [ processOrder, paidAndWithoutErrors, isProcessingOrder ] );
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CheckoutProcessor;
|
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { PluginArea } from '@wordpress/plugins';
|
||||
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
|
||||
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { PaymentMethodDataProvider } from './payment-methods';
|
||||
import { ShippingDataProvider } from './shipping';
|
||||
import { CustomerDataProvider } from './customer';
|
||||
import { CheckoutStateProvider } from './checkout-state';
|
||||
import CheckoutProcessor from './checkout-processor';
|
||||
|
||||
/**
|
||||
* Checkout provider
|
||||
* This wraps the checkout and provides an api interface for the checkout to
|
||||
* children via various hooks.
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
* @param {boolean} [props.isCart] Whether it's rendered in the Cart
|
||||
* component.
|
||||
* @param {string} [props.redirectUrl] Initialize what the checkout will
|
||||
* redirect to after successful
|
||||
* submit.
|
||||
*/
|
||||
export const CheckoutProvider = ( {
|
||||
children,
|
||||
isCart = false,
|
||||
redirectUrl,
|
||||
} ) => {
|
||||
return (
|
||||
<CheckoutStateProvider redirectUrl={ redirectUrl } isCart={ isCart }>
|
||||
<CustomerDataProvider>
|
||||
<ShippingDataProvider>
|
||||
<PaymentMethodDataProvider>
|
||||
{ children }
|
||||
{ /* If the current user is an admin, we let BlockErrorBoundary render
|
||||
the error, or we simply die silently. */ }
|
||||
<BlockErrorBoundary
|
||||
renderError={
|
||||
CURRENT_USER_IS_ADMIN ? null : () => null
|
||||
}
|
||||
>
|
||||
<PluginArea scope="woocommerce-checkout" />
|
||||
</BlockErrorBoundary>
|
||||
<CheckoutProcessor />
|
||||
</PaymentMethodDataProvider>
|
||||
</ShippingDataProvider>
|
||||
</CustomerDataProvider>
|
||||
</CheckoutStateProvider>
|
||||
);
|
||||
};
|
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { PaymentResultDataType, 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_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 >
|
||||
| PaymentResultDataType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: PaymentResultDataType ) =>
|
||||
( {
|
||||
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 ),
|
||||
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 ),
|
||||
};
|
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* 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 preloadedApiRequests = getSetting( 'preloadedApiRequests', {} ) as Record<
|
||||
string,
|
||||
{ body: Record< string, unknown > }
|
||||
>;
|
||||
|
||||
const checkoutData = {
|
||||
order_id: 0,
|
||||
customer_id: 0,
|
||||
...( preloadedApiRequests[ '/wc/store/checkout' ]?.body || {} ),
|
||||
};
|
||||
|
||||
export const DEFAULT_CHECKOUT_STATE_DATA: CheckoutStateContextType = {
|
||||
dispatchActions: {
|
||||
resetCheckout: () => void null,
|
||||
setRedirectUrl: ( url ) => void url,
|
||||
setHasError: ( hasError ) => void hasError,
|
||||
setAfterProcessing: ( response ) => void response,
|
||||
incrementCalculating: () => void null,
|
||||
decrementCalculating: () => void null,
|
||||
setCustomerId: ( id ) => void id,
|
||||
setOrderId: ( id ) => void id,
|
||||
setOrderNotes: ( orderNotes ) => void orderNotes,
|
||||
setExtensionData: ( extensionData ) => void extensionData,
|
||||
},
|
||||
onSubmit: () => void null,
|
||||
isComplete: false,
|
||||
isIdle: false,
|
||||
isCalculating: false,
|
||||
isProcessing: false,
|
||||
isBeforeProcessing: false,
|
||||
isAfterProcessing: false,
|
||||
hasError: false,
|
||||
redirectUrl: '',
|
||||
orderId: 0,
|
||||
orderNotes: '',
|
||||
customerId: 0,
|
||||
onCheckoutAfterProcessingWithSuccess: () => () => void null,
|
||||
onCheckoutAfterProcessingWithError: () => () => void null,
|
||||
onCheckoutBeforeProcessing: () => () => void null, // deprecated for onCheckoutValidationBeforeProcessing
|
||||
onCheckoutValidationBeforeProcessing: () => () => void null,
|
||||
hasOrder: false,
|
||||
isCart: false,
|
||||
shouldCreateAccount: false,
|
||||
setShouldCreateAccount: ( value ) => void value,
|
||||
extensionData: {},
|
||||
};
|
||||
|
||||
export const DEFAULT_STATE: CheckoutStateContextState = {
|
||||
redirectUrl: '',
|
||||
status: STATUS.PRISTINE,
|
||||
hasError: false,
|
||||
calculatingCount: 0,
|
||||
orderId: checkoutData.order_id,
|
||||
orderNotes: '',
|
||||
customerId: checkoutData.customer_id,
|
||||
shouldCreateAccount: false,
|
||||
processingResponse: null,
|
||||
extensionData: {},
|
||||
};
|
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
emitterCallback,
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
ActionType,
|
||||
} from '../../../event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
CHECKOUT_VALIDATION_BEFORE_PROCESSING:
|
||||
'checkout_validation_before_processing',
|
||||
CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS:
|
||||
'checkout_after_processing_with_success',
|
||||
CHECKOUT_AFTER_PROCESSING_WITH_ERROR:
|
||||
'checkout_after_processing_with_error',
|
||||
};
|
||||
|
||||
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(
|
||||
() => ( {
|
||||
onCheckoutAfterProcessingWithSuccess: emitterCallback(
|
||||
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
|
||||
observerDispatch
|
||||
),
|
||||
onCheckoutAfterProcessingWithError: emitterCallback(
|
||||
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
|
||||
observerDispatch
|
||||
),
|
||||
onCheckoutValidationBeforeProcessing: emitterCallback(
|
||||
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
|
||||
observerDispatch
|
||||
),
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
return eventEmitters;
|
||||
};
|
||||
|
||||
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
|
@ -0,0 +1,397 @@
|
||||
/**
|
||||
* 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 } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { actions } from './actions';
|
||||
import { reducer } from './reducer';
|
||||
import { getPaymentResultFromCheckoutResponse } from './utils';
|
||||
import {
|
||||
DEFAULT_STATE,
|
||||
STATUS,
|
||||
DEFAULT_CHECKOUT_STATE_DATA,
|
||||
} from './constants';
|
||||
import type {
|
||||
CheckoutStateDispatchActions,
|
||||
CheckoutStateContextType,
|
||||
} from './types';
|
||||
import {
|
||||
EMIT_TYPES,
|
||||
useEventEmitters,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
reducer as emitReducer,
|
||||
} from './event-emit';
|
||||
import { useValidationContext } from '../../validation';
|
||||
import { useStoreNotices } from '../../../hooks/use-store-notices';
|
||||
import { useStoreEvents } from '../../../hooks/use-store-events';
|
||||
import { useCheckoutNotices } from '../../../hooks/use-checkout-notices';
|
||||
import { useEmitResponse } from '../../../hooks/use-emit-response';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').CheckoutDataContext} CheckoutDataContext
|
||||
*/
|
||||
|
||||
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.
|
||||
* @param {boolean} props.isCart If context provider is being used in cart context.
|
||||
*/
|
||||
export const CheckoutStateProvider = ( {
|
||||
children,
|
||||
redirectUrl,
|
||||
isCart = false,
|
||||
}: {
|
||||
children: React.ReactChildren;
|
||||
redirectUrl: string;
|
||||
isCart: boolean;
|
||||
} ): JSX.Element => {
|
||||
// note, this is done intentionally so that the default state now has
|
||||
// the redirectUrl for when checkout is reset to PRISTINE state.
|
||||
DEFAULT_STATE.redirectUrl = redirectUrl;
|
||||
const [ checkoutState, dispatch ] = useReducer( reducer, DEFAULT_STATE );
|
||||
const { setValidationErrors } = useValidationContext();
|
||||
const { addErrorNotice, removeNotices } = useStoreNotices();
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
const isCalculating = checkoutState.calculatingCount > 0;
|
||||
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 ] );
|
||||
|
||||
const dispatchActions = useMemo(
|
||||
(): CheckoutStateDispatchActions => ( {
|
||||
resetCheckout: () => void dispatch( actions.setPristine() ),
|
||||
setRedirectUrl: ( url ) =>
|
||||
void dispatch( actions.setRedirectUrl( url ) ),
|
||||
setHasError: ( hasError ) =>
|
||||
void dispatch( actions.setHasError( hasError ) ),
|
||||
incrementCalculating: () =>
|
||||
void dispatch( actions.incrementCalculating() ),
|
||||
decrementCalculating: () =>
|
||||
void dispatch( actions.decrementCalculating() ),
|
||||
setCustomerId: ( id ) =>
|
||||
void dispatch( actions.setCustomerId( id ) ),
|
||||
setOrderId: ( orderId ) =>
|
||||
void dispatch( actions.setOrderId( orderId ) ),
|
||||
setOrderNotes: ( orderNotes ) =>
|
||||
void dispatch( actions.setOrderNotes( orderNotes ) ),
|
||||
setExtensionData: ( extensionData ) =>
|
||||
void dispatch( actions.setExtensionData( extensionData ) ),
|
||||
setAfterProcessing: ( response ) => {
|
||||
const paymentResult = getPaymentResultFromCheckoutResponse(
|
||||
response
|
||||
);
|
||||
|
||||
if ( paymentResult.redirectUrl ) {
|
||||
dispatch(
|
||||
actions.setRedirectUrl( paymentResult.redirectUrl )
|
||||
);
|
||||
}
|
||||
dispatch( actions.setProcessingResponse( paymentResult ) );
|
||||
dispatch( actions.setAfterProcessing() );
|
||||
},
|
||||
} ),
|
||||
[]
|
||||
);
|
||||
|
||||
// emit events.
|
||||
useEffect( () => {
|
||||
const status = checkoutState.status;
|
||||
if ( status === STATUS.BEFORE_PROCESSING ) {
|
||||
removeNotices( 'error' );
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.CHECKOUT_VALIDATION_BEFORE_PROCESSING,
|
||||
{}
|
||||
).then( ( response ) => {
|
||||
if ( response !== true ) {
|
||||
if ( Array.isArray( response ) ) {
|
||||
response.forEach(
|
||||
( { errorMessage, validationErrors } ) => {
|
||||
addErrorNotice( errorMessage );
|
||||
setValidationErrors( validationErrors );
|
||||
}
|
||||
);
|
||||
}
|
||||
dispatch( actions.setIdle() );
|
||||
dispatch( actions.setHasError() );
|
||||
} else {
|
||||
dispatch( actions.setProcessing() );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
checkoutState.status,
|
||||
setValidationErrors,
|
||||
addErrorNotice,
|
||||
removeNotices,
|
||||
dispatch,
|
||||
] );
|
||||
|
||||
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 ) {
|
||||
const errorOptions = response.messageContext
|
||||
? { context: response.messageContext }
|
||||
: undefined;
|
||||
errorResponse = response;
|
||||
addErrorNotice( 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 ) ) {
|
||||
dispatch( actions.setComplete( errorResponse ) );
|
||||
} else {
|
||||
dispatch( actions.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 to get assistance.',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
addErrorNotice( message, {
|
||||
id: 'checkout',
|
||||
} );
|
||||
}
|
||||
|
||||
dispatch( actions.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 ) {
|
||||
dispatch( actions.setComplete( successResponse ) );
|
||||
} else if ( isObject( errorResponse ) ) {
|
||||
if ( errorResponse.message ) {
|
||||
const errorOptions = errorResponse.messageContext
|
||||
? { context: errorResponse.messageContext }
|
||||
: undefined;
|
||||
addErrorNotice(
|
||||
errorResponse.message,
|
||||
errorOptions
|
||||
);
|
||||
}
|
||||
if ( ! shouldRetry( errorResponse ) ) {
|
||||
dispatch( actions.setComplete( errorResponse ) );
|
||||
} else {
|
||||
// this will set an error which will end up
|
||||
// triggering the onCheckoutAfterProcessingWithError emitter.
|
||||
// and then setting checkout to IDLE state.
|
||||
dispatch( actions.setHasError( true ) );
|
||||
}
|
||||
} else {
|
||||
// nothing hooked in had any response type so let's just
|
||||
// consider successful
|
||||
dispatch( actions.setComplete() );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}
|
||||
}, [
|
||||
checkoutState.status,
|
||||
checkoutState.hasError,
|
||||
checkoutState.redirectUrl,
|
||||
checkoutState.orderId,
|
||||
checkoutState.customerId,
|
||||
checkoutState.orderNotes,
|
||||
checkoutState.processingResponse,
|
||||
previousStatus,
|
||||
previousHasError,
|
||||
dispatchActions,
|
||||
addErrorNotice,
|
||||
isErrorResponse,
|
||||
isFailResponse,
|
||||
isSuccessResponse,
|
||||
shouldRetry,
|
||||
checkoutNotices,
|
||||
expressPaymentNotices,
|
||||
paymentNotices,
|
||||
] );
|
||||
|
||||
const onSubmit = useCallback( () => {
|
||||
dispatchCheckoutEvent( 'submit' );
|
||||
dispatch( actions.setBeforeProcessing() );
|
||||
}, [ dispatchCheckoutEvent ] );
|
||||
|
||||
const checkoutData: CheckoutStateContextType = {
|
||||
onSubmit,
|
||||
isComplete: checkoutState.status === STATUS.COMPLETE,
|
||||
isIdle: checkoutState.status === STATUS.IDLE,
|
||||
isCalculating,
|
||||
isProcessing: checkoutState.status === STATUS.PROCESSING,
|
||||
isBeforeProcessing: checkoutState.status === STATUS.BEFORE_PROCESSING,
|
||||
isAfterProcessing: checkoutState.status === STATUS.AFTER_PROCESSING,
|
||||
hasError: checkoutState.hasError,
|
||||
redirectUrl: checkoutState.redirectUrl,
|
||||
onCheckoutBeforeProcessing,
|
||||
onCheckoutValidationBeforeProcessing,
|
||||
onCheckoutAfterProcessingWithSuccess,
|
||||
onCheckoutAfterProcessingWithError,
|
||||
dispatchActions,
|
||||
isCart,
|
||||
orderId: checkoutState.orderId,
|
||||
hasOrder: !! checkoutState.orderId,
|
||||
customerId: checkoutState.customerId,
|
||||
orderNotes: checkoutState.orderNotes,
|
||||
shouldCreateAccount: checkoutState.shouldCreateAccount,
|
||||
setShouldCreateAccount: ( value ) =>
|
||||
dispatch( actions.setShouldCreateAccount( value ) ),
|
||||
extensionData: checkoutState.extensionData,
|
||||
};
|
||||
return (
|
||||
<CheckoutContext.Provider value={ checkoutData }>
|
||||
{ children }
|
||||
</CheckoutContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { DEFAULT_STATE, STATUS } from './constants';
|
||||
import { ActionType, ACTION } from './actions';
|
||||
import type { CheckoutStateContextState, PaymentResultDataType } from './types';
|
||||
|
||||
/**
|
||||
* Reducer for the checkout state
|
||||
*/
|
||||
export const reducer = (
|
||||
state = DEFAULT_STATE,
|
||||
{
|
||||
redirectUrl,
|
||||
type,
|
||||
customerId,
|
||||
orderId,
|
||||
orderNotes,
|
||||
extensionData,
|
||||
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 PaymentResultDataType,
|
||||
};
|
||||
break;
|
||||
|
||||
case ACTION.SET_COMPLETE:
|
||||
newState =
|
||||
state.status !== STATUS.COMPLETE
|
||||
? {
|
||||
...state,
|
||||
status: STATUS.COMPLETE,
|
||||
// @todo Investigate why redirectURL could be non-truthy and whether this would cause a bug if multiple gateways were used for payment e.g. 1st set the redirect URL but failed, and then the 2nd did not provide a redirect URL and succeeded.
|
||||
redirectUrl:
|
||||
data !== undefined &&
|
||||
typeof data.redirectUrl === 'string' &&
|
||||
data.redirectUrl
|
||||
? 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_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;
|
||||
};
|
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
export interface PaymentResultDataType {
|
||||
message: string;
|
||||
paymentStatus: string;
|
||||
paymentDetails: Record< string, string > | Record< string, never >;
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
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;
|
||||
shouldCreateAccount: boolean;
|
||||
processingResponse: PaymentResultDataType | 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 = {
|
||||
// Dispatch actions to the checkout provider.
|
||||
dispatchActions: CheckoutStateDispatchActions;
|
||||
// Submits the checkout and begins processing.
|
||||
onSubmit: () => void;
|
||||
// True when checkout is complete and ready for redirect.
|
||||
isComplete: boolean;
|
||||
// True when the checkout state has changed and checkout has no activity.
|
||||
isIdle: boolean;
|
||||
// True when something in the checkout is resulting in totals being calculated.
|
||||
isCalculating: boolean;
|
||||
// True when checkout has been submitted and is being processed. Note, payment related processing happens during this state. When payment status is success, processing happens on the server.
|
||||
isProcessing: boolean;
|
||||
// True during any observers executing logic before checkout processing (eg. validation).
|
||||
isBeforeProcessing: boolean;
|
||||
// True when checkout status is AFTER_PROCESSING.
|
||||
isAfterProcessing: boolean;
|
||||
// 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 >;
|
||||
// Set if user account should be created.
|
||||
setShouldCreateAccount: ( shouldCreateAccount: boolean ) => void;
|
||||
// True when the checkout has a draft order from the API.
|
||||
hasOrder: boolean;
|
||||
// When true, means the provider is providing data for the cart.
|
||||
isCart: boolean;
|
||||
// True when the checkout is in an error state. Whatever caused the error (validation/payment method) will likely have triggered a notice.
|
||||
hasError: CheckoutStateContextState[ 'hasError' ];
|
||||
// This is the url that checkout will redirect to when it's ready.
|
||||
redirectUrl: CheckoutStateContextState[ 'redirectUrl' ];
|
||||
// This is the ID for the draft order if one exists.
|
||||
orderId: CheckoutStateContextState[ 'orderId' ];
|
||||
// Order notes introduced by the user in the checkout form.
|
||||
orderNotes: CheckoutStateContextState[ 'orderNotes' ];
|
||||
// This is the ID of the customer the draft order belongs to.
|
||||
customerId: CheckoutStateContextState[ 'customerId' ];
|
||||
// Should a user account be created?
|
||||
shouldCreateAccount: CheckoutStateContextState[ 'shouldCreateAccount' ];
|
||||
// Custom checkout data passed to the store API on processing.
|
||||
extensionData: CheckoutStateContextState[ 'extensionData' ];
|
||||
};
|
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { PaymentResultDataType, CheckoutResponse } from './types';
|
||||
|
||||
/**
|
||||
* Prepares the payment_result data from the server checkout endpoint response.
|
||||
*/
|
||||
export const getPaymentResultFromCheckoutResponse = (
|
||||
response: CheckoutResponse
|
||||
): PaymentResultDataType => {
|
||||
const paymentResult = {
|
||||
message: '',
|
||||
paymentStatus: '',
|
||||
redirectUrl: '',
|
||||
paymentDetails: {},
|
||||
} as PaymentResultDataType;
|
||||
|
||||
// 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 to get assistance.',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
}
|
||||
|
||||
return paymentResult;
|
||||
};
|
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext, useState } from '@wordpress/element';
|
||||
import { defaultAddressFields } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCustomerData } from '../../../hooks/use-customer-data';
|
||||
import { useCheckoutContext } from '../checkout-state';
|
||||
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').CustomerDataContext} CustomerDataContext
|
||||
* @typedef {import('@woocommerce/type-defs/billing').BillingData} BillingData
|
||||
* @typedef {import('@woocommerce/type-defs/shipping').ShippingAddress} ShippingAddress
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {BillingData}
|
||||
*/
|
||||
const defaultBillingData = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {ShippingAddress}
|
||||
*/
|
||||
export const defaultShippingAddress = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates CustomerDataContext
|
||||
*/
|
||||
const CustomerDataContext = createContext( {
|
||||
billingData: defaultBillingData,
|
||||
shippingAddress: defaultShippingAddress,
|
||||
setBillingData: () => null,
|
||||
setShippingAddress: () => null,
|
||||
shippingAsBilling: true,
|
||||
setShippingAsBilling: () => null,
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {CustomerDataContext} Returns data and functions related to customer billing and shipping addresses.
|
||||
*/
|
||||
export const useCustomerDataContext = () => {
|
||||
return useContext( CustomerDataContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two addresses and see if they are the same.
|
||||
*
|
||||
* @param {Object} address1 First address.
|
||||
* @param {Object} address2 Second address.
|
||||
*/
|
||||
const isSameAddress = ( address1, address2 ) => {
|
||||
return Object.keys( defaultAddressFields ).every(
|
||||
( field ) => address1[ field ] === address2[ field ]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer Data context provider.
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
*/
|
||||
export const CustomerDataProvider = ( { children } ) => {
|
||||
const {
|
||||
billingData,
|
||||
shippingAddress,
|
||||
setBillingData,
|
||||
setShippingAddress,
|
||||
} = useCustomerData();
|
||||
const { cartNeedsShipping: needsShipping } = useStoreCart();
|
||||
const { customerId } = useCheckoutContext();
|
||||
const [ shippingAsBilling, setShippingAsBilling ] = useState(
|
||||
() =>
|
||||
needsShipping &&
|
||||
( ! customerId || isSameAddress( shippingAddress, billingData ) )
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {CustomerDataContext}
|
||||
*/
|
||||
const contextValue = {
|
||||
billingData,
|
||||
shippingAddress,
|
||||
setBillingData,
|
||||
setShippingAddress,
|
||||
shippingAsBilling,
|
||||
setShippingAsBilling,
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomerDataContext.Provider value={ contextValue }>
|
||||
{ children }
|
||||
</CustomerDataContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
export * from './payment-methods';
|
||||
export * from './shipping';
|
||||
export * from './customer';
|
||||
export * from './checkout-state';
|
||||
export * from './cart';
|
||||
export * from './checkout-processor';
|
||||
export * from './checkout-provider';
|
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
} from '@woocommerce/type-defs/payments';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ACTION, STATUS } from './constants';
|
||||
|
||||
export interface ActionType {
|
||||
type: ACTION | STATUS;
|
||||
errorMessage?: string;
|
||||
paymentMethodData?: Record< string, unknown >;
|
||||
paymentMethods?: PaymentMethods | ExpressPaymentMethods;
|
||||
shouldSavePaymentMethod?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the actions that can be dispatched for payment methods.
|
||||
*/
|
||||
export const actions = {
|
||||
statusOnly: ( type: STATUS ): { type: STATUS } => ( { type } as const ),
|
||||
error: ( errorMessage: string ): ActionType =>
|
||||
( {
|
||||
type: STATUS.ERROR,
|
||||
errorMessage,
|
||||
} as const ),
|
||||
failed: ( {
|
||||
errorMessage,
|
||||
paymentMethodData,
|
||||
}: {
|
||||
errorMessage: string;
|
||||
paymentMethodData: Record< string, unknown >;
|
||||
} ): ActionType =>
|
||||
( {
|
||||
type: STATUS.FAILED,
|
||||
errorMessage,
|
||||
paymentMethodData,
|
||||
} as const ),
|
||||
success: ( {
|
||||
paymentMethodData,
|
||||
}: {
|
||||
paymentMethodData?: Record< string, unknown >;
|
||||
} ): ActionType =>
|
||||
( {
|
||||
type: STATUS.SUCCESS,
|
||||
paymentMethodData,
|
||||
} as const ),
|
||||
started: ( {
|
||||
paymentMethodData,
|
||||
}: {
|
||||
paymentMethodData?: Record< string, unknown >;
|
||||
} ): ActionType =>
|
||||
( {
|
||||
type: STATUS.STARTED,
|
||||
paymentMethodData,
|
||||
} as const ),
|
||||
setRegisteredPaymentMethods: (
|
||||
paymentMethods: PaymentMethods
|
||||
): ActionType =>
|
||||
( {
|
||||
type: ACTION.SET_REGISTERED_PAYMENT_METHODS,
|
||||
paymentMethods,
|
||||
} as const ),
|
||||
setRegisteredExpressPaymentMethods: (
|
||||
paymentMethods: ExpressPaymentMethods
|
||||
): ActionType =>
|
||||
( {
|
||||
type: ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS,
|
||||
paymentMethods,
|
||||
} as const ),
|
||||
setShouldSavePaymentMethod: (
|
||||
shouldSavePaymentMethod: boolean
|
||||
): ActionType =>
|
||||
( {
|
||||
type: ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD,
|
||||
shouldSavePaymentMethod,
|
||||
} as const ),
|
||||
};
|
||||
|
||||
export default actions;
|
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type {
|
||||
PaymentMethodDataContextType,
|
||||
PaymentMethodDataContextState,
|
||||
} from './types';
|
||||
|
||||
export enum STATUS {
|
||||
PRISTINE = 'pristine',
|
||||
STARTED = 'started',
|
||||
PROCESSING = 'processing',
|
||||
ERROR = 'has_error',
|
||||
FAILED = 'failed',
|
||||
SUCCESS = 'success',
|
||||
COMPLETE = 'complete',
|
||||
}
|
||||
|
||||
export enum ACTION {
|
||||
SET_REGISTERED_PAYMENT_METHODS = 'set_registered_payment_methods',
|
||||
SET_REGISTERED_EXPRESS_PAYMENT_METHODS = 'set_registered_express_payment_methods',
|
||||
SET_SHOULD_SAVE_PAYMENT_METHOD = 'set_should_save_payment_method',
|
||||
}
|
||||
|
||||
// Note - if fields are added/shape is changed, you may want to update PRISTINE reducer clause to preserve your new field.
|
||||
export const DEFAULT_PAYMENT_DATA_CONTEXT_STATE: PaymentMethodDataContextState = {
|
||||
currentStatus: STATUS.PRISTINE,
|
||||
shouldSavePaymentMethod: false,
|
||||
paymentMethodData: {
|
||||
payment_method: '',
|
||||
},
|
||||
hasSavedToken: false,
|
||||
errorMessage: '',
|
||||
paymentMethods: {},
|
||||
expressPaymentMethods: {},
|
||||
};
|
||||
|
||||
export const DEFAULT_PAYMENT_METHOD_DATA: PaymentMethodDataContextType = {
|
||||
setPaymentStatus: () => ( {
|
||||
pristine: () => void null,
|
||||
started: () => void null,
|
||||
processing: () => void null,
|
||||
completed: () => void null,
|
||||
error: ( errorMessage: string ) => void errorMessage,
|
||||
failed: ( errorMessage, paymentMethodData ) =>
|
||||
void [ errorMessage, paymentMethodData ],
|
||||
success: ( paymentMethodData, billingData ) =>
|
||||
void [ paymentMethodData, billingData ],
|
||||
} ),
|
||||
currentStatus: {
|
||||
isPristine: true,
|
||||
isStarted: false,
|
||||
isProcessing: false,
|
||||
isFinished: false,
|
||||
hasError: false,
|
||||
hasFailed: false,
|
||||
isSuccessful: false,
|
||||
isDoingExpressPayment: false,
|
||||
},
|
||||
paymentStatuses: STATUS,
|
||||
paymentMethodData: {},
|
||||
errorMessage: '',
|
||||
activePaymentMethod: '',
|
||||
setActivePaymentMethod: () => void null,
|
||||
activeSavedToken: '',
|
||||
setActiveSavedToken: () => void null,
|
||||
customerPaymentMethods: {},
|
||||
paymentMethods: {},
|
||||
expressPaymentMethods: {},
|
||||
paymentMethodsInitialized: false,
|
||||
expressPaymentMethodsInitialized: false,
|
||||
onPaymentProcessing: () => () => () => void null,
|
||||
setExpressPaymentError: () => void null,
|
||||
isExpressPaymentMethodActive: false,
|
||||
setShouldSavePayment: () => void null,
|
||||
shouldSavePayment: false,
|
||||
};
|
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
emitterCallback,
|
||||
ActionType,
|
||||
} from '../../../event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
PAYMENT_PROCESSING: 'payment_processing',
|
||||
};
|
||||
|
||||
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(
|
||||
() => ( {
|
||||
onPaymentProcessing: emitterCallback(
|
||||
EMIT_TYPES.PAYMENT_PROCESSING,
|
||||
observerDispatch
|
||||
),
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
return eventEmitters;
|
||||
};
|
||||
|
||||
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
|
@ -0,0 +1,4 @@
|
||||
export {
|
||||
PaymentMethodDataProvider,
|
||||
usePaymentMethodDataContext,
|
||||
} from './payment-method-data-context';
|
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* 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 { useActivePaymentMethod } from './use-active-payment-method';
|
||||
import { useCheckoutContext } from '../checkout-state';
|
||||
import { useEditorContext } from '../../editor-context';
|
||||
import {
|
||||
EMIT_TYPES,
|
||||
useEventEmitters,
|
||||
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';
|
||||
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.ReactChildren;
|
||||
} ): JSX.Element => {
|
||||
const {
|
||||
isProcessing: checkoutIsProcessing,
|
||||
isIdle: checkoutIsIdle,
|
||||
isCalculating: checkoutIsCalculating,
|
||||
hasError: checkoutHasError,
|
||||
} = useCheckoutContext();
|
||||
const { isEditor, getPreviewData } = useEditorContext();
|
||||
const { setValidationErrors } = useValidationContext();
|
||||
const { addErrorNotice, removeNotice } = useStoreNotices();
|
||||
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 {
|
||||
activePaymentMethod,
|
||||
activeSavedToken,
|
||||
setActivePaymentMethod,
|
||||
setActiveSavedToken,
|
||||
} = useActivePaymentMethod();
|
||||
|
||||
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( 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 ]
|
||||
);
|
||||
|
||||
// Update the active (selected) payment method when it is empty, or invalid.
|
||||
useEffect( () => {
|
||||
const paymentMethodKeys = Object.keys( paymentData.paymentMethods );
|
||||
const allPaymentMethodKeys = [
|
||||
...paymentMethodKeys,
|
||||
...Object.keys( paymentData.expressPaymentMethods ),
|
||||
];
|
||||
if ( ! paymentMethodsInitialized || ! paymentMethodKeys.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActivePaymentMethod( ( currentActivePaymentMethod ) => {
|
||||
// If there's no active payment method, or the active payment method has
|
||||
// been removed (e.g. COD vs shipping methods), set one as active.
|
||||
// Note: It's possible that the active payment method might be an
|
||||
// express payment method. So registered express payment methods are
|
||||
// included in the check here.
|
||||
if (
|
||||
! currentActivePaymentMethod ||
|
||||
! allPaymentMethodKeys.includes( currentActivePaymentMethod )
|
||||
) {
|
||||
setPaymentStatus().pristine();
|
||||
return Object.keys( paymentData.paymentMethods )[ 0 ];
|
||||
}
|
||||
return currentActivePaymentMethod;
|
||||
} );
|
||||
}, [
|
||||
paymentMethodsInitialized,
|
||||
paymentData.paymentMethods,
|
||||
paymentData.expressPaymentMethods,
|
||||
setActivePaymentMethod,
|
||||
setPaymentStatus,
|
||||
] );
|
||||
|
||||
// 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 and payment is not being made with a saved token and payment status is success, then let's sync payment status back to pristine.
|
||||
useEffect( () => {
|
||||
if (
|
||||
checkoutHasError &&
|
||||
currentStatus.isSuccessful &&
|
||||
! paymentData.hasSavedToken
|
||||
) {
|
||||
setPaymentStatus().pristine();
|
||||
}
|
||||
}, [
|
||||
checkoutHasError,
|
||||
currentStatus.isSuccessful,
|
||||
paymentData.hasSavedToken,
|
||||
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?.billingData,
|
||||
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?.billingData
|
||||
);
|
||||
} 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 paymentContextData: PaymentMethodDataContextType = {
|
||||
setPaymentStatus,
|
||||
currentStatus,
|
||||
paymentStatuses: STATUS,
|
||||
paymentMethodData: paymentData.paymentMethodData,
|
||||
errorMessage: paymentData.errorMessage,
|
||||
activePaymentMethod,
|
||||
setActivePaymentMethod,
|
||||
activeSavedToken,
|
||||
setActiveSavedToken,
|
||||
onPaymentProcessing,
|
||||
customerPaymentMethods,
|
||||
paymentMethods: paymentData.paymentMethods,
|
||||
expressPaymentMethods: paymentData.expressPaymentMethods,
|
||||
paymentMethodsInitialized,
|
||||
expressPaymentMethodsInitialized,
|
||||
setExpressPaymentError,
|
||||
isExpressPaymentMethodActive,
|
||||
shouldSavePayment: paymentData.shouldSavePaymentMethod,
|
||||
setShouldSavePayment: dispatchActions.setShouldSavePayment,
|
||||
};
|
||||
|
||||
return (
|
||||
<PaymentMethodDataContext.Provider value={ paymentContextData }>
|
||||
{ children }
|
||||
</PaymentMethodDataContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
ACTION,
|
||||
STATUS,
|
||||
DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
|
||||
} from './constants';
|
||||
import type { PaymentMethodDataContextState } from './types';
|
||||
import type { ActionType } from './actions';
|
||||
|
||||
const hasSavedPaymentToken = (
|
||||
paymentMethodData: Record< string, unknown > | undefined
|
||||
): boolean => {
|
||||
return !! (
|
||||
typeof paymentMethodData === 'object' && paymentMethodData.isSavedToken
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reducer for payment data state
|
||||
*/
|
||||
const reducer = (
|
||||
state = DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
|
||||
{
|
||||
type,
|
||||
paymentMethodData,
|
||||
shouldSavePaymentMethod = false,
|
||||
errorMessage = '',
|
||||
paymentMethods = {},
|
||||
}: ActionType
|
||||
): PaymentMethodDataContextState => {
|
||||
switch ( type ) {
|
||||
case STATUS.STARTED:
|
||||
return {
|
||||
...state,
|
||||
currentStatus: STATUS.STARTED,
|
||||
paymentMethodData: paymentMethodData || state.paymentMethodData,
|
||||
hasSavedToken: hasSavedPaymentToken(
|
||||
paymentMethodData || state.paymentMethodData
|
||||
),
|
||||
};
|
||||
case STATUS.ERROR:
|
||||
return state.currentStatus !== STATUS.ERROR
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.ERROR,
|
||||
errorMessage: errorMessage || state.errorMessage,
|
||||
}
|
||||
: state;
|
||||
case STATUS.FAILED:
|
||||
return state.currentStatus !== STATUS.FAILED
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.FAILED,
|
||||
paymentMethodData:
|
||||
paymentMethodData || state.paymentMethodData,
|
||||
errorMessage: errorMessage || state.errorMessage,
|
||||
}
|
||||
: state;
|
||||
case STATUS.SUCCESS:
|
||||
return state.currentStatus !== STATUS.SUCCESS
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.SUCCESS,
|
||||
paymentMethodData:
|
||||
paymentMethodData || state.paymentMethodData,
|
||||
hasSavedToken: hasSavedPaymentToken(
|
||||
paymentMethodData || state.paymentMethodData
|
||||
),
|
||||
}
|
||||
: state;
|
||||
case STATUS.PROCESSING:
|
||||
return state.currentStatus !== STATUS.PROCESSING
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.PROCESSING,
|
||||
errorMessage: '',
|
||||
}
|
||||
: state;
|
||||
case STATUS.COMPLETE:
|
||||
return state.currentStatus !== STATUS.COMPLETE
|
||||
? {
|
||||
...state,
|
||||
currentStatus: STATUS.COMPLETE,
|
||||
}
|
||||
: state;
|
||||
|
||||
case STATUS.PRISTINE:
|
||||
return {
|
||||
...DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
|
||||
currentStatus: STATUS.PRISTINE,
|
||||
// keep payment method registration state
|
||||
paymentMethods: {
|
||||
...state.paymentMethods,
|
||||
},
|
||||
expressPaymentMethods: {
|
||||
...state.expressPaymentMethods,
|
||||
},
|
||||
shouldSavePaymentMethod: state.shouldSavePaymentMethod,
|
||||
};
|
||||
case ACTION.SET_REGISTERED_PAYMENT_METHODS:
|
||||
return {
|
||||
...state,
|
||||
paymentMethods,
|
||||
};
|
||||
case ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS:
|
||||
return {
|
||||
...state,
|
||||
expressPaymentMethods: paymentMethods,
|
||||
};
|
||||
case ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD:
|
||||
return {
|
||||
...state,
|
||||
shouldSavePaymentMethod,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default reducer;
|
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
|
||||
import {
|
||||
registerPaymentMethod,
|
||||
registerExpressPaymentMethod,
|
||||
__experimentalDeRegisterPaymentMethod,
|
||||
__experimentalDeRegisterExpressPaymentMethod,
|
||||
} from '@woocommerce/blocks-registry';
|
||||
import { default as fetchMock } from 'jest-fetch-mock';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
usePaymentMethodDataContext,
|
||||
PaymentMethodDataProvider,
|
||||
} from '../payment-method-data-context';
|
||||
import {
|
||||
CheckoutExpressPayment,
|
||||
SavedPaymentMethodOptions,
|
||||
} from '../../../../../../blocks/cart-checkout/payment-methods';
|
||||
import { defaultCartState } from '../../../../../../data/default-states';
|
||||
|
||||
jest.mock( '@woocommerce/settings', () => {
|
||||
const originalModule = jest.requireActual( '@woocommerce/settings' );
|
||||
|
||||
return {
|
||||
// @ts-ignore We know @woocommerce/settings is an object.
|
||||
...originalModule,
|
||||
getSetting: ( setting, ...rest ) => {
|
||||
if ( setting === 'customerPaymentMethods' ) {
|
||||
return {
|
||||
cc: [
|
||||
{
|
||||
method: {
|
||||
gateway: 'stripe',
|
||||
last4: '4242',
|
||||
brand: 'Visa',
|
||||
},
|
||||
expires: '12/22',
|
||||
is_default: true,
|
||||
tokenId: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return originalModule.getSetting( setting, ...rest );
|
||||
},
|
||||
};
|
||||
} );
|
||||
|
||||
const registerMockPaymentMethods = () => {
|
||||
[ 'cheque', 'bacs' ].forEach( ( name ) => {
|
||||
registerPaymentMethod( {
|
||||
name,
|
||||
label: name,
|
||||
content: <div>A payment method</div>,
|
||||
edit: <div>A payment method</div>,
|
||||
icons: null,
|
||||
canMakePayment: () => true,
|
||||
supports: {
|
||||
features: [ 'products' ],
|
||||
},
|
||||
ariaLabel: name,
|
||||
} );
|
||||
} );
|
||||
[ 'stripe' ].forEach( ( name ) => {
|
||||
registerPaymentMethod( {
|
||||
name,
|
||||
label: name,
|
||||
content: <div>A payment method</div>,
|
||||
edit: <div>A payment method</div>,
|
||||
icons: null,
|
||||
canMakePayment: () => true,
|
||||
supports: {
|
||||
showSavedCards: true,
|
||||
showSaveOption: true,
|
||||
features: [ 'products' ],
|
||||
},
|
||||
ariaLabel: name,
|
||||
} );
|
||||
} );
|
||||
[ 'express-payment' ].forEach( ( name ) => {
|
||||
const Content = ( {
|
||||
onClose = () => void null,
|
||||
onClick = () => void null,
|
||||
} ) => {
|
||||
return (
|
||||
<>
|
||||
<button onClick={ onClick }>
|
||||
{ name + ' express payment method' }
|
||||
</button>
|
||||
<button onClick={ onClose }>
|
||||
{ name + ' express payment method close' }
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
registerExpressPaymentMethod( {
|
||||
name,
|
||||
content: <Content />,
|
||||
edit: <div>An express payment method</div>,
|
||||
canMakePayment: () => true,
|
||||
paymentMethodId: name,
|
||||
supports: {
|
||||
features: [ 'products' ],
|
||||
},
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
const resetMockPaymentMethods = () => {
|
||||
[ 'cheque', 'bacs', 'stripe' ].forEach( ( name ) => {
|
||||
__experimentalDeRegisterPaymentMethod( name );
|
||||
} );
|
||||
[ 'express-payment' ].forEach( ( name ) => {
|
||||
__experimentalDeRegisterExpressPaymentMethod( name );
|
||||
} );
|
||||
};
|
||||
|
||||
describe( 'Testing Payment Method Data Context Provider', () => {
|
||||
beforeEach( async () => {
|
||||
registerMockPaymentMethods();
|
||||
fetchMock.mockResponse( ( req ) => {
|
||||
if ( req.url.match( /wc\/store\/cart/ ) ) {
|
||||
return Promise.resolve( JSON.stringify( previewCart ) );
|
||||
}
|
||||
return Promise.resolve( '' );
|
||||
} );
|
||||
// need to clear the store resolution state between tests.
|
||||
await dispatch( storeKey ).invalidateResolutionForStore();
|
||||
await dispatch( storeKey ).receiveCart( defaultCartState.cartData );
|
||||
} );
|
||||
afterEach( async () => {
|
||||
resetMockPaymentMethods();
|
||||
fetchMock.resetMocks();
|
||||
} );
|
||||
it( 'toggles active payment method correctly for express payment activation and close', async () => {
|
||||
const TriggerActiveExpressPaymentMethod = () => {
|
||||
const { activePaymentMethod } = usePaymentMethodDataContext();
|
||||
return (
|
||||
<>
|
||||
<CheckoutExpressPayment />
|
||||
{ 'Active Payment Method: ' + activePaymentMethod }
|
||||
</>
|
||||
);
|
||||
};
|
||||
const TestComponent = () => {
|
||||
return (
|
||||
<PaymentMethodDataProvider>
|
||||
<TriggerActiveExpressPaymentMethod />
|
||||
</PaymentMethodDataProvider>
|
||||
);
|
||||
};
|
||||
render( <TestComponent /> );
|
||||
// should initialize by default the first payment method.
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: cheque/
|
||||
);
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
} );
|
||||
// Express payment method clicked.
|
||||
fireEvent.click(
|
||||
screen.getByText( 'express-payment express payment method' )
|
||||
);
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: express-payment/
|
||||
);
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
} );
|
||||
// Express payment method closed.
|
||||
fireEvent.click(
|
||||
screen.getByText( 'express-payment express payment method close' )
|
||||
);
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: cheque/
|
||||
);
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
} );
|
||||
// ["`select` control in `@wordpress/data-controls` is deprecated. Please use built-in `resolveSelect` control in `@wordpress/data` instead."]
|
||||
expect( console ).toHaveWarned();
|
||||
} );
|
||||
|
||||
it( 'resets saved payment method data after starting and closing an express payment method', async () => {
|
||||
const TriggerActiveExpressPaymentMethod = () => {
|
||||
const {
|
||||
activePaymentMethod,
|
||||
paymentMethodData,
|
||||
} = usePaymentMethodDataContext();
|
||||
return (
|
||||
<>
|
||||
<CheckoutExpressPayment />
|
||||
<SavedPaymentMethodOptions onChange={ () => void null } />
|
||||
{ 'Active Payment Method: ' + activePaymentMethod }
|
||||
{ paymentMethodData[ 'wc-stripe-payment-token' ] && (
|
||||
<span>Stripe token</span>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
const TestComponent = () => {
|
||||
return (
|
||||
<PaymentMethodDataProvider>
|
||||
<TriggerActiveExpressPaymentMethod />
|
||||
</PaymentMethodDataProvider>
|
||||
);
|
||||
};
|
||||
render( <TestComponent /> );
|
||||
// Should initialize by default the default saved payment method.
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: stripe/
|
||||
);
|
||||
const stripeToken = screen.queryByText( /Stripe token/ );
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
expect( stripeToken ).not.toBeNull();
|
||||
} );
|
||||
// Express payment method clicked.
|
||||
fireEvent.click(
|
||||
screen.getByText( 'express-payment express payment method' )
|
||||
);
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: express-payment/
|
||||
);
|
||||
const stripeToken = screen.queryByText( /Stripe token/ );
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
expect( stripeToken ).toBeNull();
|
||||
} );
|
||||
// Express payment method closed.
|
||||
fireEvent.click(
|
||||
screen.getByText( 'express-payment express payment method close' )
|
||||
);
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: stripe/
|
||||
);
|
||||
const stripeToken = screen.queryByText( /Stripe token/ );
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
expect( stripeToken ).not.toBeNull();
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
PaymentMethodConfiguration,
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
} from '@woocommerce/type-defs/payments';
|
||||
import type {
|
||||
EmptyObjectType,
|
||||
ObjectType,
|
||||
} from '@woocommerce/type-defs/objects';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { emitterCallback } from '../../../event-emit';
|
||||
import { STATUS } from './constants';
|
||||
|
||||
export interface CustomerPaymentMethod {
|
||||
method: PaymentMethodConfiguration;
|
||||
expires: string;
|
||||
is_default: boolean;
|
||||
tokenId: number;
|
||||
actions: ObjectType;
|
||||
}
|
||||
export type CustomerPaymentMethods =
|
||||
| Record< string, CustomerPaymentMethod >
|
||||
| EmptyObjectType;
|
||||
|
||||
export type PaymentMethodDispatchers = {
|
||||
setRegisteredPaymentMethods: ( paymentMethods: PaymentMethods ) => void;
|
||||
setRegisteredExpressPaymentMethods: (
|
||||
paymentMethods: ExpressPaymentMethods
|
||||
) => void;
|
||||
setShouldSavePayment: ( shouldSave: boolean ) => void;
|
||||
};
|
||||
|
||||
export interface PaymentStatusDispatchers {
|
||||
pristine: () => void;
|
||||
started: ( paymentMethodData?: ObjectType | EmptyObjectType ) => void;
|
||||
processing: () => void;
|
||||
completed: () => void;
|
||||
error: ( error: string ) => void;
|
||||
failed: (
|
||||
error?: string,
|
||||
paymentMethodData?: ObjectType | EmptyObjectType,
|
||||
billingData?: ObjectType | EmptyObjectType
|
||||
) => void;
|
||||
success: (
|
||||
paymentMethodData?: ObjectType | EmptyObjectType,
|
||||
billingData?: ObjectType | EmptyObjectType,
|
||||
shippingData?: ObjectType | EmptyObjectType
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface PaymentMethodDataContextState {
|
||||
currentStatus: STATUS;
|
||||
shouldSavePaymentMethod: boolean;
|
||||
paymentMethodData: ObjectType | EmptyObjectType;
|
||||
hasSavedToken: boolean;
|
||||
errorMessage: string;
|
||||
paymentMethods: PaymentMethods;
|
||||
expressPaymentMethods: ExpressPaymentMethods;
|
||||
}
|
||||
|
||||
export type PaymentMethodCurrentStatusType = {
|
||||
// If true then the payment method state in checkout is pristine.
|
||||
isPristine: boolean;
|
||||
// If true then the payment method has been initialized and has started.
|
||||
isStarted: boolean;
|
||||
// If true then the payment method is processing payment.
|
||||
isProcessing: boolean;
|
||||
// If true then the payment method is in a finished state (which may mean it's status is either error, failed, or success).
|
||||
isFinished: boolean;
|
||||
// If true then the payment method is in an error state.
|
||||
hasError: boolean;
|
||||
// If true then the payment method has failed (usually indicates a problem with the payment method used, not logic error).
|
||||
hasFailed: boolean;
|
||||
// If true then the payment method has completed it's processing successfully.
|
||||
isSuccessful: boolean;
|
||||
// If true, an express payment is in progress.
|
||||
isDoingExpressPayment: boolean;
|
||||
};
|
||||
|
||||
export type PaymentMethodDataContextType = {
|
||||
// Sets the payment status for the payment method.
|
||||
setPaymentStatus: () => PaymentStatusDispatchers;
|
||||
// The current payment status.
|
||||
currentStatus: PaymentMethodCurrentStatusType;
|
||||
// An object of payment status constants.
|
||||
paymentStatuses: ObjectType;
|
||||
// Arbitrary data to be passed along for processing by the payment method on the server.
|
||||
paymentMethodData: ObjectType | EmptyObjectType;
|
||||
// An error message provided by the payment method if there is an error.
|
||||
errorMessage: string;
|
||||
// The active payment method slug.
|
||||
activePaymentMethod: string;
|
||||
// A function for setting the active payment method.
|
||||
setActivePaymentMethod: ( paymentMethod: string ) => void;
|
||||
// Current active token.
|
||||
activeSavedToken: string;
|
||||
// A function for setting the active payment method token.
|
||||
setActiveSavedToken: ( activeSavedToken: string ) => void;
|
||||
// Returns the customer payment for the customer if it exists.
|
||||
customerPaymentMethods:
|
||||
| Record< string, CustomerPaymentMethod >
|
||||
| EmptyObjectType;
|
||||
// Registered payment methods.
|
||||
paymentMethods: PaymentMethods;
|
||||
// Registered express payment methods.
|
||||
expressPaymentMethods: ExpressPaymentMethods;
|
||||
// True when all registered payment methods have been initialized.
|
||||
paymentMethodsInitialized: boolean;
|
||||
// True when all registered express payment methods have been initialized.
|
||||
expressPaymentMethodsInitialized: boolean;
|
||||
// Event registration callback for registering observers for the payment processing event.
|
||||
onPaymentProcessing: ReturnType< typeof emitterCallback >;
|
||||
// A function used by express payment methods to indicate an error for checkout to handle. It receives an error message string. Does not change payment status.
|
||||
setExpressPaymentError: ( error: string ) => void;
|
||||
// True if an express payment method is active.
|
||||
isExpressPaymentMethodActive: boolean;
|
||||
// A function used to set the shouldSavePayment value.
|
||||
setShouldSavePayment: ( shouldSavePayment: boolean ) => void;
|
||||
// True means that the configured payment method option is saved for the customer.
|
||||
shouldSavePayment: boolean;
|
||||
};
|
||||
|
||||
export type PaymentMethodsDispatcherType = (
|
||||
paymentMethods: PaymentMethods
|
||||
) => undefined;
|
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreEvents } from '../../../hooks/use-store-events';
|
||||
|
||||
export const useActivePaymentMethod = (): {
|
||||
activePaymentMethod: string;
|
||||
activeSavedToken: string;
|
||||
setActivePaymentMethod: React.Dispatch< React.SetStateAction< string > >;
|
||||
setActiveSavedToken: ( token: string ) => void;
|
||||
} => {
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
|
||||
// The active payment method - e.g. Stripe CC or BACS.
|
||||
const [ activePaymentMethod, setActivePaymentMethod ] = useState( '' );
|
||||
|
||||
// If a previously saved payment method is active, the token for that method. For example, a for a Stripe CC card saved to user account.
|
||||
const [ activeSavedToken, setActiveSavedToken ] = useState( '' );
|
||||
|
||||
// Trigger event on change.
|
||||
useEffect( () => {
|
||||
dispatchCheckoutEvent( 'set-active-payment-method', {
|
||||
activePaymentMethod,
|
||||
} );
|
||||
}, [ dispatchCheckoutEvent, activePaymentMethod ] );
|
||||
|
||||
return {
|
||||
activePaymentMethod,
|
||||
activeSavedToken,
|
||||
setActivePaymentMethod,
|
||||
setActiveSavedToken,
|
||||
};
|
||||
};
|
@ -0,0 +1,105 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCallback, useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { actions, ActionType } from './actions';
|
||||
import { STATUS } from './constants';
|
||||
import { useCustomerDataContext } from '../customer';
|
||||
import { useShippingDataContext } from '../shipping';
|
||||
import type {
|
||||
PaymentStatusDispatchers,
|
||||
PaymentMethodDispatchers,
|
||||
} from './types';
|
||||
|
||||
export const usePaymentMethodDataDispatchers = (
|
||||
dispatch: React.Dispatch< ActionType >
|
||||
): {
|
||||
dispatchActions: PaymentMethodDispatchers;
|
||||
setPaymentStatus: () => PaymentStatusDispatchers;
|
||||
} => {
|
||||
const { setBillingData } = useCustomerDataContext();
|
||||
const { setShippingAddress } = useShippingDataContext();
|
||||
|
||||
const dispatchActions = useMemo(
|
||||
(): PaymentMethodDispatchers => ( {
|
||||
setRegisteredPaymentMethods: ( paymentMethods ) =>
|
||||
void dispatch(
|
||||
actions.setRegisteredPaymentMethods( paymentMethods )
|
||||
),
|
||||
setRegisteredExpressPaymentMethods: ( paymentMethods ) =>
|
||||
void dispatch(
|
||||
actions.setRegisteredExpressPaymentMethods( paymentMethods )
|
||||
),
|
||||
setShouldSavePayment: ( shouldSave ) =>
|
||||
void dispatch(
|
||||
actions.setShouldSavePaymentMethod( shouldSave )
|
||||
),
|
||||
} ),
|
||||
[ dispatch ]
|
||||
);
|
||||
|
||||
const setPaymentStatus = useCallback(
|
||||
(): PaymentStatusDispatchers => ( {
|
||||
pristine: () => dispatch( actions.statusOnly( STATUS.PRISTINE ) ),
|
||||
started: ( paymentMethodData ) => {
|
||||
dispatch(
|
||||
actions.started( {
|
||||
paymentMethodData,
|
||||
} )
|
||||
);
|
||||
},
|
||||
processing: () =>
|
||||
dispatch( actions.statusOnly( STATUS.PROCESSING ) ),
|
||||
completed: () => dispatch( actions.statusOnly( STATUS.COMPLETE ) ),
|
||||
error: ( errorMessage ) =>
|
||||
dispatch( actions.error( errorMessage ) ),
|
||||
failed: (
|
||||
errorMessage,
|
||||
paymentMethodData,
|
||||
billingData = undefined
|
||||
) => {
|
||||
if ( billingData ) {
|
||||
setBillingData( billingData );
|
||||
}
|
||||
dispatch(
|
||||
actions.failed( {
|
||||
errorMessage: errorMessage || '',
|
||||
paymentMethodData: paymentMethodData || {},
|
||||
} )
|
||||
);
|
||||
},
|
||||
success: (
|
||||
paymentMethodData,
|
||||
billingData = undefined,
|
||||
shippingData = undefined
|
||||
) => {
|
||||
if ( billingData ) {
|
||||
setBillingData( billingData );
|
||||
}
|
||||
if (
|
||||
typeof shippingData !== undefined &&
|
||||
shippingData?.address
|
||||
) {
|
||||
setShippingAddress(
|
||||
shippingData.address as Record< string, unknown >
|
||||
);
|
||||
}
|
||||
dispatch(
|
||||
actions.success( {
|
||||
paymentMethodData,
|
||||
} )
|
||||
);
|
||||
},
|
||||
} ),
|
||||
[ dispatch, setBillingData, setShippingAddress ]
|
||||
);
|
||||
|
||||
return {
|
||||
dispatchActions,
|
||||
setPaymentStatus,
|
||||
};
|
||||
};
|
@ -0,0 +1,229 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import {
|
||||
getPaymentMethods,
|
||||
getExpressPaymentMethods,
|
||||
} from '@woocommerce/blocks-registry';
|
||||
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
|
||||
import type {
|
||||
PaymentMethods,
|
||||
ExpressPaymentMethods,
|
||||
PaymentMethodConfigInstance,
|
||||
ExpressPaymentMethodConfigInstance,
|
||||
} from '@woocommerce/type-defs/payments';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useEditorContext } from '../../editor-context';
|
||||
import { useShippingDataContext } from '../shipping';
|
||||
import { useCustomerDataContext } from '../customer';
|
||||
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
||||
import { useStoreNotices } from '../../../hooks/use-store-notices';
|
||||
import { useEmitResponse } from '../../../hooks/use-emit-response';
|
||||
import type { PaymentMethodsDispatcherType } from './types';
|
||||
|
||||
/**
|
||||
* This hook handles initializing registered payment methods and exposing all
|
||||
* registered payment methods that can be used in the current environment (via
|
||||
* the payment method's `canMakePayment` property).
|
||||
*
|
||||
* @param {function(Object):undefined} dispatcher A dispatcher for setting registered payment methods to an external state.
|
||||
* @param {Object} registeredPaymentMethods Registered payment methods to process.
|
||||
* @param {Array} paymentMethodsSortOrder Array of payment method names to sort by. This should match keys of registeredPaymentMethods.
|
||||
* @param {string} noticeContext Id of the context to append notices to.
|
||||
*
|
||||
* @return {boolean} Whether the payment methods have been initialized or not. True when all payment methods have been initialized.
|
||||
*/
|
||||
const usePaymentMethodRegistration = (
|
||||
dispatcher: PaymentMethodsDispatcherType,
|
||||
registeredPaymentMethods: PaymentMethods | ExpressPaymentMethods,
|
||||
paymentMethodsSortOrder: string[],
|
||||
noticeContext: string
|
||||
) => {
|
||||
const [ isInitialized, setIsInitialized ] = useState( false );
|
||||
const { isEditor } = useEditorContext();
|
||||
const { selectedRates } = useShippingDataContext();
|
||||
const { billingData, shippingAddress } = useCustomerDataContext();
|
||||
const selectedShippingMethods = useShallowEqual( selectedRates );
|
||||
const paymentMethodsOrder = useShallowEqual( paymentMethodsSortOrder );
|
||||
const cart = useStoreCart();
|
||||
const { cartTotals, cartNeedsShipping, paymentRequirements } = cart;
|
||||
const canPayArgument = useRef( {
|
||||
cart,
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
billingData,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
} );
|
||||
const { addErrorNotice } = useStoreNotices();
|
||||
|
||||
useEffect( () => {
|
||||
canPayArgument.current = {
|
||||
cart,
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
billingData,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
};
|
||||
}, [
|
||||
cart,
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
billingData,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
] );
|
||||
|
||||
const refreshCanMakePayments = useCallback( async () => {
|
||||
let availablePaymentMethods = {};
|
||||
|
||||
const addAvailablePaymentMethod = (
|
||||
paymentMethod:
|
||||
| PaymentMethodConfigInstance
|
||||
| ExpressPaymentMethodConfigInstance
|
||||
) => {
|
||||
availablePaymentMethods = {
|
||||
...availablePaymentMethods,
|
||||
[ paymentMethod.name ]: paymentMethod,
|
||||
};
|
||||
};
|
||||
|
||||
for ( let i = 0; i < paymentMethodsOrder.length; i++ ) {
|
||||
const paymentMethodName = paymentMethodsOrder[ i ];
|
||||
const paymentMethod = registeredPaymentMethods[ paymentMethodName ];
|
||||
if ( ! paymentMethod ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// See if payment method should be available. This always evaluates to true in the editor context.
|
||||
try {
|
||||
const canPay = isEditor
|
||||
? true
|
||||
: await Promise.resolve(
|
||||
paymentMethod.canMakePayment(
|
||||
canPayArgument.current
|
||||
)
|
||||
);
|
||||
|
||||
if ( canPay ) {
|
||||
if (
|
||||
typeof canPay === 'object' &&
|
||||
canPay !== null &&
|
||||
canPay.error
|
||||
) {
|
||||
throw new Error( canPay.error.message );
|
||||
}
|
||||
|
||||
addAvailablePaymentMethod( paymentMethod );
|
||||
}
|
||||
} catch ( e ) {
|
||||
if ( CURRENT_USER_IS_ADMIN || isEditor ) {
|
||||
const errorText = sprintf(
|
||||
/* translators: %s the id of the payment method being registered (bank transfer, Stripe...) */
|
||||
__(
|
||||
`There was an error registering the payment method with id '%s': `,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
paymentMethod.paymentMethodId
|
||||
);
|
||||
addErrorNotice( `${ errorText } ${ e }`, {
|
||||
context: noticeContext,
|
||||
id: `wc-${ paymentMethod.paymentMethodId }-registration-error`,
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-dispatch available payment methods to store.
|
||||
dispatcher( availablePaymentMethods );
|
||||
|
||||
// Note: some payment methods use the `canMakePayment` callback to initialize / setup.
|
||||
// Example: Stripe CC, Stripe Payment Request.
|
||||
// That's why we track "is initialized" state here.
|
||||
setIsInitialized( true );
|
||||
}, [
|
||||
addErrorNotice,
|
||||
dispatcher,
|
||||
isEditor,
|
||||
noticeContext,
|
||||
paymentMethodsOrder,
|
||||
registeredPaymentMethods,
|
||||
] );
|
||||
|
||||
const [ debouncedRefreshCanMakePayments ] = useDebouncedCallback(
|
||||
refreshCanMakePayments,
|
||||
500
|
||||
);
|
||||
|
||||
// Determine which payment methods are available initially and whenever
|
||||
// shipping methods, cart or the billing data change.
|
||||
// Some payment methods (e.g. COD) can be disabled for specific shipping methods.
|
||||
useEffect( () => {
|
||||
debouncedRefreshCanMakePayments();
|
||||
}, [
|
||||
debouncedRefreshCanMakePayments,
|
||||
cart,
|
||||
selectedShippingMethods,
|
||||
billingData,
|
||||
] );
|
||||
|
||||
return isInitialized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for setting up payment methods (standard, non-express).
|
||||
*
|
||||
* @param {function(Object):undefined} dispatcher
|
||||
*
|
||||
* @return {boolean} True when standard payment methods have been initialized.
|
||||
*/
|
||||
export const usePaymentMethods = (
|
||||
dispatcher: PaymentMethodsDispatcherType
|
||||
): boolean => {
|
||||
const standardMethods: PaymentMethods = getPaymentMethods() as PaymentMethods;
|
||||
const { noticeContexts } = useEmitResponse();
|
||||
// Ensure all methods are present in order.
|
||||
// Some payment methods may not be present in paymentGatewaySortOrder if they
|
||||
// depend on state, e.g. COD can depend on shipping method.
|
||||
const displayOrder = new Set( [
|
||||
...( getSetting( 'paymentGatewaySortOrder', [] ) as [ ] ),
|
||||
...Object.keys( standardMethods ),
|
||||
] );
|
||||
return usePaymentMethodRegistration(
|
||||
dispatcher,
|
||||
standardMethods,
|
||||
Array.from( displayOrder ),
|
||||
noticeContexts.PAYMENTS
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for setting up express payment methods.
|
||||
*
|
||||
* @param {function(Object):undefined} dispatcher
|
||||
*
|
||||
* @return {boolean} True when express payment methods have been initialized.
|
||||
*/
|
||||
export const useExpressPaymentMethods = (
|
||||
dispatcher: PaymentMethodsDispatcherType
|
||||
): boolean => {
|
||||
const expressMethods: ExpressPaymentMethods = getExpressPaymentMethods() as ExpressPaymentMethods;
|
||||
const { noticeContexts } = useEmitResponse();
|
||||
return usePaymentMethodRegistration(
|
||||
dispatcher,
|
||||
expressMethods,
|
||||
Object.keys( expressMethods ),
|
||||
noticeContexts.EXPRESS_PAYMENTS
|
||||
);
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { PaymentMethods, CustomerPaymentMethod } from './types';
|
||||
|
||||
/**
|
||||
* Gets the payment methods saved for the current user after filtering out disabled ones.
|
||||
*/
|
||||
export const getCustomerPaymentMethods = (
|
||||
availablePaymentMethods: PaymentMethods = {}
|
||||
): Record< string, CustomerPaymentMethod > => {
|
||||
if ( Object.keys( availablePaymentMethods ).length === 0 ) {
|
||||
return {};
|
||||
}
|
||||
const customerPaymentMethods = getSetting( 'customerPaymentMethods', {} );
|
||||
const paymentMethodKeys = Object.keys( customerPaymentMethods );
|
||||
const enabledCustomerPaymentMethods = {} as Record<
|
||||
string,
|
||||
CustomerPaymentMethod
|
||||
>;
|
||||
paymentMethodKeys.forEach( ( type ) => {
|
||||
const methods = customerPaymentMethods[ type ].filter(
|
||||
( {
|
||||
method: { gateway },
|
||||
}: {
|
||||
method: {
|
||||
gateway: string;
|
||||
};
|
||||
} ) =>
|
||||
gateway in availablePaymentMethods &&
|
||||
availablePaymentMethods[ gateway ].supports?.showSavedCards
|
||||
);
|
||||
if ( methods.length ) {
|
||||
enabledCustomerPaymentMethods[ type ] = methods;
|
||||
}
|
||||
} );
|
||||
return enabledCustomerPaymentMethods;
|
||||
};
|
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorTypes} ShippingErrorTypes
|
||||
* @typedef {import('@woocommerce/type-defs/shipping').ShippingAddress} ShippingAddress
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').ShippingDataContext} ShippingDataContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {ShippingErrorTypes}
|
||||
*/
|
||||
export const ERROR_TYPES = {
|
||||
NONE: 'none',
|
||||
INVALID_ADDRESS: 'invalid_address',
|
||||
UNKNOWN: 'unknown_error',
|
||||
};
|
||||
|
||||
export const shippingErrorCodes = {
|
||||
INVALID_COUNTRY: 'woocommerce_rest_cart_shipping_rates_invalid_country',
|
||||
MISSING_COUNTRY: 'woocommerce_rest_cart_shipping_rates_missing_country',
|
||||
INVALID_STATE: 'woocommerce_rest_cart_shipping_rates_invalid_state',
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {ShippingAddress}
|
||||
*/
|
||||
export const DEFAULT_SHIPPING_ADDRESS = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {ShippingDataContext}
|
||||
*/
|
||||
export const DEFAULT_SHIPPING_CONTEXT_DATA = {
|
||||
shippingErrorStatus: {
|
||||
isPristine: true,
|
||||
isValid: false,
|
||||
hasInvalidAddress: false,
|
||||
hasError: false,
|
||||
},
|
||||
dispatchErrorStatus: () => null,
|
||||
shippingErrorTypes: ERROR_TYPES,
|
||||
shippingRates: [],
|
||||
shippingRatesLoading: false,
|
||||
selectedRates: [],
|
||||
setSelectedRates: () => null,
|
||||
shippingAddress: DEFAULT_SHIPPING_ADDRESS,
|
||||
setShippingAddress: () => null,
|
||||
onShippingRateSuccess: () => null,
|
||||
onShippingRateFail: () => null,
|
||||
onShippingRateSelectSuccess: () => null,
|
||||
onShippingRateSelectFail: () => null,
|
||||
needsShipping: false,
|
||||
};
|
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { emitterCallback, reducer, emitEvent } from '../../../event-emit';
|
||||
|
||||
const EMIT_TYPES = {
|
||||
SHIPPING_RATES_SUCCESS: 'shipping_rates_success',
|
||||
SHIPPING_RATES_FAIL: 'shipping_rates_fail',
|
||||
SHIPPING_RATE_SELECT_SUCCESS: 'shipping_rate_select_success',
|
||||
SHIPPING_RATE_SELECT_FAIL: 'shipping_rate_select_fail',
|
||||
};
|
||||
|
||||
/**
|
||||
* Receives a reducer dispatcher and returns an object with the onSuccess and
|
||||
* onFail callback registration points for the shipping option emit 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} dispatcher A reducer dispatcher
|
||||
* @return {Object} An object with `onSuccess` and `onFail` emitter registration.
|
||||
*/
|
||||
const emitterObservers = ( dispatcher ) => ( {
|
||||
onSuccess: emitterCallback( EMIT_TYPES.SHIPPING_RATES_SUCCESS, dispatcher ),
|
||||
onFail: emitterCallback( EMIT_TYPES.SHIPPING_RATES_FAIL, dispatcher ),
|
||||
onSelectSuccess: emitterCallback(
|
||||
EMIT_TYPES.SHIPPING_RATE_SELECT_SUCCESS,
|
||||
dispatcher
|
||||
),
|
||||
onSelectFail: emitterCallback(
|
||||
EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL,
|
||||
dispatcher
|
||||
),
|
||||
} );
|
||||
|
||||
export { EMIT_TYPES, emitterObservers, reducer, emitEvent };
|
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from '@wordpress/element';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { deriveSelectedShippingRates } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ERROR_TYPES, DEFAULT_SHIPPING_CONTEXT_DATA } from './constants';
|
||||
import { hasInvalidShippingAddress } from './utils';
|
||||
import { errorStatusReducer } from './reducers';
|
||||
import {
|
||||
EMIT_TYPES,
|
||||
emitterObservers,
|
||||
reducer as emitReducer,
|
||||
emitEvent,
|
||||
} from './event-emit';
|
||||
import { useCheckoutContext } from '../checkout-state';
|
||||
import { useCustomerDataContext } from '../customer';
|
||||
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
|
||||
import { useSelectShippingRates } from '../../../hooks/shipping/use-select-shipping-rates';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').ShippingDataContext} ShippingDataContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const { NONE, INVALID_ADDRESS, UNKNOWN } = ERROR_TYPES;
|
||||
const ShippingDataContext = createContext( DEFAULT_SHIPPING_CONTEXT_DATA );
|
||||
|
||||
/**
|
||||
* @return {ShippingDataContext} Returns data and functions related to shipping methods.
|
||||
*/
|
||||
export const useShippingDataContext = () => {
|
||||
return useContext( ShippingDataContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* The shipping data provider exposes the interface for shipping in the checkout/cart.
|
||||
*
|
||||
* @param {Object} props Incoming props for provider
|
||||
* @param {React.ReactElement} props.children
|
||||
*/
|
||||
export const ShippingDataProvider = ( { children } ) => {
|
||||
const { dispatchActions } = useCheckoutContext();
|
||||
const { shippingAddress, setShippingAddress } = useCustomerDataContext();
|
||||
const {
|
||||
cartNeedsShipping: needsShipping,
|
||||
cartHasCalculatedShipping: hasCalculatedShipping,
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
cartErrors,
|
||||
} = useStoreCart();
|
||||
const { selectShippingRate, isSelectingRate } = useSelectShippingRates();
|
||||
const [ shippingErrorStatus, dispatchErrorStatus ] = useReducer(
|
||||
errorStatusReducer,
|
||||
NONE
|
||||
);
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const currentObservers = useRef( observers );
|
||||
const eventObservers = useMemo(
|
||||
() => ( {
|
||||
onShippingRateSuccess: emitterObservers( observerDispatch )
|
||||
.onSuccess,
|
||||
onShippingRateFail: emitterObservers( observerDispatch ).onFail,
|
||||
onShippingRateSelectSuccess: emitterObservers( observerDispatch )
|
||||
.onSelectSuccess,
|
||||
onShippingRateSelectFail: emitterObservers( observerDispatch )
|
||||
.onSelectFail,
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
|
||||
// set observers on ref so it's always current.
|
||||
useEffect( () => {
|
||||
currentObservers.current = observers;
|
||||
}, [ observers ] );
|
||||
|
||||
// set selected rates on ref so it's always current.
|
||||
const selectedRates = useRef( () =>
|
||||
deriveSelectedShippingRates( shippingRates )
|
||||
);
|
||||
useEffect( () => {
|
||||
const derivedSelectedRates = deriveSelectedShippingRates(
|
||||
shippingRates
|
||||
);
|
||||
if ( ! isShallowEqual( selectedRates.current, derivedSelectedRates ) ) {
|
||||
selectedRates.current = derivedSelectedRates;
|
||||
}
|
||||
}, [ shippingRates ] );
|
||||
|
||||
// increment/decrement checkout calculating counts when shipping is loading.
|
||||
useEffect( () => {
|
||||
if ( shippingRatesLoading ) {
|
||||
dispatchActions.incrementCalculating();
|
||||
} else {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
}, [ shippingRatesLoading, dispatchActions ] );
|
||||
|
||||
// increment/decrement checkout calculating counts when shipping rates are being selected.
|
||||
useEffect( () => {
|
||||
if ( isSelectingRate ) {
|
||||
dispatchActions.incrementCalculating();
|
||||
} else {
|
||||
dispatchActions.decrementCalculating();
|
||||
}
|
||||
}, [ isSelectingRate, dispatchActions ] );
|
||||
|
||||
// set shipping error status if there are shipping error codes
|
||||
useEffect( () => {
|
||||
if (
|
||||
cartErrors.length > 0 &&
|
||||
hasInvalidShippingAddress( cartErrors )
|
||||
) {
|
||||
dispatchErrorStatus( { type: INVALID_ADDRESS } );
|
||||
} else {
|
||||
dispatchErrorStatus( { type: NONE } );
|
||||
}
|
||||
}, [ cartErrors ] );
|
||||
|
||||
const currentErrorStatus = useMemo(
|
||||
() => ( {
|
||||
isPristine: shippingErrorStatus === NONE,
|
||||
isValid: shippingErrorStatus === NONE,
|
||||
hasInvalidAddress: shippingErrorStatus === INVALID_ADDRESS,
|
||||
hasError:
|
||||
shippingErrorStatus === UNKNOWN ||
|
||||
shippingErrorStatus === INVALID_ADDRESS,
|
||||
} ),
|
||||
[ shippingErrorStatus ]
|
||||
);
|
||||
|
||||
// emit events.
|
||||
useEffect( () => {
|
||||
if (
|
||||
! shippingRatesLoading &&
|
||||
( shippingRates.length === 0 || currentErrorStatus.hasError )
|
||||
) {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATES_FAIL,
|
||||
{
|
||||
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
|
||||
hasError: currentErrorStatus.hasError,
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
currentErrorStatus.hasError,
|
||||
currentErrorStatus.hasInvalidAddress,
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
! shippingRatesLoading &&
|
||||
shippingRates.length > 0 &&
|
||||
! currentErrorStatus.hasError
|
||||
) {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATES_SUCCESS,
|
||||
shippingRates
|
||||
);
|
||||
}
|
||||
}, [ shippingRates, shippingRatesLoading, currentErrorStatus.hasError ] );
|
||||
|
||||
// emit shipping rate selection events.
|
||||
useEffect( () => {
|
||||
if ( isSelectingRate ) {
|
||||
return;
|
||||
}
|
||||
if ( currentErrorStatus.hasError ) {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL,
|
||||
{
|
||||
hasError: currentErrorStatus.hasError,
|
||||
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
emitEvent(
|
||||
currentObservers.current,
|
||||
EMIT_TYPES.SHIPPING_RATE_SELECT_SUCCESS,
|
||||
selectedRates.current
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isSelectingRate,
|
||||
currentErrorStatus.hasError,
|
||||
currentErrorStatus.hasInvalidAddress,
|
||||
] );
|
||||
|
||||
/**
|
||||
* @type {ShippingDataContext}
|
||||
*/
|
||||
const ShippingData = {
|
||||
shippingErrorStatus: currentErrorStatus,
|
||||
dispatchErrorStatus,
|
||||
shippingErrorTypes: ERROR_TYPES,
|
||||
shippingRates,
|
||||
shippingRatesLoading,
|
||||
selectedRates: selectedRates.current,
|
||||
setSelectedRates: selectShippingRate,
|
||||
isSelectingRate,
|
||||
shippingAddress,
|
||||
setShippingAddress,
|
||||
needsShipping,
|
||||
hasCalculatedShipping,
|
||||
...eventObservers,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShippingDataContext.Provider value={ ShippingData }>
|
||||
{ children }
|
||||
</ShippingDataContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ERROR_TYPES } from './constants';
|
||||
|
||||
/**
|
||||
* Reducer for shipping status state
|
||||
*
|
||||
* @param {string} state The current status.
|
||||
* @param {Object} action The incoming action.
|
||||
* @param {string} action.type The type of action.
|
||||
*/
|
||||
export const errorStatusReducer = ( state, { type } ) => {
|
||||
if ( Object.values( ERROR_TYPES ).includes( type ) ) {
|
||||
return type;
|
||||
}
|
||||
return state;
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { shippingErrorCodes } from './constants';
|
||||
|
||||
export const hasInvalidShippingAddress = ( errors ) => {
|
||||
return errors.some( ( error ) => {
|
||||
if (
|
||||
error.code &&
|
||||
Object.values( shippingErrorCodes ).includes( error.code )
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} );
|
||||
};
|
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import triggerFetch from '@wordpress/api-fetch';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { CheckoutStateDispatchActions } from './checkout-state/types';
|
||||
|
||||
/**
|
||||
* Utility function for preparing payment data for the request.
|
||||
*/
|
||||
export const preparePaymentData = (
|
||||
//Arbitrary payment data provided by the payment method.
|
||||
paymentData: Record< string, unknown >,
|
||||
//Whether to save the payment method info to user account.
|
||||
shouldSave: boolean,
|
||||
//The current active payment method.
|
||||
activePaymentMethod: string
|
||||
): { key: string; value: unknown }[] => {
|
||||
const apiData = Object.keys( paymentData ).map( ( property ) => {
|
||||
const value = paymentData[ property ];
|
||||
return { key: property, value };
|
||||
}, [] );
|
||||
const savePaymentMethodKey = `wc-${ activePaymentMethod }-new-payment-method`;
|
||||
apiData.push( {
|
||||
key: savePaymentMethodKey,
|
||||
value: shouldSave,
|
||||
} );
|
||||
return apiData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Process headers from an API response an dispatch updates.
|
||||
*/
|
||||
export const processCheckoutResponseHeaders = (
|
||||
headers: Headers,
|
||||
dispatchActions: CheckoutStateDispatchActions
|
||||
): void => {
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
triggerFetch.setNonce &&
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
typeof triggerFetch.setNonce === 'function'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- this does exist because it's monkey patched in
|
||||
// middleware/store-api-nonce.
|
||||
triggerFetch.setNonce( headers );
|
||||
}
|
||||
|
||||
// Update user using headers.
|
||||
if ( headers?.get( 'X-WC-Store-API-User' ) ) {
|
||||
dispatchActions.setCustomerId(
|
||||
parseInt( headers.get( 'X-WC-Store-API-User' ) || '0', 10 )
|
||||
);
|
||||
}
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { createContext, useContext } from '@wordpress/element';
|
||||
import { useContainerQueries } from '@woocommerce/base-hooks';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').ContainerWidthContext} ContainerWidthContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const ContainerWidthContext = createContext( {
|
||||
hasContainerWidth: false,
|
||||
containerClassName: '',
|
||||
isMobile: false,
|
||||
isSmall: false,
|
||||
isMedium: false,
|
||||
isLarge: false,
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {ContainerWidthContext} Returns the container width context value
|
||||
*/
|
||||
export const useContainerWidthContext = () => {
|
||||
return useContext( ContainerWidthContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides an interface to useContainerQueries so children can see what size is being used by the
|
||||
* container.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {React.ReactChildren} props.children React elements wrapped by the component.
|
||||
* @param {string} props.className CSS class in use.
|
||||
*/
|
||||
export const ContainerWidthContextProvider = ( {
|
||||
children,
|
||||
className = '',
|
||||
} ) => {
|
||||
const [ resizeListener, containerClassName ] = useContainerQueries();
|
||||
|
||||
const contextValue = {
|
||||
hasContainerWidth: containerClassName !== '',
|
||||
containerClassName,
|
||||
isMobile: containerClassName === 'is-mobile',
|
||||
isSmall: containerClassName === 'is-small',
|
||||
isMedium: containerClassName === 'is-medium',
|
||||
isLarge: containerClassName === 'is-large',
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {ContainerWidthContext}
|
||||
*/
|
||||
return (
|
||||
<ContainerWidthContext.Provider value={ contextValue }>
|
||||
<div className={ classNames( className, containerClassName ) }>
|
||||
{ resizeListener }
|
||||
{ children }
|
||||
</div>
|
||||
</ContainerWidthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
ContainerWidthContextProvider.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext, useCallback } from '@wordpress/element';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').EditorDataContext} EditorDataContext
|
||||
* @typedef {import('@woocommerce/type-defs/cart').CartData} CartData
|
||||
*/
|
||||
|
||||
const EditorContext = createContext( {
|
||||
isEditor: false,
|
||||
currentPostId: 0,
|
||||
previewData: {},
|
||||
getPreviewData: () => void null,
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {EditorDataContext} Returns the editor data context value
|
||||
*/
|
||||
export const useEditorContext = () => {
|
||||
return useContext( EditorContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Editor provider
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {*} props.children The children being wrapped.
|
||||
* @param {Object} [props.previewData] The preview data for editor.
|
||||
* @param {number} [props.currentPostId] The post being edited.
|
||||
*/
|
||||
export const EditorProvider = ( {
|
||||
children,
|
||||
currentPostId = 0,
|
||||
previewData = {},
|
||||
} ) => {
|
||||
/**
|
||||
* @type {number} editingPostId
|
||||
*/
|
||||
const editingPostId = useSelect(
|
||||
( select ) => {
|
||||
if ( ! currentPostId ) {
|
||||
const store = select( 'core/editor' );
|
||||
return store.getCurrentPostId();
|
||||
}
|
||||
return currentPostId;
|
||||
},
|
||||
[ currentPostId ]
|
||||
);
|
||||
|
||||
const getPreviewData = useCallback(
|
||||
( name ) => {
|
||||
if ( name in previewData ) {
|
||||
return previewData[ name ];
|
||||
}
|
||||
return {};
|
||||
},
|
||||
[ previewData ]
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {EditorDataContext}
|
||||
*/
|
||||
const editorData = {
|
||||
isEditor: true,
|
||||
currentPostId: editingPostId,
|
||||
previewData,
|
||||
getPreviewData,
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorContext.Provider value={ editorData }>
|
||||
{ children }
|
||||
</EditorContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
export * from './editor-context';
|
||||
export * from './add-to-cart-form';
|
||||
export * from './cart-checkout';
|
||||
export * from './store-notices';
|
||||
export * from './store-snackbar-notices';
|
||||
export * from './validation';
|
||||
export * from './container-width-context';
|
||||
export * from './editor-context';
|
||||
export * from './query-state-context';
|
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Query state context is the index for used for a query state store. By
|
||||
* exposing this via context, it allows for all children blocks to be
|
||||
* synchronized to the same query state defined by the parent in the tree.
|
||||
*
|
||||
* Defaults to 'page' for general global query state shared among all blocks
|
||||
* in a view.
|
||||
*
|
||||
* @member {Object} QueryStateContext A react context object
|
||||
*/
|
||||
const QueryStateContext = createContext( 'page' );
|
||||
|
||||
export const useQueryStateContext = () => useContext( QueryStateContext );
|
||||
export const QueryStateContextProvider = QueryStateContext.Provider;
|
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { Notice } from 'wordpress-components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const getWooClassName = ( { status = 'default' } ) => {
|
||||
switch ( status ) {
|
||||
case 'error':
|
||||
return 'woocommerce-error';
|
||||
case 'success':
|
||||
return 'woocommerce-message';
|
||||
case 'info':
|
||||
case 'warning':
|
||||
return 'woocommerce-info';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const StoreNoticesContainer = ( { className, notices, removeNotice } ) => {
|
||||
const regularNotices = notices.filter(
|
||||
( notice ) => notice.type !== 'snackbar'
|
||||
);
|
||||
|
||||
if ( ! regularNotices.length ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrapperClass = classnames( className, 'wc-block-components-notices' );
|
||||
|
||||
return (
|
||||
<div className={ wrapperClass }>
|
||||
{ regularNotices.map( ( props ) => (
|
||||
<Notice
|
||||
key={ 'store-notice-' + props.id }
|
||||
{ ...props }
|
||||
className={ classnames(
|
||||
'wc-block-components-notices__notice',
|
||||
getWooClassName( props )
|
||||
) }
|
||||
onRemove={ () => {
|
||||
if ( props.isDismissible ) {
|
||||
removeNotice( props.id );
|
||||
}
|
||||
} }
|
||||
>
|
||||
{ props.content }
|
||||
</Notice>
|
||||
) ) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
StoreNoticesContainer.propTypes = {
|
||||
className: PropTypes.string,
|
||||
notices: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
content: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
isDismissible: PropTypes.bool,
|
||||
type: PropTypes.oneOf( [ 'default', 'snackbar' ] ),
|
||||
} )
|
||||
),
|
||||
};
|
||||
|
||||
export default StoreNoticesContainer;
|
@ -0,0 +1,32 @@
|
||||
.wc-block-components-notices {
|
||||
display: block;
|
||||
margin-bottom: 2em;
|
||||
.wc-block-components-notices__notice {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
.components-notice__dismiss {
|
||||
background: transparent none;
|
||||
padding: 0;
|
||||
margin: 0 0 0 auto;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: currentColor;
|
||||
svg {
|
||||
fill: currentColor;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
}
|
||||
.wc-block-components-notices__notice + .wc-block-components-notices__notice {
|
||||
margin-top: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
// @todo Either move notice style fixes to Woo core, or take full control over notice component styling in blocks.
|
||||
.theme-twentytwentyone,
|
||||
.theme-twentytwenty {
|
||||
.wc-block-components-notices__notice {
|
||||
padding: 1.5rem 3rem;
|
||||
}
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useState,
|
||||
} from '@wordpress/element';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreEvents } from '../../hooks/use-store-events';
|
||||
import { useEditorContext } from '../editor-context';
|
||||
import StoreNoticesContainer from './components/store-notices-container';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').NoticeContext} NoticeContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const StoreNoticesContext = createContext( {
|
||||
notices: [],
|
||||
createNotice: ( status, text, props ) => void { status, text, props },
|
||||
removeNotice: ( id, ctxt ) => void { id, ctxt },
|
||||
setIsSuppressed: ( val ) => void { val },
|
||||
context: 'wc/core',
|
||||
} );
|
||||
|
||||
/**
|
||||
* Returns the notices context values.
|
||||
*
|
||||
* @return {NoticeContext} The notice context value from the notice context.
|
||||
*/
|
||||
export const useStoreNoticesContext = () => {
|
||||
return useContext( StoreNoticesContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides an interface for blocks to add notices to the frontend UI.
|
||||
*
|
||||
* Statuses map to https://github.com/WordPress/gutenberg/tree/master/packages/components/src/notice
|
||||
* - Default (no status)
|
||||
* - Error
|
||||
* - Warning
|
||||
* - Info
|
||||
* - Success
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {JSX.Element} props.children The Elements wrapped by this component.
|
||||
* @param {string} [props.className] CSS class used.
|
||||
* @param {boolean} [props.createNoticeContainer] Whether to create a notice container or not.
|
||||
* @param {string} [props.context] The notice context for notices being rendered.
|
||||
*/
|
||||
export const StoreNoticesProvider = ( {
|
||||
children,
|
||||
className = '',
|
||||
createNoticeContainer = true,
|
||||
context = 'wc/core',
|
||||
} ) => {
|
||||
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
|
||||
const [ isSuppressed, setIsSuppressed ] = useState( false );
|
||||
const { dispatchStoreEvent } = useStoreEvents();
|
||||
const { isEditor } = useEditorContext();
|
||||
|
||||
const createNoticeWithContext = useCallback(
|
||||
( status = 'default', content = '', options = {} ) => {
|
||||
createNotice( status, content, {
|
||||
...options,
|
||||
context: options.context || context,
|
||||
} );
|
||||
dispatchStoreEvent( 'store-notice-create', {
|
||||
status,
|
||||
content,
|
||||
options,
|
||||
} );
|
||||
},
|
||||
[ createNotice, dispatchStoreEvent, context ]
|
||||
);
|
||||
|
||||
const removeNoticeWithContext = useCallback(
|
||||
( id, ctxt = context ) => {
|
||||
removeNotice( id, ctxt );
|
||||
},
|
||||
[ removeNotice, context ]
|
||||
);
|
||||
|
||||
const { notices } = useSelect(
|
||||
( select ) => {
|
||||
return {
|
||||
notices: select( 'core/notices' ).getNotices( context ),
|
||||
};
|
||||
},
|
||||
[ context ]
|
||||
);
|
||||
|
||||
const contextValue = {
|
||||
notices,
|
||||
createNotice: createNoticeWithContext,
|
||||
removeNotice: removeNoticeWithContext,
|
||||
context,
|
||||
setIsSuppressed,
|
||||
};
|
||||
|
||||
const noticeOutput = isSuppressed ? null : (
|
||||
<StoreNoticesContainer
|
||||
className={ className }
|
||||
notices={ contextValue.notices }
|
||||
removeNotice={ contextValue.removeNotice }
|
||||
isEditor={ isEditor }
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StoreNoticesContext.Provider value={ contextValue }>
|
||||
{ createNoticeContainer && noticeOutput }
|
||||
{ children }
|
||||
</StoreNoticesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
StoreNoticesProvider.propTypes = {
|
||||
className: PropTypes.string,
|
||||
createNoticeContainer: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
context: PropTypes.string,
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * from './components/store-notices-container';
|
||||
export * from './context';
|
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { SnackbarList } from 'wordpress-components';
|
||||
import classnames from 'classnames';
|
||||
import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout';
|
||||
|
||||
const EMPTY_SNACKBAR_NOTICES = {};
|
||||
|
||||
const SnackbarNoticesContainer = ( {
|
||||
className,
|
||||
notices,
|
||||
removeNotice,
|
||||
isEditor,
|
||||
} ) => {
|
||||
if ( isEditor ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snackbarNotices = notices.filter(
|
||||
( notice ) => notice.type === 'snackbar'
|
||||
);
|
||||
|
||||
const noticeVisibility =
|
||||
snackbarNotices.length > 0
|
||||
? snackbarNotices.reduce( ( acc, { content } ) => {
|
||||
acc[ content ] = true;
|
||||
return acc;
|
||||
}, {} )
|
||||
: EMPTY_SNACKBAR_NOTICES;
|
||||
|
||||
const filteredNotices = __experimentalApplyCheckoutFilter( {
|
||||
filterName: 'snackbarNoticeVisibility',
|
||||
defaultValue: noticeVisibility,
|
||||
} );
|
||||
|
||||
const visibleNotices = snackbarNotices.filter(
|
||||
( notice ) => filteredNotices[ notice.content ] === true
|
||||
);
|
||||
|
||||
const wrapperClass = classnames(
|
||||
className,
|
||||
'wc-block-components-notices__snackbar'
|
||||
);
|
||||
|
||||
return (
|
||||
<SnackbarList
|
||||
notices={ visibleNotices }
|
||||
className={ wrapperClass }
|
||||
onRemove={ removeNotice }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SnackbarNoticesContainer;
|
@ -0,0 +1,20 @@
|
||||
.wc-block-components-notices__snackbar {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
|
||||
@include breakpoint("<782px") {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 0;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.components-snackbar-list__notice-container {
|
||||
@include breakpoint("<782px") {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useState,
|
||||
} from '@wordpress/element';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import SnackbarNoticesContainer from '@woocommerce/base-context/providers/store-snackbar-notices/components/snackbar-notices-container';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreEvents } from '../../hooks/use-store-events';
|
||||
import { useEditorContext } from '../editor-context';
|
||||
|
||||
/**
|
||||
* @typedef {import('@woocommerce/type-defs/contexts').NoticeContext} NoticeContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const StoreSnackbarNoticesContext = createContext( {
|
||||
notices: [],
|
||||
createSnackbarNotice: ( content, options ) => void { content, options },
|
||||
removeSnackbarNotice: ( id, ctxt ) => void { id, ctxt },
|
||||
setIsSuppressed: ( val ) => void { val },
|
||||
context: 'wc/core',
|
||||
} );
|
||||
|
||||
/**
|
||||
* Returns the notices context values.
|
||||
*
|
||||
* @return {NoticeContext} The notice context value from the notice context.
|
||||
*/
|
||||
export const useStoreSnackbarNoticesContext = () => {
|
||||
return useContext( StoreSnackbarNoticesContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides an interface for blocks to add notices to the frontend UI.
|
||||
*
|
||||
* Statuses map to https://github.com/WordPress/gutenberg/tree/master/packages/components/src/notice
|
||||
* - Default (no status)
|
||||
* - Error
|
||||
* - Warning
|
||||
* - Info
|
||||
* - Success
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {React.ReactChildren} props.children The Elements wrapped by this component.
|
||||
* @param {string} props.context The notice context for notices being rendered.
|
||||
*/
|
||||
export const StoreSnackbarNoticesProvider = ( {
|
||||
children,
|
||||
context = 'wc/core',
|
||||
} ) => {
|
||||
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
|
||||
const [ isSuppressed, setIsSuppressed ] = useState( false );
|
||||
const { dispatchStoreEvent } = useStoreEvents();
|
||||
const { isEditor } = useEditorContext();
|
||||
|
||||
const createSnackbarNotice = useCallback(
|
||||
( content = '', options = {} ) => {
|
||||
createNotice( 'default', content, {
|
||||
...options,
|
||||
type: 'snackbar',
|
||||
context: options.context || context,
|
||||
} );
|
||||
dispatchStoreEvent( 'store-notice-create', {
|
||||
status: 'default',
|
||||
content,
|
||||
options,
|
||||
} );
|
||||
},
|
||||
[ createNotice, dispatchStoreEvent, context ]
|
||||
);
|
||||
|
||||
const removeSnackbarNotice = useCallback(
|
||||
( id, ctxt = context ) => {
|
||||
removeNotice( id, ctxt );
|
||||
},
|
||||
[ removeNotice, context ]
|
||||
);
|
||||
|
||||
const { notices } = useSelect(
|
||||
( select ) => {
|
||||
return {
|
||||
notices: select( 'core/notices' ).getNotices( context ),
|
||||
};
|
||||
},
|
||||
[ context ]
|
||||
);
|
||||
|
||||
const contextValue = {
|
||||
notices,
|
||||
createSnackbarNotice,
|
||||
removeSnackbarNotice,
|
||||
context,
|
||||
setIsSuppressed,
|
||||
};
|
||||
|
||||
const snackbarNoticeOutput = isSuppressed ? null : (
|
||||
<SnackbarNoticesContainer
|
||||
notices={ contextValue.notices }
|
||||
removeNotice={ contextValue.removeSnackbarNotice }
|
||||
isEditor={ isEditor }
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StoreSnackbarNoticesContext.Provider value={ contextValue }>
|
||||
{ children }
|
||||
{ snackbarNoticeOutput }
|
||||
</StoreSnackbarNoticesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
StoreSnackbarNoticesProvider.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
context: PropTypes.string,
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './context';
|
@ -0,0 +1 @@
|
||||
export * from './validation-input-error';
|
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useValidationContext } from '../../context';
|
||||
import './style.scss';
|
||||
|
||||
export const ValidationInputError = ( {
|
||||
errorMessage = '',
|
||||
propertyName = '',
|
||||
elementId = '',
|
||||
} ) => {
|
||||
const { getValidationError, getValidationErrorId } = useValidationContext();
|
||||
|
||||
if ( ! errorMessage || typeof errorMessage !== 'string' ) {
|
||||
const error = getValidationError( propertyName ) || {};
|
||||
if ( error.message && ! error.hidden ) {
|
||||
errorMessage = error.message;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wc-block-components-validation-error" role="alert">
|
||||
<p id={ getValidationErrorId( elementId ) }>{ errorMessage }</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ValidationInputError.propTypes = {
|
||||
errorMessage: PropTypes.string,
|
||||
propertyName: PropTypes.string,
|
||||
elementId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ValidationInputError;
|
@ -0,0 +1,15 @@
|
||||
.wc-block-components-validation-error {
|
||||
@include font-size(smaller);
|
||||
color: $alert-red;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-select + .wc-block-components-validation-error {
|
||||
margin-bottom: $gap-large;
|
||||
}
|
@ -0,0 +1,257 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
} from '@wordpress/element';
|
||||
import { pickBy } from 'lodash';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
|
||||
/**
|
||||
* @typedef { import('@woocommerce/type-defs/contexts').ValidationContext } ValidationContext
|
||||
* @typedef {import('react')} React
|
||||
*/
|
||||
|
||||
const ValidationContext = createContext( {
|
||||
getValidationError: () => '',
|
||||
setValidationErrors: ( errors ) => void errors,
|
||||
clearValidationError: ( property ) => void property,
|
||||
clearAllValidationErrors: () => void null,
|
||||
hideValidationError: () => void null,
|
||||
showValidationError: () => void null,
|
||||
showAllValidationErrors: () => void null,
|
||||
hasValidationErrors: false,
|
||||
getValidationErrorId: ( errorId ) => errorId,
|
||||
} );
|
||||
|
||||
/**
|
||||
* @return {ValidationContext} The context values for the validation context.
|
||||
*/
|
||||
export const useValidationContext = () => {
|
||||
return useContext( ValidationContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation context provider
|
||||
*
|
||||
* Any children of this context will be exposed to validation state and helpers
|
||||
* for tracking validation.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {JSX.Element} props.children What react elements are wrapped by this component.
|
||||
*/
|
||||
export const ValidationContextProvider = ( { children } ) => {
|
||||
const [ validationErrors, updateValidationErrors ] = useState( {} );
|
||||
|
||||
/**
|
||||
* This retrieves any validation error message that exists in state for the
|
||||
* given property name.
|
||||
*
|
||||
* @param {string} property The property the error message is for.
|
||||
*
|
||||
* @return {Object} The error object for the given property.
|
||||
*/
|
||||
const getValidationError = useCallback(
|
||||
( property ) => validationErrors[ property ],
|
||||
[ validationErrors ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Provides an id for the validation error that can be used to fill out
|
||||
* aria-describedby attribute values.
|
||||
*
|
||||
* @param {string} errorId The input css id the validation error is related
|
||||
* to.
|
||||
* @return {string} The id to use for the validation error container.
|
||||
*/
|
||||
const getValidationErrorId = useCallback(
|
||||
( errorId ) => {
|
||||
const error = validationErrors[ errorId ];
|
||||
if ( ! error || error.hidden ) {
|
||||
return '';
|
||||
}
|
||||
return `validate-error-${ errorId }`;
|
||||
},
|
||||
[ validationErrors ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Clears any validation error that exists in state for the given property
|
||||
* name.
|
||||
*
|
||||
* @param {string} property The name of the property to clear if exists in
|
||||
* validation error state.
|
||||
*/
|
||||
const clearValidationError = useCallback(
|
||||
/**
|
||||
* Callback that is memoized.
|
||||
*
|
||||
* @param {string} property
|
||||
*/
|
||||
( property ) => {
|
||||
updateValidationErrors(
|
||||
/**
|
||||
* Callback for validation Errors handling.
|
||||
*
|
||||
* @param {Object} prevErrors
|
||||
*/
|
||||
( prevErrors ) => {
|
||||
if ( ! prevErrors[ property ] ) {
|
||||
return prevErrors;
|
||||
}
|
||||
|
||||
const {
|
||||
// eslint-disable-next-line no-unused-vars -- this is intentional to omit the dynamic property from the returned object.
|
||||
[ property ]: clearedProperty,
|
||||
...newErrors
|
||||
} = prevErrors;
|
||||
return newErrors;
|
||||
}
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Clears the entire validation error state.
|
||||
*/
|
||||
const clearAllValidationErrors = useCallback(
|
||||
() => void updateValidationErrors( {} ),
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Used to record new validation errors in the state.
|
||||
*
|
||||
* @param {Object} newErrors An object where keys are the property names the
|
||||
* validation error is for and values are the
|
||||
* validation error message displayed to the user.
|
||||
*/
|
||||
const setValidationErrors = useCallback( ( newErrors ) => {
|
||||
if ( ! newErrors ) {
|
||||
return;
|
||||
}
|
||||
updateValidationErrors( ( prevErrors ) => {
|
||||
newErrors = pickBy( newErrors, ( error, property ) => {
|
||||
if ( typeof error.message !== 'string' ) {
|
||||
return false;
|
||||
}
|
||||
if ( prevErrors.hasOwnProperty( property ) ) {
|
||||
return ! isShallowEqual( prevErrors[ property ], error );
|
||||
}
|
||||
return true;
|
||||
} );
|
||||
if ( Object.values( newErrors ).length === 0 ) {
|
||||
return prevErrors;
|
||||
}
|
||||
return {
|
||||
...prevErrors,
|
||||
...newErrors,
|
||||
};
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* Used to update a validation error.
|
||||
*
|
||||
* @param {string} property The name of the property to update.
|
||||
* @param {Object} newError New validation error object.
|
||||
*/
|
||||
const updateValidationError = useCallback( ( property, newError ) => {
|
||||
updateValidationErrors( ( prevErrors ) => {
|
||||
if ( ! prevErrors.hasOwnProperty( property ) ) {
|
||||
return prevErrors;
|
||||
}
|
||||
const updatedError = {
|
||||
...prevErrors[ property ],
|
||||
...newError,
|
||||
};
|
||||
return isShallowEqual( prevErrors[ property ], updatedError )
|
||||
? prevErrors
|
||||
: {
|
||||
...prevErrors,
|
||||
[ property ]: updatedError,
|
||||
};
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* Given a property name and if an associated error exists, it sets its
|
||||
* `hidden` value to true.
|
||||
*
|
||||
* @param {string} property The name of the property to set the `hidden`
|
||||
* value to true.
|
||||
*/
|
||||
const hideValidationError = useCallback(
|
||||
( property ) =>
|
||||
void updateValidationError( property, {
|
||||
hidden: true,
|
||||
} ),
|
||||
[ updateValidationError ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Given a property name and if an associated error exists, it sets its
|
||||
* `hidden` value to false.
|
||||
*
|
||||
* @param {string} property The name of the property to set the `hidden`
|
||||
* value to false.
|
||||
*/
|
||||
const showValidationError = useCallback(
|
||||
( property ) =>
|
||||
void updateValidationError( property, {
|
||||
hidden: false,
|
||||
} ),
|
||||
[ updateValidationError ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the `hidden` value of all errors to `false`.
|
||||
*/
|
||||
const showAllValidationErrors = useCallback(
|
||||
() =>
|
||||
void updateValidationErrors( ( prevErrors ) => {
|
||||
const updatedErrors = {};
|
||||
|
||||
Object.keys( prevErrors ).forEach( ( property ) => {
|
||||
if ( prevErrors[ property ].hidden ) {
|
||||
updatedErrors[ property ] = {
|
||||
...prevErrors[ property ],
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
} );
|
||||
|
||||
if ( Object.values( updatedErrors ).length === 0 ) {
|
||||
return prevErrors;
|
||||
}
|
||||
|
||||
return {
|
||||
...prevErrors,
|
||||
...updatedErrors,
|
||||
};
|
||||
} ),
|
||||
[]
|
||||
);
|
||||
|
||||
const context = {
|
||||
getValidationError,
|
||||
setValidationErrors,
|
||||
clearValidationError,
|
||||
clearAllValidationErrors,
|
||||
hideValidationError,
|
||||
showValidationError,
|
||||
showAllValidationErrors,
|
||||
hasValidationErrors: Object.keys( validationErrors ).length > 0,
|
||||
getValidationErrorId,
|
||||
};
|
||||
|
||||
return (
|
||||
<ValidationContext.Provider value={ context }>
|
||||
{ children }
|
||||
</ValidationContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * from './context';
|
||||
export * from './components';
|
Reference in New Issue
Block a user