@shopify/shop-minis-react 0.17.1 → 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 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.17.1",
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.15.1",
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
- const variantImageUrl = variants?.find(
47
- variant => variant.id === productVariantId
48
- )?.image?.url
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 (disabled) return
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
- disabled,
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 addToCartText = isAdded ? 'Added to cart' : 'Add to cart'
120
- const buttonText = referral ? 'View product' : addToCartText
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={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 className={cn(isAdded && 'pl-5', 'transition-all duration-300')}>
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 (disabled) return
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
- disabled,
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={disabled || isPurchasing}
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
- {referral ? 'View product' : 'Buy now'}
118
+ {buttonText}
105
119
  </motion.span>
106
120
  </AnimatePresence>
107
121
  </Button>
@@ -297,6 +297,7 @@ describe('ProductLink', () => {
297
297
  id: 'selected-variant-id',
298
298
  title: 'Selected Variant',
299
299
  isFavorited: false,
300
+ availableForSale: true,
300
301
  price: {amount: '29.99', currencyCode: 'USD'},
301
302
  compareAtPrice: null,
302
303
  image: null,
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'},
@@ -617,5 +618,11 @@ export const injectMocks = ({force}: {force?: boolean} = {}) => {
617
618
  initialUrl: '/',
618
619
  platform: 'web',
619
620
  }
621
+ // Mock minisEvents so NavigationManager and event hooks work in dev
622
+ let listenerId = 0
623
+ window.minisEvents = {
624
+ on: () => ++listenerId,
625
+ off: () => {},
626
+ }
620
627
  }
621
628
  }
package/src/test-setup.ts CHANGED
@@ -43,6 +43,16 @@ global.ResizeObserver = vi.fn().mockImplementation(() => ({
43
43
  disconnect: vi.fn(),
44
44
  }))
45
45
 
46
+ // Mock window.minisEvents (used by NavigationManager)
47
+ let _testListenerId = 0
48
+ Object.defineProperty(window, 'minisEvents', {
49
+ writable: true,
50
+ value: {
51
+ on: () => ++_testListenerId,
52
+ off: () => {},
53
+ },
54
+ })
55
+
46
56
  // Set default window size for virtuoso calculations
47
57
  Object.defineProperty(window, 'innerHeight', {
48
58
  writable: true,
@@ -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(),