woocommerce/plugins/woocommerce-blocks/assets/js/data/cart/thunks.ts

414 lines
11 KiB
TypeScript

/**
* External dependencies
*/
import {
Cart,
CartResponse,
ApiErrorResponse,
isApiErrorResponse,
ExtensionCartUpdateArgs,
CartShippingPackageShippingRate,
CartShippingRate,
BillingAddressShippingAddress,
} from '@woocommerce/types';
import {
camelCaseKeys,
triggerAddedToCartEvent,
triggerAddingToCartEvent,
} from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { notifyQuantityChanges } from './notify-quantity-changes';
import { notifyCartErrors } from './notify-errors';
import { CartDispatchFromMap, CartSelectFromMap } from './index';
import { apiFetchWithHeaders } from '../shared-controls';
/**
* A thunk used in updating the store with the cart items retrieved from a request. This also notifies the shopper
* of any unexpected quantity changes occurred.
*/
export const receiveCart =
( response: Partial< CartResponse > ) =>
( {
dispatch,
select,
}: {
dispatch: CartDispatchFromMap;
select: CartSelectFromMap;
} ) => {
const newCart = camelCaseKeys( response ) as unknown as Cart;
const oldCart = select.getCartData();
notifyCartErrors( newCart.errors, oldCart.errors );
notifyQuantityChanges( {
oldCart,
newCart,
cartItemsPendingQuantity: select.getItemsPendingQuantityUpdate(),
cartItemsPendingDelete: select.getItemsPendingDelete(),
} );
dispatch.setCartData( newCart );
dispatch.setErrorData( null );
};
/**
* Updates the store with the provided cart but omits the customer addresses.
*
* This is useful when currently editing address information to prevent it being overwritten from the server.
*
* @param {CartResponse} response
*/
export const receiveCartContents =
( response: Partial< CartResponse > ) =>
( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { shipping_address, billing_address, ...cartWithoutAddress } =
response;
dispatch.receiveCart( cartWithoutAddress );
};
/**
* A thunk used in updating the store with cart errors retrieved from a request.
*/
export const receiveError =
( response: ApiErrorResponse | null = null ) =>
( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
if ( ! isApiErrorResponse( response ) ) {
return;
}
if ( response.data?.cart ) {
dispatch.receiveCart( response?.data?.cart );
}
dispatch.setErrorData( response );
};
/**
* POSTs to the /cart/extensions endpoint with the data supplied by the extension.
*
* @param {Object} args The data to be posted to the endpoint
*/
export const applyExtensionCartUpdate =
( args: ExtensionCartUpdateArgs ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
const { response } = await apiFetchWithHeaders< {
response: CartResponse;
} >( {
path: '/wc/store/v1/cart/extensions',
method: 'POST',
data: { namespace: args.namespace, data: args.data },
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
return Promise.reject( error );
}
};
/**
* Applies a coupon code and either invalidates caches, or receives an error if
* the coupon cannot be applied.
*
* @param {string} couponCode The coupon code to apply to the cart.
* @throws Will throw an error if there is an API problem.
*/
export const applyCoupon =
( couponCode: string ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
dispatch.receiveApplyingCoupon( couponCode );
const { response } = await apiFetchWithHeaders< {
response: CartResponse;
} >( {
path: '/wc/store/v1/cart/apply-coupon',
method: 'POST',
data: {
code: couponCode,
},
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
return Promise.reject( error );
} finally {
dispatch.receiveApplyingCoupon( '' );
}
};
/**
* Removes a coupon code and either invalidates caches, or receives an error if
* the coupon cannot be removed.
*
* @param {string} couponCode The coupon code to remove from the cart.
* @throws Will throw an error if there is an API problem.
*/
export const removeCoupon =
( couponCode: string ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
dispatch.receiveRemovingCoupon( couponCode );
const { response } = await apiFetchWithHeaders< {
response: CartResponse;
} >( {
path: '/wc/store/v1/cart/remove-coupon',
method: 'POST',
data: {
code: couponCode,
},
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
return Promise.reject( error );
} finally {
dispatch.receiveRemovingCoupon( '' );
}
};
/**
* Adds an item to the cart:
* - Calls API to add item.
* - If successful, yields action to add item from store.
* - If error, yields action to store error.
*
* @param {number} productId Product ID to add to cart.
* @param {number} [quantity=1] Number of product ID being added to cart.
* @throws Will throw an error if there is an API problem.
*/
export const addItemToCart =
( productId: number, quantity = 1 ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
triggerAddingToCartEvent();
const { response } = await apiFetchWithHeaders< {
response: CartResponse;
} >( {
path: `/wc/store/v1/cart/add-item`,
method: 'POST',
data: {
id: productId,
quantity,
},
cache: 'no-store',
} );
dispatch.receiveCart( response );
triggerAddedToCartEvent( { preserveCartData: true } );
return response;
} catch ( error ) {
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
return Promise.reject( error );
}
};
/**
* Removes specified item from the cart:
* - Calls API to remove item.
* - If successful, yields action to remove item from store.
* - If error, yields action to store error.
* - Sets cart item as pending while API request is in progress.
*
* @param {string} cartItemKey Cart item being updated.
*/
export const removeItemFromCart =
( cartItemKey: string ) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
dispatch.itemIsPendingDelete( cartItemKey );
const { response } = await apiFetchWithHeaders< {
response: CartResponse;
} >( {
path: `/wc/store/v1/cart/remove-item`,
data: {
key: cartItemKey,
},
method: 'POST',
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
return Promise.reject( error );
} finally {
dispatch.itemIsPendingDelete( cartItemKey, false );
}
};
/**
* Persists a quantity change the for specified cart item:
* - Calls API to set quantity.
* - If successful, yields action to update store.
* - If error, yields action to store error.
*
* @param {string} cartItemKey Cart item being updated.
* @param {number} quantity Specified (new) quantity.
*/
export const changeCartItemQuantity =
(
cartItemKey: string,
quantity: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- unclear how to represent multiple different yields as type
) =>
async ( {
dispatch,
select,
}: {
dispatch: CartDispatchFromMap;
select: CartSelectFromMap;
} ) => {
const cartItem = select.getCartItem( cartItemKey );
if ( cartItem?.quantity === quantity ) {
return;
}
try {
dispatch.itemIsPendingQuantity( cartItemKey );
const { response } = await apiFetchWithHeaders< {
response: CartResponse;
} >( {
path: '/wc/store/v1/cart/update-item',
method: 'POST',
data: {
key: cartItemKey,
quantity,
},
cache: 'no-store',
} );
dispatch.receiveCart( response );
return response;
} catch ( error ) {
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
return Promise.reject( error );
} finally {
dispatch.itemIsPendingQuantity( cartItemKey, false );
}
};
// Facilitates aborting fetch requests.
let abortController: AbortController | null = null;
/**
* Selects a shipping rate.
*
* @param {string} rateId The id of the rate being selected.
* @param {number | string} [packageId] The key of the packages that we will select within.
*/
export const selectShippingRate =
( rateId: string, packageId: number | null = null ) =>
async ( {
dispatch,
select,
}: {
dispatch: CartDispatchFromMap;
select: CartSelectFromMap;
} ) => {
const selectedShippingRate = select
.getShippingRates()
.find(
( shippingPackage: CartShippingRate ) =>
shippingPackage.package_id === packageId
)
?.shipping_rates.find(
( rate: CartShippingPackageShippingRate ) =>
rate.selected === true
);
if ( selectedShippingRate?.rate_id === rateId ) {
return;
}
try {
dispatch.shippingRatesBeingSelected( true );
if ( abortController ) {
abortController.abort();
}
abortController =
typeof AbortController === 'undefined'
? null
: new AbortController();
const { response } = await apiFetchWithHeaders< {
response: CartResponse;
} >( {
path: `/wc/store/v1/cart/select-shipping-rate`,
method: 'POST',
data: {
package_id: packageId,
rate_id: rateId,
},
cache: 'no-store',
signal: abortController?.signal || null,
} );
// Remove shipping and billing address from the response, so we don't overwrite what the shopper is
// entering in the form if rates suddenly appear mid-edit.
const {
shipping_address: shippingAddress,
billing_address: billingAddress,
...rest
} = response;
dispatch.receiveCart( rest );
dispatch.shippingRatesBeingSelected( false );
return response as CartResponse;
} catch ( error ) {
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
dispatch.shippingRatesBeingSelected( false );
return Promise.reject( error );
}
};
/**
* Updates the shipping and/or billing address for the customer and returns an updated cart.
*/
export const updateCustomerData =
(
// Address data to be updated; can contain both billing_address and shipping_address.
customerData: Partial< BillingAddressShippingAddress >,
// If the address is being edited, we don't update the customer data in the store from the response.
editing = true
) =>
async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => {
try {
dispatch.updatingCustomerData( true );
const { response } = await apiFetchWithHeaders< {
response: CartResponse;
} >( {
path: '/wc/store/v1/cart/update-customer',
method: 'POST',
data: customerData,
cache: 'no-store',
} );
if ( editing ) {
dispatch.receiveCartContents( response );
} else {
dispatch.receiveCart( response );
}
return response;
} catch ( error ) {
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
return Promise.reject( error );
} finally {
dispatch.updatingCustomerData( false );
}
};
export type Thunks =
| typeof receiveCart
| typeof receiveCartContents
| typeof receiveError
| typeof applyExtensionCartUpdate
| typeof applyCoupon
| typeof removeCoupon
| typeof addItemToCart
| typeof removeItemFromCart
| typeof changeCartItemQuantity
| typeof selectShippingRate
| typeof updateCustomerData;