diff --git a/assets/js/base/context/cart-checkout/checkout/actions.js b/assets/js/base/context/cart-checkout/checkout/actions.js new file mode 100644 index 00000000000..335e5f16491 --- /dev/null +++ b/assets/js/base/context/cart-checkout/checkout/actions.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import { TYPES } from './constants'; + +const { + SET_PRISTINE, + SET_PROCESSING, + SET_REDIRECT_URL, + SET_COMPLETE, + SET_HAS_ERROR, + SET_NO_ERROR, + INCREMENT_CALCULATING, + DECREMENT_CALCULATING, +} = TYPES; + +export const actions = { + setPristine: () => ( { + type: SET_PRISTINE, + } ), + setProcessing: () => ( { + type: SET_PROCESSING, + } ), + setRedirectUrl: ( url ) => ( { + type: SET_REDIRECT_URL, + url, + } ), + setComplete: () => ( { + type: SET_COMPLETE, + } ), + setHasError: () => ( { + type: SET_HAS_ERROR, + } ), + clearError: () => ( { + type: SET_NO_ERROR, + } ), + incrementCalculating: () => ( { + type: INCREMENT_CALCULATING, + } ), + decrementCalculating: () => ( { + type: DECREMENT_CALCULATING, + } ), +}; diff --git a/assets/js/base/context/cart-checkout/checkout/constants.js b/assets/js/base/context/cart-checkout/checkout/constants.js new file mode 100644 index 00000000000..7868e6235ec --- /dev/null +++ b/assets/js/base/context/cart-checkout/checkout/constants.js @@ -0,0 +1,31 @@ +/** + * @type {import("@woocommerce/type-defs/checkout").CheckoutStatusConstants} + */ +export const STATUS = { + PRISTINE: 'pristine', + IDLE: 'idle', + CALCULATING: 'calculating', + PROCESSING: 'processing', + COMPLETE: 'complete', +}; + +export const DEFAULT_STATE = { + redirectUrl: '', + status: STATUS.PRISTINE, + // this is used by the reducer to set what status the state will + // take after calculating is complete. + nextStatus: STATUS.IDLE, + hasError: false, + calculatingCount: 0, +}; + +export const TYPES = { + SET_PRISTINE: 'set_pristine', + SET_REDIRECT_URL: 'set_redirect_url', + SET_COMPLETE: 'set_checkout_complete', + SET_PROCESSING: 'set_checkout_is_processing', + SET_HAS_ERROR: 'set_checkout_has_error', + SET_NO_ERROR: 'set_checkout_no_error', + INCREMENT_CALCULATING: 'increment_calculating', + DECREMENT_CALCULATING: 'decrement_calculating', +}; diff --git a/assets/js/base/context/cart-checkout/checkout/event-emit.js b/assets/js/base/context/cart-checkout/checkout/event-emit.js new file mode 100644 index 00000000000..aed614c09a3 --- /dev/null +++ b/assets/js/base/context/cart-checkout/checkout/event-emit.js @@ -0,0 +1,73 @@ +/** + * Internal dependencies + */ +import { actions, reducer, emitEvent } from '../event_emit'; + +const EMIT_TYPES = { + CHECKOUT_COMPLETE_WITH_SUCCESS: 'checkout_complete', + CHECKOUT_COMPLETE_WITH_ERROR: 'checkout_complete_error', + CHECKOUT_PROCESSING: 'checkout_processing', +}; + +/** + * Receives a reducer dispatcher and returns an object with the + * onCheckoutComplete callback registration function for the checkout 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 `onCheckoutComplete` emmitter registration + */ +const emitterSubscribers = ( dispatcher ) => ( { + onCheckoutCompleteSuccess: ( callback ) => { + const action = actions.addEventCallback( + EMIT_TYPES.CHECKOUT_COMPLETE_WITH_SUCCESS, + callback + ); + dispatcher( action ); + return () => { + dispatcher( + actions.removeEventCallback( + EMIT_TYPES.CHECKOUT_COMPLETE_WITH_SUCCESS, + action.id + ) + ); + }; + }, + onCheckoutCompleteError: ( callback ) => { + const action = actions.addEventCallback( + EMIT_TYPES.CHECKOUT_COMPLETE_WITH_ERROR, + callback + ); + dispatcher( action ); + return () => { + dispatcher( + actions.removeEventCallback( + EMIT_TYPES.CHECKOUT_COMPLETE_WITH_ERROR, + action.id + ) + ); + }; + }, + onCheckoutProcessing: ( callback ) => { + const action = actions.addEventCallback( + EMIT_TYPES.CHECKOUT_PROCESSING, + callback + ); + dispatcher( action ); + return () => { + dispatcher( + actions.removeEventCallback( + EMIT_TYPES.CHECKOUT_PROCESSING, + action.id + ) + ); + }; + }, +} ); + +export { EMIT_TYPES, emitterSubscribers, reducer, emitEvent }; diff --git a/assets/js/base/context/cart-checkout/checkout/index.js b/assets/js/base/context/cart-checkout/checkout/index.js new file mode 100644 index 00000000000..8bbfe573f22 --- /dev/null +++ b/assets/js/base/context/cart-checkout/checkout/index.js @@ -0,0 +1,189 @@ +/** + * Internal dependencies + */ +import { PaymentMethodDataProvider } from '../payment-methods'; +import { ShippingMethodDataProvider } from '../shipping'; +import { actions } from './actions'; +import { reducer } from './reducer'; +import { DEFAULT_STATE, STATUS } from './constants'; +import { + EMIT_TYPES, + emitterSubscribers, + emitEvent, + reducer as emitReducer, +} from './event-emit'; + +/** + * External dependencies + */ +import { + createContext, + useContext, + useReducer, + useRef, + useMemo, + useEffect, +} from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * @typedef {import('@woocommerce/type-defs/checkout').CheckoutDispatchActions} CheckoutDispatchActions + * @typedef {import('@woocommerce/type-defs/contexts').CheckoutDataContext} CheckoutDataContext + */ + +const CheckoutContext = createContext( { + submitLabel: '', + onSubmit: () => void null, + isComplete: false, + isIdle: false, + isCalculating: false, + isProcessing: false, + hasError: false, + redirectUrl: '', + onCheckoutCompleteSuccess: () => void null, + onCheckoutCompleteError: () => void null, + onCheckoutProcessing: () => void null, + dispatchActions: { + resetCheckout: () => void null, + setRedirectUrl: () => void null, + setHasError: () => void null, + clearError: () => void null, + incrementCalculating: () => void null, + decrementCalculating: () => void null, + }, +} ); + +/** + * @return {CheckoutDataContext} Returns the checkout data context value + */ +export const useCheckoutContext = () => { + return useContext( CheckoutContext ); +}; + +// create context provider for coupons + +// todo have a local state (reducer) for validation error objects (see checkout processing event). +// this will need exposed for checkout/cart steps to read from when checkout.hasError to be able +// to show what fields need addressed. + +// checkout and cart provider context will be different context but implement +// the above (in terms of finalization.). For example cart context will redirect +// checkout for finalization. Checkout context handles pinging the server for +// processing checkout, validation of existing fields, and redirecting on +// success. + +export const CheckoutProvider = ( { + children, + activePaymentMethod: initialActivePaymentMethod, + redirectUrl, + submitLabel = __( 'Place Order', 'woo-gutenberg-product-block' ), +} ) => { + // 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 [ observers, subscriber ] = useReducer( emitReducer, {} ); + const currentObservers = useRef( observers ); + // set observers on ref so it's always current + useEffect( () => { + currentObservers.current = observers; + }, [ observers ] ); + const onCheckoutCompleteSuccess = emitterSubscribers( subscriber ) + .onCheckoutCompleteSuccess; + const onCheckoutCompleteError = emitterSubscribers( subscriber ) + .onCheckoutCompleteError; + const onCheckoutProcessing = emitterSubscribers( subscriber ) + .onCheckoutProcessing; + + /** + * @type {CheckoutDispatchActions} + */ + const dispatchActions = useMemo( + () => ( { + resetCheckout: () => void dispatch( actions.setPristine() ), + setRedirectUrl: ( url ) => + void dispatch( actions.setRedirectUrl( url ) ), + setHasError: () => void dispatch( actions.setHasError() ), + clearError: () => void dispatch( actions.clearError() ), + incrementCalculating: () => + void dispatch( actions.incrementCalculating() ), + decrementCalculating: () => + void dispatch( actions.decrementCalculating() ), + } ), + [] + ); + + // emit events + useEffect( () => { + const status = checkoutState.status; + if ( status === STATUS.PROCESSING ) { + const error = emitEvent( + currentObservers.current, + EMIT_TYPES.CHECKOUT_PROCESSING, + {} + ); + //@todo bail if error object detected (see flow). + //Fire off checkoutFail event, and then reset checkout + //status to idle (with hasError flag) - then return from this hook. + // Finally after the event subscribers have processed, do the + // checkout submit sending the order to the server for processing + // and followup on errors from it. + if ( error ) { + dispatchActions.setHasError(); + } + dispatch( actions.setComplete() ); + } + if ( checkoutState.isComplete ) { + if ( checkoutState.hasError ) { + emitEvent( + currentObservers.current, + EMIT_TYPES.CHECKOUT_COMPLETE_WITH_ERROR, + {} + ); + } else { + emitEvent( + currentObservers.current, + EMIT_TYPES.CHECKOUT_COMPLETE_WITH_SUCCESS, + {} + ); + } + // all observers have done their thing so let's redirect (if no error) + if ( ! checkoutState.hasError ) { + window.location = checkoutState.redirectUrl; + } + } + }, [ checkoutState.status, checkoutState.hasError ] ); + + const onSubmit = () => { + dispatch( actions.setProcessing() ); + }; + + /** + * @type {CheckoutDataContext} + */ + const checkoutData = { + submitLabel, + onSubmit, + isComplete: checkoutState.status === STATUS.COMPLETE, + isIdle: checkoutState.status === STATUS.IDLE, + isCalculating: checkoutState.status === STATUS.CALCULATING, + isProcessing: checkoutState.status === STATUS.PROCESSING, + hasError: checkoutState.hasError, + redirectUrl: checkoutState.redirectUrl, + onCheckoutCompleteSuccess, + onCheckoutCompleteError, + onCheckoutProcessing, + dispatchActions, + }; + return ( + + + + { children } + + + + ); +}; diff --git a/assets/js/base/context/cart-checkout/checkout/reducer.js b/assets/js/base/context/cart-checkout/checkout/reducer.js new file mode 100644 index 00000000000..b50c32a0a8f --- /dev/null +++ b/assets/js/base/context/cart-checkout/checkout/reducer.js @@ -0,0 +1,134 @@ +/** + * Internal dependencies + */ +import { TYPES, DEFAULT_STATE, STATUS } from './constants'; + +const { + SET_PRISTINE, + SET_PROCESSING, + SET_REDIRECT_URL, + SET_COMPLETE, + SET_HAS_ERROR, + SET_NO_ERROR, + INCREMENT_CALCULATING, + DECREMENT_CALCULATING, +} = TYPES; + +const { PRISTINE, IDLE, CALCULATING, PROCESSING, COMPLETE } = STATUS; + +/** + * Reducer for the checkout state + * + * @param {Object} state Current state. + * @param {Object} action Incoming action object. + */ +export const reducer = ( state = DEFAULT_STATE, { url, type } ) => { + let status, nextStatus, newState; + switch ( type ) { + case SET_PRISTINE: + newState = DEFAULT_STATE; + break; + case SET_REDIRECT_URL: + newState = + url !== state.url + ? { + ...state, + redirectUrl: url, + } + : state; + break; + case SET_COMPLETE: + status = COMPLETE; + nextStatus = state.status; + if ( state.status === CALCULATING ) { + status = CALCULATING; + nextStatus = COMPLETE; + } + newState = + status !== state.status + ? { + ...state, + status, + nextStatus, + } + : state; + break; + case SET_PROCESSING: + status = PROCESSING; + nextStatus = state.status; + if ( state.status === CALCULATING ) { + status = CALCULATING; + nextStatus = PROCESSING; + } + newState = + status !== state.status + ? { + ...state, + status, + nextStatus, + hasError: false, + } + : state; + // clear any error state. + newState = + newState.hasError === false + ? newState + : { ...newState, hasError: false }; + break; + case SET_HAS_ERROR: + newState = state.hasError + ? state + : { + ...state, + hasError: true, + }; + newState = + state.status === PROCESSING + ? { + ...newState, + status: IDLE, + } + : newState; + break; + case SET_NO_ERROR: + newState = state.hasError + ? { + ...state, + hasError: false, + } + : state; + break; + case INCREMENT_CALCULATING: + newState = { + ...state, + status: CALCULATING, + nextStatus: state.status, + calculatingCount: state.calculatingCount + 1, + }; + break; + case DECREMENT_CALCULATING: + status = CALCULATING; + nextStatus = state.status; + if ( state.calculatingCount <= 1 ) { + status = state.nextStatus; + nextStatus = IDLE; + } + newState = { + ...state, + status, + nextStatus, + calculatingCount: Math.max( 0, state.calculatingCount - 1 ), + }; + 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; +}; diff --git a/assets/js/base/context/cart-checkout/event_emit/index.js b/assets/js/base/context/cart-checkout/event_emit/index.js new file mode 100644 index 00000000000..38f5723ee1a --- /dev/null +++ b/assets/js/base/context/cart-checkout/event_emit/index.js @@ -0,0 +1 @@ +export * from './reducer'; diff --git a/assets/js/base/context/cart-checkout/event_emit/reducer.js b/assets/js/base/context/cart-checkout/event_emit/reducer.js new file mode 100644 index 00000000000..f318bb74f6f --- /dev/null +++ b/assets/js/base/context/cart-checkout/event_emit/reducer.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import { omit, uniqueId } from 'lodash'; + +export const TYPES = { + ADD_EVENT_CALLBACK: 'add_event_callback', + REMOVE_EVENT_CALLBACK: 'remove_event_callback', +}; + +export const actions = { + addEventCallback: ( eventType, callback ) => { + return { + id: uniqueId(), + type: TYPES.ADD_EVENT_CALLBACK, + eventType, + callback, + }; + }, + removeEventCallback: ( eventType, id ) => { + return { + id, + type: TYPES.REMOVE_EVENT_CALLBACK, + eventType, + }; + }, +}; + +/** + * Emits events on registered observers for the provided type and passes along + * the provided data. + * + * @param {Object} observers The registered observers to omit to. + * @param {string} eventType The event type being emitted. + * @param {*} data Data passed along to the observer when it is + * invoked. + */ +export const emitEvent = ( observers, eventType, data ) => { + const observersByType = observers[ eventType ] || []; + let hasError = false; + observersByType.forEach( ( observer ) => { + if ( typeof observer === 'function' ) { + hasError = !! observer( data, hasError ); + } + } ); + return hasError; +}; + +/** + * Handles actions for emmitters + * + * @param {Object} state Current state. + * @param {Object} action Incoming action object + */ +export const reducer = ( state = {}, { type, eventType, id, callback } ) => { + switch ( type ) { + case TYPES.ADD_EVENT_CALLBACK: + return { + ...state, + [ eventType ]: { + ...state[ eventType ], + [ id ]: callback, + }, + }; + case TYPES.REMOVE_EVENT_CALLBACK: + return { + ...state, + [ eventType ]: omit( state[ eventType ], [ id ] ), + }; + } + return state; +}; diff --git a/assets/js/base/context/cart-checkout/index.js b/assets/js/base/context/cart-checkout/index.js new file mode 100644 index 00000000000..f3a2609c7bd --- /dev/null +++ b/assets/js/base/context/cart-checkout/index.js @@ -0,0 +1,3 @@ +export * from './payment-methods'; +export * from './shipping'; +export * from './checkout'; diff --git a/assets/js/base/context/cart-checkout/payment-methods/actions.js b/assets/js/base/context/cart-checkout/payment-methods/actions.js new file mode 100644 index 00000000000..9ee80930427 --- /dev/null +++ b/assets/js/base/context/cart-checkout/payment-methods/actions.js @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import { STATUS } from './constants'; +const { ERROR, FAILED, SUCCESS } = STATUS; +const SET_BILLING_DATA = 'set_billing_data'; + +export const statusOnly = ( type ) => ( { type } ); +export const error = ( errorMessage ) => ( { + type: ERROR, + errorMessage, +} ); +export const failed = ( { + errorMessage, + billingData, + paymentMethodData, +} ) => ( { + type: FAILED, + errorMessage, + billingData, + paymentMethodData, +} ); +export const success = ( { billingData, paymentMethodData } ) => ( { + type: SUCCESS, + billingData, + paymentMethodData, +} ); + +export const setBillingData = ( billingData ) => ( { + type: SET_BILLING_DATA, + billingData, +} ); diff --git a/assets/js/base/context/cart-checkout/payment-methods/constants.js b/assets/js/base/context/cart-checkout/payment-methods/constants.js new file mode 100644 index 00000000000..b09e3f95b84 --- /dev/null +++ b/assets/js/base/context/cart-checkout/payment-methods/constants.js @@ -0,0 +1,61 @@ +/** + * @typedef {import('@woocommerce/type-defs/cart').CartBillingAddress} CartBillingAddress + * @typedef {import('@woocommerce/type-defs/contexts').PaymentMethodDataContext} PaymentMethodDataContext + */ + +export const STATUS = { + PRISTINE: 'pristine', + STARTED: 'started', + PROCESSING: 'processing', + ERROR: 'has_error', + FAILED: 'failed', + SUCCESS: 'success', + COMPLETE: 'complete', +}; + +/** + * @type {CartBillingAddress} + */ +const DEFAULT_BILLING_DATA = { + first_name: '', + last_name: '', + company: '', + email: '', + phone: '', + country: '', + address_1: '', + address_2: '', + city: '', + state: '', + postcode: '', +}; + +/** + * @todo do typedefs for the payment event state. + */ + +export const DEFAULT_PAYMENT_DATA = { + currentStatus: STATUS.PRISTINE, + billingData: DEFAULT_BILLING_DATA, + paymentMethodData: { + payment_method: '', + // arbitrary data the payment method + // wants to pass along for payment + // processing server side. + }, + errorMessage: '', +}; + +/** + * @type {PaymentMethodDataContext} + */ +export const DEFAULT_PAYMENT_METHOD_DATA = { + setPaymentStatus: () => null, + currentStatus: STATUS.PRISTINE, + paymentStatuses: STATUS, + billingData: DEFAULT_BILLING_DATA, + paymentMethodData: {}, + errorMessage: '', + activePaymentMethod: '', + setActivePaymentMethod: () => null, +}; diff --git a/assets/js/base/context/cart-checkout/payment-methods/index.js b/assets/js/base/context/cart-checkout/payment-methods/index.js new file mode 100644 index 00000000000..a6db02d53f9 --- /dev/null +++ b/assets/js/base/context/cart-checkout/payment-methods/index.js @@ -0,0 +1,4 @@ +export { + PaymentMethodDataProvider, + usePaymentMethodDataContext, +} from './payment-method-data-context'; diff --git a/assets/js/base/context/cart-checkout/payment-methods/payment-method-data-context.js b/assets/js/base/context/cart-checkout/payment-methods/payment-method-data-context.js new file mode 100644 index 00000000000..39d3a92bc56 --- /dev/null +++ b/assets/js/base/context/cart-checkout/payment-methods/payment-method-data-context.js @@ -0,0 +1,141 @@ +/** + * Internal dependencies + */ +import { + STATUS, + DEFAULT_PAYMENT_DATA, + DEFAULT_PAYMENT_METHOD_DATA, +} from './constants'; +import reducer from './reducer'; +import { + statusOnly, + error, + failed, + success, + setBillingData as setBilling, +} from './actions'; + +/** + * External dependencies + */ +import { + createContext, + useContext, + useState, + useReducer, +} from '@wordpress/element'; + +/** + * @typedef {import('@woocommerce/type-defs/contexts').PaymentMethodDataContext} PaymentMethodDataContext + * @typedef {import('@woocommerce/type-defs/contexts').PaymentStatusDispatch} PaymentStatusDispatch + * @typedef {import('@woocommerce/type-defs/contexts').PaymentStatusDispatchers} PaymentStatusDispatchers + * @typedef {import('@woocommerce/type-defs/cart').CartBillingAddress} CartBillingAddress + */ + +const { + STARTED, + PROCESSING, + COMPLETE, + PRISTINE, + ERROR, + FAILED, + SUCCESS, +} = STATUS; + +const PaymentMethodDataContext = createContext( DEFAULT_PAYMENT_METHOD_DATA ); + +/** + * @return {PaymentMethodDataContext} The data and functions exposed by the + * payment method context provider. + */ +export const usePaymentMethodDataContext = () => { + return useContext( PaymentMethodDataContext ); +}; + +// @todo implement billing (and payment method) data saved to the cart data +// store. That way checkout can obtain it for processing the order. + +export const PaymentMethodDataProvider = ( { + children, + activePaymentMethod: initialActivePaymentMethod, +} ) => { + // dispatcher from checkout for updating it's status + const [ activePaymentMethod, setActive ] = useState( + initialActivePaymentMethod + ); + const [ paymentStatus, dispatch ] = useReducer( + reducer, + DEFAULT_PAYMENT_DATA + ); + const setActivePaymentMethod = ( paymentMethodSlug ) => { + setActive( paymentMethodSlug ); + dispatch( statusOnly( PRISTINE ) ); + }; + + /** + * @type {PaymentStatusDispatch} + */ + const setPaymentStatus = () => ( { + started: () => dispatch( statusOnly( STARTED ) ), + processing: () => dispatch( statusOnly( PROCESSING ) ), + completed: () => dispatch( statusOnly( COMPLETE ) ), + /** + * @param {string} errorMessage An error message + */ + error: ( errorMessage ) => dispatch( error( errorMessage ) ), + /** + * @param {string} errorMessage An error message + * @param {CartBillingAddress} billingData The billing data accompanying the payment method + * @param {Object} paymentMethodData Arbitrary payment method data to accompany the checkout submission. + */ + failed: ( errorMessage, billingData, paymentMethodData ) => + dispatch( + failed( { + errorMessage, + billingData, + paymentMethodData, + } ) + ), + /** + * @param {CartBillingAddress} billingData The billing data accompanying the payment method. + * @param {Object} paymentMethodData Arbitrary payment method data to accompany the checkout. + */ + success: ( billingData, paymentMethodData ) => + dispatch( + success( { + billingData, + paymentMethodData, + } ) + ), + } ); + const setBillingData = ( billingData ) => + dispatch( setBilling( billingData ) ); + const currentStatus = { + isPristine: paymentStatus === PRISTINE, + isStarted: paymentStatus === STARTED, + isProcessing: paymentStatus === PROCESSING, + isFinished: [ ERROR, FAILED, SUCCESS ].includes( paymentStatus ), + hasError: paymentStatus === ERROR, + hasFailed: paymentStatus === FAILED, + isSuccessful: paymentStatus === SUCCESS, + }; + /** + * @type {PaymentMethodDataContext} + */ + const paymentData = { + setPaymentStatus, + currentStatus, + paymentStatuses: STATUS, + billingData: paymentStatus.billingData, + paymentMethodData: paymentStatus.paymentMethodData, + errorMessage: paymentStatus.errorMessage, + activePaymentMethod, + setActivePaymentMethod, + setBillingData, + }; + return ( + + { children } + + ); +}; diff --git a/assets/js/base/context/cart-checkout/payment-methods/reducer.js b/assets/js/base/context/cart-checkout/payment-methods/reducer.js new file mode 100644 index 00000000000..1c8915aae74 --- /dev/null +++ b/assets/js/base/context/cart-checkout/payment-methods/reducer.js @@ -0,0 +1,79 @@ +/** + * Internal dependencies + */ +import { STATUS, DEFAULT_PAYMENT_DATA } from './constants'; +const { + STARTED, + ERROR, + FAILED, + SUCCESS, + PROCESSING, + PRISTINE, + COMPLETE, +} = STATUS; + +const SET_BILLING_DATA = 'set_billing_data'; + +/** + * Reducer for payment data state + * + * @param {Object} state Current state. + * @param {Object} action Current action. + */ +const reducer = ( + state = DEFAULT_PAYMENT_DATA, + { type, billingData, paymentMethodData, errorMessage } +) => { + switch ( type ) { + case STARTED: + return { + ...state, + currentStatus: STARTED, + }; + case ERROR: + return { + ...state, + currentStatus: ERROR, + errorMessage: errorMessage || state.errorMessage, + }; + case FAILED: + return { + ...state, + currentStatus: FAILED, + billingData: billingData || state.billingData, + paymentMethodData: paymentMethodData || state.paymentMethodData, + errorMessage: errorMessage || state.errorMessage, + }; + case SUCCESS: + return { + ...state, + currentStatus: SUCCESS, + billingData: billingData || state.billingData, + paymentMethodData: paymentMethodData || state.paymentMethodData, + }; + case PROCESSING: + return { + ...state, + currentStatus: PROCESSING, + }; + case COMPLETE: + return { + ...state, + currentStatus: COMPLETE, + }; + + case PRISTINE: + return { + ...DEFAULT_PAYMENT_DATA, + currentStatus: PRISTINE, + }; + case SET_BILLING_DATA: + return { + ...state, + billingData, + }; + } + return state; +}; + +export default reducer; diff --git a/assets/js/base/context/cart-checkout/shipping/constants.js b/assets/js/base/context/cart-checkout/shipping/constants.js new file mode 100644 index 00000000000..c51089e5db8 --- /dev/null +++ b/assets/js/base/context/cart-checkout/shipping/constants.js @@ -0,0 +1,42 @@ +/** + * @type {import('@woocommerce/type-defs/contexts').ShippingErrorTypes} + */ +export const ERROR_TYPES = { + NONE: 'none', + INVALID_ADDRESS: 'invalid_address', + UNKNOWN: 'unknown_error', +}; + +/** + * @type {import('@woocommerce/type-defs/cart').CartShippingAddress} + */ +export const DEFAULT_SHIPPING_ADDRESS = { + first_name: '', + last_name: '', + company: '', + address_1: '', + address_2: '', + city: '', + state: '', + postcode: '', + country: '', +}; + +/** + * @type {import('@woocommerce/type-defs/contexts').ShippingMethodDataContext} + */ +export const DEFAULT_SHIPPING_CONTEXT_DATA = { + shippingErrorStatus: ERROR_TYPES.NONE, + dispatchErrorStatus: () => null, + shippingErrorTypes: ERROR_TYPES, + shippingRates: [], + setShippingRates: () => null, + shippingRatesLoading: false, + selectedRates: [], + setSelectedRates: () => null, + shippingAddress: DEFAULT_SHIPPING_ADDRESS, + setShippingAddress: () => null, + onShippingRateSuccess: () => null, + onShippingRateFail: () => null, + needsShipping: false, +}; diff --git a/assets/js/base/context/cart-checkout/shipping/event-emit.js b/assets/js/base/context/cart-checkout/shipping/event-emit.js new file mode 100644 index 00000000000..e65c1c1143d --- /dev/null +++ b/assets/js/base/context/cart-checkout/shipping/event-emit.js @@ -0,0 +1,82 @@ +/** + * Internal dependencies + */ +import { actions, 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 emitterSubscribers = ( dispatcher ) => ( { + onSuccess: ( callback ) => { + const action = actions.addEventCallback( + EMIT_TYPES.SHIPPING_RATES_SUCCESS, + callback + ); + dispatcher( action ); + return () => { + dispatcher( + actions.removeEventCallback( + EMIT_TYPES.SHIPPING_RATES_SUCCESS, + action.id + ) + ); + }; + }, + onFail: ( callback ) => { + const action = actions.removeEventCallback( + EMIT_TYPES.SHIPPING_RATES_FAIL, + callback + ); + dispatcher( action ); + return () => { + dispatcher( EMIT_TYPES.SHIPPING_RATES_FAIL, action.id ); + }; + }, + onSelectSuccess: ( callback ) => { + const action = actions.addEventCallback( + EMIT_TYPES.SHIPPING_RATE_SELECT_SUCCESS, + callback + ); + dispatcher( action ); + return () => { + dispatcher( + actions.removeEventCallback( + EMIT_TYPES.SHIPPING_RATE_SELECT_SUCCESS, + action.id + ) + ); + }; + }, + onSelectFail: ( callback ) => { + const action = actions.addEventCallback( + EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL, + callback + ); + dispatcher( action ); + return () => { + dispatcher( + actions.removeEventCallback( + EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL, + action.id + ) + ); + }; + }, +} ); + +export { EMIT_TYPES, emitterSubscribers, reducer, emitEvent }; diff --git a/assets/js/base/context/cart-checkout/shipping/index.js b/assets/js/base/context/cart-checkout/shipping/index.js new file mode 100644 index 00000000000..1cfff9bbf95 --- /dev/null +++ b/assets/js/base/context/cart-checkout/shipping/index.js @@ -0,0 +1,4 @@ +export { + useShippingMethodDataContext, + ShippingMethodDataProvider, +} from './shipping-data-context'; diff --git a/assets/js/base/context/cart-checkout/shipping/shipping-data-context.js b/assets/js/base/context/cart-checkout/shipping/shipping-data-context.js new file mode 100644 index 00000000000..3973c636f45 --- /dev/null +++ b/assets/js/base/context/cart-checkout/shipping/shipping-data-context.js @@ -0,0 +1,208 @@ +/** + * Internal dependencies + */ +import { + ERROR_TYPES, + DEFAULT_SHIPPING_ADDRESS, + DEFAULT_SHIPPING_CONTEXT_DATA, +} from './constants'; +import { + EMIT_TYPES, + emitterSubscribers, + reducer as emitReducer, + emitEvent, +} from './event-emit'; + +/** + * External dependencies + */ +import { + createContext, + useContext, + useState, + useReducer, + useEffect, + useCallback, + useRef, +} from '@wordpress/element'; +import { useShippingRates, useStoreCart } from '@woocommerce/base-hooks'; +import { useCheckoutContext } from '@woocommerce/base-context'; + +/** + * @typedef {import('@woocommerce/type-defs/contexts').ShippingMethodDataContext} ShippingMethodDataContext + */ + +const { NONE, INVALID_ADDRESS, UNKNOWN } = ERROR_TYPES; + +const setStatusAction = ( status ) => ( { + type: status, +} ); + +/** + * Reducer for shipping status state + * + * @param {string} state The current status. + * @param {Object} action The incoming action. + */ +const errorStatusReducer = ( state, { type } ) => { + if ( Object.keys( ERROR_TYPES ).includes( type ) ) { + return state; + } + return type; +}; + +const ShippingMethodDataContext = createContext( + DEFAULT_SHIPPING_CONTEXT_DATA +); + +/** + * @return {ShippingMethodDataContext} Returns data and functions related to + * shipping methods. + */ +export const useShippingMethodDataContext = () => { + return useContext( ShippingMethodDataContext ); +}; + +/** + * Calculating component for shipping rates. + * + * @param {Object} props Incoming props for the component + */ +const ShippingRateCalculation = ( { address, onChange } ) => { + // @todo, it'd be handy if we could pass through callbacks that are fired on + // successful rate retrieval vs callbacks fired on unsuccessful rates + // retrieval. That way emitters could just be fed into the hook directly. + const { shippingRates, shippingRatesLoading } = useShippingRates( address ); + useEffect( () => { + onChange( shippingRates, shippingRatesLoading ); + }, [ shippingRates, shippingRatesLoading ] ); + return null; +}; + +// @todo wire up checkout context needed here (like isCalculating etc) +// @todo useShippingRates needs to be wired up with error handling so we know +// when an invalid address is provided for it (because payment methods might +// provide an invalid address) +export const ShippingMethodDataProvider = ( { children } ) => { + const { dispatchActions } = useCheckoutContext(); + const { cartNeedsShipping: needsShipping } = useStoreCart(); + const [ shippingErrorStatus, dispatchErrorStatus ] = useReducer( + errorStatusReducer, + NONE + ); + const [ observers, subscriber ] = useReducer( emitReducer, {} ); + const [ currentShippingAddress, setAddressState ] = useState( + DEFAULT_SHIPPING_ADDRESS + ); + const currentObservers = useRef( observers ); + const [ shippingOptions, setShippingOptions ] = useState( [] ); + const [ shippingOptionsLoading, setShippingOptionsLoading ] = useState( + false + ); + // @todo, this will need wired up to persistence (useSelectedRates?) which + // will be setup similar to `useShippingRates` (or maybe in the same hook?) + const [ selectedRates, setSelectedRates ] = useState( [] ); + const setShippingAddress = useCallback( ( address ) => { + setAddressState( ( prevAddress ) => ( { ...prevAddress, address } ) ); + }, [] ); + const onShippingRateSuccess = emitterSubscribers( subscriber ).onSuccess; + const onShippingRateFail = emitterSubscribers( subscriber ).onFail; + const onShippingRateSelectSuccess = emitterSubscribers( subscriber ) + .onSelectSuccess; + const onShippingRateSelectFail = emitterSubscribers( subscriber ) + .onShippingRateSelectFail; + + // set observers on ref so it's always current + useEffect( () => { + currentObservers.current = observers; + }, [ observers ] ); + + // increment/decrement checkout calculating counts when shipping is loading + useEffect( () => { + if ( shippingOptionsLoading ) { + dispatchActions.incrementCalculating(); + } else { + dispatchActions.decrementCalculating(); + } + }, [ shippingOptionsLoading ] ); + + // @todo need to add error handling to useShippingRates so that errors are + // exposed. We need error types exposed by the error handling as well. + // also we need to add similar logic for selection/unselection of rates and + // emit the events (see emit events block) + const onRateChange = ( shippingRates, shippingRatesLoading, error ) => { + setShippingOptions( shippingRates ); + setShippingOptionsLoading( shippingRatesLoading ); + if ( ! shippingRatesLoading && error && error.type ) { + // @todo this type might need normalizing to something recognized by + // the ERROR_TYPE constants. + dispatchErrorStatus( setStatusAction( error.type ) ); + } else if ( ! shippingRatesLoading && shippingRates ) { + dispatchErrorStatus( NONE ); + } + }; + + const currentErrorStatus = { + isPristine: shippingErrorStatus === NONE, + isValid: shippingErrorStatus === NONE, + hasInvalidAddress: shippingErrorStatus === INVALID_ADDRESS, + hasError: + shippingErrorStatus === UNKNOWN || + shippingErrorStatus === INVALID_ADDRESS, + }; + + // emit events + // @todo add emitters for shipping rate selection. + useEffect( () => { + if ( ! shippingOptionsLoading && currentErrorStatus.hasError ) { + emitEvent( + currentObservers.current, + EMIT_TYPES.SHIPPING_RATES_SUCCESS, + shippingErrorStatus + ); + } else if ( ! shippingOptionsLoading && shippingOptions ) { + emitEvent( + currentObservers.current, + EMIT_TYPES.SHIPPING_RATES_SUCCESS, + shippingOptions + ); + } + }, [ + shippingOptions, + shippingOptionsLoading, + currentErrorStatus, + shippingErrorStatus, + ] ); + + /** + * @type {ShippingMethodDataContext} + */ + const shippingMethodData = { + shippingErrorStatus, + dispatchErrorStatus, + shippingErrorTypes: ERROR_TYPES, + shippingRates: shippingOptions, + setShippingRates: setShippingOptions, + shippingRatesLoading: shippingOptionsLoading, + selectedRates, + setSelectedRates, + shippingAddress: currentShippingAddress, + setShippingAddress, + onShippingRateSuccess, + onShippingRateFail, + onShippingRateSelectSuccess, + onShippingRateSelectFail, + needsShipping, + }; + return ( + <> + + + { children } + + + ); +}; diff --git a/assets/js/base/context/checkout-context.js b/assets/js/base/context/checkout-context.js deleted file mode 100644 index b0bb3aff1fb..00000000000 --- a/assets/js/base/context/checkout-context.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * External dependencies - */ -import { - createContext, - useContext, - useState, - useMemo, -} from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; - -const CheckoutContext = createContext( {} ); - -export const useCheckoutContext = () => { - return useContext( CheckoutContext ); -}; - -const CheckoutProvider = ( { - children, - initialActivePaymentMethod, - placeOrderLabel = __( 'Place Order', 'woo-gutenberg-product-block' ), -} ) => { - const [ successRedirectUrl, setSuccessRedirectUrl ] = useState( '' ); - const [ failureRedirectUrl, setFailureRedirectUrl ] = useState( '' ); - const [ isCheckoutComplete, setIsCheckoutComplete ] = useState( false ); - const [ checkoutHasError, setCheckoutHasError ] = useState( false ); - const [ notices, updateNotices ] = useState( [] ); - const [ isCalculating, setIsCalculating ] = useState( false ); - const [ activePaymentMethod, setActivePaymentMethod ] = useState( - initialActivePaymentMethod - ); - const contextValue = useMemo( () => { - return { - successRedirectUrl, - setSuccessRedirectUrl, - failureRedirectUrl, - setFailureRedirectUrl, - isCheckoutComplete, - setIsCheckoutComplete, - checkoutHasError, - setCheckoutHasError, - isCalculating, - setIsCalculating, - notices, - updateNotices, - activePaymentMethod, - setActivePaymentMethod, - placeOrderLabel, - }; - }, [ - successRedirectUrl, - failureRedirectUrl, - isCheckoutComplete, - isCalculating, - checkoutHasError, - activePaymentMethod, - placeOrderLabel, - notices, - ] ); - return ( - - { children } - - ); -}; - -export default CheckoutProvider; diff --git a/assets/js/base/context/index.js b/assets/js/base/context/index.js new file mode 100644 index 00000000000..e5f1e8dbcf6 --- /dev/null +++ b/assets/js/base/context/index.js @@ -0,0 +1,4 @@ +export * from './cart-checkout'; +export * from './inner-block-configuration-context'; +export * from './product-layout-context'; +export * from './query-state-context';