Skip to content

Commit

Permalink
Add the ability to edit bundle products from the cart page
Browse files Browse the repository at this point in the history
Magento 2.4.7: Render discounts for bundle products
Calculate the product page price dynamically based on the options and quantities selected.
  • Loading branch information
paales committed Feb 19, 2025
1 parent 02dcc99 commit 8ffd9b9
Show file tree
Hide file tree
Showing 13 changed files with 338 additions and 71 deletions.
5 changes: 5 additions & 0 deletions .changeset/chilled-pans-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphcommerce/magento-product-bundle': patch
---

Add the ability to edit bundle products from the cart page
5 changes: 5 additions & 0 deletions .changeset/curly-meals-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphcommerce/magento-product-bundle': patch
---

Magento 2.4.7: Render discounts for bundle products
5 changes: 5 additions & 0 deletions .changeset/seven-avocados-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphcommerce/magento-product-bundle': patch
---

Calculate the product page price dynamically based on the options and quantities selected.
Original file line number Diff line number Diff line change
Expand Up @@ -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<BundleOptionProps>((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 (
<div>
<SectionHeader labelLeft={title} sx={{ mt: 0 }} />
<SectionHeader
labelLeft={
<>
{title} {required && ' *'}
</>
}
/>
<ActionCardListForm<BundleOptionValueProps & ActionCardItemBase, AddProductsToCartFields>
control={control}
required={required}
required={Boolean(required)}
multiple={type === 'checkbox' || type === 'multi'}
color={color}
layout={layout}
size={size}
Expand All @@ -30,22 +37,21 @@ export const BundleOption = React.memo<BundleOptionProps>((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],
)}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -16,35 +18,38 @@ const swatchSizes = {
export function BundleOptionValue(props: ActionCardItemRenderProps<BundleOptionValueProps>) {
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 (
<ActionCard
{...props}
title={label}
price={price ? <Money value={price} /> : undefined}
title={option.label}
price={<ProductListPrice {...pricing} />}
image={
thumbnail &&
!thumbnail.includes('/placeholder/') && (
<Image
src={thumbnail}
width={40}
height={40}
alt={label ?? ''}
alt={option.label ?? option.product?.name ?? ''}
sizes={swatchSizes[size]}
sx={{
display: 'block',
Expand All @@ -55,40 +60,24 @@ export function BundleOptionValue(props: ActionCardItemRenderProps<BundleOptionV
/>
)
}
action={
(can_change_quantity || !required) && (
<Button disableRipple variant='inline' color='inherit' size='small' tabIndex={-1}>
<Trans id='Select' />
</Button>
)
}
reset={
(can_change_quantity || !required) && (
<Button disableRipple variant='inline' color='inherit' size='small' onClick={onReset}>
{can_change_quantity ? <Trans id='Change' /> : <Trans id='Remove' />}
</Button>
)
}
reset={<></>}
secondaryAction={
selected &&
can_change_quantity && (
option.can_change_quantity && (
<NumberFieldElement
size='small'
label='Quantity'
color={color}
inputProps={{ min: 1 }}
required
defaultValue={`${quantity}`}
defaultValue={`${option.quantity}`}
control={control}
sx={{
width: responsiveVal(80, 120),
mt: 2,
'& .MuiFormHelperText-root': {
margin: 1,
width: '100%',
},
'& .MuiFormHelperText-root': { margin: 1, width: '100%' },
}}
name={`cartItems.${index}.entered_options.${idx}.value`}
name={`cartItems.${index}.entered_options_record.${option.uid}`}
onMouseDown={(e) => e.stopPropagation()}
/>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
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<
ActionCardListProps,
'size' | 'layout' | 'color' | 'variant'
> & {
renderer?: React.FC<BundleOptionValueProps>
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) => (
<BundleOption
index={index}
key={item.uid}
color='primary'
{...props}
{...item}
idx={item.position ?? 0 + 1000}
item={item}
product={product}
{...rest}
renderer={BundleOptionValue}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -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] },
}
}
Original file line number Diff line number Diff line change
@@ -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<NonNullable<BundleProductType['items']>[number]>
export type BundleProductItemOptionType = NonNullable<
NonNullable<BundleProductItemType['options']>[number]
>

export type BundleOptionProps = {
idx: number
index: number
renderer?: React.FC<ActionCardItemRenderProps<BundleOptionValueProps>>
} & NonNullable<NonNullable<BundleProductOptionsFragment['items']>[number]> &
Pick<ActionCardListProps, 'size' | 'layout' | 'color' | 'variant'>
index: number
product: BundleProductType
item: BundleProductItemType
} & Pick<ActionCardListProps, 'size' | 'layout' | 'color' | 'variant'>

export type BundleOptionValueProps = NonNullable<
NonNullable<BundleOptionProps['options']>[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'
}
6 changes: 6 additions & 0 deletions packages/magento-product-bundle/graphql/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
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
sku
title
type
options {
__typename
can_change_quantity
uid
is_default
Expand All @@ -29,6 +41,13 @@ fragment BundleProductOptions on BundleProduct {
thumbnail {
...ProductImage
}
price_range {
minimum_price {
final_price {
...Money
}
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fragment ShipmentItem_Bundle on BundleShipmentItem @inject(into: ["ShipmentItem"]) {
bundle_options {
...ItemSelectedBundleOption
}
}
Loading

0 comments on commit 8ffd9b9

Please sign in to comment.