@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.
- package/generated-hook-maps/hook-actions-map.json +6 -0
- 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/hooks/index.ts +2 -0
- package/src/hooks/util/useCheckScopesConsent.ts +61 -0
- package/src/hooks/util/useRequestPermissions.ts +1 -0
- package/src/hooks/util/useRequestScopesConsent.ts +38 -0
- package/src/mocks.ts +9 -0
- package/src/test-utils.tsx +1 -0
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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/hooks/index.ts
CHANGED
|
@@ -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},
|
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(),
|