@graphcommerce/magento-product 9.1.0-canary.18 → 9.1.0-canary.19

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 +24 -0
  2. package/components/AddProductsToCart/AddProductsToCartForm.tsx +20 -45
  3. package/components/AddProductsToCart/useFormAddProductsToCart.ts +16 -1
  4. package/components/ProductCustomizable/CustomizableAreaOption.tsx +22 -37
  5. package/components/ProductCustomizable/CustomizableCheckboxOption.tsx +61 -41
  6. package/components/ProductCustomizable/CustomizableDateOption.graphql +1 -0
  7. package/components/ProductCustomizable/CustomizableDateOption.tsx +42 -87
  8. package/components/ProductCustomizable/CustomizableDropDownOption.tsx +13 -5
  9. package/components/ProductCustomizable/CustomizableFieldOption.tsx +22 -40
  10. package/components/ProductCustomizable/CustomizableMultipleOption.tsx +54 -35
  11. package/components/ProductCustomizable/CustomizablePrice.tsx +40 -0
  12. package/components/ProductCustomizable/CustomizableRadioOption.tsx +56 -36
  13. package/components/ProductCustomizable/ProductCustomizable.graphql +1 -0
  14. package/components/ProductCustomizable/ProductCustomizable.tsx +10 -2
  15. package/components/ProductCustomizable/productCustomizableSelectors.ts +5 -7
  16. package/components/ProductListItem/ProductDiscountLabel.tsx +1 -1
  17. package/components/ProductListItem/ProductListItem.tsx +5 -2
  18. package/components/ProductListItem/ProductNewLabel.tsx +36 -0
  19. package/components/ProductListItems/renderer.tsx +1 -1
  20. package/components/ProductPageDescription/ProductPageDescription.tsx +10 -1
  21. package/components/ProductPageGallery/ProductPageGallery.tsx +22 -19
  22. package/components/ProductPageGallery/ProductVideo.graphql +1 -0
  23. package/components/ProductPageGallery/ProductVideo.tsx +169 -0
  24. package/components/ProductPageMeta/ProductPageMeta.graphql +1 -0
  25. package/components/ProductPageMeta/ProductPageMeta.tsx +2 -0
  26. package/components/ProductPagePrice/ProductPagePrice.graphql +1 -10
  27. package/components/ProductPagePrice/ProductPrice.graphql +12 -0
  28. package/components/ProductPagePrice/getProductTierPrice.ts +3 -5
  29. package/components/ProductPagePrice/useCustomizableOptionPrice.ts +38 -29
  30. package/{Api → graphql/fragments}/ProductListItem.graphql +2 -0
  31. package/graphql/index.ts +2 -0
  32. package/index.ts +1 -2
  33. package/package.json +13 -13
  34. /package/{Api → graphql/fragments}/ProductPageItem.graphql +0 -0
@@ -0,0 +1,169 @@
1
+ import type { MotionImageAspectPropsAdditional } from '@graphcommerce/framer-scroller'
2
+ import { Fab, iconPlay, IconSvg, sxx, type FabProps } from '@graphcommerce/next-ui'
3
+ import { Avatar, Box, IconButton, styled, type SxProps, type Theme } from '@mui/material'
4
+ import { m, type MotionProps, type MotionStyle } from 'framer-motion'
5
+ import React, { useState } from 'react'
6
+ import type { ProductVideoFragment } from './ProductVideo.gql'
7
+
8
+ const youtubeRegExp = new RegExp(
9
+ '^(?:https?://|//)?(?:www\\.|m\\.)?' +
10
+ '(?:youtu\\.be/|(?:youtube\\.com/|youtube-nocookie\\.com/)(?:embed/|v/|watch\\?v=|watch\\?.+&v=))' +
11
+ '([\\w-]{11})(?![\\w-])',
12
+ )
13
+ const vimeoRegExp = new RegExp(
14
+ 'https?://(?:www\\.|player\\.)?vimeo.com/(?:channels/' +
15
+ '(?:\\w+/)?|groups/([^/]*)/videos/|album/(\\d+)/video/|video/|)(\\d+)(?:$|/|\\?)',
16
+ )
17
+
18
+ export function isHostedVideo(src: string): boolean {
19
+ return vimeoRegExp.test(src) || youtubeRegExp.test(src)
20
+ }
21
+
22
+ const Iframe = m.create(styled('iframe')({}))
23
+ const Video = m.create(styled('video')({}))
24
+
25
+ type IframeProps = React.ComponentProps<typeof Iframe>
26
+ type VideoProps = React.ComponentProps<typeof Video>
27
+
28
+ export type ProductVideoProps = {
29
+ video?: ProductVideoFragment
30
+ autoplay?: boolean
31
+ iframeProps?: IframeProps
32
+ videoProps?: VideoProps
33
+ sx?: SxProps<Theme>
34
+ width?: number
35
+ height?: number
36
+ } & MotionImageAspectPropsAdditional
37
+
38
+ export function PlayCircle(props: Omit<FabProps, 'icon'>) {
39
+ return (
40
+ <Fab
41
+ // color='inherit'
42
+ size='responsive'
43
+ sx={{
44
+ position: 'absolute',
45
+ top: '50%',
46
+ left: '50%',
47
+ transform: 'translate(-50%, -50%)',
48
+ '& svg': { pl: '2px' },
49
+ }}
50
+ icon={iconPlay}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ /**
57
+ * The regular URL will be something like https://vimeo.com/1059670239 The resulting URL will be
58
+ * something like https://player.vimeo.com/video/1059670239?autoplay=1&loop=1&muted=1
59
+ *
60
+ * https://developer.vimeo.com/player/sdk/embed
61
+ */
62
+ function vimeoUrl(regularUrl: string, noCookie: boolean, muted: boolean = true) {
63
+ const vimUrl = new URL(regularUrl)
64
+
65
+ vimUrl.host = 'player.vimeo.com'
66
+ vimUrl.pathname = `/video${vimUrl.pathname}`
67
+ vimUrl.searchParams.set('autoplay', '1')
68
+ vimUrl.searchParams.set('loop', '1')
69
+ vimUrl.searchParams.set('muted', muted ? '1' : '0')
70
+
71
+ return vimUrl.toString()
72
+ }
73
+
74
+ function youtubeUrl(regularUrl: string, noCookie: boolean = false, autoplay: boolean = true) {
75
+ const videoId = regularUrl.split('v=')[1]
76
+ if (!videoId) return null
77
+ const ytUrl = noCookie ? 'https://www.youtube-nocookie.com' : 'https://www.youtube.com'
78
+ let iframeSrc = `${ytUrl}/embed/${videoId}`
79
+ iframeSrc = autoplay ? `${iframeSrc}?autoplay=1&mute=1&loop=1&disablekb=1` : iframeSrc
80
+ return iframeSrc
81
+ }
82
+
83
+ function getEmbedUrl(regularUrl: string, noCookie: boolean = true, muted: boolean = true) {
84
+ if (youtubeRegExp.test(regularUrl)) {
85
+ return youtubeUrl(regularUrl, noCookie, muted)
86
+ }
87
+ if (vimeoRegExp.test(regularUrl)) {
88
+ return vimeoUrl(regularUrl, noCookie, muted)
89
+ }
90
+ return null
91
+ }
92
+
93
+ export function ProductVideo(props: ProductVideoProps) {
94
+ const { video, autoplay, iframeProps, videoProps, sx, style, layout, width, height } = props
95
+
96
+ const [play, setPlay] = useState(autoplay)
97
+
98
+ const videoContent = video?.video_content
99
+
100
+ if (!videoContent?.video_url) return null
101
+
102
+ const src = videoContent.video_url
103
+ const title = videoContent.video_title || undefined
104
+
105
+ const baseSx: SxProps<Theme> = (theme) => ({
106
+ display: 'block',
107
+ maxWidth: '99.6%',
108
+ maxHeight: '100%',
109
+ width: '100%',
110
+ height: 'auto',
111
+ position: 'absolute',
112
+ top: '50%',
113
+ left: '50%',
114
+ transform: 'translate(-50%, -50%)',
115
+ aspectRatio: width && height ? `${width} / ${height}` : '16 / 9',
116
+ border: 'none',
117
+ })
118
+
119
+ const embedUrl = getEmbedUrl(src)
120
+
121
+ return (
122
+ <>
123
+ {play &&
124
+ (embedUrl ? (
125
+ <Box
126
+ sx={(theme) => ({
127
+ width: '100%',
128
+ height: '100%',
129
+ background: theme.palette.background.image,
130
+ })}
131
+ >
132
+ <Iframe
133
+ title={title}
134
+ frameBorder='none'
135
+ allowFullScreen
136
+ loading={autoplay ? 'eager' : 'lazy'}
137
+ // src={autoplay ? `${src}?autoplay=1&mute=1&loop=1&controls=0&disablekb=1` : src}
138
+ {...iframeProps}
139
+ style={style}
140
+ src={embedUrl}
141
+ allow='autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media'
142
+ sx={sxx(baseSx, sx, iframeProps?.sx)}
143
+ />
144
+ </Box>
145
+ ) : (
146
+ <Video
147
+ src={src}
148
+ title={title}
149
+ controls={false}
150
+ autoPlay={Boolean(autoplay)}
151
+ muted={Boolean(autoplay)}
152
+ playsInline
153
+ {...videoProps}
154
+ style={style}
155
+ sx={sxx(baseSx, sx, videoProps?.sx)}
156
+ />
157
+ ))}
158
+
159
+ {!play && (
160
+ <PlayCircle
161
+ onClick={(e) => {
162
+ e.preventDefault()
163
+ setPlay(true)
164
+ }}
165
+ />
166
+ )}
167
+ </>
168
+ )
169
+ }
@@ -8,6 +8,7 @@ fragment ProductPageMeta on ProductInterface {
8
8
  }
9
9
  meta_title
10
10
  meta_description
11
+ meta_keyword
11
12
  price_range {
12
13
  minimum_price {
13
14
  final_price {
@@ -17,6 +17,7 @@ export function ProductPageMeta(props: ProductPageMetaProps) {
17
17
  media_gallery,
18
18
  name,
19
19
  meta_title,
20
+ meta_keyword,
20
21
  meta_description,
21
22
  url_key,
22
23
  __typename,
@@ -27,6 +28,7 @@ export function ProductPageMeta(props: ProductPageMetaProps) {
27
28
  title={meta_title ?? name ?? ''}
28
29
  metaDescription={meta_description ?? name ?? ''}
29
30
  canonical={productLink({ url_key, __typename })}
31
+ metaKeywords={meta_keyword ?? name ?? ''}
30
32
  ogImage={media_gallery?.[0]?.url}
31
33
  ogType='product'
32
34
  {...rest}
@@ -3,16 +3,7 @@ fragment ProductPagePrice on ProductInterface {
3
3
  url_key
4
4
  price_range {
5
5
  minimum_price {
6
- regular_price {
7
- ...Money
8
- }
9
- discount {
10
- amount_off
11
- percent_off
12
- }
13
- final_price {
14
- ...Money
15
- }
6
+ ...ProductPrice
16
7
  }
17
8
  }
18
9
  price_tiers {
@@ -0,0 +1,12 @@
1
+ fragment ProductPrice on ProductPrice {
2
+ regular_price {
3
+ ...Money
4
+ }
5
+ discount {
6
+ amount_off
7
+ percent_off
8
+ }
9
+ final_price {
10
+ ...Money
11
+ }
12
+ }
@@ -4,14 +4,12 @@ import type { ProductPagePriceFragment } from './ProductPagePrice.gql'
4
4
  export function getProductTierPrice(
5
5
  price: Pick<ProductPagePriceFragment, 'price_tiers'>,
6
6
  quantity: number,
7
- ): MoneyFragment | undefined {
7
+ ): MoneyFragment | undefined | null {
8
8
  const { price_tiers } = price
9
- let result
9
+ let result: MoneyFragment | undefined | null
10
10
 
11
11
  price_tiers?.forEach((priceTier) => {
12
- if (priceTier?.quantity && quantity >= priceTier?.quantity) {
13
- result = priceTier?.final_price
14
- }
12
+ if (priceTier?.quantity && quantity >= priceTier?.quantity) result = priceTier?.final_price
15
13
  })
16
14
 
17
15
  return result
@@ -4,7 +4,6 @@ import { filterNonNullableKeys, isTypename, nonNullable } from '@graphcommerce/n
4
4
  import type { AddToCartItemSelector } from '../AddProductsToCart'
5
5
  import { useFormAddProductsToCart } from '../AddProductsToCart'
6
6
  import type {
7
- AnyOption,
8
7
  CustomizableProductOptionBase,
9
8
  OptionValueSelector,
10
9
  SelectorsProp,
@@ -18,14 +17,18 @@ export type UseCustomizableOptionPriceProps = {
18
17
  } & AddToCartItemSelector &
19
18
  SelectorsProp
20
19
 
21
- function calcOptionPrice(option: CustomizableProductOptionBase, product: MoneyFragment) {
20
+ function calcOptionPrice(
21
+ option: CustomizableProductOptionBase,
22
+ productPrice: MoneyFragment | null | undefined,
23
+ ) {
22
24
  if (!option?.price) return 0
23
25
  switch (option.price_type) {
24
26
  case 'DYNAMIC':
27
+ return productPrice?.value ?? 0
25
28
  case 'FIXED':
26
29
  return option.price
27
30
  case 'PERCENT':
28
- return (product?.value ?? 0) * (option.price / 100)
31
+ return (productPrice?.value ?? 0) * (option.price / 100)
29
32
  }
30
33
 
31
34
  return 0
@@ -40,47 +43,53 @@ export function useCustomizableOptionPrice(props: UseCustomizableOptionPriceProp
40
43
  getProductTierPrice(product, cartItem?.quantity) ??
41
44
  product.price_range.minimum_price.final_price
42
45
 
43
- const allSelectors: OptionValueSelector = { ...productCustomizableSelectors, ...selectors }
46
+ const allSelectors = { ...productCustomizableSelectors, ...selectors } as OptionValueSelector
44
47
 
45
48
  if (isTypename(product, ['GroupedProduct'])) return price.value
46
49
  if (!product.options || product.options.length === 0) return price.value
47
50
 
48
- const finalPrice = product.options.filter(nonNullable).reduce((optionPrice, productOption) => {
49
- const isCustomizable = Boolean(cartItem.customizable_options?.[productOption.uid])
50
- const isEntered = Boolean(
51
- cartItem.entered_options?.find((o) => productOption.uid && o?.uid && o?.value),
52
- )
53
- if (!isCustomizable && !isEntered) return optionPrice
51
+ // If a product option is required the cheapest option is already included in the price of the product.
52
+ // We remove the value as the value is added in the finalPrice.
53
+ const optionPriceIncluded = product.options
54
+ .filter((o) => !!o)
55
+ .reduce((acc, productOption) => {
56
+ if (!productOption.required) return acc
54
57
 
55
- const selector = allSelectors[productOption.__typename] as
56
- | undefined
57
- | ((option: AnyOption) => CustomizableProductOptionBase | CustomizableProductOptionBase[])
58
+ const value = allSelectors[productOption.__typename]?.(productOption).reduce((p, v) => {
59
+ if (!v) return p
60
+ return Math.min(p, calcOptionPrice(v, price))
61
+ }, 0)
62
+
63
+ return acc + value
64
+ }, 0)
65
+
66
+ const optionPrice = product.options.filter(nonNullable).reduce((acc, productOption) => {
67
+ const { uid } = productOption
68
+
69
+ const selected = cartItem.selected_options_record?.[uid]
70
+ const entered = cartItem.entered_options_record?.[uid]
71
+
72
+ const selector = allSelectors[productOption.__typename]
58
73
  const value = selector ? selector(productOption) : null
59
74
 
60
75
  if (!value) return 0
61
76
 
62
- // If the option can have multiple values
63
- if (Array.isArray(value)) {
77
+ if (selected) {
64
78
  return (
65
- optionPrice +
79
+ acc +
66
80
  filterNonNullableKeys(value)
67
- .filter(
68
- (v) =>
69
- cartItem.customizable_options?.[productOption.uid] &&
70
- cartItem.customizable_options?.[productOption.uid].includes(v.uid),
71
- )
81
+ .filter((v) => (Array.isArray(selected) ? selected.includes(v.uid) : selected === v.uid))
72
82
  .reduce((p, v) => p + calcOptionPrice(v, price), 0)
73
83
  )
74
84
  }
75
85
 
76
- // If the option can have a single value entered.
77
- if (
78
- cartItem.entered_options?.filter((v) => v?.uid === productOption.uid && v.value).length !== 0
79
- )
80
- return optionPrice + calcOptionPrice(value, price)
86
+ if (entered) {
87
+ // If the option can have a single value entered.
88
+ return acc + calcOptionPrice(value[0], price)
89
+ }
81
90
 
82
- return optionPrice
83
- }, price.value ?? 0)
91
+ return acc
92
+ }, 0)
84
93
 
85
- return finalPrice
94
+ return (price.value ?? 0) + (optionPrice - optionPriceIncluded)
86
95
  }
@@ -3,6 +3,8 @@ fragment ProductListItem on ProductInterface {
3
3
  ...ProductLink
4
4
  sku
5
5
  name
6
+ new_to_date
7
+ new_from_date
6
8
  small_image {
7
9
  ...ProductImage
8
10
  }
@@ -0,0 +1,2 @@
1
+ export * from './fragments/ProductListItem.gql'
2
+ export * from './fragments/ProductPageItem.gql'
package/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export * from './components'
2
- export * from './Api/ProductListItem.gql'
3
- export * from './Api/ProductPageItem.gql'
2
+ export * from './graphql'
4
3
  export * from './hooks/useProductLink'
5
4
  export * from './hooks/useProductListLink'
6
5
  export * from './hooks/useProductListLinkReplace'
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@graphcommerce/magento-product",
3
3
  "homepage": "https://www.graphcommerce.org/",
4
4
  "repository": "github:graphcommerce-org/graphcommerce",
5
- "version": "9.1.0-canary.18",
5
+ "version": "9.1.0-canary.19",
6
6
  "sideEffects": false,
7
7
  "prettier": "@graphcommerce/prettier-config-pwa",
8
8
  "eslintConfig": {
@@ -18,18 +18,18 @@
18
18
  "typescript": "5.7.2"
19
19
  },
20
20
  "peerDependencies": {
21
- "@graphcommerce/ecommerce-ui": "^9.1.0-canary.18",
22
- "@graphcommerce/eslint-config-pwa": "^9.1.0-canary.18",
23
- "@graphcommerce/framer-next-pages": "^9.1.0-canary.18",
24
- "@graphcommerce/framer-scroller": "^9.1.0-canary.18",
25
- "@graphcommerce/graphql": "^9.1.0-canary.18",
26
- "@graphcommerce/graphql-mesh": "^9.1.0-canary.18",
27
- "@graphcommerce/image": "^9.1.0-canary.18",
28
- "@graphcommerce/magento-cart": "^9.1.0-canary.18",
29
- "@graphcommerce/magento-store": "^9.1.0-canary.18",
30
- "@graphcommerce/next-ui": "^9.1.0-canary.18",
31
- "@graphcommerce/prettier-config-pwa": "^9.1.0-canary.18",
32
- "@graphcommerce/typescript-config-pwa": "^9.1.0-canary.18",
21
+ "@graphcommerce/ecommerce-ui": "^9.1.0-canary.19",
22
+ "@graphcommerce/eslint-config-pwa": "^9.1.0-canary.19",
23
+ "@graphcommerce/framer-next-pages": "^9.1.0-canary.19",
24
+ "@graphcommerce/framer-scroller": "^9.1.0-canary.19",
25
+ "@graphcommerce/graphql": "^9.1.0-canary.19",
26
+ "@graphcommerce/graphql-mesh": "^9.1.0-canary.19",
27
+ "@graphcommerce/image": "^9.1.0-canary.19",
28
+ "@graphcommerce/magento-cart": "^9.1.0-canary.19",
29
+ "@graphcommerce/magento-store": "^9.1.0-canary.19",
30
+ "@graphcommerce/next-ui": "^9.1.0-canary.19",
31
+ "@graphcommerce/prettier-config-pwa": "^9.1.0-canary.19",
32
+ "@graphcommerce/typescript-config-pwa": "^9.1.0-canary.19",
33
33
  "@lingui/core": "^4.2.1",
34
34
  "@lingui/macro": "^4.2.1",
35
35
  "@lingui/react": "^4.2.1",