@shopgate/engage
Version:
Shopgate's ENGAGE library.
473 lines (456 loc) • 15.9 kB
JavaScript
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { logger, i18n, UIEvents } from "../../core";
import { withCurrentProduct } from "../../core/hocs/withCurrentProduct";
import { FulfillmentContext } from "../locations.context";
import { FulfillmentPathSelector } from "../components/FulfillmentPathSelector";
import { STAGE_SELECT_STORE, STAGE_RESERVE_FORM, STAGE_RESPONSE_SUCCESS, STAGE_RESPONSE_ERROR, STAGE_FULFILLMENT_METHOD, QUICK_RESERVE, MULTI_LINE_RESERVE, DIRECT_SHIP, ROPIS, BOPIS } from "../constants";
import connect from "./FulfillmentProvider.connector";
/**
* @typedef {import('./FulfillmentProvider.types').OwnProps} OwnProps
* @typedef {import('./FulfillmentProvider.types').StateProps} StateProps
* @typedef {import('./FulfillmentProvider.types').DispatchProps} DispatchProps
* @typedef {import('../locations.types').SheetOpenParams} SheetOpenParams
* @typedef {import('../locations.types').SheetStage} SheetStage
* @typedef {import('../locations.types').Location} Location
* @typedef {import('../locations.types').ReservationFormValues} ReservationFormValues
* @typedef {import('../locations.types').FulfillmentPath} FulfillmentPath
*/
/**
* @typedef {OwnProps & StateProps & DispatchProps} Props
*/
import { jsx as _jsx } from "react/jsx-runtime";
export const EVENT_SET_OPEN = 'FulfillmentProvider.setOpen';
let callback = null;
/**
* Provides the fulfillment context.
* @param {Props} props The component props.
* @returns {JSX.Element} The rendered component.
*/
const FulfillmentProvider = props => {
const {
children,
locations,
baseProduct: propsBaseProduct,
product: propsProduct,
location: productLocation,
inventory,
userInput,
fulfillmentMethod: defaultFulfillmentMethod,
fulfillmentPath: defaultFulfillmentPath,
fulfillmentPaths,
fulfillmentMethods,
enabledFulfillmentMethods,
shopSettings,
selectLocation,
submitReservation,
storeFormInput,
updateProductsInCart,
isFetching,
noInventory = false,
open = false,
noLocationSelection = false,
isStoreFinder = false,
isCart = false,
isInitialized: defaultIsInitialized,
updatePreferredLocation,
restrictMultiLocationOrders = false,
cartProducts = [],
showModal,
fulfillmentSchedulingEnabled = null,
activeFulfillmentSlot = null,
activeFulfillmentSlotLocationCode = null
} = props;
const [fulfillmentPath, setFulfillmentPath] = useState(defaultFulfillmentPath || null);
const [changeOnly, setChangeOnly] = useState(props.changeOnly);
const [isOpen, setIsOpen] = useState(open);
const [stage, setStage] = useState(props.stage || null);
const [fulfillmentMethod, setFulfillmentMethod] = useState(null);
const [orderNumbers, setOrderNumbers] = useState(null);
const [errors, setErrors] = useState(null);
const [product, setProduct] = useState(propsProduct);
const [isChangeFulfillment, setIsChangeFulfillment] = useState(false);
const [cartItem, setCartItem] = useState(null);
const [storeFinderLocation, setStoreFinderLocation] = useState(productLocation);
const [isLoading, setIsLoading] = useState(!isFetching);
const isInitialized = useRef(defaultIsInitialized);
useEffect(() => {
if (defaultFulfillmentMethod) {
setFulfillmentMethod(defaultFulfillmentMethod);
}
}, [defaultFulfillmentMethod]);
const title = useMemo(() => {
if (props.title !== null) {
return i18n.text(props.title);
}
switch (stage) {
case STAGE_RESERVE_FORM:
return i18n.text('locations.place_reservation');
case STAGE_RESPONSE_SUCCESS:
return i18n.text('locations.success_heading');
case STAGE_RESPONSE_ERROR:
return i18n.text('locations.error_heading');
case STAGE_FULFILLMENT_METHOD:
return i18n.text('locations.change_fulfillment_method');
case STAGE_SELECT_STORE:
default:
return i18n.text('locations.headline');
}
}, [props.title, stage]);
/** Effects for updating a state based on new props */
useEffect(() => setIsOpen(open), [open]);
useEffect(() => setProduct(propsProduct), [propsProduct]);
useEffect(() => {
// eslint-disable-next-line require-jsdoc
const exec = async () => {
if (updatePreferredLocation && productLocation && !isInitialized.current) {
isInitialized.current = true;
await selectLocation({
location: productLocation,
skipLocationSync: true
});
}
};
exec();
}, [productLocation, selectLocation, updatePreferredLocation]);
useEffect(() => {
setIsLoading(isFetching);
}, [isFetching]);
/**
* Checks whether the given stage is currently set.
* @param {SheetStage} inputStage The stage to check for.
* @returns {boolean}
*/
const isStage = useCallback(inputStage => inputStage === stage, [stage]);
/**
* Handles opening of the sheet.
* @param {SheetOpenParams} params The sheet open parameters.
*/
const handleOpen = params => {
setIsOpen(true);
setStage(prevState => params.stage || prevState);
setChangeOnly(prevState => params.changeOnly || prevState);
setFulfillmentPath(prevState => params.fulfillmentPath || prevState);
callback = params.callback || null;
};
/**
* Handles the closing of the sheet.
* @param {Location|null} location - The selected location or null if no location is selected.
* @param {string|null} productId - The product ID or null if no product is provided.
* @returns {void}
*/
const handleClose = useCallback((location = null, productId = null) => {
let orderSuccess = true;
if (isStage(STAGE_RESPONSE_ERROR)) {
orderSuccess = errors === null;
}
if (isStage(STAGE_RESERVE_FORM)) {
orderSuccess = null;
}
if (props.onClose) {
props.onClose(location, productId, orderSuccess);
} else if (callback) {
callback(location, productId, orderSuccess);
callback = null;
}
setIsOpen(false);
setStage(null);
setOrderNumbers(null);
setErrors(null);
setIsChangeFulfillment(false);
setFulfillmentMethod(null);
}, [errors, isStage, props]);
useEffect(() => {
UIEvents.addListener(EVENT_SET_OPEN, handleOpen);
return () => {
UIEvents.removeListener(EVENT_SET_OPEN, handleOpen);
};
}, []);
/**
* Handles the sending of the reservation.
* @param {ReservationFormValues} values The reservation form values.
*/
const sendReservation = useCallback(async values => {
try {
const response = await submitReservation(values, product);
if (response.errors && response.errors.length > 0) {
setStage(STAGE_RESPONSE_ERROR);
setOrderNumbers(null);
setErrors(response.errors);
}
setStage(STAGE_RESPONSE_SUCCESS);
setOrderNumbers(response.orderNumbers);
setErrors(null);
} catch (error) {
logger.error(error);
setStage(STAGE_RESPONSE_ERROR);
setOrderNumbers(null);
setErrors([error.message]);
}
// Store the user's form in the user data.
storeFormInput(values);
}, [product, storeFormInput, submitReservation]);
/**
* @param {Location} location The selected location.
* @returns {boolean}
*/
const confirmSelection = useCallback(async location => {
const {
code,
name
} = location;
const ropeCartProducts = cartProducts.filter(cartProduct => [ROPIS, BOPIS].includes(cartProduct?.fulfillment?.method));
if (!restrictMultiLocationOrders || ropeCartProducts.length === 0) {
return true;
}
if (isCart && ropeCartProducts.length === 1) {
// Location changed for the one and only cart item
return true;
}
const cartHasDifferentCodes = !!ropeCartProducts.map(({
fulfillment
}) => fulfillment?.location?.code).filter(Boolean).filter(cartProductCode => cartProductCode !== code).length;
if (ropeCartProducts.length >= 1 && !cartHasDifferentCodes) {
return true;
}
const confirmed = await showModal({
title: 'locations.multi_location_modal.title',
message: 'locations.multi_location_modal.message',
confirm: 'locations.multi_location_modal.change_store',
dismiss: 'common.cancel'
});
if (confirmed && ropeCartProducts.length) {
const updateData = ropeCartProducts.map(({
id: cartItemId,
fulfillment
}) => ({
cartItemId,
fulfillment: {
method: fulfillment?.method,
location: {
code,
name: name || ''
}
}
}));
setIsLoading(true);
await updateProductsInCart(updateData);
setIsLoading(false);
}
return confirmed;
}, [cartProducts, isCart, restrictMultiLocationOrders, showModal, updateProductsInCart]);
/**
* Handles multiline reservation.
* @param {Location} location The selected location.
*/
const handleMultilineReservation = useCallback(location => {
if (product === null || location.code === null) {
return;
}
const fulfillment = {
method: fulfillmentMethod,
location: {
code: location.code,
name: location.name || ''
},
...(fulfillmentSchedulingEnabled && activeFulfillmentSlotLocationCode === location.code && activeFulfillmentSlot?.id ? {
slotId: activeFulfillmentSlot.id
} : null)
};
if (isChangeFulfillment && cartItem) {
updateProductsInCart([{
quantity: cartItem.quantity,
cartItemId: cartItem.id,
fulfillment
}]);
}
handleClose(location, product.id);
}, [activeFulfillmentSlot?.id, activeFulfillmentSlotLocationCode, cartItem, fulfillmentMethod, fulfillmentSchedulingEnabled, handleClose, isChangeFulfillment, product, updateProductsInCart]);
/**
* Handles quick reservation.
*/
function handleQuickReservation() {
setStage(STAGE_RESERVE_FORM);
}
/**
* Handles the selection of a store location from the sheet.
* @param {Location} location The selected location.
* @returns {Promise<void>}
*/
const handleSelectLocation = useCallback(async location => {
if (isLoading) {
return;
}
let selectionConfirmed;
if (updatePreferredLocation) {
selectionConfirmed = await confirmSelection(location);
if (selectionConfirmed) {
await selectLocation({
location: {
code: location.code,
name: location.name
}
});
}
}
if (changeOnly) {
if (selectionConfirmed) {
handleClose(location, product && product.id);
}
return;
}
/**
* Select the reservation method strategy.
* @param {string|FulfillmentPath} method The reservation method.
* @param {Location} storeLocation The selected store location.
*/
const handleReservationMethod = (method, storeLocation) => {
if (!method) {
return;
}
if (method === QUICK_RESERVE) {
handleQuickReservation();
}
if (method === MULTI_LINE_RESERVE) {
handleMultilineReservation(storeLocation);
}
};
// No fulfillment path selected yet.
if (fulfillmentPath === null) {
if (fulfillmentPaths.length > 1) {
/**
* @param {FulfillmentPath} method - The selected fulfillment path.
*/
FulfillmentPathSelector.open(method => {
if (!method) {
return;
}
handleReservationMethod(method, location);
});
} else if (fulfillmentPaths.length === 1) {
handleReservationMethod(fulfillmentPaths[0], location);
}
return;
}
if (fulfillmentPath === MULTI_LINE_RESERVE && fulfillmentPaths.includes(MULTI_LINE_RESERVE)) {
handleMultilineReservation(location);
return;
}
if (fulfillmentPath === QUICK_RESERVE) {
handleQuickReservation();
}
}, [changeOnly, confirmSelection, fulfillmentPath, fulfillmentPaths, handleClose, handleMultilineReservation, isLoading, product, selectLocation, updatePreferredLocation]);
/**
* @param {string} method The selected fulfillment method.
* @param {Object} item The cart item to change.
*/
const handleChangeFulfillmentMethod = useCallback((method, item) => {
logger.assert(item.product.id === product.id, 'Change fulfillment method is called with unexpected product id');
setIsChangeFulfillment(true);
setCartItem(item);
if ([ROPIS, BOPIS].includes(method) && (item.fulfillment === null || item.fulfillment.method === DIRECT_SHIP)) {
/**
* When the fulfillment method of the current cart item was DIRECT_SHIP before, and is
* switched to a ROPE method, the customer needs to pick a store for the item.
*/
setFulfillmentPath(MULTI_LINE_RESERVE);
setStage(STAGE_SELECT_STORE);
setFulfillmentMethod(method);
setIsOpen(true);
return;
}
if ([DIRECT_SHIP, ROPIS, BOPIS].includes(method)) {
updateProductsInCart([{
quantity: item.quantity,
cartItemId: item.id,
fulfillment: {
method
}
}]);
handleClose(null, item.product.id);
}
}, [handleClose, product?.id, updateProductsInCart]);
const handleSelectStoreFinderLocation = useCallback(location => {
setStoreFinderLocation(location);
}, []);
const context = useMemo(() => ({
stage,
title,
fulfillmentPath,
changeOnly,
isStage,
isOpen,
handleOpen,
handleClose,
locations,
inventory,
baseProduct: propsBaseProduct,
product,
location: productLocation,
storeFinderLocation,
userInput,
fulfillmentPaths,
fulfillmentMethods,
enabledFulfillmentMethods,
shopSettings,
selectLocation: handleSelectLocation,
selectStoreFinderLocation: handleSelectStoreFinderLocation,
changeFulfillment: handleChangeFulfillmentMethod,
sendReservation,
orderNumbers,
errors,
noInventory,
noLocationSelection,
isStoreFinder,
isFetching,
isLoading,
setIsLoading,
meta: props.meta || undefined
}), [changeOnly, enabledFulfillmentMethods, errors, fulfillmentMethods, fulfillmentPath, fulfillmentPaths, handleChangeFulfillmentMethod, handleClose, handleSelectLocation, handleSelectStoreFinderLocation, inventory, isFetching, isLoading, isOpen, isStage, isStoreFinder, locations, noInventory, noLocationSelection, orderNumbers, product, productLocation, props.meta, propsBaseProduct, sendReservation, shopSettings, stage, storeFinderLocation, title, userInput]);
return /*#__PURE__*/_jsx(FulfillmentContext.Provider, {
value: context,
children: children
});
};
FulfillmentProvider.defaultProps = {
open: false,
changeOnly: false,
updatePreferredLocation: true,
fulfillmentMethods: null,
title: null,
activeFulfillmentSlot: null,
activeFulfillmentSlotLocationCode: null,
baseProduct: null,
cartProducts: [],
enabledFulfillmentMethods: [],
fulfillmentMethod: null,
fulfillmentPath: null,
fulfillmentPaths: [],
fulfillmentSchedulingEnabled: false,
inventory: null,
isCart: false,
isFetching: false,
isInitialized: false,
isStoreFinder: false,
location: null,
locations: [],
noInventory: false,
noLocationSelection: false,
product: null,
restrictMultiLocationOrders: false,
shopSettings: null,
showModal: null,
userInput: null,
stage: null,
onClose: null,
meta: null
};
const FulfillmentProviderWrapped = withCurrentProduct(connect(FulfillmentProvider));
/**
* Opens the sheet that is wrapped inside the provider.
* @param {SheetOpenParams} params The opening parameters.
*/
export const openSheet = params => {
UIEvents.emit(EVENT_SET_OPEN, params);
};
export default FulfillmentProviderWrapped;