@graphcommerce/magento-product 8.1.0-canary.2 → 8.1.0-canary.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/components/AddProductsToCart/AddProductsToCartSnackbar.tsx +13 -14
  3. package/components/AddProductsToCart/findAddedItems.ts +81 -0
  4. package/components/AddProductsToCart/index.ts +3 -0
  5. package/components/AddProductsToCart/useAddProductsToCartAction.ts +4 -2
  6. package/components/JsonLdProduct/JsonLdProductOffer.graphql +2 -3
  7. package/components/JsonLdProduct/ProductPageJsonLd.tsx +9 -4
  8. package/components/JsonLdProduct/index.ts +1 -0
  9. package/components/ProductAddToCart/ProductAddToCart.tsx +6 -4
  10. package/components/ProductCustomizable/CustomizableAreaOption.tsx +41 -7
  11. package/components/ProductCustomizable/CustomizableDateOption.tsx +60 -7
  12. package/components/ProductCustomizable/CustomizableDropDownOption.tsx +63 -15
  13. package/components/ProductCustomizable/CustomizableFieldOption.tsx +40 -4
  14. package/components/ProductFiltersPro/ProductFiltersPro.tsx +25 -10
  15. package/components/ProductFiltersPro/ProductFiltersProAllFiltersChip.tsx +6 -2
  16. package/components/ProductFiltersPro/ProductFiltersProAllFiltersSidebar.tsx +6 -2
  17. package/components/ProductFiltersPro/ProductFiltersProSortChip.tsx +9 -28
  18. package/components/ProductFiltersPro/ProductFiltersProSortDirectionArrow.tsx +17 -0
  19. package/components/ProductFiltersPro/ProductFiltersProSortSection.tsx +7 -32
  20. package/components/ProductFiltersPro/useProductFiltersProSort.tsx +74 -0
  21. package/components/ProductListItems/CategoryDefault.graphql +5 -0
  22. package/components/ProductListItems/ProductListItemsBase.tsx +1 -1
  23. package/components/ProductListItems/filterTypes.tsx +1 -1
  24. package/components/ProductListItems/filteredProductList.tsx +1 -1
  25. package/components/ProductListItems/productListApplyCategoryDefaults.ts +28 -0
  26. package/components/ProductPageBreadcrumb/ProductPageBreadcrumb.tsx +5 -3
  27. package/components/ProductPagePrice/ProductPagePrice.graphql +3 -0
  28. package/components/ProductPagePrice/ProductPagePrice.tsx +11 -4
  29. package/components/ProductPagePrice/useCustomizableOptionPrice.ts +127 -0
  30. package/components/index.ts +2 -0
  31. package/hooks/useProductListLink.ts +10 -5
  32. package/hooks/useProductListLinkReplace.ts +3 -0
  33. package/package.json +13 -13
  34. package/tsconfig.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { Money } from '@graphcommerce/magento-store'
1
2
  import { TextFieldElement } from '@graphcommerce/ecommerce-ui'
2
3
  import { SectionHeader } from '@graphcommerce/next-ui'
3
4
  import { i18n } from '@lingui/core'
@@ -10,10 +11,12 @@ type CustomizableFieldOptionProps = React.ComponentProps<
10
11
  >
11
12
 
12
13
  export function CustomizableFieldOption(props: CustomizableFieldOptionProps) {
13
- const { uid, required, optionIndex, index, title, fieldValue } = props
14
- const { control, register } = useFormAddProductsToCart()
14
+ const { uid, required, optionIndex, index, title, fieldValue, productPrice, currency } = props
15
+ const { control, register, resetField, getValues } = useFormAddProductsToCart()
15
16
 
16
- const maxLength = fieldValue?.max_characters ?? 0
17
+ if (!fieldValue) return null
18
+
19
+ const maxLength = fieldValue.max_characters ?? 0
17
20
  return (
18
21
  <Box>
19
22
  <SectionHeader labelLeft={title} sx={{ mt: 0 }} />
@@ -29,7 +32,36 @@ export function CustomizableFieldOption(props: CustomizableFieldOptionProps) {
29
32
  control={control}
30
33
  name={`cartItems.${index}.entered_options.${optionIndex}.value`}
31
34
  required={Boolean(required)}
32
- validation={{
35
+ InputProps={{
36
+ endAdornment:
37
+ fieldValue.price === 0
38
+ ? null
39
+ : fieldValue.price && (
40
+ <Box
41
+ sx={{
42
+ display: 'flex',
43
+ typography: 'body1',
44
+ '&.sizeMedium': { typographty: 'subtitle1' },
45
+ '&.sizeLarge': { typography: 'h6' },
46
+ color: getValues(`cartItems.${index}.entered_options.${optionIndex}.value`)
47
+ ? 'text.primary'
48
+ : 'text.secondary',
49
+ }}
50
+ >
51
+ {/* Change fontFamily so the + is properly outlined */}
52
+ <span style={{ fontFamily: 'arial', paddingTop: '1px' }}>+{'\u00A0'}</span>
53
+ <Money
54
+ value={
55
+ fieldValue.price_type === 'PERCENT'
56
+ ? productPrice * (fieldValue.price / 100)
57
+ : fieldValue.price
58
+ }
59
+ currency={currency}
60
+ />
61
+ </Box>
62
+ ),
63
+ }}
64
+ rules={{
33
65
  maxLength: {
34
66
  value: maxLength,
35
67
  message: i18n._(/* i18n*/ 'There is a maximum of ‘{maxLength}’ characters', {
@@ -43,6 +75,10 @@ export function CustomizableFieldOption(props: CustomizableFieldOptionProps) {
43
75
  maxLength,
44
76
  })
45
77
  }
78
+ onChange={(data) => {
79
+ if (!data.currentTarget.value)
80
+ resetField(`cartItems.${index}.entered_options.${optionIndex}.value`)
81
+ }}
46
82
  />
47
83
  </Box>
48
84
  )
@@ -1,14 +1,15 @@
1
1
  import { useForm, UseFormProps, UseFormReturn } from '@graphcommerce/ecommerce-ui'
2
- import { useMemoObject } from '@graphcommerce/next-ui'
3
- import { useEventCallback } from '@mui/material'
4
- import React, { BaseSyntheticEvent, createContext, useContext, useMemo } from 'react'
5
- import { useProductListLinkReplace } from '../../hooks/useProductListLinkReplace'
2
+ import { useMatchMediaMotionValue, useMemoObject } from '@graphcommerce/next-ui'
3
+ import { useEventCallback, useTheme } from '@mui/material'
4
+ import { m, useTransform } from 'framer-motion'
5
+ import { useRouter } from 'next/router'
6
+ import React, { BaseSyntheticEvent, createContext, useContext, useMemo, useRef } from 'react'
7
+ import { productListLinkFromFilter } from '../../hooks/useProductListLink'
6
8
  import { ProductListFiltersFragment } from '../ProductListFilters/ProductListFilters.gql'
7
9
  import {
8
10
  ProductFilterParams,
9
11
  ProductListParams,
10
12
  toFilterParams,
11
- toProductListParams,
12
13
  } from '../ProductListItems/filterTypes'
13
14
 
14
15
  type DataProps = {
@@ -44,17 +45,31 @@ export type FilterFormProviderProps = Omit<
44
45
  params: ProductListParams
45
46
  } & DataProps
46
47
 
48
+ const isSidebar = import.meta.graphCommerce.productFiltersLayout === 'SIDEBAR'
49
+
47
50
  export function ProductFiltersPro(props: FilterFormProviderProps) {
48
51
  const { children, params, aggregations, appliedAggregations, filterTypes, ...formProps } = props
49
52
 
50
53
  const defaultValues = useMemoObject(toFilterParams(params))
51
54
  const form = useForm<ProductFilterParams>({ defaultValues, ...formProps })
55
+ const ref = useRef<HTMLFormElement>(null)
56
+
57
+ const router = useRouter()
58
+ const theme = useTheme()
59
+ const isDesktop = useMatchMediaMotionValue('up', 'md')
60
+ const scrollMarginTop = useTransform(() => (isDesktop.get() ? 0 : theme.appShell.headerHeightSm))
61
+ const scroll = useTransform(() => !isSidebar || isDesktop.get())
52
62
 
53
- const push = useProductListLinkReplace({ scroll: false })
54
63
  const submit = useEventCallback(
55
- form.handleSubmit(async (formValues) =>
56
- push({ ...toProductListParams(formValues), currentPage: 1 }),
57
- ),
64
+ form.handleSubmit(async (formValues) => {
65
+ const path = productListLinkFromFilter({ ...formValues, currentPage: 1 })
66
+ if (router.asPath === path) return false
67
+
68
+ const opts = { scroll: scroll.get() }
69
+ return (router.query.url ?? []).includes('q')
70
+ ? router.replace(path, path, opts)
71
+ : router.push(path, path, opts)
72
+ }),
58
73
  )
59
74
 
60
75
  const filterFormContext: FilterFormContextProps = useMemo(
@@ -71,7 +86,7 @@ export function ProductFiltersPro(props: FilterFormProviderProps) {
71
86
 
72
87
  return (
73
88
  <FilterFormContext.Provider value={filterFormContext}>
74
- <form noValidate onSubmit={submit} id='products' />
89
+ <m.form ref={ref} noValidate onSubmit={submit} id='products' style={{ scrollMarginTop }} />
75
90
  {children}
76
91
  </FilterFormContext.Provider>
77
92
  )
@@ -29,7 +29,7 @@ const defaultRenderer = {
29
29
  }
30
30
 
31
31
  export function ProductFiltersProAllFiltersChip(props: ProductFiltersProAllFiltersChipProps) {
32
- const { sort_fields, total_count, renderer, ...rest } = props
32
+ const { sort_fields, total_count, renderer, category, ...rest } = props
33
33
 
34
34
  const { submit, params, aggregations, appliedAggregations } = useProductFiltersPro()
35
35
  const { sort } = params
@@ -59,7 +59,11 @@ export function ProductFiltersProAllFiltersChip(props: ProductFiltersProAllFilte
59
59
  >
60
60
  {() => (
61
61
  <>
62
- <ProductFiltersProSortSection sort_fields={sort_fields} total_count={total_count} />
62
+ <ProductFiltersProSortSection
63
+ sort_fields={sort_fields}
64
+ total_count={total_count}
65
+ category={category}
66
+ />
63
67
  <ProductFiltersProLimitSection />
64
68
  <ProductFiltersProAggregations renderer={{ ...defaultRenderer, ...renderer }} />
65
69
  </>
@@ -20,11 +20,15 @@ const defaultRenderer = {
20
20
  }
21
21
 
22
22
  export function ProductFiltersProAllFiltersSidebar(props: ProductFiltersProAllFiltersSidebarProps) {
23
- const { sort_fields, total_count, renderer, sx = [] } = props
23
+ const { sort_fields, total_count, renderer, sx = [], category } = props
24
24
 
25
25
  return (
26
26
  <Box sx={[{ display: { xs: 'none', md: 'grid' } }, ...(Array.isArray(sx) ? sx : [sx])]}>
27
- <ProductFiltersProSortSection sort_fields={sort_fields} total_count={total_count} />
27
+ <ProductFiltersProSortSection
28
+ sort_fields={sort_fields}
29
+ total_count={total_count}
30
+ category={category}
31
+ />
28
32
  <ProductFiltersProLimitSection />
29
33
  <ProductFiltersProAggregations renderer={{ ...defaultRenderer, ...renderer }} />
30
34
  </Box>
@@ -1,53 +1,34 @@
1
- import { useWatch } from '@graphcommerce/ecommerce-ui'
2
- import { useQuery } from '@graphcommerce/graphql'
3
- import { StoreConfigDocument } from '@graphcommerce/magento-store'
4
1
  import {
5
2
  ActionCard,
6
3
  ActionCardListForm,
7
4
  ChipOverlayOrPopper,
8
5
  ChipOverlayOrPopperProps,
9
- filterNonNullableKeys,
10
6
  } from '@graphcommerce/next-ui'
11
7
  import { Trans } from '@lingui/react'
12
- import { useMemo } from 'react'
13
- import { ProductListSortFragment } from '../ProductListSort/ProductListSort.gql'
14
8
  import { useProductFiltersPro } from './ProductFiltersPro'
9
+ import { UseProductFiltersProSortProps, useProductFiltersProSort } from './useProductFiltersProSort'
15
10
 
16
- export type ProductListActionSortProps = ProductListSortFragment &
11
+ export type ProductListActionSortProps = UseProductFiltersProSortProps &
17
12
  Omit<
18
13
  ChipOverlayOrPopperProps,
19
14
  'label' | 'selected' | 'selectedLabel' | 'onApply' | 'onReset' | 'onClose' | 'children'
20
15
  >
21
16
 
22
17
  export function ProductFiltersProSortChip(props: ProductListActionSortProps) {
23
- const { sort_fields, chipProps, ...rest } = props
24
- const { params, form, submit } = useProductFiltersPro()
25
- const { control } = form
26
- const activeSort = useWatch({ control, name: 'sort' })
27
-
28
- const { data: storeConfigQuery } = useQuery(StoreConfigDocument)
29
- const defaultSort = storeConfigQuery?.storeConfig?.catalog_default_sort_by
30
-
31
- const options = useMemo(
32
- () =>
33
- filterNonNullableKeys(sort_fields?.options, ['value', 'label']).map((option) => ({
34
- ...option,
35
- value: option.value === defaultSort ? null : option.value,
36
- title: option.label,
37
- })),
38
- [defaultSort, sort_fields?.options],
39
- )
18
+ const { sort_fields, chipProps, category, ...rest } = props
19
+ const { submit, form } = useProductFiltersPro()
20
+ const { options, showReset, selected, selectedLabel } = useProductFiltersProSort(props)
40
21
 
41
22
  return (
42
23
  <ChipOverlayOrPopper
43
24
  {...rest}
44
25
  overlayProps={{ sizeSm: 'minimal', sizeMd: 'minimal', ...rest.overlayProps }}
45
26
  label={<Trans id='Sort By' />}
46
- selected={Boolean(params.sort)}
47
- selectedLabel={options.find((option) => option.value === params.sort)?.label}
27
+ selected={selected}
28
+ selectedLabel={selectedLabel}
48
29
  onApply={submit}
49
30
  onReset={
50
- activeSort
31
+ showReset
51
32
  ? () => {
52
33
  form.setValue('sort', null)
53
34
  form.setValue('dir', null)
@@ -60,7 +41,7 @@ export function ProductFiltersProSortChip(props: ProductListActionSortProps) {
60
41
  >
61
42
  {() => (
62
43
  <ActionCardListForm
63
- control={control}
44
+ control={form.control}
64
45
  name='sort'
65
46
  layout='list'
66
47
  variant='default'
@@ -0,0 +1,17 @@
1
+ import { SortEnum } from '@graphcommerce/graphql-mesh'
2
+ import { IconSvg } from '@graphcommerce/next-ui'
3
+ import * as IconArrowDown from '@graphcommerce/next-ui/icons/arrow-down.svg'
4
+ import * as IconArrowUp from '@graphcommerce/next-ui/icons/arrow-up.svg'
5
+
6
+ type Props = {
7
+ sortDirection: SortEnum | null
8
+ }
9
+
10
+ export function ProductFiltersProSortDirectionArrow({ sortDirection }: Props) {
11
+ return (
12
+ <IconSvg
13
+ src={sortDirection === 'ASC' || sortDirection === null ? IconArrowUp : IconArrowDown}
14
+ sx={{ display: 'flex' }}
15
+ />
16
+ )
17
+ }
@@ -1,46 +1,21 @@
1
- import { useWatch } from '@graphcommerce/ecommerce-ui'
2
- import { useQuery } from '@graphcommerce/graphql'
3
- import { StoreConfigDocument } from '@graphcommerce/magento-store'
4
- import {
5
- ActionCard,
6
- ActionCardAccordion,
7
- ActionCardListForm,
8
- Button,
9
- filterNonNullableKeys,
10
- } from '@graphcommerce/next-ui'
1
+ import { ActionCard, ActionCardAccordion, ActionCardListForm, Button } from '@graphcommerce/next-ui'
11
2
  import { Trans } from '@lingui/react'
12
- import { useMemo } from 'react'
13
- import { ProductListSortFragment } from '../ProductListSort/ProductListSort.gql'
14
3
  import { useProductFiltersPro } from './ProductFiltersPro'
4
+ import { UseProductFiltersProSortProps, useProductFiltersProSort } from './useProductFiltersProSort'
15
5
 
16
- export type ProductFiltersProSortSectionProps = ProductListSortFragment
6
+ export type ProductFiltersProSortSectionProps = UseProductFiltersProSortProps
17
7
 
18
8
  export function ProductFiltersProSortSection(props: ProductFiltersProSortSectionProps) {
19
- const { sort_fields } = props
20
9
  const { form } = useProductFiltersPro()
21
- const { control } = form
22
- const activeSort = useWatch({ control, name: 'sort' })
23
-
24
- const { data: storeConfigQuery } = useQuery(StoreConfigDocument)
25
- const defaultSort = storeConfigQuery?.storeConfig?.catalog_default_sort_by
26
-
27
- const options = useMemo(
28
- () =>
29
- filterNonNullableKeys(sort_fields?.options, ['value', 'label']).map((option) => ({
30
- ...option,
31
- value: option.value === defaultSort ? null : option.value,
32
- title: option.label,
33
- })),
34
- [defaultSort, sort_fields?.options],
35
- )
10
+ const { options, showReset, selected } = useProductFiltersProSort(props)
36
11
 
37
12
  return (
38
13
  <ActionCardAccordion
39
- defaultExpanded={!!activeSort}
14
+ defaultExpanded={selected}
40
15
  summary={<Trans id='Sort By' />}
41
16
  details={
42
17
  <ActionCardListForm
43
- control={control}
18
+ control={form.control}
44
19
  name='sort'
45
20
  layout='list'
46
21
  variant='default'
@@ -50,7 +25,7 @@ export function ProductFiltersProSortSection(props: ProductFiltersProSortSection
50
25
  />
51
26
  }
52
27
  right={
53
- activeSort ? (
28
+ showReset ? (
54
29
  <Button
55
30
  color='primary'
56
31
  onClick={(e) => {
@@ -0,0 +1,74 @@
1
+ import { useWatch } from '@graphcommerce/ecommerce-ui'
2
+ import { useQuery } from '@graphcommerce/graphql'
3
+ import { StoreConfigDocument } from '@graphcommerce/magento-store'
4
+ import { filterNonNullableKeys } from '@graphcommerce/next-ui'
5
+ import { i18n } from '@lingui/core'
6
+ import { useMemo } from 'react'
7
+ import { CategoryDefaultFragment } from '../ProductListItems/CategoryDefault.gql'
8
+ import { ProductFilterParams } from '../ProductListItems/filterTypes'
9
+ import { ProductListSortFragment } from '../ProductListSort'
10
+ import { useProductFiltersPro } from './ProductFiltersPro'
11
+ import type { ProductListActionSortProps } from './ProductFiltersProSortChip'
12
+ import { ProductFiltersProSortDirectionArrow } from './ProductFiltersProSortDirectionArrow'
13
+
14
+ const exclude = ['relevance', 'position']
15
+
16
+ export type UseProductFiltersProSortProps = ProductListSortFragment & {
17
+ category?: CategoryDefaultFragment
18
+ }
19
+
20
+ export function useProductFiltersProSort(props: ProductListActionSortProps) {
21
+ const { sort_fields, category } = props
22
+
23
+ const { params, form } = useProductFiltersPro()
24
+ const { control, setValue } = form
25
+
26
+ const sortFields = useMemo(
27
+ () =>
28
+ filterNonNullableKeys(sort_fields?.options).map((o) =>
29
+ !category?.uid && o.value === 'position'
30
+ ? { value: 'relevance', label: i18n._('Relevance') }
31
+ : o,
32
+ ),
33
+ [category?.uid, sort_fields?.options],
34
+ )
35
+ const availableSortBy = category?.available_sort_by ?? sortFields.map((o) => o.value)
36
+
37
+ const conf = useQuery(StoreConfigDocument).data?.storeConfig
38
+ const defaultSortBy = (
39
+ category ? category.default_sort_by ?? conf?.catalog_default_sort_by ?? 'position' : 'relevance'
40
+ ) as ProductFilterParams['sort']
41
+
42
+ const formSort = useWatch({ control, name: 'sort' })
43
+ const formDirection = useWatch({ control, name: 'dir' })
44
+ const showReset = Boolean(formSort !== defaultSortBy || formDirection === 'DESC')
45
+ const selected = Boolean(params.sort && (params.sort !== defaultSortBy || params.dir === 'DESC'))
46
+
47
+ const options = useMemo(
48
+ () =>
49
+ sortFields
50
+ .filter((o) => availableSortBy.includes(o.value))
51
+ .map((option) => {
52
+ const value = option.value === defaultSortBy ? null : option.value
53
+ const showSort = formSort === value && !exclude.includes(option.value)
54
+
55
+ return {
56
+ ...option,
57
+ value,
58
+ title: option.label,
59
+ ...(showSort && {
60
+ onClick: () => setValue('dir', formDirection === 'DESC' ? null : 'DESC'),
61
+ price: <ProductFiltersProSortDirectionArrow sortDirection={formDirection} />,
62
+ }),
63
+ }
64
+ }),
65
+ [sortFields, availableSortBy, defaultSortBy, formSort, formDirection, setValue],
66
+ )
67
+
68
+ return {
69
+ options,
70
+ selected,
71
+ showReset,
72
+ selectedLabel: options.find((option) => option.value === params.sort)?.label,
73
+ }
74
+ }
@@ -0,0 +1,5 @@
1
+ fragment CategoryDefault on CategoryInterface {
2
+ uid
3
+ default_sort_by
4
+ available_sort_by
5
+ }
@@ -1,7 +1,7 @@
1
1
  import { LazyHydrate, RenderType, extendableComponent, responsiveVal } from '@graphcommerce/next-ui'
2
2
  import { Box, BoxProps } from '@mui/material'
3
3
  import { ProductListItemFragment } from '../../Api/ProductListItem.gql'
4
- import { AddProductsToCartForm } from '../../index'
4
+ import { AddProductsToCartForm } from '../AddProductsToCart'
5
5
  import { ProductListItemProps } from '../ProductListItem/ProductListItem'
6
6
  import { ProductListItemRenderer } from './renderer'
7
7
 
@@ -31,7 +31,7 @@ export type ProductFilterParams = {
31
31
 
32
32
  export function toFilterParams(params: ProductListParams): ProductFilterParams {
33
33
  const [sortKey] = Object.keys(params.sort) as [keyof ProductAttributeSortInput]
34
- const dir = params.sort[sortKey] as SortEnum | undefined
34
+ const dir = params.sort[sortKey]?.toUpperCase() as SortEnum | undefined
35
35
 
36
36
  return {
37
37
  ...params,
@@ -35,7 +35,7 @@ export function parseParams(
35
35
  }
36
36
  if (param === 'dir') {
37
37
  const [sortBy] = Object.keys(categoryVariables.sort)
38
- if (sortBy) categoryVariables.sort[sortBy] = value as SortEnum
38
+ if (sortBy) categoryVariables.sort[sortBy] = value?.toUpperCase() as SortEnum
39
39
  return undefined
40
40
  }
41
41
 
@@ -0,0 +1,28 @@
1
+ import { StoreConfigQuery } from '@graphcommerce/magento-store'
2
+ import { CategoryDefaultFragment } from './CategoryDefault.gql'
3
+ import { ProductListParams } from './filterTypes'
4
+ import { cloneDeep } from '@graphcommerce/graphql'
5
+
6
+ export async function productListApplyCategoryDefaults(
7
+ params: ProductListParams | undefined,
8
+ conf: StoreConfigQuery,
9
+ category: Promise<CategoryDefaultFragment | null | undefined>,
10
+ ) {
11
+ if (!params) return params
12
+
13
+ const newParams = cloneDeep(params)
14
+ if (!newParams.pageSize) newParams.pageSize = conf.storeConfig?.grid_per_page ?? 12
15
+
16
+ if (Object.keys(params.sort).length === 0) {
17
+ const categorySort = (await category)?.default_sort_by as keyof ProductListParams['sort']
18
+ const defaultSort = conf.storeConfig?.catalog_default_sort_by as keyof ProductListParams['sort']
19
+ if (categorySort) newParams.sort = { [categorySort]: 'ASC' }
20
+ else if (defaultSort) newParams.sort = { [defaultSort]: 'ASC' }
21
+ }
22
+
23
+ if (!newParams.filters.category_uid?.in?.[0]) {
24
+ newParams.filters.category_uid = { eq: (await category)?.uid }
25
+ }
26
+
27
+ return newParams
28
+ }
@@ -32,9 +32,11 @@ export function ProductPageBreadcrumb(props: ProductPageBreadcrumbsProps) {
32
32
  {breadcrumb.category_name}
33
33
  </Link>
34
34
  ))}
35
- <Link href={`/${category?.url_path}`} underline='hover' color='inherit'>
36
- {category?.name}
37
- </Link>
35
+ {category && (
36
+ <Link href={`/${category?.url_path}`} underline='hover' color='inherit'>
37
+ {category?.name}
38
+ </Link>
39
+ )}
38
40
  <Typography color='text.primary'>{name}</Typography>
39
41
  </Breadcrumbs>
40
42
  )
@@ -1,4 +1,5 @@
1
1
  fragment ProductPagePrice on ProductInterface {
2
+ __typename
2
3
  url_key
3
4
  price_range {
4
5
  minimum_price {
@@ -30,4 +31,6 @@ fragment ProductPagePrice on ProductInterface {
30
31
  }
31
32
  quantity
32
33
  }
34
+
35
+ ...ProductCustomizable
33
36
  }
@@ -1,12 +1,17 @@
1
1
  import { useWatch } from '@graphcommerce/ecommerce-ui'
2
2
  import { Money } from '@graphcommerce/magento-store'
3
+ import { extendableComponent } from '@graphcommerce/next-ui'
4
+ import { Box } from '@mui/material'
3
5
  import { AddToCartItemSelector, useFormAddProductsToCart } from '../AddProductsToCart'
4
6
  import { ProductPagePriceFragment } from './ProductPagePrice.gql'
5
7
  import { getProductTierPrice } from './getProductTierPrice'
6
- import { extendableComponent } from '@graphcommerce/next-ui'
7
- import { Box } from '@mui/material'
8
+ import {
9
+ UseCustomizableOptionPriceProps,
10
+ useCustomizableOptionPrice,
11
+ } from './useCustomizableOptionPrice'
8
12
 
9
- export type ProductPagePriceProps = { product: ProductPagePriceFragment } & AddToCartItemSelector
13
+ export type ProductPagePriceProps = { product: ProductPagePriceFragment } & AddToCartItemSelector &
14
+ UseCustomizableOptionPriceProps
10
15
 
11
16
  const { classes } = extendableComponent('ProductPagePrice', ['root', 'discountPrice'] as const)
12
17
 
@@ -18,6 +23,8 @@ export function ProductPagePrice(props: ProductPagePriceProps) {
18
23
  const price =
19
24
  getProductTierPrice(product, quantity) ?? product.price_range.minimum_price.final_price
20
25
 
26
+ const priceValue = useCustomizableOptionPrice(props)
27
+
21
28
  return (
22
29
  <>
23
30
  {product.price_range.minimum_price.regular_price.value !== price.value && (
@@ -33,7 +40,7 @@ export function ProductPagePrice(props: ProductPagePriceProps) {
33
40
  <Money {...product.price_range.minimum_price.regular_price} />
34
41
  </Box>
35
42
  )}
36
- <Money {...price} />
43
+ <Money {...price} value={priceValue} />
37
44
  </>
38
45
  )
39
46
  }
@@ -0,0 +1,127 @@
1
+ import { useWatch } from '@graphcommerce/ecommerce-ui'
2
+ import { PriceTypeEnum } from '@graphcommerce/graphql-mesh'
3
+ import { MoneyFragment } from '@graphcommerce/magento-store'
4
+ import { filterNonNullableKeys, isTypename, nonNullable } from '@graphcommerce/next-ui'
5
+ import type { Simplify } from 'type-fest'
6
+ import { AddToCartItemSelector, useFormAddProductsToCart } from '../AddProductsToCart'
7
+ import type { CustomizableAreaOptionFragment } from '../ProductCustomizable/CustomizableAreaOption.gql'
8
+ import type { CustomizableCheckboxOptionFragment } from '../ProductCustomizable/CustomizableCheckboxOption.gql'
9
+ import type { CustomizableDateOptionFragment } from '../ProductCustomizable/CustomizableDateOption.gql'
10
+ import type { CustomizableDropDownOptionFragment } from '../ProductCustomizable/CustomizableDropDownOption.gql'
11
+ import type { CustomizableFieldOptionFragment } from '../ProductCustomizable/CustomizableFieldOption.gql'
12
+ import type { CustomizableFileOptionFragment } from '../ProductCustomizable/CustomizableFileOption.gql'
13
+ import type { CustomizableMultipleOptionFragment } from '../ProductCustomizable/CustomizableMultipleOption.gql'
14
+ import { CustomizableRadioOptionFragment } from '../ProductCustomizable/CustomizableRadioOption.gql'
15
+ import { ProductCustomizable_SimpleProduct_Fragment } from '../ProductCustomizable/ProductCustomizable.gql'
16
+ import { ProductPagePriceFragment } from './ProductPagePrice.gql'
17
+ import { getProductTierPrice } from './getProductTierPrice'
18
+
19
+ type AnyOption = NonNullable<
20
+ NonNullable<ProductCustomizable_SimpleProduct_Fragment['options']>[number]
21
+ >
22
+ type OptionValueSelector = {
23
+ [T in AnyOption as T['__typename']]: (option: T) => Option | Option[]
24
+ }
25
+
26
+ const defaultSelectors = {
27
+ CustomizableAreaOption: (o: CustomizableAreaOptionFragment) => o.areaValue,
28
+ CustomizableCheckboxOption: (o: CustomizableCheckboxOptionFragment) => o.checkboxValue,
29
+ CustomizableFileOption: (o: CustomizableFileOptionFragment) => o.fileValue,
30
+ CustomizableDateOption: (o: CustomizableDateOptionFragment) => o.dateValue,
31
+ CustomizableDropDownOption: (o: CustomizableDropDownOptionFragment) => o.dropdownValue,
32
+ CustomizableFieldOption: (o: CustomizableFieldOptionFragment) => o.fieldValue,
33
+ CustomizableMultipleOption: (o: CustomizableMultipleOptionFragment) => o.multipleValue,
34
+ CustomizableRadioOption: (o: CustomizableRadioOptionFragment) => o.radioValue,
35
+ }
36
+
37
+ type MissingOptionValueSelectors = Omit<OptionValueSelector, keyof typeof defaultSelectors>
38
+ type DefinedOptionValueSelectors = Partial<Pick<OptionValueSelector, keyof typeof defaultSelectors>>
39
+
40
+ type Selectors = Simplify<
41
+ keyof MissingOptionValueSelectors extends never
42
+ ? (MissingOptionValueSelectors & DefinedOptionValueSelectors) | undefined
43
+ : MissingOptionValueSelectors & DefinedOptionValueSelectors
44
+ >
45
+
46
+ export type UseCustomizableOptionPriceProps = {
47
+ product: ProductPagePriceFragment
48
+ } & AddToCartItemSelector &
49
+ (keyof MissingOptionValueSelectors extends never
50
+ ? { selectors?: Selectors }
51
+ : { selectors: Selectors })
52
+
53
+ type Option =
54
+ | {
55
+ price?: number | null | undefined
56
+ price_type?: PriceTypeEnum | null | undefined
57
+ uid?: string | null | undefined
58
+ }
59
+ | undefined
60
+ | null
61
+
62
+ function calcOptionPrice(option: Option, product: MoneyFragment) {
63
+ if (!option?.price) return 0
64
+ switch (option.price_type) {
65
+ case 'DYNAMIC':
66
+ case 'FIXED':
67
+ return option.price
68
+ case 'PERCENT':
69
+ return (product?.value ?? 0) * (option.price / 100)
70
+ }
71
+
72
+ return 0
73
+ }
74
+
75
+ export function useCustomizableOptionPrice(props: UseCustomizableOptionPriceProps) {
76
+ const { product, selectors, index = 0 } = props
77
+
78
+ const { control } = useFormAddProductsToCart()
79
+ const cartItem = useWatch({ control, name: `cartItems.${index}` }) ?? {}
80
+ const price =
81
+ getProductTierPrice(product, cartItem?.quantity) ??
82
+ product.price_range.minimum_price.final_price
83
+
84
+ const allSelectors: OptionValueSelector = { ...defaultSelectors, ...selectors }
85
+
86
+ if (isTypename(product, ['GroupedProduct'])) return price.value
87
+ if (!product.options || product.options.length === 0) return price.value
88
+
89
+ const finalPrice = product.options.filter(nonNullable).reduce((optionPrice, productOption) => {
90
+ const isCustomizable = Boolean(cartItem.customizable_options?.[productOption.uid])
91
+ const isEntered = Boolean(
92
+ cartItem.entered_options?.find((o) => productOption.uid && o?.uid && o?.value),
93
+ )
94
+ if (!isCustomizable && !isEntered) return optionPrice
95
+
96
+ const selector = allSelectors[productOption.__typename] as
97
+ | undefined
98
+ | ((option: AnyOption) => Option | Option[])
99
+ const value = selector ? selector(productOption) : null
100
+
101
+ if (!value) return 0
102
+
103
+ // If the option can have multiple values
104
+ if (Array.isArray(value)) {
105
+ return (
106
+ optionPrice +
107
+ filterNonNullableKeys(value)
108
+ .filter(
109
+ (v) =>
110
+ cartItem.customizable_options?.[productOption.uid] &&
111
+ cartItem.customizable_options?.[productOption.uid].includes(v.uid),
112
+ )
113
+ .reduce((p, v) => p + calcOptionPrice(v, price), 0)
114
+ )
115
+ }
116
+
117
+ // If the option can have a single value entered.
118
+ if (
119
+ cartItem.entered_options?.filter((v) => v?.uid === productOption.uid && v.value).length !== 0
120
+ )
121
+ return optionPrice + calcOptionPrice(value, price)
122
+
123
+ return optionPrice
124
+ }, price.value ?? 0)
125
+
126
+ return finalPrice
127
+ }