diff --git a/packages/storefront/src/lib/components/KitComposition.vue b/packages/storefront/src/lib/components/KitComposition.vue new file mode 100644 index 000000000..29e17b7a1 --- /dev/null +++ b/packages/storefront/src/lib/components/KitComposition.vue @@ -0,0 +1,96 @@ + + + diff --git a/packages/storefront/src/lib/composables/use-product-card.ts b/packages/storefront/src/lib/composables/use-product-card.ts index 109e58d0b..f6f3f73a2 100644 --- a/packages/storefront/src/lib/composables/use-product-card.ts +++ b/packages/storefront/src/lib/composables/use-product-card.ts @@ -5,6 +5,7 @@ import type { SearchItem, ResourceListResult, } from '@cloudcommerce/api/types'; +import type { ExtendedCartItem } from '@@sf/state/shopping-cart'; import { ref, computed, @@ -21,7 +22,11 @@ import { onPromotion as checkOnPromotion, } from '@ecomplus/utils'; import { slugify } from '@@sf/sf-lib'; -import { addProductToCart } from '@@sf/state/shopping-cart'; +import { + addCartItem, + addProductToCart, + parseProduct, +} from '@@sf/state/shopping-cart'; import { emitGtagEvent, getGtagItem } from '@@sf/state/use-analytics'; const idsToStockRefetch: string[] = []; @@ -56,13 +61,119 @@ export const kitItemFields = [ 'name' as const, 'slug' as const, 'available' as const, + 'visible' as const, 'price' as const, + 'base_price' as const, 'quantity' as const, + 'min_quantity' as const, 'pictures.normal' as const, + 'variations' as const, ]; export type KitItems = ProductsList; +export type KitItem = KitItems[number]; + +/** + * Variation selected for each `kit_composition` entry, by composition index. + * Entries with a fixed `variation_id` or without variations are ignored. + */ +export type KitVariationIds = Array; + +type KitComposition = Exclude; + +type CartKitComposition = Exclude< + Exclude['composition'], + undefined +>; + +const getKitItemStock = (kitItem: KitItem, variationId?: ResourceId | null) => { + const variation = variationId + ? kitItem.variations?.find(({ _id }) => _id === variationId) + : undefined; + const quantity = variation?.quantity ?? kitItem.quantity; + return typeof quantity === 'number' ? quantity : Infinity; +}; + +/** + * Matches a `kit_composition` entry to its (buyable) product and variation, + * `null` when the item can't be sold with the given selection. + */ +const matchKitItem = ( + kitItems: KitItems, + composition: KitComposition[number], + selectedVariationId?: ResourceId | null, +) => { + const kitItem = kitItems.find(({ _id }) => _id === composition._id); + if (!kitItem?.available || kitItem.visible === false) return null; + const variationId = composition.variation_id || selectedVariationId || undefined; + const variation = variationId + ? kitItem.variations?.find(({ _id }) => _id === variationId) + : undefined; + // Kit item with variations requires a selected (and valid) SKU + if (kitItem.variations?.length && !variation) return null; + return { kitItem, variationId, variation }; +}; + +const sumToKitComposition = ( + composition: CartKitComposition, + newItem: CartKitComposition[number], +) => { + const currentItem = composition.find((item) => { + return item._id === newItem._id && item.variation_id === newItem.variation_id; + }); + if (currentItem) { + currentItem.quantity = (currentItem.quantity || 0) + (newItem.quantity || 0); + } else { + composition.push(newItem); + } +}; + +/** + * Cart items (with `kit_product` set) for one kit pack, `null` when any item + * is unavailable, out of stock or missing its variation selection. + */ +const parseKitCartItems = ( + kitProduct: { _id: ResourceId, name?: string, price: number }, + kitComposition: KitComposition, + kitItems: KitItems, + quantityToAdd: number, + kitVariationIds?: KitVariationIds, +) => { + let packQuantity = 0; + const composition: CartKitComposition = []; + /* The same product may be repeated on kit composition (with distinct + variations), items must be merged to a single cart item each. */ + const cartItemsByKey: Record = {}; + for (let i = 0; i < kitComposition.length; i++) { + const matched = matchKitItem(kitItems, kitComposition[i], kitVariationIds?.[i]); + if (!matched) return null; + const { kitItem, variationId, variation } = matched; + const quantity = (kitComposition[i].quantity || 1) * quantityToAdd; + if (!checkInStock({ ...kitItem, ...variation, min_quantity: quantity })) { + return null; + } + packQuantity += quantity; + sumToKitComposition(composition, { _id: kitItem._id, variation_id: variationId, quantity }); + const key = `${kitItem._id}:${variationId || ''}`; + if (cartItemsByKey[key]) { + cartItemsByKey[key].quantity += quantity; + } else { + cartItemsByKey[key] = parseProduct(kitItem, variationId, quantity); + } + } + return Object.keys(cartItemsByKey).map((key) => ({ + ...cartItemsByKey[key], + kit_product: { + _id: kitProduct._id, + name: kitProduct.name, + price: kitProduct.price, + pack_quantity: packQuantity, + composition, + }, + })); +}; + export type Props = { product?: ProductItem & { __ssr?: boolean }; productId?: ResourceId; @@ -164,38 +275,60 @@ const useProductCard = (props: Pr }); const kitItems = ref(null); - const loadKitItems = async () => { + const isLoadingKitItems = ref(false); + let loadingKitItems: Promise | null = null; + const loadKitItems = () => { const kitComposition = product.kit_composition; - if (kitComposition?.length) { + if (!kitComposition?.length) return Promise.resolve(); + if (loadingKitItems) return loadingKitItems; + isLoadingKitItems.value = true; + loadingKitItems = (async () => { + const productIds: ResourceId[] = []; + kitComposition.forEach(({ _id }) => { + if (!productIds.includes(_id)) productIds.push(_id); + }); const { data } = await api.get('products', { - params: { _id: kitComposition.map(({ _id }) => _id) }, + params: { _id: productIds }, fields: kitItemFields, }); kitItems.value = data.result; - let maxKitQnt = product.quantity || 1; - for (let i = 0; i < kitItems.value.length; i++) { - const kitItem = kitItems.value[i]; - if (!kitItem.quantity) { + /* Kit availability is bound to its least available item, + so `quantity` must be the lowest number of packs any item can fill. */ + let maxKitQnt = Infinity; + for (let i = 0; i < kitComposition.length; i++) { + const { _id, quantity, variation_id: variationId } = kitComposition[i]; + const kitItem = data.result.find((item) => item._id === _id); + if (!kitItem) { maxKitQnt = 0; break; } - const compositionQnt = kitComposition - .find(({ _id }) => _id === kitItem._id)?.quantity || 1; - const maxKitQntByItem = Math.floor(kitItem.quantity / compositionQnt); - if (maxKitQntByItem > maxKitQnt) { + const itemStock = getKitItemStock(kitItem, variationId); + const maxKitQntByItem = Math.floor(itemStock / (quantity || 1)); + if (maxKitQntByItem < maxKitQnt) { maxKitQnt = maxKitQntByItem; } } - product.quantity = maxKitQnt; - } + if (maxKitQnt !== Infinity) { + product.quantity = typeof product.quantity === 'number' + ? Math.min(product.quantity, maxKitQnt) + : maxKitQnt; + } + })().catch((err) => { + loadingKitItems = null; + console.error(err); + }).finally(() => { + isLoadingKitItems.value = false; + }); + return loadingKitItems; }; const isLoadingToCart = ref(false); const isFailedToCart = ref(false); const loadToCart = async ( quantityToAdd = 1, - { variationId }: { + { variationId, kitVariationIds }: { variationId?: ResourceId | null, + kitVariationIds?: KitVariationIds, } = {}, ) => { isLoadingToCart.value = true; @@ -206,47 +339,18 @@ const useProductCard = (props: Pr if (kitComposition?.length) { if (variationId) return [null]; if (!kitItems.value) await loadKitItems(); - if (kitItems.value?.length !== kitComposition.length) { - return [null]; - } - for (let i = 0; i < kitComposition.length; i++) { - const { _id, quantity } = kitComposition[i]; - const kitItem = kitItems.value.find((item) => item._id === _id); - if ( - !kitItem?.available - || !checkInStock(kitItem) - || (quantity && kitItem.quantity! < quantity) - ) { - return [null]; - } - } - let packQuantity = 0; - const cartKitComposition: Array<{ _id: ResourceId, quantity: number }> = []; - kitComposition.forEach(({ _id, quantity }) => { - const kitItemQuantity = (quantity || 1) * quantityToAdd; - packQuantity += kitItemQuantity; - cartKitComposition.push(({ - _id, - quantity: kitItemQuantity, - })); - }); - return kitItems.value.map((kitItem) => { - const { quantity } = cartKitComposition.find(({ _id }) => { - return _id === kitItem._id; - }) || {}; - if (!quantity) return null; - const cartItem = addProductToCart(kitItem, undefined, quantity); - if (cartItem) { - cartItem.kit_product = { - _id: product._id, - name: product.name, - price: product.price, - pack_quantity: packQuantity, - composition: cartKitComposition, - }; - } - return cartItem; - }); + if (!kitItems.value?.length) return [null]; + const kitCartItems = parseKitCartItems( + product, + kitComposition, + kitItems.value, + quantityToAdd, + kitVariationIds, + ); + if (!kitCartItems) return [null]; + /* `kit_product` is set before adding to cart, otherwise each new item + may be merged with a matching (standalone) item already on cart. */ + return kitCartItems.map(addCartItem); } return [addProductToCart(product, variationId || undefined, quantityToAdd)]; })(); @@ -268,6 +372,7 @@ const useProductCard = (props: Pr discountPercentage, hasVariations, kitItems, + isLoadingKitItems, loadKitItems, loadToCart, isLoadingToCart, diff --git a/packages/storefront/src/lib/composables/use-product-details.ts b/packages/storefront/src/lib/composables/use-product-details.ts index 473a34d2f..3038ea6a9 100644 --- a/packages/storefront/src/lib/composables/use-product-details.ts +++ b/packages/storefront/src/lib/composables/use-product-details.ts @@ -9,7 +9,8 @@ import { onMounted, } from 'vue'; import { useUrlSearchParams } from '@vueuse/core'; -import { useProductCard } from '@@sf/composables/use-product-card'; +import { inStock as checkInStock } from '@ecomplus/utils'; +import { type KitItem, useProductCard } from '@@sf/composables/use-product-card'; export type Props = Partial & { product: Products; @@ -17,12 +18,30 @@ export type Props = Partial & { canUseUrlParams?: boolean; } +export type KitCompositionItem = { + /** Index on product `kit_composition`, also the key for variation selections */ + index: number; + productId: ResourceId; + /** Units of this item on each kit pack */ + quantity: number; + /** Product body, `null` while kit items are still loading */ + product: KitItem | null; + /** Selectable variations, empty when the kit fixes the variation itself */ + variations: Exclude; + variationId: ResourceId | null; + isSelected: boolean; + isInStock: boolean; +}; + export const useProductDetails = (props: Props) => { const { canUseUrlParams = true } = props; const { product, title, isActive, + kitItems, + isLoadingKitItems, + loadKitItems, loadToCart, isFailedToCart, } = useProductCard(props); @@ -51,7 +70,52 @@ export const useProductDetails = (props: Props) => { }); } + const isKit = computed(() => Boolean(product.kit_composition?.length)); + const kitVariationIds = reactive>([]); + onMounted(() => { + if (isKit.value) loadKitItems(); + }); + const kitComposition = computed(() => { + if (!product.kit_composition?.length) return []; + return product.kit_composition.map((composition, index) => { + const { _id: productId, variation_id: fixedVariationId } = composition; + const kitItem = kitItems.value?.find(({ _id }) => _id === productId) || null; + const _variationId = fixedVariationId || kitVariationIds[index] || null; + const variation = _variationId + ? kitItem?.variations?.find(({ _id }) => _id === _variationId) + : undefined; + const variations = (!fixedVariationId && kitItem?.variations) || []; + const quantityPerKit = composition.quantity || 1; + return { + index, + productId, + quantity: quantityPerKit, + product: kitItem, + variations, + variationId: _variationId, + isSelected: Boolean(!variations.length || _variationId), + isInStock: !kitItem || ( + kitItem.available !== false + && checkInStock({ + ...kitItem, + ...variation, + min_quantity: quantityPerKit * quantity.value, + }) + ), + }; + }); + }); + const selectKitVariation = (index: number, _variationId: ResourceId | null) => { + kitVariationIds[index] = _variationId; + if (kitComposition.value.every(({ isSelected }) => isSelected)) { + hasSkuSelectionAlert.value = false; + } + }; + const isSkuSelected = computed(() => { + if (isKit.value) { + return kitComposition.value.every(({ isSelected }) => isSelected); + } return Boolean(!product.variations?.length || variationId.value); }); const checkVariation = (ev?: Event) => { @@ -65,18 +129,40 @@ export const useProductDetails = (props: Props) => { }; const addToCart = () => { - if (!checkVariation()) return; - loadToCart(quantity.value, { variationId: variationId.value }); + if (!checkVariation()) return null; + return loadToCart(quantity.value, { + variationId: variationId.value, + kitVariationIds: isKit.value ? kitVariationIds : undefined, + }); }; const shippedItems = reactive([{ ...product, body_html: undefined, quantity: 1, - }]); - watch(quantity, () => { - shippedItems[0].quantity = quantity.value; - }); + }] as Array>); + /* Kits are shipped as their composition items, the kit product itself + usually has no weight/dimensions to calculate shipping with. */ + watch([quantity, kitComposition], () => { + if (!isKit.value) { + shippedItems[0].quantity = quantity.value; + return; + } + const kitShippedItems = kitComposition.value.reduce((items, item) => { + if (item.product) { + items.push({ + ...item.product, + body_html: undefined, + variation_id: item.variationId || undefined, + quantity: item.quantity * quantity.value, + }); + } + return items; + }, [] as Array>); + if (kitShippedItems.length) { + shippedItems.splice(0, shippedItems.length, ...kitShippedItems); + } + }, { immediate: true }); return { product, @@ -90,6 +176,11 @@ export const useProductDetails = (props: Props) => { addToCart, isFailedToCart, shippedItems, + isKit, + isLoadingKitItems, + kitComposition, + kitVariationIds, + selectKitVariation, }; };