@shopify/shop-minis-react 0.2.3 → 0.2.6

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.
Files changed (24) hide show
  1. package/dist/_virtual/index10.js +2 -2
  2. package/dist/_virtual/index4.js +2 -3
  3. package/dist/_virtual/index4.js.map +1 -1
  4. package/dist/_virtual/index7.js +3 -2
  5. package/dist/_virtual/index7.js.map +1 -1
  6. package/dist/_virtual/index9.js +2 -2
  7. package/dist/components/atoms/touchable.js +31 -28
  8. package/dist/components/atoms/touchable.js.map +1 -1
  9. package/dist/components/commerce/product-card.js +61 -58
  10. package/dist/components/commerce/product-card.js.map +1 -1
  11. package/dist/components/commerce/product-link.js +144 -131
  12. package/dist/components/commerce/product-link.js.map +1 -1
  13. package/dist/shop-minis-react/node_modules/.pnpm/@radix-ui_react-use-is-hydrated@0.1.0_@types_react@19.1.6_react@19.1.0/node_modules/@radix-ui/react-use-is-hydrated/dist/index.js +1 -1
  14. package/dist/shop-minis-react/node_modules/.pnpm/@xmldom_xmldom@0.8.10/node_modules/@xmldom/xmldom/lib/index.js +1 -1
  15. package/dist/shop-minis-react/node_modules/.pnpm/querystringify@2.2.0/node_modules/querystringify/index.js +1 -1
  16. package/dist/shop-minis-react/node_modules/.pnpm/use-sync-external-store@1.5.0_react@19.1.0/node_modules/use-sync-external-store/shim/index.js +1 -1
  17. package/eslint/rules/prefer-sdk-components.cjs +18 -8
  18. package/eslint/rules/validate-manifest.cjs +99 -1
  19. package/package.json +1 -1
  20. package/src/components/atoms/touchable.tsx +7 -4
  21. package/src/components/commerce/product-card.test.tsx +68 -0
  22. package/src/components/commerce/product-card.tsx +10 -2
  23. package/src/components/commerce/product-link.test.tsx +81 -0
  24. package/src/components/commerce/product-link.tsx +36 -10
@@ -82,6 +82,10 @@ module.exports = {
82
82
  messages: {
83
83
  missingScope:
84
84
  '{{source}} requires scope "{{scope}}" in src/manifest.json. Add "{{scope}}" to the "scopes" array.',
85
+ missingScopeProductCard:
86
+ 'Component "ProductCard" requires scope "{{scope}}" in src/manifest.json. Add "{{scope}}" to the "scopes" array or set favoriteButtonDisabled to true on all ProductCard instances.',
87
+ missingScopeProductLink:
88
+ 'Component "ProductLink" requires scope "{{scope}}" in src/manifest.json. Add "{{scope}}" to the "scopes" array or set hideFavoriteAction to true (or provide a customAction) on all ProductLink instances.',
85
89
  missingPermission:
86
90
  '{{reason}} requires permission "{{permission}}" in src/manifest.json. Add "{{permission}}" to the "permissions" array.',
87
91
  missingTrustedDomain:
@@ -105,6 +109,8 @@ module.exports = {
105
109
  const requiredPermissions = new Set()
106
110
  const requiredDomains = new Set()
107
111
  const fixedIssues = new Set()
112
+ // Track how components are actually used (e.g., with specific props)
113
+ const componentUsagePatterns = new Map()
108
114
 
109
115
  // Check module-level cache first to avoid repeated file I/O
110
116
  if (manifestPathCache && fs.existsSync(manifestPathCache)) {
@@ -361,6 +367,78 @@ module.exports = {
361
367
  }
362
368
  },
363
369
 
370
+ // Track ProductCard and ProductLink usage with disabled favorite functionality
371
+ JSXElement(node) {
372
+ const elementName = node.openingElement.name.name
373
+
374
+ // Handle ProductCard with favoriteButtonDisabled prop
375
+ if (elementName === 'ProductCard') {
376
+ // Check if favoriteButtonDisabled prop is present and true
377
+ const favoriteDisabledProp = node.openingElement.attributes.find(
378
+ attr =>
379
+ attr.type === 'JSXAttribute' &&
380
+ attr.name?.name === 'favoriteButtonDisabled'
381
+ )
382
+
383
+ const isDisabled =
384
+ favoriteDisabledProp &&
385
+ // Shorthand syntax: <ProductCard favoriteButtonDisabled />
386
+ (favoriteDisabledProp.value === null ||
387
+ // Explicit true: <ProductCard favoriteButtonDisabled={true} />
388
+ (favoriteDisabledProp.value?.type === 'JSXExpressionContainer' &&
389
+ favoriteDisabledProp.value?.expression?.type === 'Literal' &&
390
+ favoriteDisabledProp.value?.expression?.value === true))
391
+
392
+ // Track usage pattern
393
+ const componentPath = 'commerce/product-card'
394
+ if (!componentUsagePatterns.has(componentPath)) {
395
+ componentUsagePatterns.set(componentPath, {
396
+ allDisabled: true,
397
+ hasUsage: true,
398
+ })
399
+ }
400
+ const pattern = componentUsagePatterns.get(componentPath)
401
+ pattern.allDisabled = pattern.allDisabled && isDisabled
402
+ }
403
+
404
+ // Handle ProductLink with hideFavoriteAction or customAction props
405
+ if (elementName === 'ProductLink') {
406
+ // Check if hideFavoriteAction prop is present and true
407
+ const hideFavoriteProp = node.openingElement.attributes.find(
408
+ attr =>
409
+ attr.type === 'JSXAttribute' &&
410
+ attr.name?.name === 'hideFavoriteAction'
411
+ )
412
+
413
+ // Check if customAction prop is present (replaces favorite action)
414
+ const customActionProp = node.openingElement.attributes.find(
415
+ attr =>
416
+ attr.type === 'JSXAttribute' && attr.name?.name === 'customAction'
417
+ )
418
+
419
+ const isFavoriteDisabled =
420
+ // hideFavoriteAction={true} or shorthand
421
+ (hideFavoriteProp &&
422
+ (hideFavoriteProp.value === null || // shorthand
423
+ (hideFavoriteProp.value?.type === 'JSXExpressionContainer' &&
424
+ hideFavoriteProp.value?.expression?.type === 'Literal' &&
425
+ hideFavoriteProp.value?.expression?.value === true))) ||
426
+ // customAction is provided (any truthy value replaces favorites)
427
+ customActionProp !== undefined
428
+
429
+ // Track usage pattern
430
+ const componentPath = 'commerce/product-link'
431
+ if (!componentUsagePatterns.has(componentPath)) {
432
+ componentUsagePatterns.set(componentPath, {
433
+ allDisabled: true,
434
+ hasUsage: true,
435
+ })
436
+ }
437
+ const pattern = componentUsagePatterns.get(componentPath)
438
+ pattern.allDisabled = pattern.allDisabled && isFavoriteDisabled
439
+ }
440
+ },
441
+
364
442
  // Check JSX attributes for external URLs
365
443
  JSXAttribute(node) {
366
444
  if (!node.value || node.value.type !== 'Literal') {
@@ -489,6 +567,18 @@ module.exports = {
489
567
  // Check scopes for components
490
568
  usedComponents.forEach(
491
569
  ({path: componentPath, name: componentName, node}) => {
570
+ // Special handling for components with conditional favorite functionality
571
+ if (
572
+ componentPath === 'commerce/product-card' ||
573
+ componentPath === 'commerce/product-link'
574
+ ) {
575
+ const usagePattern = componentUsagePatterns.get(componentPath)
576
+ // Skip scope requirement if all usages have favorites disabled
577
+ if (usagePattern?.hasUsage && usagePattern?.allDisabled) {
578
+ return // No scope required when favorite functionality is disabled
579
+ }
580
+ }
581
+
492
582
  const componentData = componentScopesMap[componentPath]
493
583
  if (
494
584
  !componentData ||
@@ -597,9 +687,17 @@ module.exports = {
597
687
  const sourceName = issue.hookName || issue.componentName
598
688
  const sourceType = issue.hookName ? 'Hook' : 'Component'
599
689
 
690
+ // Use custom message for ProductCard and ProductLink
691
+ let messageId = 'missingScope'
692
+ if (issue.componentName === 'ProductCard') {
693
+ messageId = 'missingScopeProductCard'
694
+ } else if (issue.componentName === 'ProductLink') {
695
+ messageId = 'missingScopeProductLink'
696
+ }
697
+
600
698
  context.report({
601
699
  loc: {line: 1, column: 0},
602
- messageId: 'missingScope',
700
+ messageId,
603
701
  data: {
604
702
  source: `${sourceType} "${sourceName}"`,
605
703
  scope: issue.scope,
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.2.3",
4
+ "version": "0.2.6",
5
5
  "sideEffects": false,
6
6
  "type": "module",
7
7
  "engines": {
@@ -38,8 +38,10 @@ export const Touchable = ({
38
38
 
39
39
  // Animate to pressed state
40
40
  controls.start({
41
- scale: 0.95,
42
41
  opacity: 0.7,
42
+ transition: {
43
+ opacity: {type: 'tween', duration: 0.08, ease: 'linear'},
44
+ },
43
45
  })
44
46
  }
45
47
 
@@ -49,8 +51,10 @@ export const Touchable = ({
49
51
 
50
52
  // Animate back to normal state
51
53
  controls.start({
52
- scale: 1,
53
54
  opacity: 1,
55
+ transition: {
56
+ opacity: {type: 'tween', duration: 0.08, ease: 'linear'},
57
+ },
54
58
  })
55
59
  }
56
60
 
@@ -70,9 +74,8 @@ export const Touchable = ({
70
74
  data-touchable="true"
71
75
  className="flex w-full"
72
76
  animate={stopPropagation ? controls : undefined}
73
- whileTap={stopPropagation ? undefined : {scale: 0.95, opacity: 0.7}}
77
+ whileTap={stopPropagation ? undefined : {opacity: 0.7}}
74
78
  transition={{
75
- scale: {type: 'tween', duration: 0.08, ease: 'linear'},
76
79
  opacity: {type: 'tween', duration: 0.08, ease: 'linear'},
77
80
  }}
78
81
  onClick={handleClick}
@@ -147,6 +147,33 @@ describe('ProductCard', () => {
147
147
 
148
148
  expect(screen.getByText('No Image')).toBeInTheDocument()
149
149
  })
150
+
151
+ it('renders favorite button by default', () => {
152
+ const product = mockProduct()
153
+ render(<ProductCard product={product} />)
154
+
155
+ // Favorite button should be present
156
+ const favoriteButton = screen.getByRole('button')
157
+ expect(favoriteButton).toBeInTheDocument()
158
+ })
159
+
160
+ it('hides favorite button when favoriteButtonDisabled is true', () => {
161
+ const product = mockProduct()
162
+ render(<ProductCard product={product} favoriteButtonDisabled />)
163
+
164
+ // Favorite button should not be present
165
+ const favoriteButton = screen.queryByRole('button')
166
+ expect(favoriteButton).not.toBeInTheDocument()
167
+ })
168
+
169
+ it('shows favorite button when favoriteButtonDisabled is false', () => {
170
+ const product = mockProduct()
171
+ render(<ProductCard product={product} favoriteButtonDisabled={false} />)
172
+
173
+ // Favorite button should be present
174
+ const favoriteButton = screen.getByRole('button')
175
+ expect(favoriteButton).toBeInTheDocument()
176
+ })
150
177
  })
151
178
 
152
179
  describe('Interactions', () => {
@@ -262,6 +289,28 @@ describe('ProductCard', () => {
262
289
  expect(favoriteButton).toHaveClass('bg-button-overlay/30')
263
290
  })
264
291
  })
292
+
293
+ it('does not allow favorite toggle when favoriteButtonDisabled is true', async () => {
294
+ const product = mockProduct({isFavorited: false})
295
+ const onFavoriteToggled = vi.fn()
296
+
297
+ render(
298
+ <ProductCard
299
+ product={product}
300
+ favoriteButtonDisabled
301
+ onFavoriteToggled={onFavoriteToggled}
302
+ />
303
+ )
304
+
305
+ // Button should not exist at all
306
+ const favoriteButton = screen.queryByRole('button')
307
+ expect(favoriteButton).not.toBeInTheDocument()
308
+
309
+ // Callbacks should not be called
310
+ expect(onFavoriteToggled).not.toHaveBeenCalled()
311
+ expect(mockMinisSDK.saveProduct).not.toHaveBeenCalled()
312
+ expect(mockMinisSDK.unsaveProduct).not.toHaveBeenCalled()
313
+ })
265
314
  })
266
315
 
267
316
  describe('Custom Composition', () => {
@@ -307,6 +356,25 @@ describe('ProductCard', () => {
307
356
  expect(screen.getByText(product.title)).toBeInTheDocument()
308
357
  expect(screen.getByText('$99.99')).toBeInTheDocument()
309
358
  })
359
+
360
+ it('respects favoriteButtonDisabled in custom composition', () => {
361
+ const product = mockProduct()
362
+
363
+ render(
364
+ <ProductCard product={product} favoriteButtonDisabled>
365
+ <ProductCardContainer>
366
+ <ProductCardImageContainer>
367
+ <ProductCardImage />
368
+ <ProductCardFavoriteButton />
369
+ </ProductCardImageContainer>
370
+ </ProductCardContainer>
371
+ </ProductCard>
372
+ )
373
+
374
+ // Favorite button should not be rendered even when explicitly included
375
+ const favoriteButton = screen.queryByRole('button')
376
+ expect(favoriteButton).not.toBeInTheDocument()
377
+ })
310
378
  })
311
379
 
312
380
  describe('Edge Cases', () => {
@@ -28,6 +28,7 @@ interface ProductCardContextValue {
28
28
 
29
29
  // State
30
30
  isFavorited: boolean
31
+ isFavoriteButtonDisabled: boolean
31
32
 
32
33
  // Actions
33
34
  onClick: () => void
@@ -194,7 +195,10 @@ function ProductCardFavoriteButton({
194
195
  className,
195
196
  ...props
196
197
  }: React.ComponentProps<'div'>) {
197
- const {isFavorited, onFavoriteToggle} = useProductCardContext()
198
+ const {isFavorited, isFavoriteButtonDisabled, onFavoriteToggle} =
199
+ useProductCardContext()
200
+ if (isFavoriteButtonDisabled) return null
201
+
198
202
  return (
199
203
  <div className={cn('absolute bottom-3 right-3 z-10', className)} {...props}>
200
204
  <FavoriteButton onClick={onFavoriteToggle} filled={isFavorited} />
@@ -292,6 +296,8 @@ export interface ProductCardProps {
292
296
  onFavoriteToggled?: (isFavorited: boolean) => void
293
297
  /** Custom layout via children */
294
298
  children?: React.ReactNode
299
+ /** Whether the favorite button is disabled */
300
+ favoriteButtonDisabled?: boolean
295
301
  }
296
302
 
297
303
  function ProductCard({
@@ -304,6 +310,7 @@ function ProductCard({
304
310
  onProductClick,
305
311
  onFavoriteToggled,
306
312
  children,
313
+ favoriteButtonDisabled = false,
307
314
  }: ProductCardProps) {
308
315
  const {navigateToProduct} = useShopNavigation()
309
316
  const {saveProduct, unsaveProduct} = useSavedProductsActions()
@@ -374,7 +381,7 @@ function ProductCard({
374
381
 
375
382
  // State
376
383
  isFavorited: isFavoritedLocal,
377
-
384
+ isFavoriteButtonDisabled: favoriteButtonDisabled,
378
385
  // Actions
379
386
  onClick: handleClick,
380
387
  onFavoriteToggle: handleFavoriteClick,
@@ -389,6 +396,7 @@ function ProductCard({
389
396
  isFavoritedLocal,
390
397
  handleClick,
391
398
  handleFavoriteClick,
399
+ favoriteButtonDisabled,
392
400
  ]
393
401
  )
394
402
 
@@ -184,6 +184,53 @@ describe('ProductLink', () => {
184
184
  // Should not have favorite button
185
185
  expect(screen.queryByRole('button')).not.toBeInTheDocument()
186
186
  })
187
+
188
+ it('renders custom action when provided', () => {
189
+ const product = mockProduct()
190
+ const onCustomActionClick = vi.fn()
191
+ render(
192
+ <ProductLink
193
+ product={product}
194
+ customAction={
195
+ <button type="button" data-testid="custom-cta">
196
+ Add to Cart
197
+ </button>
198
+ }
199
+ onCustomActionClick={onCustomActionClick}
200
+ />
201
+ )
202
+
203
+ // Custom action should be rendered
204
+ expect(screen.getByTestId('custom-cta')).toBeInTheDocument()
205
+ expect(screen.getByText('Add to Cart')).toBeInTheDocument()
206
+
207
+ // Favorite button should not be rendered when custom action is present
208
+ // Check that there's only one button (the custom one)
209
+ const buttons = screen.getAllByRole('button')
210
+ expect(buttons).toHaveLength(1)
211
+ expect(buttons[0]).toHaveAttribute('data-testid', 'custom-cta')
212
+ })
213
+
214
+ it('renders custom action even when hideFavoriteAction is true', () => {
215
+ const product = mockProduct()
216
+ const onCustomActionClick = vi.fn()
217
+ render(
218
+ <ProductLink
219
+ product={product}
220
+ hideFavoriteAction
221
+ customAction={
222
+ <button type="button" data-testid="custom-action">
223
+ Buy Now
224
+ </button>
225
+ }
226
+ onCustomActionClick={onCustomActionClick}
227
+ />
228
+ )
229
+
230
+ // Custom action should still be rendered
231
+ expect(screen.getByTestId('custom-action')).toBeInTheDocument()
232
+ expect(screen.getByText('Buy Now')).toBeInTheDocument()
233
+ })
187
234
  })
188
235
 
189
236
  describe('Interactions', () => {
@@ -313,6 +360,40 @@ describe('ProductLink', () => {
313
360
  expect(onClick).not.toHaveBeenCalled()
314
361
  expect(mockMinisSDK.navigateToProduct).not.toHaveBeenCalled()
315
362
  })
363
+
364
+ it('calls onCustomActionClick when custom action is clicked', async () => {
365
+ const user = userEvent.setup()
366
+ const product = mockProduct()
367
+ const onClick = vi.fn()
368
+ const onCustomActionClick = vi.fn()
369
+
370
+ render(
371
+ <ProductLink
372
+ product={product}
373
+ onClick={onClick}
374
+ customAction={
375
+ <button type="button" data-testid="custom-btn">
376
+ Quick Add
377
+ </button>
378
+ }
379
+ onCustomActionClick={onCustomActionClick}
380
+ />
381
+ )
382
+
383
+ const customButton = screen.getByTestId('custom-btn')
384
+ await user.click(customButton)
385
+
386
+ // Should call custom action handler
387
+ expect(onCustomActionClick).toHaveBeenCalledTimes(1)
388
+
389
+ // Should NOT call favorite save/unsave
390
+ expect(mockMinisSDK.saveProduct).not.toHaveBeenCalled()
391
+ expect(mockMinisSDK.unsaveProduct).not.toHaveBeenCalled()
392
+
393
+ // Should NOT trigger product navigation
394
+ expect(onClick).not.toHaveBeenCalled()
395
+ expect(mockMinisSDK.navigateToProduct).not.toHaveBeenCalled()
396
+ })
316
397
  })
317
398
 
318
399
  describe('Star Rating Display', () => {
@@ -194,13 +194,21 @@ function ProductLinkRating({className, ...props}: React.ComponentProps<'div'>) {
194
194
 
195
195
  function ProductLinkActions({
196
196
  className,
197
+ hideFavoriteAction = false,
197
198
  onPress,
198
199
  filled = false,
200
+ customAction,
199
201
  ...props
200
202
  }: React.ComponentProps<typeof CardAction> & {
203
+ hideFavoriteAction?: boolean
201
204
  onPress?: () => void
202
205
  filled?: boolean
206
+ customAction?: React.ReactNode
203
207
  }) {
208
+ const favoriteAction = hideFavoriteAction ? null : (
209
+ <FavoriteButton filled={filled} onClick={onPress} />
210
+ )
211
+
204
212
  return (
205
213
  <CardAction
206
214
  className={cn('flex-shrink-0 self-center px-0 py-0', className)}
@@ -215,23 +223,34 @@ function ProductLinkActions({
215
223
  scale: {type: 'tween', duration: 0.08, ease: 'easeInOut'},
216
224
  }}
217
225
  >
218
- <FavoriteButton filled={filled} onClick={onPress} />
226
+ {customAction ? <>{customAction}</> : favoriteAction}
219
227
  </Touchable>
220
228
  </CardAction>
221
229
  )
222
230
  }
223
231
 
224
- export interface ProductLinkProps {
232
+ export type ProductLinkProps = {
225
233
  product: Product
226
234
  hideFavoriteAction?: boolean
227
235
  onClick?: (product: Product) => void
228
- }
236
+ } & (
237
+ | {
238
+ customAction?: never
239
+ onCustomActionClick?: never
240
+ }
241
+ | {
242
+ customAction: React.ReactNode
243
+ onCustomActionClick: () => void
244
+ }
245
+ )
229
246
 
230
247
  // Composed ProductLink component
231
248
  function ProductLink({
232
249
  product,
233
250
  hideFavoriteAction = false,
234
251
  onClick,
252
+ customAction,
253
+ onCustomActionClick,
235
254
  }: ProductLinkProps) {
236
255
  const {navigateToProduct} = useShopNavigation()
237
256
  const {saveProduct, unsaveProduct} = useSavedProductsActions()
@@ -272,6 +291,11 @@ function ProductLink({
272
291
  }, [navigateToProduct, id, onClick, product])
273
292
 
274
293
  const handleActionPress = React.useCallback(async () => {
294
+ if (customAction || onCustomActionClick) {
295
+ onCustomActionClick?.()
296
+ return
297
+ }
298
+
275
299
  const previousState = isFavoritedLocal
276
300
 
277
301
  // Optimistic update
@@ -296,13 +320,15 @@ function ProductLink({
296
320
  setIsFavoritedLocal(previousState)
297
321
  }
298
322
  }, [
323
+ customAction,
324
+ onCustomActionClick,
299
325
  isFavoritedLocal,
326
+ unsaveProduct,
300
327
  id,
301
328
  shop.id,
302
329
  selectedVariant?.id,
303
330
  defaultVariantId,
304
331
  saveProduct,
305
- unsaveProduct,
306
332
  ])
307
333
 
308
334
  return (
@@ -366,12 +392,12 @@ function ProductLink({
366
392
  </ProductLinkPrice>
367
393
  </ProductLinkInfo>
368
394
 
369
- {hideFavoriteAction ? null : (
370
- <ProductLinkActions
371
- filled={isFavoritedLocal}
372
- onPress={handleActionPress}
373
- />
374
- )}
395
+ <ProductLinkActions
396
+ filled={isFavoritedLocal}
397
+ onPress={handleActionPress}
398
+ customAction={customAction}
399
+ hideFavoriteAction={hideFavoriteAction}
400
+ />
375
401
  </ProductLinkRoot>
376
402
  )
377
403
  }