/* global WP_DEFINE_IS_NODE */
import debugLib from 'debug';
import { formValueSelector, change } from 'redux-form';
import createAction from 'redux-actions/lib/createAction';
import Configuration from 'common/src/app/config/Configuration';
import { makeCollectionSelector } from '../../selectors/collectionSelector';
import {
COUNTRY_REGIONS,
productCollectionId,
packageCollectionId,
} from '../../data/collectionIds';
import authenticate from '../../util/auth/authenticate';
import ProductType from '../../data/enum/ProductType';
import { setEntity } from '../entities/entityActions';
import { getAccount, updateAccount } from './accountActions';
import FormNames from '../../data/enum/FormNames';
import BasketFields from '../../data/enum/FieldNames/BasketFields';
import HifiRequirements from '../../data/enum/HifiRequirements';
import { userAccountSelector, userIdSelector } from '../../selectors/userAccountSelectors';
import { createCountryRegionSelector } from '../../selectors/shopSelectors';
import { regionCode as region } from '../../data/enum/Regions';
import { GATEWAY_SHOP, GATEWAY_SHOP_AUTH } from '../../data/Injectables';
import {
PRODUCT,
SHIPPING_COST,
COUNTRY_REGION,
PACKAGE,
PURCHASE,
DISCOUNT_ITEM,
} from '../../data/entityTypes';
import dedupeAsyncThunk from '../../util/dedupeAsyncThunk';
import {
clearValidation,
HANDLE_SUBMIT_ERRORS,
} from '../../enhanced-redux-form/actions/enhancedFormActions';
import { BASKET_CANNOT_GET_SHIPPING } from '../../data/validationErrorCodes';
import { hasFlag } from '../../util/bitwiseUtils';
import AccountState from '../../data/enum/AccountState';
import ProductCategory from '../../data/enum/ProductCategory';
import { shopFields } from '../../data/enum/FieldNames/AccountFieldNames';
import {
discountItemSelector,
makePackagesWithDiscountSelector,
voucherSelector,
} from '../../selectors/packageVoucherSelectors';
import { apiGet, apiPatch, apiPost } from './apiActions/apiRequest';
import apiGetEntity from './apiActions/apiGetEntity';
import apiGetCollection, { collectionCachingNoPagination } from './apiActions/apiGetCollection';
export const GET_PRODUCT_DETAIL = 'shopActions/GET_PRODUCT_DETAIL';
export const GET_PRODUCTS = 'shopActions/GET_PRODUCTS';
export const GET_BASKET_CONTENT = 'shopActions/GET_BASKET_CONTENT';
export const GET_SHIPPING_COSTS = 'shopActions/GET_SHIPPING_COSTS';
export const GET_ORDER_LIMITS = 'shopActions/GET_ORDER_LIMITS';
export const REQUEST_NOTIFICATION = 'shopActions/REQUEST_NOTIFICATION';
const basketValueSelector = formValueSelector(FormNames.SHOP_BASKET);
const debug = debugLib('SlimmingWorld:shopActions');
const DEFAULT_LIMIT = 20;
let btoaNode = null;
if (WP_DEFINE_IS_NODE) {
btoaNode = require('btoa'); // eslint-disable-line global-require
}
/**
* Encodes basket content object to Base64 string
* @param itemsDictionary
*/
const encodeBase64 = itemsDictionary =>
(btoaNode || btoa)(
typeof itemsDictionary === 'string' ? itemsDictionary : JSON.stringify(itemsDictionary),
);
export const getProducts = (limit = DEFAULT_LIMIT, category, regionCode) => (
dispatch,
getState,
) => {
const state = getState();
const account = userAccountSelector(state);
const accountState = getState().authentication?.userInfo?.account_state; // eslint-disable-line camelcase
const hasValidSubscription =
account &&
(hasFlag(accountState, AccountState.ONLINE_SUBSCRIPTION_VALID) ||
hasFlag(accountState, AccountState.GROUP_MEMBERSHIP_VALID));
const isGroupMember =
account && hasFlag(account.accountState, AccountState.GROUP_MEMBERSHIP_VALID);
return dispatch(
apiGetCollection(
GET_PRODUCTS,
GATEWAY_SHOP_AUTH,
isGroupMember ? '/products/catalogue' : '/products',
productCollectionId({ limit, category, regionCode }),
{
limit,
category: hasValidSubscription ? category : ProductCategory.SUBSCRIPTION,
regionCode,
},
{
requestData: {
category: hasValidSubscription ? category : ProductCategory.SUBSCRIPTION,
regionCode,
},
entityType: PRODUCT,
caching: collectionCachingNoPagination,
},
),
).catch(error => {
debug(error);
});
};
export const getProductDetail = dedupeAsyncThunk(
(productId, updateDetailView = true, productInfo, hiFiNoIngredients, isGroupMember) => {
const getFresh = () => {
/**
* If the product is not already in state do the product detail call to get all info (mostly used when hitting the page directly)
*/
if (productInfo === null) {
return true;
}
/**
* If we have already tried to get hifi ingredients before and there where
* not any do not fire the call again
*/
if (hiFiNoIngredients[productInfo.id]) {
return false;
}
/**
* If the product is a hifi and does not have ingredients & allergens
* fire the product detail call to get them
*/
return (
productInfo.category === ProductType.HIFI &&
(!productInfo.ingredients || !productInfo.allergens)
);
};
return apiGetEntity(
GET_PRODUCT_DETAIL,
GATEWAY_SHOP_AUTH,
isGroupMember ? `/products/catalogue/${productId}` : `/products/${productId}`,
PRODUCT,
productId,
updateDetailView
? {
caching: getFresh() ? false : undefined,
updateEntityView: 'view.pages.productDetail.product',
}
: {},
);
},
true,
);
/**
* Validates that the products in the basket are up to date.
*/
export const validateBasket = () => (dispatch, getState) => {
const state = getState();
const userId = userIdSelector(state);
const basketItems = basketValueSelector(state, BasketFields.ITEMS);
const { currentDeliveryRegion } = state.view.pages.shop || {};
const itemsDictionary = basketItems.reduce((acc, item) => {
acc[item.id] = item.quantity; // eslint-disable-line no-param-reassign
return acc;
}, {});
if (basketItems.length === 0) {
return undefined;
}
// Let's pass the voucherCode to get the discounted price
const voucherCodeObject = voucherSelector(
state,
basketValueSelector(state, shopFields.VOUCHER_CODE),
);
const voucherCode = voucherCodeObject?.code;
dispatch(clearValidation(FormNames.SHOP_BASKET));
return Promise.all(basketItems.map(({ id }) => dispatch(getProductDetail(id, false, null))))
.then(() =>
dispatch(
apiGet(
GET_BASKET_CONTENT,
GATEWAY_SHOP_AUTH,
`/baskets/${encodeBase64(itemsDictionary)}`,
{
userId,
voucherCode,
},
{
credentials: 'include',
},
),
),
)
.then(({ items, shippingCost, discountItem, voucherAvailable }) => {
const products = state.entities[PRODUCT];
products &&
items.forEach(item => {
const currentProduct = state.entities[PRODUCT][item.productId];
if (currentProduct.price.amount !== item.itemPrice) {
debug(
`product price outdated for product id ${item.productId}. Updating ${currentProduct.price.amount}=>${item.itemPrice}`,
);
dispatch(
setEntity(
PRODUCT,
item.productId,
{
price: {
amount: item.itemPrice,
currencyCode: item.currencyCode,
},
quantity: item.quantity,
},
true,
),
);
}
});
if (discountItem) {
dispatch(setEntity(DISCOUNT_ITEM, voucherCode, discountItem));
} else {
// This boolean is used for showing the fallback copy
dispatch(setEntity(DISCOUNT_ITEM, voucherCode, { hasItems: false }));
}
dispatch(setIsVoucherAvailable(voucherAvailable));
if (shippingCost) {
dispatch(setEntity(SHIPPING_COST, 0, shippingCost));
} else {
dispatch({
type: HANDLE_SUBMIT_ERRORS,
payload: {
errors: {},
generalError: { locale: 'basket.errors.shipping', code: BASKET_CANNOT_GET_SHIPPING },
},
meta: { form: FormNames.SHOP_BASKET },
});
}
})
.catch(e => {
// eslint-disable-line consistent-return
if (e.error && e.error.code && e.error.code === 'out-of-stock') {
dispatch({
type: HANDLE_SUBMIT_ERRORS,
payload: {
errors: {},
generalError: { locale: 'basket.errors.outOfStock' },
},
meta: { form: FormNames.SHOP_BASKET },
});
dispatch(change(FormNames.SHOP_BASKET, BasketFields.ITEMS, []));
return undefined;
}
if (e.error && e.error.code && e.error.code === 'quantity-overflow') {
// some item has a larger quantity then the order limit. Get new order limits and
// update the item quantities
return dispatch(
getUserShoppingDetails(
basketItems.map(item => item.id),
true,
DEFAULT_LIMIT,
null,
currentDeliveryRegion,
),
).then(() => {
basketItems.forEach(({ id, quantity }, index) => {
const productEntity = getState().entities[PRODUCT][id];
if (quantity > productEntity.personalOrderLimit) {
dispatch(
change(
FormNames.SHOP_BASKET,
`items[${index}].quantity`,
productEntity.personalOrderLimit,
),
);
}
});
});
}
// unknown error. empty the basket and set a general error
dispatch({
type: HANDLE_SUBMIT_ERRORS,
payload: {
errors: {},
generalError: { locale: 'basket.errors.general' },
},
meta: { form: FormNames.SHOP_BASKET },
});
dispatch(change(FormNames.SHOP_BASKET, BasketFields.ITEMS, []));
return undefined;
});
};
export const GET_PURCHASE = 'shopActions/GET_PURCHASE';
export const getPurchase = (id, fullSnapshot = false) => dispatch =>
dispatch(
apiGetEntity(GET_PURCHASE, GATEWAY_SHOP_AUTH, `/purchases/${id}`, PURCHASE, id, {
requestData: {
fullSnapshot,
},
}),
);
export const getPurchaseDetail = ({
firstName,
lastName,
phoneNumber,
addressLine1,
addressLine2,
addressLine3,
city,
state: countryState,
county,
zip,
country,
}) => {
const address = {
addressLine1,
addressLine2,
addressLine3,
city,
state: countryState,
county,
zip,
country,
};
return {
personalDetails: {
firstName,
lastName,
phoneNumber,
},
shippingAddress: address,
billingAddress: address,
};
};
export const createPurchaseShop = purchaseDetail => (dispatch, getState) => {
const state = getState();
const items = basketValueSelector(state, BasketFields.ITEMS);
return dispatch(createPurchase(purchaseDetail, items));
};
const CREATE_PACKAGE_PURCHASE = 'shopActions/CREATE_PACKAGE_PURCHASE';
/**
* Create a purchase for the given package id. A package contains multiple products.
*
* @param {object} purchaseDetail
* @param {string} packageId
* @param {string} voucherCode
*/
export const createPackagePurchase = (_purchaseDetail, packageId, voucherCode) => (
dispatch,
getState,
) =>
dispatch(getAccount()).then(() => {
const state = getState();
const account = userAccountSelector(state);
const purchaseDetail = _purchaseDetail || getPurchaseDetail(account);
return dispatch(
apiPost(
CREATE_PACKAGE_PURCHASE,
GATEWAY_SHOP_AUTH,
`/packages/${packageId}/purchase`,
{
purchaseDetail,
voucherCode,
},
{ credentials: 'include' },
),
);
});
/**
* TODO: rename function to createProductPurchase
* TODO: rename second argument from selectedPackage to product, this should then represent a single item. This will make this funciton reusable for any product
*
* @param _purchaseDetail
* @param selectedPackage
* @param voucherCode
*
* @returns {Promise}
*/
export const createPurchaseSubscription = (_purchaseDetail, selectedPackage) => (
dispatch,
getState,
) => {
const state = getState();
const account = userAccountSelector(state);
const purchaseDetail = _purchaseDetail || getPurchaseDetail(account);
const items = [{ id: selectedPackage, quantity: 1 }];
return dispatch(createPurchase(purchaseDetail, items));
};
const createPurchase = (purchaseDetail, items) => (dispatch, getState) => {
const state = getState();
const itemsDictionary = items.reduce((acc, item) => {
acc[item.id] = item.quantity; // eslint-disable-line no-param-reassign
return acc;
}, {});
const voucherCodeObject = voucherSelector(
state,
basketValueSelector(state, shopFields.VOUCHER_CODE),
);
const voucherCode = voucherCodeObject?.code || null;
const discountItem = discountItemSelector(state, voucherCode);
const discountAmount = discountItem?.discountAmount || null;
return dispatch(
apiPost(
GET_BASKET_CONTENT,
GATEWAY_SHOP_AUTH,
`/baskets/${encodeBase64(itemsDictionary)}/purchase`,
{
purchaseDetail,
basket: {
items: items.map(item => {
const productEntity =
state.entities[PRODUCT]?.[item.id] || state.entities.subscriptionPackage?.[item.id];
return {
productId: item.id,
itemPrice: productEntity.price.amount,
price: productEntity.price.amount * item.quantity,
currencyCode: productEntity.price.currencyCode,
quantity: item.quantity,
};
}),
discountAmount,
},
voucherCode,
},
{ credentials: 'include' },
),
);
};
export const getUserShoppingDetails = (
productIds,
noCache = false,
limit = 20,
category,
regionCode,
) => (dispatch, getState) => {
const state = getState();
const userId = userAccountSelector(state).id;
const account = userAccountSelector(state);
const collectionSelector = makeCollectionSelector();
const hasValidSubscription =
account &&
(hasFlag(account.accountState, AccountState.ONLINE_SUBSCRIPTION_VALID) ||
hasFlag(account.accountState, AccountState.GROUP_MEMBERSHIP_VALID));
const products = collectionSelector(state, {
collectionId: productCollectionId({
limit,
category: hasValidSubscription ? category : ProductCategory.SUBSCRIPTION,
regionCode,
}),
}).entityRefs;
const targetProductIds = productIds || products.map(ref => ref.id);
const requestProductIds = noCache
? targetProductIds
: targetProductIds.filter(id => {
const product = products.find(p => p.id === id);
if (!product) {
return true;
}
return typeof product.data.personalOrderLimit !== 'number';
});
targetProductIds.filter(id => {
const product = products.find(p => p.id === id);
return product ? typeof product.data.personalOrderLimit !== 'number' : true;
});
if (!requestProductIds.length) {
return Promise.resolve();
}
return dispatch(
apiGet(GET_ORDER_LIMITS, GATEWAY_SHOP_AUTH, '/user-shopping-details', {
userId,
productIds: requestProductIds.join(','),
}),
).then(({ data: result = [] }) =>
result.forEach(({ productId, personalOrderLimit, states }) =>
dispatch(
setEntity(
PRODUCT,
productId,
{ personalOrderLimit, hasRequestedNotification: states === 1 },
true,
),
),
),
);
};
export const requestNotification = id => (dispatch, getState) => {
const userId = userIdSelector(getState());
// optimistic update: set hasRequestedNotification to make CartButton respond immediately
dispatch(setEntity(PRODUCT, id, { hasRequestedNotification: true }, true));
return dispatch(
apiPatch(REQUEST_NOTIFICATION, GATEWAY_SHOP_AUTH, '/user-shopping-details', {
productId: id,
states: 1,
userId,
}),
).catch(e => {
// something went wrong. restore the CartButton state
dispatch(setEntity(PRODUCT, id, { hasRequestedNotification: false }, true));
debug('Unable to request notification: ');
debug(e);
});
};
export const GET_COUNTRY_REGIONS = 'shopActions/GET_COUNTRY_REGIONS';
export const getCountryRegions = () => async (dispatch, getState) => {
await dispatch(getAccount());
const state = getState();
const account = userAccountSelector(state);
const countryRegionSelector = createCountryRegionSelector(account.country);
// only fetch country codes if we don't already have them in state
if (!state.entities.countryRegion) {
await dispatch(
apiGetCollection(
GET_COUNTRY_REGIONS,
GATEWAY_SHOP_AUTH,
'/country-regions',
COUNTRY_REGIONS,
{},
{
cache: collectionCachingNoPagination,
entityType: COUNTRY_REGION,
getId: entity => entity.countryCode,
},
),
);
}
const initialRegion = countryRegionSelector(getState())?.deliveryRegion;
const deliveryRegion =
initialRegion || initialRegion === 0 ? initialRegion : region.OUTSIDE_EUROPE;
return deliveryRegion;
};
export const UPDATE_DELIVERY_ADDRESS = 'shopActions/UPDATE_DELIVERY_ADDRESS';
export const deliveryRegionUpdated = createAction(UPDATE_DELIVERY_ADDRESS, regionUpdated => ({
regionUpdated,
}));
export const SET_CURRENT_DELIVERY_REGION = 'shopActions/SET_CURRENT_DELIVERY_REGION';
export const setCurrentDeliveryRegion = createAction(SET_CURRENT_DELIVERY_REGION);
export const updateDeliveryAddress = (initialValues, values, addressField) => (
dispatch,
getState,
) => {
const state = getState();
const initialCountry = initialValues.country;
const newCountry = values.country;
const initialRegionSelector = createCountryRegionSelector(initialCountry);
const newRegionSelector = createCountryRegionSelector(newCountry);
const initialRegion = initialRegionSelector(state)?.deliveryRegion;
const newRegion = newRegionSelector(state)?.deliveryRegion;
const initialDeliveryRegion =
initialRegion || initialRegion === 0 ? initialRegion : region.OUTSIDE_EUROPE;
const newDeliveryRegion = newRegion || newRegion === 0 ? newRegion : region.OUTSIDE_EUROPE;
dispatch(change(FormNames.SHOP_CHECKOUT, addressField, { ...values }));
// Clear the basket when the region is not the same
if (initialDeliveryRegion !== newDeliveryRegion) {
dispatch(change(FormNames.SHOP_BASKET, BasketFields.ITEMS, []));
// Show an error message about the region update
dispatch(deliveryRegionUpdated(true));
dispatch(getProducts(DEFAULT_LIMIT, null, newDeliveryRegion));
dispatch(setCurrentDeliveryRegion(newDeliveryRegion));
}
// Save the mutated values
if (values.updateProfile) {
dispatch(updateAccount(values));
}
};
export const GET_USER_HIFI_LIMITS = 'shopActions/GET_USER_HIFI_LIMITS';
export const ADD_USER_HIFI_LIMITS = 'shopActions/ADD_USER_HIFI_LIMITS';
const addUserHifiLimits = createAction(ADD_USER_HIFI_LIMITS);
export const getUserHifiLimits = () => async (dispatch, getState) => {
await authenticate();
const state = getState();
const userId = userIdSelector(state);
return dispatch(apiGet(GET_USER_HIFI_LIMITS, GATEWAY_SHOP_AUTH, `/user-hifi-limits/${userId}`))
.then(({ data }) => dispatch(addUserHifiLimits(data)))
.catch(() =>
dispatch(
addUserHifiLimits({ resetCountAtDateUTC: null, limit: HifiRequirements.MAXIMUM_AMOUNT }),
),
);
};
export const GET_PACKAGE = 'shopActions/GET_PACKAGE';
export const getPackage = id => dispatch =>
dispatch(apiGetEntity(GET_PACKAGE, GATEWAY_SHOP, `/packages/${id}`, PACKAGE, id));
export const GET_PACKAGES = 'shopActions/GET_PACKAGES';
/**
* @param {Object} query
* @param {Number} query.limit
* @param {Object} query.regionCode
* @param {Object} [query.sinceId]
* @param {Object} [query.offsetId]
*/
export const getPackages = query => dispatch =>
dispatch(
apiGetCollection(
GET_PACKAGES,
GATEWAY_SHOP,
`/packages`,
packageCollectionId(query),
{},
{
requestData: query,
entityType: PACKAGE,
caching: collectionCachingNoPagination,
},
),
);
export const clearPackageField = () => (dispatch, getState) => {
const packagesSelector = makePackagesWithDiscountSelector();
const checkoutFormSelector = formValueSelector(FormNames.CHECKOUT);
const voucherCode = checkoutFormSelector(getState(), shopFields.VOUCHER_CODE);
const regionCode = Configuration.hasRegionPicker
? checkoutFormSelector(getState(), shopFields.REGION)
: Configuration.defaultRegion;
const packages = packagesSelector(getState(), {
regionCode,
voucherCode,
});
dispatch(change(FormNames.CHECKOUT, shopFields.PACKAGE, packages?.[0]?.id));
};
export const SET_FIRST_LOAD = 'shopActions/SET_FIRST_LOAD';
export const setFirstLoad = createAction(SET_FIRST_LOAD);
export const SET_HIFI_NO_INGREDIENTS = 'shopActions/SET_HIFI_NO_INGREDIENTS';
export const setHifiNoIngredients = createAction(SET_HIFI_NO_INGREDIENTS);
export const SET_IS_VOUCHER_AVAILABLE = 'shopActions/SET_IS_VOUCHER_AVAILABLE';
export const setIsVoucherAvailable = createAction(SET_IS_VOUCHER_AVAILABLE);