@shopify/shop-minis-react 0.17.2 → 0.19.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.
@@ -15,6 +15,9 @@
15
15
  "useCheckPermissions": [
16
16
  "CHECK_PERMISSION"
17
17
  ],
18
+ "useCheckScopesConsent": [
19
+ "CHECK_SCOPES_CONSENT"
20
+ ],
18
21
  "useCloseMini": [
19
22
  "CLOSE_MINI"
20
23
  ],
@@ -104,6 +107,9 @@
104
107
  "useRequestPermissions": [
105
108
  "REQUEST_PERMISSION"
106
109
  ],
110
+ "useRequestScopesConsent": [
111
+ "REQUEST_SCOPES_CONSENT"
112
+ ],
107
113
  "useSavedProducts": [
108
114
  "GET_SAVED_PRODUCTS"
109
115
  ],
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.2",
4
+ "version": "0.19.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.17.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,
@@ -51,6 +51,8 @@ export * from './util/useImagePicker'
51
51
  export * from './util/useKeyboardAvoidingView'
52
52
  export * from './util/useRequestPermissions'
53
53
  export * from './util/useCheckPermissions'
54
+ export * from './util/useCheckScopesConsent'
55
+ export * from './util/useRequestScopesConsent'
54
56
 
55
57
  // - Intent Hooks
56
58
  export * from './intents/useIntent'
@@ -0,0 +1,61 @@
1
+ import {CheckScopesConsentResponse} from '@shopify/shop-minis-platform/actions'
2
+
3
+ import {useShopActionQuery} from '../../internal/reactQuery'
4
+ import {useShopActions} from '../../internal/useShopActions'
5
+
6
+ export interface UseCheckScopesConsentReturns {
7
+ /**
8
+ * Required scopes declared by the mini, fetched fresh from the API.
9
+ */
10
+ requiredScopes: string[] | undefined
11
+ /**
12
+ * Scopes already granted by the user.
13
+ */
14
+ grantedScopes: string[] | undefined
15
+ /**
16
+ * Consent status derived from comparing required vs granted scopes.
17
+ *
18
+ * - `'granted'` — all required scopes are granted, or none declared
19
+ * - `'partially_granted'` — at least one required scope is granted but not all
20
+ * - `'not_granted'` — no required scopes are granted
21
+ */
22
+ status: CheckScopesConsentResponse['status'] | undefined
23
+ loading: boolean
24
+ error: Error | null
25
+ /**
26
+ * Re-fetch scope consent status. Call this after `requestScopesConsent()` resolves
27
+ * to get the updated status.
28
+ */
29
+ refetch: () => Promise<void>
30
+ }
31
+
32
+ export const useCheckScopesConsent = (): UseCheckScopesConsentReturns => {
33
+ const {checkScopesConsent} = useShopActions()
34
+ const {data, loading, error, refetch} = useShopActionQuery(
35
+ ['scopesConsent'],
36
+ checkScopesConsent,
37
+ {}
38
+ )
39
+ return {
40
+ requiredScopes: data?.requiredScopes,
41
+ grantedScopes: data?.grantedScopes,
42
+ status: data?.status,
43
+ loading,
44
+ error,
45
+ refetch,
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Returns the current scope consent status for this mini, fetched fresh from the API on mount.
51
+ *
52
+ * Use `status` to branch UI on whether the user has granted all required scopes:
53
+ * - `'granted'` — all required scopes are granted (or none declared)
54
+ * - `'partially_granted'` — at least one required scope is granted but not all
55
+ * - `'not_granted'` — no required scopes are granted
56
+ *
57
+ * Call `refetch()` after `requestScopesConsent()` resolves to get the updated status.
58
+ * @publicDocs
59
+ */
60
+ export type UseCheckScopesConsentGeneratedType =
61
+ () => UseCheckScopesConsentReturns
@@ -26,6 +26,7 @@ export const useRequestPermissions = (): UseRequestPermissionsReturns => {
26
26
  /**
27
27
  * The `useRequestPermissions` hook provides a function to request native device permissions from the user. It handles both app-level and system-level permission requests, showing appropriate dialogs and managing permission state. Supported permissions include camera, microphone, and device motion access.
28
28
  *
29
+ * `errorMessage` is normally a string describing the failure. The value `'request_blocked'` is a reserved sentinel — it means the request couldn't be served because another consent or permission sheet is already showing, or the Mini's scope state hasn't finished loading yet. Don't surface this string to users.
29
30
  *
30
31
  * > Note: Before using this hook, add the required permissions to your Mini's manifest file: `"permissions": ["CAMERA"]`.
31
32
  * @publicDocs
@@ -0,0 +1,38 @@
1
+ import {RequestScopesConsentResponse} from '@shopify/shop-minis-platform/actions'
2
+
3
+ import {useHandleAction} from '../../internal/useHandleAction'
4
+ import {useShopActions} from '../../internal/useShopActions'
5
+
6
+ export interface UseRequestScopesConsentReturns {
7
+ /**
8
+ * Programmatically show the consent sheet for the Mini's required scopes.
9
+ *
10
+ * The sheet will show even if the user previously rejected it in the same
11
+ * session. Use this to build retry UX (e.g., a "Connect account" button).
12
+ *
13
+ * Resolves with `{granted: boolean}`. Rejects with `'request_blocked'`
14
+ * if the request can't be served right now — either another consent or
15
+ * permission sheet is already showing, or the Mini's scope state hasn't
16
+ * finished loading yet.
17
+ *
18
+ * Call `refetch()` on `useCheckScopesConsent` after resolution to get the
19
+ * updated scope status.
20
+ */
21
+ requestScopesConsent: () => Promise<RequestScopesConsentResponse>
22
+ }
23
+
24
+ export const useRequestScopesConsent = (): UseRequestScopesConsentReturns => {
25
+ const {requestScopesConsent} = useShopActions()
26
+ return {
27
+ requestScopesConsent: useHandleAction(requestScopesConsent),
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Returns a function to programmatically show the consent sheet. Use this to
33
+ * build re-consent UX after the user has previously rejected, or when partial
34
+ * scope grants are detected via `useCheckScopesConsent`.
35
+ * @publicDocs
36
+ */
37
+ export type UseRequestScopesConsentGeneratedType =
38
+ () => UseRequestScopesConsentReturns
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'},
@@ -577,6 +578,14 @@ export function makeMockActions(): ShopActions {
577
578
  checkPermission: {
578
579
  status: 'granted',
579
580
  },
581
+ checkScopesConsent: {
582
+ data: {
583
+ requiredScopes: ['profile'],
584
+ grantedScopes: ['profile'],
585
+ status: 'granted',
586
+ },
587
+ },
588
+ requestScopesConsent: {granted: true},
580
589
  reportError: undefined,
581
590
  reportFetch: undefined,
582
591
  invokeIntent: {code: 'closed' as const},
@@ -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(),