@shopify/shop-minis-react 0.0.33 → 0.0.35

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 (86) hide show
  1. package/dist/_virtual/index10.js +2 -2
  2. package/dist/_virtual/index2.js +4 -4
  3. package/dist/_virtual/index3.js +4 -4
  4. package/dist/_virtual/index8.js +2 -2
  5. package/dist/_virtual/index9.js +2 -2
  6. package/dist/components/atoms/alert-dialog.js.map +1 -1
  7. package/dist/components/atoms/icon-button.js +12 -12
  8. package/dist/components/atoms/icon-button.js.map +1 -1
  9. package/dist/components/atoms/image.js +52 -0
  10. package/dist/components/atoms/image.js.map +1 -0
  11. package/dist/components/atoms/text-input.js +22 -0
  12. package/dist/components/atoms/text-input.js.map +1 -0
  13. package/dist/components/commerce/merchant-card.js +2 -1
  14. package/dist/components/commerce/merchant-card.js.map +1 -1
  15. package/dist/components/commerce/product-card.js +11 -11
  16. package/dist/components/commerce/product-card.js.map +1 -1
  17. package/dist/components/content/image-content-wrapper.js +29 -22
  18. package/dist/components/content/image-content-wrapper.js.map +1 -1
  19. package/dist/components/ui/input.js +15 -9
  20. package/dist/components/ui/input.js.map +1 -1
  21. package/dist/hooks/content/useCreateImageContent.js +16 -22
  22. package/dist/hooks/content/useCreateImageContent.js.map +1 -1
  23. package/dist/hooks/storage/useImageUpload.js +36 -37
  24. package/dist/hooks/storage/useImageUpload.js.map +1 -1
  25. package/dist/hooks/util/useKeyboardAvoidingView.js +23 -0
  26. package/dist/hooks/util/useKeyboardAvoidingView.js.map +1 -0
  27. package/dist/index.js +218 -212
  28. package/dist/index.js.map +1 -1
  29. package/dist/mocks.js +4 -1
  30. package/dist/mocks.js.map +1 -1
  31. package/dist/shop-minis-platform/src/types/content.js.map +1 -1
  32. package/dist/shop-minis-react/node_modules/.pnpm/@xmldom_xmldom@0.8.10/node_modules/@xmldom/xmldom/lib/index.js +1 -1
  33. package/dist/shop-minis-react/node_modules/.pnpm/color-string@1.9.1/node_modules/color-string/index.js +1 -1
  34. 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
  35. package/dist/shop-minis-react/node_modules/.pnpm/video.js@8.23.3/node_modules/video.js/dist/video.es.js +1 -1
  36. package/dist/utils/colors.js +1 -1
  37. package/dist/utils/image.js +46 -9
  38. package/dist/utils/image.js.map +1 -1
  39. package/package.json +21 -4
  40. package/src/components/atoms/alert-dialog.test.tsx +67 -0
  41. package/src/components/atoms/alert-dialog.tsx +13 -11
  42. package/src/components/atoms/favorite-button.test.tsx +56 -0
  43. package/src/components/atoms/icon-button.tsx +1 -1
  44. package/src/components/atoms/image.test.tsx +108 -0
  45. package/src/components/atoms/{thumbhash-image.tsx → image.tsx} +14 -14
  46. package/src/components/atoms/product-variant-price.test.tsx +128 -0
  47. package/src/components/atoms/text-input.test.tsx +104 -0
  48. package/src/components/atoms/text-input.tsx +31 -0
  49. package/src/components/commerce/merchant-card.test.tsx +261 -0
  50. package/src/components/commerce/merchant-card.tsx +4 -2
  51. package/src/components/commerce/product-card.test.tsx +364 -0
  52. package/src/components/commerce/product-card.tsx +2 -2
  53. package/src/components/commerce/product-link.test.tsx +483 -0
  54. package/src/components/commerce/quantity-selector.test.tsx +382 -0
  55. package/src/components/commerce/search.test.tsx +487 -0
  56. package/src/components/content/image-content-wrapper.test.tsx +92 -0
  57. package/src/components/content/image-content-wrapper.tsx +9 -2
  58. package/src/components/index.ts +2 -1
  59. package/src/components/navigation/transition-link.test.tsx +155 -0
  60. package/src/components/ui/input.test.tsx +21 -0
  61. package/src/components/ui/input.tsx +10 -1
  62. package/src/hooks/content/useCreateImageContent.test.ts +352 -0
  63. package/src/hooks/content/useCreateImageContent.ts +1 -7
  64. package/src/hooks/index.ts +1 -0
  65. package/src/hooks/navigation/useNavigateWithTransition.test.ts +371 -0
  66. package/src/hooks/navigation/useViewTransitions.test.ts +469 -0
  67. package/src/hooks/product/useProductSearch.test.ts +470 -0
  68. package/src/hooks/storage/useAsyncStorage.test.ts +225 -0
  69. package/src/hooks/storage/useImageUpload.test.ts +322 -0
  70. package/src/hooks/storage/useImageUpload.ts +22 -20
  71. package/src/hooks/util/useKeyboardAvoidingView.ts +37 -0
  72. package/src/internal/useHandleAction.test.ts +265 -0
  73. package/src/internal/useShopActionsDataFetching.test.ts +465 -0
  74. package/src/mocks.ts +3 -1
  75. package/src/providers/ImagePickerProvider.test.tsx +467 -0
  76. package/src/stories/ProductCard.stories.tsx +2 -2
  77. package/src/stories/TextInput.stories.tsx +26 -0
  78. package/src/test-setup.ts +34 -0
  79. package/src/test-utils.tsx +167 -0
  80. package/src/utils/image.ts +73 -0
  81. package/src/utils/index.ts +1 -1
  82. package/dist/components/atoms/thumbhash-image.js +0 -54
  83. package/dist/components/atoms/thumbhash-image.js.map +0 -1
  84. package/dist/utils/imageToDataUri.js +0 -10
  85. package/dist/utils/imageToDataUri.js.map +0 -1
  86. package/src/utils/imageToDataUri.ts +0 -8
@@ -0,0 +1,483 @@
1
+ import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
2
+
3
+ import {
4
+ render,
5
+ screen,
6
+ userEvent,
7
+ waitFor,
8
+ mockProduct,
9
+ mockMinisSDK,
10
+ resetAllMocks,
11
+ } from '../../test-utils'
12
+
13
+ import {ProductLink} from './product-link'
14
+
15
+ // Mock hooks
16
+ vi.mock('../../hooks/navigation/useShopNavigation', () => ({
17
+ useShopNavigation: () => ({
18
+ navigateToProduct: mockMinisSDK.navigateToProduct,
19
+ navigateToShop: mockMinisSDK.navigateToShop,
20
+ }),
21
+ }))
22
+
23
+ vi.mock('../../hooks/user/useSavedProductsActions', () => ({
24
+ useSavedProductsActions: () => ({
25
+ saveProduct: mockMinisSDK.saveProduct,
26
+ unsaveProduct: mockMinisSDK.unsaveProduct,
27
+ }),
28
+ }))
29
+
30
+ // Mock formatMoney
31
+ vi.mock('../../lib/formatMoney', () => ({
32
+ formatMoney: vi.fn((amount: string, currencyCode: string) => {
33
+ const numAmount = parseFloat(amount)
34
+ return currencyCode === 'USD'
35
+ ? `$${numAmount.toFixed(2)}`
36
+ : `${currencyCode} ${numAmount.toFixed(2)}`
37
+ }),
38
+ }))
39
+
40
+ describe('ProductLink', () => {
41
+ beforeEach(() => {
42
+ resetAllMocks()
43
+ })
44
+
45
+ afterEach(() => {
46
+ vi.clearAllMocks()
47
+ })
48
+
49
+ describe('Rendering', () => {
50
+ it('renders product information correctly', () => {
51
+ const product = mockProduct({
52
+ title: 'Test Product',
53
+ price: {amount: '29.99', currencyCode: 'USD'},
54
+ })
55
+
56
+ render(<ProductLink product={product} />)
57
+
58
+ expect(screen.getByText('Test Product')).toBeInTheDocument()
59
+ expect(screen.getByText('$29.99')).toBeInTheDocument()
60
+ })
61
+
62
+ it('renders product image when available', () => {
63
+ const product = mockProduct({
64
+ featuredImage: {
65
+ url: 'https://example.com/image.jpg',
66
+ altText: 'Product Image',
67
+ width: 100,
68
+ height: 100,
69
+ sensitive: false,
70
+ thumbhash: null,
71
+ },
72
+ })
73
+
74
+ render(<ProductLink product={product} />)
75
+
76
+ const image = screen.getByRole('img')
77
+ expect(image).toHaveAttribute('src', 'https://example.com/image.jpg')
78
+ expect(image).toHaveAttribute('alt', 'Product Image')
79
+ })
80
+
81
+ it('shows no image placeholder when image is not available', () => {
82
+ const product = mockProduct({
83
+ featuredImage: undefined,
84
+ })
85
+
86
+ render(<ProductLink product={product} />)
87
+
88
+ expect(screen.getByText('No Image')).toBeInTheDocument()
89
+ })
90
+
91
+ it('uses product title as alt text when image altText is missing', () => {
92
+ const product = mockProduct({
93
+ title: 'My Product',
94
+ featuredImage: {
95
+ url: 'https://example.com/image.jpg',
96
+ altText: '',
97
+ width: 100,
98
+ height: 100,
99
+ sensitive: false,
100
+ thumbhash: null,
101
+ },
102
+ })
103
+
104
+ render(<ProductLink product={product} />)
105
+
106
+ const image = screen.getByRole('img')
107
+ expect(image).toHaveAttribute('alt', 'My Product')
108
+ })
109
+
110
+ it('displays discount pricing when compareAtPrice exists', () => {
111
+ const product = mockProduct({
112
+ price: {amount: '19.99', currencyCode: 'USD'},
113
+ compareAtPrice: {amount: '29.99', currencyCode: 'USD'},
114
+ })
115
+
116
+ render(<ProductLink product={product} />)
117
+
118
+ // Discounted price should be shown
119
+ expect(screen.getByText('$19.99')).toBeInTheDocument()
120
+ // Original price with line-through
121
+ const originalPrice = screen.getByText('$29.99')
122
+ expect(originalPrice).toBeInTheDocument()
123
+ expect(originalPrice).toHaveClass('line-through')
124
+ })
125
+
126
+ it('shows regular price when no discount', () => {
127
+ const product = mockProduct({
128
+ price: {amount: '29.99', currencyCode: 'USD'},
129
+ compareAtPrice: undefined,
130
+ })
131
+
132
+ render(<ProductLink product={product} />)
133
+
134
+ expect(screen.getByText('$29.99')).toBeInTheDocument()
135
+ expect(screen.queryByText(/line-through/)).not.toBeInTheDocument()
136
+ })
137
+
138
+ it('displays review rating and count when available', () => {
139
+ const product = mockProduct({
140
+ reviewAnalytics: {
141
+ averageRating: 4.5,
142
+ reviewCount: 123,
143
+ },
144
+ })
145
+
146
+ render(<ProductLink product={product} />)
147
+
148
+ // Should show review count
149
+ expect(screen.getByText('(123)')).toBeInTheDocument()
150
+
151
+ // Should show star rating
152
+ const stars = document.querySelectorAll('svg')
153
+ expect(stars.length).toBeGreaterThan(0)
154
+ })
155
+
156
+ it('does not show rating when review data is missing', () => {
157
+ const product = mockProduct({
158
+ reviewAnalytics: {
159
+ averageRating: null,
160
+ reviewCount: 0,
161
+ },
162
+ })
163
+
164
+ render(<ProductLink product={product} />)
165
+
166
+ expect(
167
+ screen.queryByTestId('product-link-rating')
168
+ ).not.toBeInTheDocument()
169
+ })
170
+
171
+ it('shows favorite button by default', () => {
172
+ const product = mockProduct()
173
+ render(<ProductLink product={product} />)
174
+
175
+ // Should have favorite button
176
+ const buttons = screen.getAllByRole('button')
177
+ expect(buttons.length).toBeGreaterThan(0)
178
+ })
179
+
180
+ it('hides favorite button when hideFavoriteAction is true', () => {
181
+ const product = mockProduct()
182
+ render(<ProductLink product={product} hideFavoriteAction />)
183
+
184
+ // Should not have favorite button
185
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
186
+ })
187
+ })
188
+
189
+ describe('Interactions', () => {
190
+ it('navigates to product on click', async () => {
191
+ const user = userEvent.setup()
192
+ const product = mockProduct()
193
+ const onClick = vi.fn()
194
+
195
+ render(<ProductLink product={product} onClick={onClick} />)
196
+
197
+ const productElement = screen.getByText(product.title).closest('div')
198
+ ?.parentElement?.parentElement
199
+ await user.click(productElement!)
200
+
201
+ expect(mockMinisSDK.navigateToProduct).toHaveBeenCalledWith({
202
+ productId: product.id,
203
+ })
204
+ expect(onClick).toHaveBeenCalledWith(product)
205
+ })
206
+
207
+ it('saves product when favorite button is clicked (unfavorited to favorited)', async () => {
208
+ const user = userEvent.setup()
209
+ const product = mockProduct({isFavorited: false})
210
+ mockMinisSDK.saveProduct.mockResolvedValue(undefined)
211
+
212
+ render(<ProductLink product={product} />)
213
+
214
+ const favoriteButton = screen.getByRole('button')
215
+ await user.click(favoriteButton)
216
+
217
+ await waitFor(() => {
218
+ expect(mockMinisSDK.saveProduct).toHaveBeenCalledWith({
219
+ productId: product.id,
220
+ shopId: product.shop.id,
221
+ productVariantId: product.defaultVariantId,
222
+ })
223
+ })
224
+ })
225
+
226
+ it('unsaves product when favorite button is clicked (favorited to unfavorited)', async () => {
227
+ const user = userEvent.setup()
228
+ const product = mockProduct({isFavorited: true})
229
+ mockMinisSDK.unsaveProduct.mockResolvedValue(undefined)
230
+
231
+ render(<ProductLink product={product} />)
232
+
233
+ const favoriteButton = screen.getByRole('button')
234
+ await user.click(favoriteButton)
235
+
236
+ await waitFor(() => {
237
+ expect(mockMinisSDK.unsaveProduct).toHaveBeenCalledWith({
238
+ productId: product.id,
239
+ shopId: product.shop.id,
240
+ productVariantId: product.defaultVariantId,
241
+ })
242
+ })
243
+ })
244
+
245
+ it('uses selected variant ID for favorite actions when available', async () => {
246
+ const user = userEvent.setup()
247
+ const product = mockProduct({
248
+ isFavorited: false,
249
+ selectedVariant: {
250
+ id: 'selected-variant-id',
251
+ isFavorited: false,
252
+ price: {amount: '29.99', currencyCode: 'USD'},
253
+ compareAtPrice: null,
254
+ image: null,
255
+ },
256
+ })
257
+ mockMinisSDK.saveProduct.mockResolvedValue(undefined)
258
+
259
+ render(<ProductLink product={product} />)
260
+
261
+ const favoriteButton = screen.getByRole('button')
262
+ await user.click(favoriteButton)
263
+
264
+ await waitFor(() => {
265
+ expect(mockMinisSDK.saveProduct).toHaveBeenCalledWith({
266
+ productId: product.id,
267
+ shopId: product.shop.id,
268
+ productVariantId: 'selected-variant-id',
269
+ })
270
+ })
271
+ })
272
+
273
+ it('reverts favorite state on error', async () => {
274
+ const user = userEvent.setup()
275
+ const product = mockProduct({isFavorited: false})
276
+
277
+ // Mock save to reject
278
+ mockMinisSDK.saveProduct.mockRejectedValue(new Error('Save failed'))
279
+
280
+ render(<ProductLink product={product} />)
281
+
282
+ const favoriteButton = screen.getByRole('button')
283
+
284
+ // Initially unfilled
285
+ expect(favoriteButton).toHaveClass('bg-button-overlay/30')
286
+
287
+ await user.click(favoriteButton)
288
+
289
+ // Should call save
290
+ expect(mockMinisSDK.saveProduct).toHaveBeenCalled()
291
+
292
+ // Wait for error handling and revert
293
+ await waitFor(() => {
294
+ // Should revert to unfilled state
295
+ expect(favoriteButton).toHaveClass('bg-button-overlay/30')
296
+ })
297
+ })
298
+
299
+ it('prevents event propagation on favorite button click', async () => {
300
+ const user = userEvent.setup()
301
+ const product = mockProduct()
302
+ const onClick = vi.fn()
303
+ mockMinisSDK.saveProduct.mockResolvedValue(undefined)
304
+
305
+ render(<ProductLink product={product} onClick={onClick} />)
306
+
307
+ const favoriteButton = screen.getByRole('button')
308
+ await user.click(favoriteButton)
309
+
310
+ // Should save product but not trigger navigation
311
+ expect(mockMinisSDK.saveProduct).toHaveBeenCalled()
312
+ expect(onClick).not.toHaveBeenCalled()
313
+ expect(mockMinisSDK.navigateToProduct).not.toHaveBeenCalled()
314
+ })
315
+ })
316
+
317
+ describe('Star Rating Display', () => {
318
+ it('displays correct number of filled stars based on rating', () => {
319
+ const product = mockProduct({
320
+ reviewAnalytics: {
321
+ averageRating: 3.7,
322
+ reviewCount: 50,
323
+ },
324
+ })
325
+
326
+ render(<ProductLink product={product} />)
327
+
328
+ // Check that rating is displayed
329
+ expect(screen.getByText('(50)')).toBeInTheDocument()
330
+
331
+ // Check for star icons (there's at least one star svg)
332
+ const stars = document.querySelectorAll('svg')
333
+ expect(stars.length).toBeGreaterThan(0)
334
+ })
335
+
336
+ it('shows all empty stars for zero rating', () => {
337
+ const product = mockProduct({
338
+ reviewAnalytics: {
339
+ averageRating: 0,
340
+ reviewCount: 10,
341
+ },
342
+ })
343
+
344
+ render(<ProductLink product={product} />)
345
+
346
+ const stars = document.querySelectorAll('svg')
347
+ const filledStars = Array.from(stars).filter(
348
+ star => star.getAttribute('fill') === 'currentColor'
349
+ )
350
+ expect(filledStars).toHaveLength(0)
351
+ })
352
+
353
+ it('shows all filled stars for 5 star rating', () => {
354
+ const product = mockProduct({
355
+ reviewAnalytics: {
356
+ averageRating: 5,
357
+ reviewCount: 100,
358
+ },
359
+ })
360
+
361
+ render(<ProductLink product={product} />)
362
+
363
+ const stars = document.querySelectorAll('svg')
364
+ const filledStars = Array.from(stars).filter(
365
+ star => star.getAttribute('fill') === 'currentColor'
366
+ )
367
+ expect(filledStars).toHaveLength(5)
368
+ })
369
+ })
370
+
371
+ describe('Edge Cases', () => {
372
+ it('handles product without price', () => {
373
+ const product = mockProduct({
374
+ price: undefined,
375
+ })
376
+
377
+ render(<ProductLink product={product} />)
378
+
379
+ // Should still render without crashing
380
+ expect(screen.getByText(product.title)).toBeInTheDocument()
381
+ })
382
+
383
+ it('handles very long product titles', () => {
384
+ const product = mockProduct({
385
+ title:
386
+ 'This is a very long product title that should be truncated in the UI to prevent layout issues',
387
+ })
388
+
389
+ render(<ProductLink product={product} />)
390
+
391
+ // Title should be rendered and have truncate classes
392
+ const titleElement = screen.getByText(product.title)
393
+ expect(titleElement).toBeInTheDocument()
394
+ expect(titleElement.className).toContain('text-ellipsis')
395
+ })
396
+
397
+ it('handles rapid favorite toggling', async () => {
398
+ const user = userEvent.setup()
399
+ const product = mockProduct({isFavorited: false})
400
+
401
+ mockMinisSDK.saveProduct.mockResolvedValue(undefined)
402
+ mockMinisSDK.unsaveProduct.mockResolvedValue(undefined)
403
+
404
+ render(<ProductLink product={product} />)
405
+
406
+ const favoriteButton = screen.getByRole('button')
407
+
408
+ // Rapid clicks
409
+ await user.click(favoriteButton)
410
+ await user.click(favoriteButton)
411
+ await user.click(favoriteButton)
412
+
413
+ // Should handle all clicks gracefully
414
+ expect(mockMinisSDK.saveProduct.mock.calls.length).toBeGreaterThan(0)
415
+ })
416
+
417
+ it('handles product without shop data', () => {
418
+ const product = mockProduct({
419
+ shop: {id: '', name: ''},
420
+ })
421
+
422
+ render(<ProductLink product={product} />)
423
+
424
+ // Should still render
425
+ expect(screen.getByText(product.title)).toBeInTheDocument()
426
+ })
427
+ })
428
+
429
+ describe('Visual States', () => {
430
+ it('applies hover/tap animation styles', () => {
431
+ const product = mockProduct()
432
+ const {container} = render(<ProductLink product={product} />)
433
+
434
+ // Check that touchable wrapper exists
435
+ const touchable = container.querySelector('[data-touchable="true"]')
436
+ expect(touchable).toBeTruthy()
437
+ })
438
+
439
+ it('shows correct favorite button state', async () => {
440
+ const product = mockProduct({isFavorited: true})
441
+ render(<ProductLink product={product} />)
442
+
443
+ const favoriteButton = screen.getByRole('button')
444
+ expect(favoriteButton).toHaveClass('bg-primary')
445
+ })
446
+
447
+ it('applies correct layout styles', () => {
448
+ const product = mockProduct()
449
+ render(<ProductLink product={product} />)
450
+
451
+ const image = screen.getByRole('img')
452
+ expect(image.parentElement).toHaveClass('h-16', 'w-16')
453
+ })
454
+ })
455
+
456
+ describe('Accessibility', () => {
457
+ it('has proper heading hierarchy', () => {
458
+ const product = mockProduct()
459
+ render(<ProductLink product={product} />)
460
+
461
+ const heading = screen.getByRole('heading', {level: 3})
462
+ expect(heading).toHaveTextContent(product.title)
463
+ })
464
+
465
+ it('provides proper alt text for images', () => {
466
+ const product = mockProduct({
467
+ featuredImage: {
468
+ url: 'https://example.com/image.jpg',
469
+ altText: 'Descriptive alt text',
470
+ width: 100,
471
+ height: 100,
472
+ sensitive: false,
473
+ thumbhash: null,
474
+ },
475
+ })
476
+
477
+ render(<ProductLink product={product} />)
478
+
479
+ const image = screen.getByRole('img')
480
+ expect(image).toHaveAttribute('alt', 'Descriptive alt text')
481
+ })
482
+ })
483
+ })