@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.
- package/CHANGELOG.md +24 -0
- package/components/AddProductsToCart/AddProductsToCartForm.tsx +20 -45
- package/components/AddProductsToCart/useFormAddProductsToCart.ts +16 -1
- package/components/ProductCustomizable/CustomizableAreaOption.tsx +22 -37
- package/components/ProductCustomizable/CustomizableCheckboxOption.tsx +61 -41
- package/components/ProductCustomizable/CustomizableDateOption.graphql +1 -0
- package/components/ProductCustomizable/CustomizableDateOption.tsx +42 -87
- package/components/ProductCustomizable/CustomizableDropDownOption.tsx +13 -5
- package/components/ProductCustomizable/CustomizableFieldOption.tsx +22 -40
- package/components/ProductCustomizable/CustomizableMultipleOption.tsx +54 -35
- package/components/ProductCustomizable/CustomizablePrice.tsx +40 -0
- package/components/ProductCustomizable/CustomizableRadioOption.tsx +56 -36
- package/components/ProductCustomizable/ProductCustomizable.graphql +1 -0
- package/components/ProductCustomizable/ProductCustomizable.tsx +10 -2
- package/components/ProductCustomizable/productCustomizableSelectors.ts +5 -7
- package/components/ProductListItem/ProductDiscountLabel.tsx +1 -1
- package/components/ProductListItem/ProductListItem.tsx +5 -2
- package/components/ProductListItem/ProductNewLabel.tsx +36 -0
- package/components/ProductListItems/renderer.tsx +1 -1
- package/components/ProductPageDescription/ProductPageDescription.tsx +10 -1
- package/components/ProductPageGallery/ProductPageGallery.tsx +22 -19
- package/components/ProductPageGallery/ProductVideo.graphql +1 -0
- package/components/ProductPageGallery/ProductVideo.tsx +169 -0
- package/components/ProductPageMeta/ProductPageMeta.graphql +1 -0
- package/components/ProductPageMeta/ProductPageMeta.tsx +2 -0
- package/components/ProductPagePrice/ProductPagePrice.graphql +1 -10
- package/components/ProductPagePrice/ProductPrice.graphql +12 -0
- package/components/ProductPagePrice/getProductTierPrice.ts +3 -5
- package/components/ProductPagePrice/useCustomizableOptionPrice.ts +38 -29
- package/{Api → graphql/fragments}/ProductListItem.graphql +2 -0
- package/graphql/index.ts +2 -0
- package/index.ts +1 -2
- package/package.json +13 -13
- /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
|
+
}
|
@@ -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
|
-
|
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 {
|
@@ -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(
|
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 (
|
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
|
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
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
)
|
53
|
-
|
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
|
-
|
56
|
-
|
57
|
-
|
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
|
-
|
63
|
-
if (Array.isArray(value)) {
|
77
|
+
if (selected) {
|
64
78
|
return (
|
65
|
-
|
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
|
-
|
77
|
-
|
78
|
-
|
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
|
83
|
-
},
|
91
|
+
return acc
|
92
|
+
}, 0)
|
84
93
|
|
85
|
-
return
|
94
|
+
return (price.value ?? 0) + (optionPrice - optionPriceIncluded)
|
86
95
|
}
|
package/graphql/index.ts
ADDED
package/index.ts
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
export * from './components'
|
2
|
-
export * from './
|
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.
|
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.
|
22
|
-
"@graphcommerce/eslint-config-pwa": "^9.1.0-canary.
|
23
|
-
"@graphcommerce/framer-next-pages": "^9.1.0-canary.
|
24
|
-
"@graphcommerce/framer-scroller": "^9.1.0-canary.
|
25
|
-
"@graphcommerce/graphql": "^9.1.0-canary.
|
26
|
-
"@graphcommerce/graphql-mesh": "^9.1.0-canary.
|
27
|
-
"@graphcommerce/image": "^9.1.0-canary.
|
28
|
-
"@graphcommerce/magento-cart": "^9.1.0-canary.
|
29
|
-
"@graphcommerce/magento-store": "^9.1.0-canary.
|
30
|
-
"@graphcommerce/next-ui": "^9.1.0-canary.
|
31
|
-
"@graphcommerce/prettier-config-pwa": "^9.1.0-canary.
|
32
|
-
"@graphcommerce/typescript-config-pwa": "^9.1.0-canary.
|
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",
|
File without changes
|