diff --git a/apps/storefront/src/components/HeadlessController.tsx b/apps/storefront/src/components/HeadlessController.tsx index 85d82915..c31243e0 100644 --- a/apps/storefront/src/components/HeadlessController.tsx +++ b/apps/storefront/src/components/HeadlessController.tsx @@ -18,6 +18,7 @@ import { B3SStorage, endMasquerade, getCurrentCustomerInfo, + LineItems, startMasquerade, } from '@/utils' @@ -25,17 +26,19 @@ interface HeadlessControllerProps { setOpenPage: Dispatch> } -const transformOptionSelectionsToAttributes = (items: CustomFieldItems[]) => +const transformOptionSelectionsToAttributes = (items: LineItems[]) => items.map((product) => { const { optionSelections } = product return { ...product, - optionSelections: optionSelections.map( - ({ optionId, optionValue }: Record) => ({ - optionId: `attribute[${optionId}]`, - optionValue, - }) + optionSelections: optionSelections?.reduce( + (accumulator: Record, { optionId, optionValue }) => { + accumulator[`attribute[${optionId}]`] = optionValue + + return accumulator + }, + {} ), } }) @@ -112,7 +115,7 @@ export default function HeadlessController({ quote: { addProductFromPage: addProductFromPageToQuoteRef.current, addProductsFromCart: () => addProductsFromCart(), - addProducts: (items) => addProductsToDraftQuote(items), + addProducts: (items) => addProductsToDraftQuote(items, setOpenPage), }, user: { getProfile: () => ({ ...customerRef.current, role }), diff --git a/apps/storefront/src/constants/index.ts b/apps/storefront/src/constants/index.ts index 56a613e0..69d8b079 100644 --- a/apps/storefront/src/constants/index.ts +++ b/apps/storefront/src/constants/index.ts @@ -32,6 +32,14 @@ export enum HeadlessRoutes { REGISTER_ACCOUNT = '/registered', DRAFT_QUOTE = '/quoteDraft', SHOPPING_LISTS = '/shoppingLists', + DASHBOARD = '/dashboard', + ORDERS = '/orders', + COMPANY_ORDERS = '/company-orders', + QUOTES = '/quotes', + PURCHASED_PRODUCTS = '/purchased-products', + ADDRESSES = '/addresses', + USER_MANAGEMENT = '/user-management', + ACCOUNT_SETTINGS = '/accountSettings', } export type HeadlessRoute = keyof typeof HeadlessRoutes diff --git a/apps/storefront/src/hooks/dom/utils.ts b/apps/storefront/src/hooks/dom/utils.ts index e807478b..74e6c9c1 100644 --- a/apps/storefront/src/hooks/dom/utils.ts +++ b/apps/storefront/src/hooks/dom/utils.ts @@ -1,10 +1,8 @@ import { Dispatch, SetStateAction } from 'react' import globalB3 from '@b3/global-b3' import type { OpenPageState } from '@b3/hooks' -import { v1 as uuid } from 'uuid' import { B3AddToQuoteTip } from '@/components' -import { PRODUCT_DEFAULT_IMAGE } from '@/constants' import { searchB2BProducts, searchBcProducts } from '@/shared/service/b2b' import { getCartInfoWithOptions } from '@/shared/service/bc' import { @@ -12,10 +10,11 @@ import { addQuoteDraftProducts, B3LStorage, B3SStorage, - calculateProductListPrice, + calculateProductsPrice, getCalculatedProductPrice, globalSnackbar, isAllRequiredOptionFilled, + LineItems, validProductQty, } from '@/utils' @@ -31,50 +30,64 @@ interface DiscountsProps { interface ProductOptionsProps { name: string - nameId: number | string + nameId: number value: number | string - valueId: number | string + valueId: number } -interface ProductItemProps { - brand: string | number - couponAmount: number - discountAmount: number - discounts: Array +interface CustomItemProps { extendedListPrice: number - extendedSalePrice: number - giftWrapping: any id: string - imageUrl: string - isMutable: boolean - isShippingRequired: boolean - isTaxable: boolean listPrice: number name: string + quantity: number + sku: string +} + +interface DigitalItemProps extends CustomItemProps { options: ProductOptionsProps[] + brand: string + couponAmount: number + discountAmount: number + discounts: DiscountsProps[] + extendedSalePrice: number + imageUrl: string + isTaxable: boolean originalPrice: number - parentId: string | number | null + parentId?: string productId: number - quantity: number salePrice: number - sku: string - type: string url: string variantId: number } -interface LineItemsProps { - customItems: Array - digitalItems: Array - giftCertificates: Array - physicalItems: ProductItemProps[] +interface PhysicalItemProps extends DigitalItemProps { + giftWrapping: { + amount: number + message: string + name: string + } + isShippingRequire: boolean +} +interface Contact { + email: string + name: string +} +interface GiftCertificateProps { + amount: number + id: string + isTaxable: boolean + name: string + recipient: Contact + sender: Contact } -type Cart = - | 'customItems' - | 'digitalItems' - | 'giftCertificates' - | 'physicalItems' +interface LineItemsProps { + customItems: CustomItemProps[] + digitalItems: DigitalItemProps[] + giftCertificates: GiftCertificateProps[] + physicalItems: PhysicalItemProps[] +} interface CartInfoProps { baseAmount: number @@ -98,13 +111,6 @@ interface CartInfoProps { updatedTime: string } -const productTypes: Array = [ - 'customItems', - 'digitalItems', - 'giftCertificates', - 'physicalItems', -] - const addLoadding = (b3CartToQuote: any) => { const loadingDiv = document.createElement('div') loadingDiv.setAttribute('id', 'b2b-div-loading') @@ -136,61 +142,41 @@ const gotoQuoteDraft = (setOpenPage: DispatchProps) => { }) } -const getCartProducts = (lineItems: LineItemsProps) => { - const cartProductsList: CustomFieldItems[] = [] - - productTypes.forEach((type: Cart) => { - if (lineItems[type].length > 0) { - lineItems[type].forEach( - (product: ProductItemProps | CustomFieldItems) => { - if (!product.parentId) { - cartProductsList.push(product) - } +const getCartProducts = (lineItems: LineItemsProps) => + Object.values(lineItems) + .flat() + .reduce( + (accumulator, { options = [], sku, ...product }) => { + if (!sku) { + accumulator.noSkuProducts.push(product) + return accumulator } - ) - } - }) - - return cartProductsList -} - -const getOptionsList = (options: ProductOptionsProps[] | []) => { - if (!options?.length) return [] - const option: CustomFieldItems = [] - options.forEach(({ nameId, valueId, value }) => { - let optionValue: number | string = valueId ? `${valueId}`.toString() : value - if (typeof valueId === 'number' && `${valueId}`.toString().length === 10) { - optionValue = valueId - const date = new Date(+valueId * 1000) - const year = date.getFullYear() - const month = date.getMonth() + 1 - const day = date.getDate() - option.push({ - optionId: `attribute[${nameId}][year]`, - optionValue: year, - }) - option.push({ - optionId: `attribute[${nameId}][month]`, - optionValue: month, - }) - option.push({ - optionId: `attribute[${nameId}][day]`, - optionValue: day, - }) - } else { - option.push({ - optionId: `attribute[${nameId}]`, - optionValue, - }) - } - }) - - return option -} + if (!product.parentId) { + accumulator.cartProductsList.push({ + ...product, + sku, + optionSelections: options.map( + ({ nameId, valueId }: ProductOptionsProps) => ({ + optionId: nameId, + optionValue: valueId, + }) + ), + }) + } + return accumulator + }, + { cartProductsList: [], noSkuProducts: [] } + ) -const addProductsToDraftQuote = async (products: CustomFieldItems[]) => { +const addProductsToDraftQuote = async ( + products: LineItems[], + setOpenPage: DispatchProps, + cartId?: string +) => { // filter products with SKU - const productsWithSKU = products.filter(({ sku }) => !!sku) + const productsWithSKUOrVariantId = products.filter( + ({ sku, variantId }) => sku || variantId + ) const companyId = B3SStorage.get('B3CompanyInfo')?.id || B3SStorage.get('salesRepCompanyId') @@ -199,64 +185,47 @@ const addProductsToDraftQuote = async (products: CustomFieldItems[]) => { // fetch data with products IDs const { productsSearch } = await searchB2BProducts({ productIds: Array.from( - new Set(products.map(({ productId }) => +productId)) + new Set(productsWithSKUOrVariantId.map(({ productId }) => +productId)) ), companyId, customerGroupId, }) - // convert to product search response format - const productsListSearch: CustomFieldItems[] = - conversionProductsList(productsSearch) - - // create products list structure compatible with quote structure - const productsList = productsWithSKU.map((product) => { - const { - options, - sku, - productId, - name, - quantity, - variantId, - salePrice, - imageUrl, - listPrice, - } = product - - const optionsList = getOptionsList(options) - - const currentProductSearch = productsListSearch.find( - (product: any) => +product.id === +productId - ) - - const quoteListitem = { - node: { - id: uuid(), - variantSku: sku, - variantId, - productsSearch: currentProductSearch, - primaryImage: imageUrl || PRODUCT_DEFAULT_IMAGE, - productName: name, - quantity: +quantity || 1, - optionList: JSON.stringify(optionsList), - productId, - basePrice: listPrice, - taxPrice: salePrice - listPrice, - }, - } - - return quoteListitem - }) - - // update prices for products list - await calculateProductListPrice(productsList, '2') + // get products prices + const productsListSearch = conversionProductsList(productsSearch) + const productsList = await calculateProductsPrice( + productsWithSKUOrVariantId, + productsListSearch + ) const isSuccess = validProductQty(productsList) if (isSuccess) { addQuoteDraftProducts(productsList) } - return isSuccess + if (isSuccess) { + // Save the shopping cart id, used to clear the shopping cart after submitting the quote + if (cartId) B3LStorage.set('cartToQuoteId', cartId) + + globalSnackbar.success('', { + jsx: () => + B3AddToQuoteTip({ + gotoQuoteDraft: () => gotoQuoteDraft(setOpenPage), + msg: 'Product was added to your quote.', + }), + isClose: true, + }) + return + } + + globalSnackbar.error('', { + jsx: () => + B3AddToQuoteTip({ + gotoQuoteDraft: () => gotoQuoteDraft(setOpenPage), + msg: 'The quantity of each product in Quote is 1-1000000.', + }), + isClose: true, + }) } const addProductsFromCartToQuote = (setOpenPage: DispatchProps) => { @@ -274,9 +243,7 @@ const addProductsFromCartToQuote = (setOpenPage: DispatchProps) => { const { lineItems, id: cartId } = cartInfoWithOptions[0] - const cartProductsList = getCartProducts(lineItems) - - const noSkuProducts = cartProductsList.filter(({ sku }) => !sku) + const { cartProductsList, noSkuProducts } = getCartProducts(lineItems) if (noSkuProducts.length > 0) { globalSnackbar.error('Can not add products without SKU.', { @@ -291,29 +258,7 @@ const addProductsFromCartToQuote = (setOpenPage: DispatchProps) => { } if (noSkuProducts.length === cartProductsList.length) return - const isSuccess = await addProductsToDraftQuote(cartProductsList) - if (isSuccess) { - // Save the shopping cart id, used to clear the shopping cart after submitting the quote - B3LStorage.set('cartToQuoteId', cartId) - - globalSnackbar.success('', { - jsx: () => - B3AddToQuoteTip({ - gotoQuoteDraft: () => gotoQuoteDraft(setOpenPage), - msg: 'Product was added to your quote.', - }), - isClose: true, - }) - } else { - globalSnackbar.error('', { - jsx: () => - B3AddToQuoteTip({ - gotoQuoteDraft: () => gotoQuoteDraft(setOpenPage), - msg: 'The quantity of each product in Quote is 1-1000000.', - }), - isClose: true, - }) - } + await addProductsToDraftQuote(cartProductsList, setOpenPage, cartId) } catch (e) { console.log(e) } finally { diff --git a/apps/storefront/src/index.d.ts b/apps/storefront/src/index.d.ts index c8a17b05..8cf46dff 100644 --- a/apps/storefront/src/index.d.ts +++ b/apps/storefront/src/index.d.ts @@ -18,7 +18,7 @@ declare interface Window { quote: { addProductFromPage: () => Promise addProductsFromCart: () => Promise - addProducts: (items: CustomFieldItems[]) => Promise + addProducts: (items: import('@/utils').LineItems[]) => Promise } user: { getProfile: () => Record @@ -37,7 +37,7 @@ declare interface Window { addProductFromPage: () => void addProducts: ( shoppingListId: number, - items: CustomFieldItems[] + items: import('@/utils').LineItems[] ) => Promise createNewShoppingList: ( name: string, diff --git a/apps/storefront/src/utils/b3Product/b3Product.ts b/apps/storefront/src/utils/b3Product/b3Product.ts index de5dbc68..62561e1d 100644 --- a/apps/storefront/src/utils/b3Product/b3Product.ts +++ b/apps/storefront/src/utils/b3Product/b3Product.ts @@ -17,7 +17,10 @@ import { Product, Variant, } from '@/types/products' -import { ShoppingListProductItemModifiers } from '@/types/shoppingList' +import { + ShoppingListProductItem, + ShoppingListProductItemModifiers, +} from '@/types/shoppingList' import { B3LStorage, B3SStorage, @@ -52,10 +55,34 @@ interface AdditionalCalculatedPricesProps { } interface NewOptionProps { + optionId: string + optionValue: number +} + +interface ProductOption { + optionId: number + optionValue: number +} + +interface ProductOptionString { optionId: string optionValue: string } +interface ProductInfo extends Variant { + quantity: number + productsSearch: ShoppingListProductItem + optionSelections?: ProductOptionString[] +} + +export interface LineItems { + quantity: number + productId: number + optionSelections?: ProductOption[] + sku?: string + variantId?: number +} + const getModifiersPrice = ( modifiers: CustomFieldItems[], options: CustomFieldItems @@ -438,6 +465,27 @@ const getNewProductsList = async ( return undefined } +const getDateValuesArray = (id: number, value: number) => { + const data = new Date(value * 1000) + const year = data.getFullYear() + const month = data.getMonth() + 1 + const day = data.getDate() + return [ + { + option_id: id, + value_id: month, + }, + { + option_id: id, + value_id: year, + }, + { + option_id: id, + value_id: day, + }, + ] +} + const calculatedDate = ( newOption: NewOptionProps, itemOption: Partial @@ -614,6 +662,11 @@ const getCustomerGroupId = () => { return customerGroupId } +/** + * Calculate price for a product. + * + * @deprecated Use the new {@link calculateProductsPrice} function instead. + */ const getCalculatedProductPrice = async ( { optionList, productsSearch, sku, qty }: CalculatedProductPrice, calculatedValue?: CustomFieldItems @@ -679,6 +732,137 @@ const getCalculatedProductPrice = async ( return '' } +const formatOptionsSelections = ( + options: ProductOption[], + allOptions: Partial[] +) => + options.reduce((accumulator: CalculatedOptions[], option) => { + const matchedOption = allOptions.find(({ id, type, option_values }) => { + if (option.optionId === id) { + if ( + (type !== 'text' && option_values?.length) || + (type === 'date' && option.optionValue) + ) { + return true + } + } + return false + }) + + if (matchedOption) { + if (matchedOption.type === 'date') { + const id = matchedOption.id ? +matchedOption.id : 0 + accumulator.push(...getDateValuesArray(id, option.optionValue)) + } else { + accumulator.push({ + option_id: matchedOption.id ? +matchedOption.id : 0, + value_id: option.optionValue, + }) + } + } + + return accumulator + }, []) +const formatLineItemsToGetPrices = ( + items: LineItems[], + productsSearch: ShoppingListProductItem[] +) => + items.reduce( + ( + formatedLineItems: { + items: Calculateditems[] + variants: ProductInfo[] + }, + { optionSelections = [], productId, sku, variantId, quantity } + ) => { + const selectedProduct = productsSearch.find(({ id }) => id === productId) + const variantItem = selectedProduct?.variants?.find( + ({ sku: skuResult, variant_id: variantIdResult }) => + sku === skuResult || variantIdResult === variantId + ) + + if (!variantItem || !selectedProduct) { + return formatedLineItems + } + const { allOptions = [] } = selectedProduct + + const options = formatOptionsSelections(optionSelections, allOptions) + + formatedLineItems.items.push({ + product_id: variantItem.product_id, + variant_id: variantItem.variant_id, + options, + }) + formatedLineItems.variants.push({ + ...variantItem, + quantity, + productsSearch: selectedProduct, + optionSelections: optionSelections.map(({ optionId, optionValue }) => ({ + optionId: `attribute[${optionId}]`, + optionValue: `${optionValue}`, + })), + }) + return formatedLineItems + }, + { items: [], variants: [] } + ) +const calculateProductsPrice = async ( + lineItems: LineItems[], + products: ShoppingListProductItem[], + calculatedValue: CustomFieldItems[] = [] +) => { + let calculatedPrices = calculatedValue + const { variants, items } = formatLineItemsToGetPrices(lineItems, products) + + // check if it's included calculatedValue + // if not, prepare items array to get prices by `/v3/pricing/products` endpoint + // then fetch them + if (calculatedValue.length === 0) { + const data = { + channel_id: B3SStorage.get('B3channelId'), + currency_code: getDefaultCurrencyInfo().currency_code, + customer_group_id: getCustomerGroupId(), + items, + } + const res = await getProxyInfo({ + storeHash, + method: 'post', + url: '/v3/pricing/products', + data, + }) + calculatedPrices = res.data + } + + // create quote array struture and return it + return calculatedPrices.map((calculatedPrice, index) => { + const { + productsSearch, + quantity, + optionSelections, + sku: variantSku, + variant_id: variantId, + image_url: primaryImage, + product_id: productId, + } = variants[index] + const { taxPrice, itemPrice } = getBulkPrice(calculatedPrice, quantity) + return { + node: { + id: uuid(), + variantSku, + variantId, + productsSearch, + primaryImage, + productName: productsSearch.name, + quantity, + optionList: JSON.stringify(optionSelections), + productId, + basePrice: itemPrice.toFixed(2), + taxPrice: taxPrice.toFixed(2), + calculatedValue: calculatedPrice, + }, + } + }) +} const calculateProductListPrice = async ( products: Partial[], @@ -1012,6 +1196,7 @@ export { addQuoteDraftProducts, calculateIsInclude, calculateProductListPrice, + calculateProductsPrice, compareOption, getBCPrice, getCalculatedParams, diff --git a/apps/storefront/src/utils/index.ts b/apps/storefront/src/utils/index.ts index 4bccc067..e65c2b29 100644 --- a/apps/storefront/src/utils/index.ts +++ b/apps/storefront/src/utils/index.ts @@ -39,21 +39,7 @@ import { } from './loginInfo' import { validatorRules } from './validatorRules' -export { - addQuoteDraftProduce, - addQuoteDraftProducts, - calculateIsInclude, - calculateProductListPrice, - compareOption, - getCalculatedParams, - getCalculatedProductPrice, - getModifiersPrice, - getNewProductsList, - getProductExtraPrice, - getQuickAddProductExtraPrice, - setModifierQtyPrice, - validProductQty, -} from './b3Product/b3Product' +export * from './b3Product/b3Product' export * from './masquerade' export { getQuoteConfig,