@shopify/shop-minis-react 0.17.2 → 0.18.0
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/package.json +2 -2
- package/src/components/commerce/add-to-cart.test.tsx +110 -0
- package/src/components/commerce/add-to-cart.tsx +29 -12
- package/src/components/commerce/buy-now.test.tsx +109 -0
- package/src/components/commerce/buy-now.tsx +22 -8
- package/src/components/commerce/product-link.test.tsx +1 -0
- package/src/mocks.ts +1 -0
- package/src/test-utils.tsx +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shopify/shop-minis-react",
|
|
3
3
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.18.0",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"typescript": ">=5.0.0"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@shopify/shop-minis-platform": "0.
|
|
66
|
+
"@shopify/shop-minis-platform": "0.16.0",
|
|
67
67
|
"@tailwindcss/vite": "4.1.8",
|
|
68
68
|
"@tanstack/react-query": "5.86.0",
|
|
69
69
|
"@types/lodash": "4.17.20",
|
|
@@ -51,6 +51,7 @@ describe('AddToCartButton', () => {
|
|
|
51
51
|
id: 'gid://shopify/ProductVariant/456',
|
|
52
52
|
title: 'Default',
|
|
53
53
|
isFavorited: false,
|
|
54
|
+
availableForSale: true,
|
|
54
55
|
price: {
|
|
55
56
|
amount: '10.00',
|
|
56
57
|
currencyCode: 'USD',
|
|
@@ -231,6 +232,7 @@ describe('AddToCartButton', () => {
|
|
|
231
232
|
id: 'gid://shopify/ProductVariant/999',
|
|
232
233
|
title: 'Different',
|
|
233
234
|
isFavorited: false,
|
|
235
|
+
availableForSale: true,
|
|
234
236
|
price: {
|
|
235
237
|
amount: '15.00',
|
|
236
238
|
currencyCode: 'USD',
|
|
@@ -285,4 +287,112 @@ describe('AddToCartButton', () => {
|
|
|
285
287
|
variantImageUrl: 'https://example.com/variant-image.jpg',
|
|
286
288
|
})
|
|
287
289
|
})
|
|
290
|
+
|
|
291
|
+
describe('sold-out state', () => {
|
|
292
|
+
const soldOutProduct: Product = {
|
|
293
|
+
...mockProduct,
|
|
294
|
+
variants: [
|
|
295
|
+
{
|
|
296
|
+
id: 'gid://shopify/ProductVariant/456',
|
|
297
|
+
title: 'Default',
|
|
298
|
+
isFavorited: false,
|
|
299
|
+
availableForSale: false,
|
|
300
|
+
price: {
|
|
301
|
+
amount: '10.00',
|
|
302
|
+
currencyCode: 'USD',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
it('shows "Sold out" text when matching variant is not available for sale', () => {
|
|
309
|
+
render(
|
|
310
|
+
<AddToCartButton
|
|
311
|
+
product={soldOutProduct}
|
|
312
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
313
|
+
/>
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
expect(screen.getByText('Sold out')).toBeInTheDocument()
|
|
317
|
+
expect(screen.queryByText('Add to cart')).not.toBeInTheDocument()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('disables the button when matching variant is sold out', () => {
|
|
321
|
+
render(
|
|
322
|
+
<AddToCartButton
|
|
323
|
+
product={soldOutProduct}
|
|
324
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
325
|
+
/>
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('does not call addToCart when matching variant is sold out', async () => {
|
|
332
|
+
const user = userEvent.setup()
|
|
333
|
+
|
|
334
|
+
render(
|
|
335
|
+
<AddToCartButton
|
|
336
|
+
product={soldOutProduct}
|
|
337
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
338
|
+
/>
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
const button = screen.getByRole('button')
|
|
342
|
+
await user.click(button)
|
|
343
|
+
|
|
344
|
+
expect(mockMinisSDK.addToCart).not.toHaveBeenCalled()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('referral products show "View product" and stay clickable even when sold out', async () => {
|
|
348
|
+
const user = userEvent.setup()
|
|
349
|
+
const referralSoldOutProduct: Product = {
|
|
350
|
+
...soldOutProduct,
|
|
351
|
+
referral: true,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
render(
|
|
355
|
+
<AddToCartButton
|
|
356
|
+
product={referralSoldOutProduct}
|
|
357
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
358
|
+
/>
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
expect(screen.getByText('View product')).toBeInTheDocument()
|
|
362
|
+
expect(screen.queryByText('Sold out')).not.toBeInTheDocument()
|
|
363
|
+
|
|
364
|
+
const button = screen.getByRole('button')
|
|
365
|
+
expect(button).toBeEnabled()
|
|
366
|
+
|
|
367
|
+
await user.click(button)
|
|
368
|
+
expect(mockMinisSDK.navigateToProduct).toHaveBeenCalledWith({
|
|
369
|
+
productId: referralSoldOutProduct.id,
|
|
370
|
+
})
|
|
371
|
+
expect(mockMinisSDK.addToCart).not.toHaveBeenCalled()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('falls back to selectedVariant when variants array is missing', () => {
|
|
375
|
+
const productWithSelectedVariant: Product = {
|
|
376
|
+
...mockProduct,
|
|
377
|
+
variants: undefined,
|
|
378
|
+
selectedVariant: {
|
|
379
|
+
id: 'gid://shopify/ProductVariant/456',
|
|
380
|
+
title: 'Default',
|
|
381
|
+
isFavorited: false,
|
|
382
|
+
availableForSale: false,
|
|
383
|
+
price: {amount: '10.00', currencyCode: 'USD'},
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
render(
|
|
388
|
+
<AddToCartButton
|
|
389
|
+
product={productWithSelectedVariant}
|
|
390
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
391
|
+
/>
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
expect(screen.getByText('Sold out')).toBeInTheDocument()
|
|
395
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
396
|
+
})
|
|
397
|
+
})
|
|
288
398
|
})
|
|
@@ -41,16 +41,23 @@ export function AddToCartButton({
|
|
|
41
41
|
const {navigateToProduct} = useShopNavigation()
|
|
42
42
|
const [isAdded, setIsAdded] = useState(false)
|
|
43
43
|
const timeoutRef = React.useRef<number | undefined>(undefined)
|
|
44
|
-
const {id, referral, variants} = product ?? {}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
const {id, referral, variants, selectedVariant} = product ?? {}
|
|
45
|
+
|
|
46
|
+
// Look up the matching variant from `variants` first (productLists/savedProducts
|
|
47
|
+
// path); otherwise fall back to `selectedVariant` (useProduct/useProducts marketplace path).
|
|
48
|
+
const matchingVariant =
|
|
49
|
+
variants?.find(variant => variant.id === productVariantId) ??
|
|
50
|
+
(selectedVariant?.id === productVariantId ? selectedVariant : undefined)
|
|
51
|
+
const variantImageUrl = matchingVariant?.image?.url
|
|
52
|
+
const isSoldOut = matchingVariant?.availableForSale === false
|
|
53
|
+
// Referral products only navigate to the PDP — never gate them on stock so a
|
|
54
|
+
// sold-out selected variant doesn't strand the user without a way to switch.
|
|
55
|
+
const isDisabled = disabled || (!referral && isSoldOut)
|
|
49
56
|
|
|
50
57
|
const {showErrorToast} = useErrorToast()
|
|
51
58
|
|
|
52
59
|
const handleClick = useCallback(async () => {
|
|
53
|
-
if (
|
|
60
|
+
if (isDisabled) return
|
|
54
61
|
|
|
55
62
|
if (id && referral) {
|
|
56
63
|
navigateToProduct({
|
|
@@ -95,7 +102,7 @@ export function AddToCartButton({
|
|
|
95
102
|
console.error('Failed to add to cart:', error)
|
|
96
103
|
}
|
|
97
104
|
}, [
|
|
98
|
-
|
|
105
|
+
isDisabled,
|
|
99
106
|
id,
|
|
100
107
|
referral,
|
|
101
108
|
isAdded,
|
|
@@ -116,13 +123,18 @@ export function AddToCartButton({
|
|
|
116
123
|
}
|
|
117
124
|
}, [])
|
|
118
125
|
|
|
119
|
-
const
|
|
120
|
-
|
|
126
|
+
const getButtonText = () => {
|
|
127
|
+
if (referral) return 'View product'
|
|
128
|
+
if (isSoldOut) return 'Sold out'
|
|
129
|
+
if (isAdded) return 'Added to cart'
|
|
130
|
+
return 'Add to cart'
|
|
131
|
+
}
|
|
132
|
+
const buttonText = getButtonText()
|
|
121
133
|
|
|
122
134
|
return (
|
|
123
135
|
<Button
|
|
124
136
|
onClick={handleClick}
|
|
125
|
-
disabled={
|
|
137
|
+
disabled={isDisabled}
|
|
126
138
|
className={cn(
|
|
127
139
|
'relative overflow-hidden transition-all duration-300',
|
|
128
140
|
className
|
|
@@ -131,7 +143,7 @@ export function AddToCartButton({
|
|
|
131
143
|
>
|
|
132
144
|
<div className="relative flex items-center justify-center">
|
|
133
145
|
<AnimatePresence>
|
|
134
|
-
{isAdded && (
|
|
146
|
+
{isAdded && !isSoldOut && (
|
|
135
147
|
<motion.div
|
|
136
148
|
initial={{scale: 0, rotate: -180}}
|
|
137
149
|
animate={{scale: 1, rotate: 0}}
|
|
@@ -147,7 +159,12 @@ export function AddToCartButton({
|
|
|
147
159
|
</motion.div>
|
|
148
160
|
)}
|
|
149
161
|
</AnimatePresence>
|
|
150
|
-
<span
|
|
162
|
+
<span
|
|
163
|
+
className={cn(
|
|
164
|
+
isAdded && !isSoldOut && 'pl-5',
|
|
165
|
+
'transition-all duration-300'
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
151
168
|
{buttonText}
|
|
152
169
|
</span>
|
|
153
170
|
</div>
|
|
@@ -269,4 +269,113 @@ describe('BuyNowButton', () => {
|
|
|
269
269
|
discountCode: undefined,
|
|
270
270
|
})
|
|
271
271
|
})
|
|
272
|
+
|
|
273
|
+
describe('sold-out state', () => {
|
|
274
|
+
const soldOutProduct: Product = {
|
|
275
|
+
...mockProduct,
|
|
276
|
+
variants: [
|
|
277
|
+
{
|
|
278
|
+
id: 'gid://shopify/ProductVariant/456',
|
|
279
|
+
title: 'Default',
|
|
280
|
+
isFavorited: false,
|
|
281
|
+
availableForSale: false,
|
|
282
|
+
price: {
|
|
283
|
+
amount: '10.00',
|
|
284
|
+
currencyCode: 'USD',
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
it('shows "Sold out" text when matching variant is not available for sale', () => {
|
|
291
|
+
render(
|
|
292
|
+
<BuyNowButton
|
|
293
|
+
product={soldOutProduct}
|
|
294
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
295
|
+
/>
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
expect(screen.getByText('Sold out')).toBeInTheDocument()
|
|
299
|
+
expect(screen.queryByText('Buy now')).not.toBeInTheDocument()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('referral products show "View product" and stay clickable even when sold out', async () => {
|
|
303
|
+
const user = userEvent.setup()
|
|
304
|
+
const referralSoldOutProduct: Product = {
|
|
305
|
+
...soldOutProduct,
|
|
306
|
+
referral: true,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
render(
|
|
310
|
+
<BuyNowButton
|
|
311
|
+
product={referralSoldOutProduct}
|
|
312
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
313
|
+
/>
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
expect(screen.getByText('View product')).toBeInTheDocument()
|
|
317
|
+
expect(screen.queryByText('Sold out')).not.toBeInTheDocument()
|
|
318
|
+
|
|
319
|
+
const button = screen.getByRole('button')
|
|
320
|
+
expect(button).toBeEnabled()
|
|
321
|
+
|
|
322
|
+
await user.click(button)
|
|
323
|
+
expect(mockMinisSDK.navigateToProduct).toHaveBeenCalledWith({
|
|
324
|
+
productId: referralSoldOutProduct.id,
|
|
325
|
+
})
|
|
326
|
+
expect(mockMinisSDK.buyProduct).not.toHaveBeenCalled()
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('falls back to selectedVariant when variants array is missing', () => {
|
|
330
|
+
const productWithSelectedVariant: Product = {
|
|
331
|
+
...mockProduct,
|
|
332
|
+
variants: undefined,
|
|
333
|
+
selectedVariant: {
|
|
334
|
+
id: 'gid://shopify/ProductVariant/456',
|
|
335
|
+
title: 'Default',
|
|
336
|
+
isFavorited: false,
|
|
337
|
+
availableForSale: false,
|
|
338
|
+
price: {amount: '10.00', currencyCode: 'USD'},
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
render(
|
|
343
|
+
<BuyNowButton
|
|
344
|
+
product={productWithSelectedVariant}
|
|
345
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
346
|
+
/>
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
expect(screen.getByText('Sold out')).toBeInTheDocument()
|
|
350
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('disables the button when matching variant is sold out', () => {
|
|
354
|
+
render(
|
|
355
|
+
<BuyNowButton
|
|
356
|
+
product={soldOutProduct}
|
|
357
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
358
|
+
/>
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('does not call buyProduct when matching variant is sold out', async () => {
|
|
365
|
+
const user = userEvent.setup()
|
|
366
|
+
|
|
367
|
+
render(
|
|
368
|
+
<BuyNowButton
|
|
369
|
+
product={soldOutProduct}
|
|
370
|
+
productVariantId="gid://shopify/ProductVariant/456"
|
|
371
|
+
/>
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
const button = screen.getByRole('button')
|
|
375
|
+
await user.click(button)
|
|
376
|
+
|
|
377
|
+
expect(mockMinisSDK.buyProduct).not.toHaveBeenCalled()
|
|
378
|
+
expect(mockMinisSDK.navigateToProduct).not.toHaveBeenCalled()
|
|
379
|
+
})
|
|
380
|
+
})
|
|
272
381
|
})
|
|
@@ -38,12 +38,22 @@ export function BuyNowButton({
|
|
|
38
38
|
const {buyProduct} = useShopCartActions()
|
|
39
39
|
const {navigateToProduct} = useShopNavigation()
|
|
40
40
|
const [isPurchasing, setIsPurchasing] = useState(false)
|
|
41
|
-
const {id, referral} = product ?? {}
|
|
41
|
+
const {id, referral, variants, selectedVariant} = product ?? {}
|
|
42
|
+
|
|
43
|
+
// Look up the matching variant from `variants` first (productLists/savedProducts
|
|
44
|
+
// path); otherwise fall back to `selectedVariant` (useProduct/useProducts marketplace path).
|
|
45
|
+
const matchingVariant =
|
|
46
|
+
variants?.find(variant => variant.id === productVariantId) ??
|
|
47
|
+
(selectedVariant?.id === productVariantId ? selectedVariant : undefined)
|
|
48
|
+
const isSoldOut = matchingVariant?.availableForSale === false
|
|
49
|
+
// Referral products only navigate to the PDP — never gate them on stock so a
|
|
50
|
+
// sold-out selected variant doesn't strand the user without a way to switch.
|
|
51
|
+
const isDisabled = disabled || isPurchasing || (!referral && isSoldOut)
|
|
42
52
|
|
|
43
53
|
const {showErrorToast} = useErrorToast()
|
|
44
54
|
|
|
45
55
|
const handleClick = useCallback(async () => {
|
|
46
|
-
if (
|
|
56
|
+
if (isDisabled) return
|
|
47
57
|
|
|
48
58
|
if (id && referral) {
|
|
49
59
|
navigateToProduct({
|
|
@@ -53,8 +63,6 @@ export function BuyNowButton({
|
|
|
53
63
|
return
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
if (isPurchasing) return
|
|
57
|
-
|
|
58
66
|
try {
|
|
59
67
|
if (id && productVariantId) {
|
|
60
68
|
setIsPurchasing(true)
|
|
@@ -73,10 +81,9 @@ export function BuyNowButton({
|
|
|
73
81
|
setIsPurchasing(false)
|
|
74
82
|
}
|
|
75
83
|
}, [
|
|
76
|
-
|
|
84
|
+
isDisabled,
|
|
77
85
|
id,
|
|
78
86
|
referral,
|
|
79
|
-
isPurchasing,
|
|
80
87
|
navigateToProduct,
|
|
81
88
|
productVariantId,
|
|
82
89
|
buyProduct,
|
|
@@ -84,10 +91,17 @@ export function BuyNowButton({
|
|
|
84
91
|
showErrorToast,
|
|
85
92
|
])
|
|
86
93
|
|
|
94
|
+
const getButtonText = () => {
|
|
95
|
+
if (referral) return 'View product'
|
|
96
|
+
if (isSoldOut) return 'Sold out'
|
|
97
|
+
return 'Buy now'
|
|
98
|
+
}
|
|
99
|
+
const buttonText = getButtonText()
|
|
100
|
+
|
|
87
101
|
return (
|
|
88
102
|
<Button
|
|
89
103
|
onClick={handleClick}
|
|
90
|
-
disabled={
|
|
104
|
+
disabled={isDisabled}
|
|
91
105
|
className={cn('relative overflow-hidden', className)}
|
|
92
106
|
size={size}
|
|
93
107
|
aria-live="polite"
|
|
@@ -101,7 +115,7 @@ export function BuyNowButton({
|
|
|
101
115
|
exit={{opacity: 0, y: -10}}
|
|
102
116
|
transition={{duration: 0.2}}
|
|
103
117
|
>
|
|
104
|
-
{
|
|
118
|
+
{buttonText}
|
|
105
119
|
</motion.span>
|
|
106
120
|
</AnimatePresence>
|
|
107
121
|
</Button>
|
package/src/mocks.ts
CHANGED
|
@@ -479,6 +479,7 @@ export function makeMockActions(): ShopActions {
|
|
|
479
479
|
id: 'variant-1',
|
|
480
480
|
title: 'Variant 1',
|
|
481
481
|
isFavorited: false,
|
|
482
|
+
availableForSale: true,
|
|
482
483
|
image: {url: 'https://example.com/variant-1.jpg'},
|
|
483
484
|
price: {amount: '19.99', currencyCode: 'USD'},
|
|
484
485
|
compareAtPrice: {amount: '29.99', currencyCode: 'USD'},
|
package/src/test-utils.tsx
CHANGED
|
@@ -128,6 +128,7 @@ export const mockProductVariant = (
|
|
|
128
128
|
id: 'variant-1',
|
|
129
129
|
title: 'Default Variant',
|
|
130
130
|
isFavorited: false,
|
|
131
|
+
availableForSale: true,
|
|
131
132
|
price: mockMoney('29.99', 'USD'),
|
|
132
133
|
compareAtPrice: mockMoney('39.99', 'USD'),
|
|
133
134
|
image: mockProductImage(),
|