diff --git a/.changeset/chilled-pans-watch.md b/.changeset/chilled-pans-watch.md new file mode 100644 index 0000000000..ffaf4c22d4 --- /dev/null +++ b/.changeset/chilled-pans-watch.md @@ -0,0 +1,5 @@ +--- +'@graphcommerce/magento-product-bundle': patch +--- + +Add the ability to edit bundle products from the cart page diff --git a/.changeset/curly-meals-bathe.md b/.changeset/curly-meals-bathe.md new file mode 100644 index 0000000000..31813e83b1 --- /dev/null +++ b/.changeset/curly-meals-bathe.md @@ -0,0 +1,5 @@ +--- +'@graphcommerce/magento-product-bundle': patch +--- + +Magento 2.4.7: Render discounts for bundle products diff --git a/.changeset/seven-avocados-argue.md b/.changeset/seven-avocados-argue.md new file mode 100644 index 0000000000..48ac672aa8 --- /dev/null +++ b/.changeset/seven-avocados-argue.md @@ -0,0 +1,5 @@ +--- +'@graphcommerce/magento-product-bundle': patch +--- + +Calculate the product page price dynamically based on the options and quantities selected. diff --git a/packages/magento-product-bundle/components/BundleProductOptions/BundleOption.tsx b/packages/magento-product-bundle/components/BundleProductOptions/BundleOption.tsx index 9c0db83b58..247ecac302 100644 --- a/packages/magento-product-bundle/components/BundleProductOptions/BundleOption.tsx +++ b/packages/magento-product-bundle/components/BundleProductOptions/BundleOption.tsx @@ -6,20 +6,27 @@ import { filterNonNullableKeys, SectionHeader } from '@graphcommerce/next-ui' import { i18n } from '@lingui/core' import React, { useMemo } from 'react' import { BundleOptionValue } from './BundleOptionValue' -import type { BundleOptionProps, BundleOptionValueProps } from './types' +import { toBundleOptionType, type BundleOptionProps, type BundleOptionValueProps } from './types' export const BundleOption = React.memo((props) => { - const { idx, index, options, title, color, layout, size, variant, required: _required } = props + const { index, item, color, layout, size, variant, product, renderer } = props + const { options, title, required, type: incomingType, uid, price_range } = item const { control } = useFormAddProductsToCart() - - const required = _required ?? false + const type = toBundleOptionType(incomingType) return (
- + + {title} {required && ' *'} + + } + /> control={control} - required={required} + required={Boolean(required)} + multiple={type === 'checkbox' || type === 'multi'} color={color} layout={layout} size={size} @@ -30,22 +37,21 @@ export const BundleOption = React.memo((props) => { ? i18n._(/* i18n*/ 'Please select a value for ‘{label}’', { label: title }) : false, }} - name={ - options?.some((o) => o?.can_change_quantity) - ? `cartItems.${index}.entered_options.${idx}.uid` - : `cartItems.${index}.selected_options.${idx}` - } - render={BundleOptionValue} + name={`cartItems.${index}.selected_options_record.${uid}`} + render={renderer ?? BundleOptionValue} + requireOptionSelection={Boolean(required)} items={useMemo( () => filterNonNullableKeys(options).map((option) => ({ - ...option, + product, + item, + option, value: option.uid, - idx, index, - required, + dynamicPrice: product.dynamic_price ?? false, + discountPercent: price_range.minimum_price.discount?.percent_off ?? 0, })), - [idx, index, options, required], + [index, options, required], )} />
diff --git a/packages/magento-product-bundle/components/BundleProductOptions/BundleOptionValue.tsx b/packages/magento-product-bundle/components/BundleProductOptions/BundleOptionValue.tsx index 4bd7e5787d..d45a3bd9b5 100644 --- a/packages/magento-product-bundle/components/BundleProductOptions/BundleOptionValue.tsx +++ b/packages/magento-product-bundle/components/BundleProductOptions/BundleOptionValue.tsx @@ -1,10 +1,12 @@ import type { ActionCardItemRenderProps } from '@graphcommerce/ecommerce-ui' import { NumberFieldElement } from '@graphcommerce/ecommerce-ui' import { Image } from '@graphcommerce/image' -import { useFormAddProductsToCart } from '@graphcommerce/magento-product' -import { Money } from '@graphcommerce/magento-store' -import { ActionCard, Button, responsiveVal } from '@graphcommerce/next-ui' -import { Trans } from '@lingui/react' +import { ProductListPrice, useFormAddProductsToCart } from '@graphcommerce/magento-product' +import { ActionCard, responsiveVal } from '@graphcommerce/next-ui' +import { + calculateBundleOptionValuePrice, + toProductListPriceFragment, +} from './calculateBundleOptionValuePrice' import type { BundleOptionValueProps } from './types' const swatchSizes = { @@ -16,27 +18,30 @@ const swatchSizes = { export function BundleOptionValue(props: ActionCardItemRenderProps) { const { selected, - idx, - index, - price, + item, + option, product, - label, + index, size = 'large', color, - can_change_quantity, - quantity = 1, - required, + price, onReset, + ...rest } = props const { control } = useFormAddProductsToCart() - const thumbnail = product?.thumbnail?.url + const thumbnail = option.product?.thumbnail?.url + + const pricing = toProductListPriceFragment( + calculateBundleOptionValuePrice(product, item, option), + item.price_range.minimum_price.final_price.currency, + ) return ( : undefined} + title={option.label} + price={} image={ thumbnail && !thumbnail.includes('/placeholder/') && ( @@ -44,7 +49,7 @@ export function BundleOptionValue(props: ActionCardItemRenderProps ) } - action={ - (can_change_quantity || !required) && ( - - ) - } - reset={ - (can_change_quantity || !required) && ( - - ) - } + reset={<>} secondaryAction={ selected && - can_change_quantity && ( + option.can_change_quantity && ( e.stopPropagation()} /> ) diff --git a/packages/magento-product-bundle/components/BundleProductOptions/BundleProductOptions.tsx b/packages/magento-product-bundle/components/BundleProductOptions/BundleProductOptions.tsx index 454319b25b..c25734561f 100644 --- a/packages/magento-product-bundle/components/BundleProductOptions/BundleProductOptions.tsx +++ b/packages/magento-product-bundle/components/BundleProductOptions/BundleProductOptions.tsx @@ -1,9 +1,9 @@ import type { AddToCartItemSelector } from '@graphcommerce/magento-product' import type { ActionCardListProps } from '@graphcommerce/next-ui' -import { filterNonNullableKeys } from '@graphcommerce/next-ui' +import { nonNullable } from '@graphcommerce/next-ui' +import type { ProductPageItem_BundleFragment } from '../../graphql' import { BundleOption } from './BundleOption' import { BundleOptionValue } from './BundleOptionValue' -import type { BundleProductOptionsFragment } from './BundleProductOptions.gql' import type { BundleOptionValueProps } from './types' export type BundelProductOptionsProps = Pick< @@ -11,22 +11,22 @@ export type BundelProductOptionsProps = Pick< 'size' | 'layout' | 'color' | 'variant' > & { renderer?: React.FC - product: BundleProductOptionsFragment + product: ProductPageItem_BundleFragment } & AddToCartItemSelector export function BundleProductOptions(props: BundelProductOptionsProps) { - const { product, index = 0 } = props + const { product, index = 0, ...rest } = props return ( <> - {filterNonNullableKeys(product?.items, ['uid', 'title', 'type']).map((item) => ( + {(product?.items ?? []).filter(nonNullable).map((item) => ( ))} diff --git a/packages/magento-product-bundle/components/BundleProductOptions/calculateBundleOptionValuePrice.ts b/packages/magento-product-bundle/components/BundleProductOptions/calculateBundleOptionValuePrice.ts new file mode 100644 index 0000000000..08d8cfa156 --- /dev/null +++ b/packages/magento-product-bundle/components/BundleProductOptions/calculateBundleOptionValuePrice.ts @@ -0,0 +1,45 @@ +import type { CurrencyEnum } from '@graphcommerce/graphql-mesh' +import type { ProductListPriceFragment } from '@graphcommerce/magento-product/components' +import type { BundleProductItemOptionType, BundleProductItemType, BundleProductType } from './types' + +export type CalculatedBundleOptionValuePrice = [number, number] // [regularPrice, finalPrice] + +export function calculateBundleOptionValuePrice( + product: BundleProductType, + item: BundleProductItemType, + option: BundleProductItemOptionType, + quantity = 1, +): CalculatedBundleOptionValuePrice { + const { dynamic_price = false } = product + const precentOff = item?.price_range.minimum_price.discount?.percent_off ?? 0 + + const regularPrice = + (dynamic_price ? option.product?.price_range.minimum_price.final_price.value : option.price) ?? + 0 + + const finalPrice = regularPrice * (1 - precentOff / 100) + return [regularPrice * quantity, finalPrice * quantity] +} + +export function sumCalculatedBundleOptionValuePrices( + prices: CalculatedBundleOptionValuePrice[], +): CalculatedBundleOptionValuePrice { + return prices.reduce((acc, [regular, final]) => [acc[0] + regular, acc[1] + final], [0, 0]) +} + +export function substractCalculatedBundleOptionValuePrices( + basePrice: CalculatedBundleOptionValuePrice, + substractPrice: CalculatedBundleOptionValuePrice, +): CalculatedBundleOptionValuePrice { + return [basePrice[0] - substractPrice[0], basePrice[1] - substractPrice[1]] +} + +export function toProductListPriceFragment( + price: CalculatedBundleOptionValuePrice, + currency: CurrencyEnum | null | undefined, +): ProductListPriceFragment { + return { + regular_price: { currency: currency, value: price[0] }, + final_price: { currency, value: price[1] }, + } +} diff --git a/packages/magento-product-bundle/components/BundleProductOptions/types.ts b/packages/magento-product-bundle/components/BundleProductOptions/types.ts index eae84ce3a3..38c3ad330c 100644 --- a/packages/magento-product-bundle/components/BundleProductOptions/types.ts +++ b/packages/magento-product-bundle/components/BundleProductOptions/types.ts @@ -1,18 +1,32 @@ import type { ActionCardItemRenderProps } from '@graphcommerce/ecommerce-ui' import type { ActionCardListProps } from '@graphcommerce/next-ui' -import type { BundleProductOptionsFragment } from './BundleProductOptions.gql' +import type { ProductPageItem_BundleFragment } from '../../graphql' + +export type BundleProductType = ProductPageItem_BundleFragment +export type BundleProductItemType = NonNullable[number]> +export type BundleProductItemOptionType = NonNullable< + NonNullable[number] +> export type BundleOptionProps = { - idx: number - index: number renderer?: React.FC> -} & NonNullable[number]> & - Pick + index: number + product: BundleProductType + item: BundleProductItemType +} & Pick -export type BundleOptionValueProps = NonNullable< - NonNullable[number] -> & { - idx: number +export type BundleOptionValueProps = { index: number - required: boolean + product: BundleProductType + item: BundleProductItemType + option: BundleProductItemOptionType +} + +const possibleTypes = ['radio', 'checkbox', 'multi', 'select'] as const +export type BundleOptionType = (typeof possibleTypes)[number] + +export function toBundleOptionType(type: string | null | undefined): BundleOptionType { + if (!type) return 'radio' + if (possibleTypes.includes(type as BundleOptionType)) return type as BundleOptionType + return 'radio' } diff --git a/packages/magento-product-bundle/graphql/index.ts b/packages/magento-product-bundle/graphql/index.ts index 04c60d7026..d6256881b0 100644 --- a/packages/magento-product-bundle/graphql/index.ts +++ b/packages/magento-product-bundle/graphql/index.ts @@ -1,2 +1,8 @@ +export * from './inject/CreditMemoItem_Bundle.gql' +export * from './inject/InvoiceItem_Bundle.gql' +export * from './inject/OrderItem_Bundle.gql' export * from './inject/ProductListItemBundle.gql' +export * from './inject/ProductPageItem_Bundle.gql' +export * from './inject/ShipmentItem_Bundle.gql' +export * from './fragments/ItemSelectedBundleOption.gql' export * from './fragments/SelectedBundleOption.gql' diff --git a/packages/magento-product-bundle/components/BundleProductOptions/BundleProductOptions.graphql b/packages/magento-product-bundle/graphql/inject/ProductPageItem_Bundle.graphql similarity index 53% rename from packages/magento-product-bundle/components/BundleProductOptions/BundleProductOptions.graphql rename to packages/magento-product-bundle/graphql/inject/ProductPageItem_Bundle.graphql index fceab52812..0cb3b01d7b 100644 --- a/packages/magento-product-bundle/components/BundleProductOptions/BundleProductOptions.graphql +++ b/packages/magento-product-bundle/graphql/inject/ProductPageItem_Bundle.graphql @@ -1,9 +1,20 @@ -fragment BundleProductOptions on BundleProduct { +fragment ProductPageItem_Bundle on BundleProduct @inject(into: ["ProductPageItem"]) { ship_bundle_items dynamic_sku dynamic_price dynamic_weight + price_view items { + price_range { + minimum_price { + final_price { + currency + } + discount { + percent_off + } + } + } uid position required @@ -11,6 +22,7 @@ fragment BundleProductOptions on BundleProduct { title type options { + __typename can_change_quantity uid is_default @@ -29,6 +41,13 @@ fragment BundleProductOptions on BundleProduct { thumbnail { ...ProductImage } + price_range { + minimum_price { + final_price { + ...Money + } + } + } } } } diff --git a/packages/magento-product-bundle/graphql/inject/ShipmentItem_Bundle.graphql b/packages/magento-product-bundle/graphql/inject/ShipmentItem_Bundle.graphql new file mode 100644 index 0000000000..24dcc669e5 --- /dev/null +++ b/packages/magento-product-bundle/graphql/inject/ShipmentItem_Bundle.graphql @@ -0,0 +1,5 @@ +fragment ShipmentItem_Bundle on BundleShipmentItem @inject(into: ["ShipmentItem"]) { + bundle_options { + ...ItemSelectedBundleOption + } +} diff --git a/packages/magento-product-bundle/plugins/BundleProductPagePrice.tsx b/packages/magento-product-bundle/plugins/BundleProductPagePrice.tsx new file mode 100644 index 0000000000..961cf731cc --- /dev/null +++ b/packages/magento-product-bundle/plugins/BundleProductPagePrice.tsx @@ -0,0 +1,118 @@ +import { useWatch } from '@graphcommerce/ecommerce-ui' +import { + useFormAddProductsToCart, + type AddToCartItemSelector, + type ProductPagePriceProps, +} from '@graphcommerce/magento-product' +import type { PluginConfig, PluginProps } from '@graphcommerce/next-config' +import { filterNonNullableKeys, nonNullable } from '@graphcommerce/next-ui' +import { bundleProductPriceFraction } from '../components/BundleProductOptions/BundleOptionValue' +import { + calculateBundleOptionValuePrice, + substractCalculatedBundleOptionValuePrices, + sumCalculatedBundleOptionValuePrices, + toProductListPriceFragment, + type CalculatedBundleOptionValuePrice, +} from '../components/BundleProductOptions/calculateBundleOptionValuePrice' +import type { BundleProductItemOptionType } from '../components/BundleProductOptions/types' +import type { ProductPageItem_BundleFragment } from '../graphql' + +export const config: PluginConfig = { + type: 'component', + module: '@graphcommerce/magento-product', +} + +function isBundleProduct( + product: + | ProductPagePriceProps['product'] + | (ProductPagePriceProps['product'] & ProductPageItem_BundleFragment), +): product is ProductPagePriceProps['product'] & ProductPageItem_BundleFragment { + return ( + product.__typename === 'BundleProduct' && + Array.isArray((product as ProductPageItem_BundleFragment).items) + ) +} + +export function ProductPagePrice( + props: PluginProps & AddToCartItemSelector, +) { + const { Prev, product, index = 0, ...rest } = props + + const form = useFormAddProductsToCart() + const allSelectedOptions = + useWatch({ + control: form.control, + name: `cartItems.${index}.selected_options_record`, + }) ?? {} + + const allEnteredOptions = + useWatch({ + control: form.control, + name: `cartItems.${index}.entered_options_record`, + }) ?? {} + + if (!isBundleProduct(product)) { + return + } + + const cheapestPricesAlreadyIncludedInBasePrice = filterNonNullableKeys(product.items) + .filter((item) => item.required) + .map((item) => + item.options + .filter(nonNullable) + .map((option) => calculateBundleOptionValuePrice(product, item, option)) + .reduce((acc, price) => (price[1] < acc[1] ? price : acc)), + ) + + const reduceBase = sumCalculatedBundleOptionValuePrices(cheapestPricesAlreadyIncludedInBasePrice) + + const basePrice: CalculatedBundleOptionValuePrice = [ + product.price_range.minimum_price.regular_price.value ?? 0, + product.price_range.minimum_price.final_price.value ?? 0, + ] + const base = substractCalculatedBundleOptionValuePrices(basePrice, reduceBase) + + // This only works with Magento 2.4.7, but that is fine. + const itemPrices = filterNonNullableKeys(product.items) + .map((item) => { + const selectedOption = allSelectedOptions[item.uid] + const allOptions = item.options.filter(nonNullable) + + let options: BundleProductItemOptionType[] = selectedOption + ? allOptions.filter((o) => { + if (Array.isArray(selectedOption)) return selectedOption.includes(o?.uid ?? '') + return selectedOption === o?.uid + }) + : allOptions.filter((o) => o?.is_default) + + return options.map((option) => { + const quantity = allEnteredOptions[option.uid] + ? Number(allEnteredOptions[option.uid]) + : (option.quantity ?? 1) + return calculateBundleOptionValuePrice(product, item, option, quantity) + }) + }) + .flat(1) + + const totalPrice = toProductListPriceFragment( + sumCalculatedBundleOptionValuePrices([base, ...itemPrices]), + product.price_range.minimum_price.final_price.currency, + ) + + return ( + + ) +} diff --git a/packages/magento-product-bundle/plugins/Bundle_cartItemToCartItemInput.ts b/packages/magento-product-bundle/plugins/Bundle_cartItemToCartItemInput.ts new file mode 100644 index 0000000000..9a99af7a75 --- /dev/null +++ b/packages/magento-product-bundle/plugins/Bundle_cartItemToCartItemInput.ts @@ -0,0 +1,50 @@ +import type { CartItemInput } from '@graphcommerce/graphql-mesh' +import { type cartItemToCartItemInput as cartItemToCartItemInputType } from '@graphcommerce/magento-cart-items' +import type { AddProductsToCartFields } from '@graphcommerce/magento-product/components' +import type { FunctionPlugin, PluginConfig } from '@graphcommerce/next-config' +import { filterNonNullableKeys, isTypename } from '@graphcommerce/next-ui' +import { toBundleOptionType } from '../components/BundleProductOptions/types' + +export const config: PluginConfig = { + type: 'function', + module: '@graphcommerce/magento-cart-items', +} + +export const cartItemToCartItemInput: FunctionPlugin = ( + prev, + props, +) => { + const result = prev(props) + const { product, cartItem } = props + + if (!result) return result + if (!isTypename(product, ['BundleProduct'])) return result + if (!isTypename(cartItem, ['BundleCartItem'])) return result + + const selected: AddProductsToCartFields['cartItems'][number]['selected_options_record'] = {} + const entered: AddProductsToCartFields['cartItems'][number]['entered_options_record'] = {} + + const items = filterNonNullableKeys(product.items) + + filterNonNullableKeys(cartItem.bundle_options).forEach((option) => { + const values = filterNonNullableKeys(option?.values) + const productItem = items.find((item) => item.uid === option.uid) + const type = toBundleOptionType(productItem?.type) + + const vals = values.map((v) => v.uid) + console.log(option.uid) + selected[option.uid] = type === 'multi' || type === 'checkbox' ? vals : vals[0] + + values.forEach((v) => { + const productOptions = filterNonNullableKeys(productItem?.options) + const productOption = productOptions.find((o) => o.uid === v.uid) + if (productOption?.can_change_quantity) entered[v.uid] = v.quantity + }) + }) + + return { + ...result, + selected_options_record: { ...result.selected_options_record, ...selected }, + entered_options_record: { ...result.entered_options_record, ...entered }, + } +}