414 lines
11 KiB
TypeScript
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;
|