@thorprovider/blocks 0.1.3 → 1.0.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +73 -29
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  7. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  8. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  9. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  10. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  11. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  12. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  13. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  14. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  15. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  16. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  17. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  18. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  19. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  20. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  21. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  22. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  23. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  24. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  25. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  26. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  27. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  28. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  29. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  30. package/src/blocks/marketing/features/feature.tsx +195 -0
  31. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +27 -9
  32. package/src/blocks/marketing/navbars/navbar-4.test.tsx +33 -2
  33. package/src/blocks/marketing/navbars/navbar-4.tsx +29 -11
  34. package/src/index.ts +77 -48
  35. package/src/lib/contracts-curated.test.ts +42 -24
  36. package/src/lib/contracts.test.ts +143 -2
  37. package/src/lib/contracts.ts +346 -14
  38. package/src/ui/button-group.tsx +1 -1
  39. package/src/ui/carousel.tsx +1 -1
  40. package/src/ui/dialog.tsx +1 -1
  41. package/src/ui/field.tsx +2 -2
  42. package/src/ui/input-group.tsx +3 -3
  43. package/src/ui/pagination.tsx +1 -1
  44. package/src/ui/sidebar.tsx +7 -7
  45. package/src/ui/toggle-group.tsx +1 -1
  46. package/tsconfig.build.json +12 -0
  47. package/tsup.config.ts +11 -7
  48. package/.turbo/turbo-build.log +0 -24
  49. package/.turbo/turbo-test.log +0 -47
  50. package/.turbo/turbo-type-check.log +0 -0
  51. package/dist/.tsbuildinfo +0 -1
  52. package/dist/contracts.d.mts +0 -1732
  53. package/dist/contracts.d.ts +0 -1732
  54. package/dist/contracts.js +0 -423
  55. package/dist/contracts.js.map +0 -1
  56. package/dist/contracts.mjs +0 -365
  57. package/dist/contracts.mjs.map +0 -1
  58. package/dist/index.d.mts +0 -3068
  59. package/dist/index.d.ts +0 -3068
  60. package/dist/index.js +0 -17834
  61. package/dist/index.js.map +0 -1
  62. package/dist/index.mjs +0 -17740
  63. package/dist/index.mjs.map +0 -1
  64. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  65. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  66. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  67. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  68. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  69. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  70. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  71. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  72. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  73. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  74. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  75. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  76. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  77. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  78. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  79. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  80. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  81. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  82. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  83. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  84. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  85. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  86. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  87. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  88. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  89. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  90. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  91. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  92. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  93. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  94. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  95. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  96. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  97. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  98. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  99. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -0,0 +1,412 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import StorefrontHero, { type FeaturedProduct } from './storefront-hero'
7
+ import { storefrontHeroPropsSchema } from '../../../lib/contracts'
8
+
9
+ // The showcase presentation mounts a carousel (embla) and motion elements, so
10
+ // the jsdom environment needs the browser observers those libraries read.
11
+ beforeAll(() => {
12
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
13
+ if (!window.matchMedia) {
14
+ window.matchMedia = ((query: string) => ({
15
+ matches: false,
16
+ media: query,
17
+ onchange: null,
18
+ addListener: () => {},
19
+ removeListener: () => {},
20
+ addEventListener: () => {},
21
+ removeEventListener: () => {},
22
+ dispatchEvent: () => false,
23
+ })) as unknown as typeof window.matchMedia
24
+ }
25
+ if (!('ResizeObserver' in globalThis)) {
26
+ ;(globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
27
+ observe() {}
28
+ unobserve() {}
29
+ disconnect() {}
30
+ }
31
+ }
32
+ if (!('IntersectionObserver' in globalThis)) {
33
+ ;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = class {
34
+ observe() {}
35
+ unobserve() {}
36
+ disconnect() {}
37
+ takeRecords() {
38
+ return []
39
+ }
40
+ }
41
+ }
42
+ })
43
+
44
+ const fullProduct: FeaturedProduct = {
45
+ id: 'p1',
46
+ name: 'Taladro percutor',
47
+ brand: 'INGCO',
48
+ price: 69.99,
49
+ originalPrice: 99.99,
50
+ rating: 4.8,
51
+ reviewCount: 236,
52
+ inStock: true,
53
+ isNew: true,
54
+ image: '/products/drill.png',
55
+ colors: ['#111111'],
56
+ }
57
+
58
+ const bareProduct: FeaturedProduct = {
59
+ id: 'p2',
60
+ name: 'Decibelímetro',
61
+ price: 129.5,
62
+ }
63
+
64
+ const base = {
65
+ title: 'Potencia para cada proyecto',
66
+ description: 'Herramientas profesionales',
67
+ products: [] as FeaturedProduct[],
68
+ }
69
+
70
+ describe('StorefrontHero — compact: optional editorial image', () => {
71
+ it('renders no editorial image when none is supplied', () => {
72
+ const html = renderToStaticMarkup(<StorefrontHero {...base} />)
73
+ expect(html).not.toContain('<img')
74
+ })
75
+
76
+ it('renders a native editorial image with its src and alt text', () => {
77
+ const html = renderToStaticMarkup(
78
+ <StorefrontHero {...base} heroImage={{ src: '/hero-tools.png', alt: 'Taladro inalámbrico y accesorios' }} />,
79
+ )
80
+ expect(html).toContain('src="/hero-tools.png"')
81
+ expect(html).toContain('alt="Taladro inalámbrico y accesorios"')
82
+ })
83
+
84
+ it('never renders an empty image src', () => {
85
+ const blank = renderToStaticMarkup(
86
+ <StorefrontHero {...base} heroImage={{ src: ' ', alt: 'Vacío' }} />,
87
+ )
88
+ expect(blank).not.toContain('<img')
89
+ })
90
+
91
+ it('preserves the product grid when the editorial image is omitted', () => {
92
+ const html = renderToStaticMarkup(<StorefrontHero {...base} products={[fullProduct]} />)
93
+ expect(html).toContain('Taladro percutor')
94
+ expect(html).toContain('src="/products/drill.png"')
95
+ expect(html).not.toContain('/hero-tools.png')
96
+ })
97
+
98
+ it('keeps the product grid when the editorial image is supplied', () => {
99
+ const html = renderToStaticMarkup(
100
+ <StorefrontHero
101
+ {...base}
102
+ products={[fullProduct]}
103
+ heroImage={{ src: '/hero-tools.png', alt: 'Taladro inalámbrico' }}
104
+ />,
105
+ )
106
+ expect(html).toContain('src="/hero-tools.png"')
107
+ expect(html).toContain('Taladro percutor')
108
+ })
109
+ })
110
+
111
+ describe('StorefrontHero — compact: detail affordance', () => {
112
+ it('defaults the product-card action to "Ver producto"', () => {
113
+ const html = renderToStaticMarkup(<StorefrontHero {...base} products={[fullProduct]} />)
114
+ expect(html).toContain('Ver producto')
115
+ })
116
+
117
+ it('renders the add-to-cart control only when its handler is supplied', () => {
118
+ const withoutHandler = renderToStaticMarkup(<StorefrontHero {...base} products={[fullProduct]} />)
119
+ expect(withoutHandler).not.toContain('Agregar al carrito')
120
+
121
+ const withHandler = renderToStaticMarkup(
122
+ <StorefrontHero {...base} products={[fullProduct]} onAddToCart={() => {}} />,
123
+ )
124
+ expect(withHandler).toContain('Agregar al carrito')
125
+ })
126
+
127
+ it('disables purchase and shows the out-of-stock label only from an explicit inStock: false', () => {
128
+ const available = renderToStaticMarkup(<StorefrontHero {...base} products={[bareProduct]} />)
129
+ expect(available).not.toContain('Agotado')
130
+
131
+ const outOfStock = renderToStaticMarkup(
132
+ <StorefrontHero {...base} products={[{ ...bareProduct, inStock: false }]} />,
133
+ )
134
+ expect(outOfStock).toContain('Agotado')
135
+ expect(outOfStock).toContain('disabled')
136
+ })
137
+ })
138
+
139
+ describe('StorefrontHero — compact: currency never hardcoded', () => {
140
+ it('formats prices through Intl with the supplied currency', () => {
141
+ const html = renderToStaticMarkup(
142
+ <StorefrontHero {...base} currency='EUR' products={[fullProduct]} />,
143
+ )
144
+ expect(html).toContain('69.99')
145
+ expect(html).toContain('99.99')
146
+ expect(html).not.toContain('$')
147
+ })
148
+
149
+ it('renders a plain localized amount when no currency is supplied', () => {
150
+ const html = renderToStaticMarkup(<StorefrontHero {...base} products={[bareProduct]} />)
151
+ expect(html).toContain('129.5')
152
+ expect(html).not.toContain('$')
153
+ })
154
+ })
155
+
156
+ describe('StorefrontHero — compact: truthful evidence only', () => {
157
+ it('omits brand, colors, and rating rows when the host supplies none', () => {
158
+ const html = renderToStaticMarkup(<StorefrontHero {...base} products={[bareProduct]} />)
159
+ expect(html).not.toContain('Colores:')
160
+ expect(html).not.toContain('INGCO')
161
+ expect(html).not.toContain('(236)')
162
+ expect(html).not.toContain('Nuevo')
163
+ })
164
+
165
+ it('renders rating and review count only when supplied', () => {
166
+ const html = renderToStaticMarkup(<StorefrontHero {...base} products={[fullProduct]} />)
167
+ expect(html).toContain('(236)')
168
+ })
169
+
170
+ it('renders the discount badge and struck original price only from a real price pair', () => {
171
+ const onSale = renderToStaticMarkup(<StorefrontHero {...base} products={[fullProduct]} />)
172
+ expect(onSale).toContain('% OFF')
173
+ expect(onSale).toContain('line-through')
174
+
175
+ const withoutPair = renderToStaticMarkup(
176
+ <StorefrontHero {...base} products={[{ ...bareProduct, originalPrice: 99 }]} />,
177
+ )
178
+ expect(withoutPair).not.toContain('% OFF')
179
+ expect(withoutPair).not.toContain('line-through')
180
+ })
181
+
182
+ it('renders no invented claims with no optional evidence and a non-USD currency', () => {
183
+ const html = renderToStaticMarkup(
184
+ <StorefrontHero {...base} products={[bareProduct]} currency='EUR' />,
185
+ )
186
+ expect(html).toContain('Decibelímetro')
187
+ expect(html).toContain(new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(129.5))
188
+ expect(html).not.toContain('$')
189
+ expect(html).not.toContain('%')
190
+ })
191
+
192
+ it('renders the product grid text-forward when the list is empty', () => {
193
+ const html = renderToStaticMarkup(<StorefrontHero {...base} />)
194
+ expect(html).toContain('Potencia para cada proyecto')
195
+ expect(html).not.toContain('src="/products/drill.png"')
196
+ })
197
+ })
198
+
199
+ describe('StorefrontHero — showcase: search affordance only with a handler', () => {
200
+ const showcase = { ...base, presentation: 'showcase' as const, products: [fullProduct] }
201
+
202
+ it('renders the search input only when onSearch is supplied', () => {
203
+ const withHandler = renderToStaticMarkup(<StorefrontHero {...showcase} onSearch={() => {}} />)
204
+ expect(withHandler).toContain('type="search"')
205
+ expect(withHandler).toContain('Buscar')
206
+
207
+ const withoutHandler = renderToStaticMarkup(<StorefrontHero {...showcase} />)
208
+ expect(withoutHandler).not.toContain('type="search"')
209
+ })
210
+
211
+ it('produces a carousel structure (slide dots) absent from compact', () => {
212
+ const html = renderToStaticMarkup(<StorefrontHero {...showcase} />)
213
+ expect(html).toContain('Ir a la diapositiva 1')
214
+ expect(html).toContain('Taladro percutor')
215
+
216
+ const compact = renderToStaticMarkup(<StorefrontHero {...base} products={[fullProduct]} />)
217
+ expect(compact).not.toContain('Ir a la diapositiva 1')
218
+ })
219
+
220
+ it('formats slide prices with the supplied currency', () => {
221
+ const html = renderToStaticMarkup(<StorefrontHero {...showcase} currency='EUR' />)
222
+ expect(html).toContain(new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(69.99))
223
+ expect(html).not.toContain('$')
224
+ })
225
+ })
226
+
227
+ describe('StorefrontHero — showcase: purchase affordance is handler-gated and stock-consistent', () => {
228
+ const purchaseLabel = 'Adquirir ahora'
229
+ const available: FeaturedProduct = { id: 'p1', name: 'Taladro percutor', price: 69.99, inStock: true }
230
+ const showcase = { ...base, presentation: 'showcase' as const, buyNowLabel: purchaseLabel }
231
+
232
+ it('renders no purchase-labeled control when no onBuyNow handler is supplied', () => {
233
+ render(<StorefrontHero {...showcase} products={[available]} />)
234
+ expect(screen.queryByRole('button', { name: purchaseLabel })).toBeNull()
235
+ expect(screen.queryByText(purchaseLabel)).toBeNull()
236
+ })
237
+
238
+ it('does not offer an actionable purchase control for an out-of-stock product', async () => {
239
+ const user = userEvent.setup()
240
+ const onBuyNow = vi.fn()
241
+ render(<StorefrontHero {...showcase} products={[{ ...available, inStock: false }]} onBuyNow={onBuyNow} />)
242
+ const buy = screen.getByRole('button', { name: purchaseLabel }) as HTMLButtonElement
243
+ expect(buy.disabled).toBe(true)
244
+ await user.click(buy)
245
+ expect(onBuyNow).not.toHaveBeenCalled()
246
+ })
247
+
248
+ it('renders an enabled purchase control that reports the product through the handler', async () => {
249
+ const user = userEvent.setup()
250
+ const onBuyNow = vi.fn()
251
+ render(<StorefrontHero {...showcase} products={[available]} onBuyNow={onBuyNow} />)
252
+ const buy = screen.getByRole('button', { name: purchaseLabel }) as HTMLButtonElement
253
+ expect(buy.disabled).toBe(false)
254
+ await user.click(buy)
255
+ expect(onBuyNow).toHaveBeenCalledWith(available)
256
+ })
257
+ })
258
+
259
+ describe('StorefrontHero — compact: host-supplied action label is handler-gated', () => {
260
+ const customLabel = 'Adquirir ahora'
261
+
262
+ it('omits a host-supplied action label when no onBuyNow handler is supplied', () => {
263
+ const html = renderToStaticMarkup(<StorefrontHero {...base} products={[fullProduct]} buyNowLabel={customLabel} />)
264
+ expect(html).not.toContain(customLabel)
265
+ })
266
+
267
+ it('renders a host-supplied action label when onBuyNow is supplied', () => {
268
+ const html = renderToStaticMarkup(
269
+ <StorefrontHero {...base} products={[fullProduct]} buyNowLabel={customLabel} onBuyNow={() => {}} />,
270
+ )
271
+ expect(html).toContain(customLabel)
272
+ })
273
+ })
274
+
275
+ describe('StorefrontHero — editorial: honest department and deal evidence', () => {
276
+ it('renders departments with the count row only when supplied', () => {
277
+ const html = renderToStaticMarkup(
278
+ <StorefrontHero
279
+ {...base}
280
+ presentation='editorial'
281
+ departments={[
282
+ { id: 'd1', name: 'Herramientas', count: '120 referencias' },
283
+ { id: 'd2', name: 'Medición' },
284
+ ]}
285
+ />,
286
+ )
287
+ expect(html).toContain('Herramientas')
288
+ expect(html).toContain('120 referencias')
289
+ expect(html).toContain('Medición')
290
+ })
291
+
292
+ it('renders deal description and struck price only when supplied', () => {
293
+ const html = renderToStaticMarkup(
294
+ <StorefrontHero
295
+ {...base}
296
+ presentation='editorial'
297
+ featuredDeals={[
298
+ { id: 'deal1', name: 'Combo taller', price: 199, originalPrice: 249 },
299
+ { id: 'deal2', name: 'Rebate', description: 'Con descripción', price: 59 },
300
+ ]}
301
+ />,
302
+ )
303
+ expect(html).toContain('Con descripción')
304
+ expect(html).toContain('Combo taller')
305
+ expect(html).toContain('line-through')
306
+ })
307
+
308
+ it('renders deal and locator buttons only with a real destination or handler', () => {
309
+ const html = renderToStaticMarkup(
310
+ <StorefrontHero
311
+ {...base}
312
+ presentation='editorial'
313
+ featuredDeals={[{ id: 'deal1', name: 'Combo taller', price: 199 }]}
314
+ storeLocator={{ title: 'Nuestras tiendas', description: 'Cerca de ti', ctaLabel: 'Ver tiendas' }}
315
+ />,
316
+ )
317
+ expect(html).not.toContain('Comprar ahora')
318
+ expect(html).not.toContain('Ver tiendas')
319
+ })
320
+ })
321
+
322
+ describe('StorefrontHero — promotional: no invented scarcity', () => {
323
+ const promotional = {
324
+ ...base,
325
+ presentation: 'promotional' as const,
326
+ categories: [
327
+ { id: 'c1', name: 'Electricidad', count: '2.5k+' },
328
+ { id: 'c2', name: 'Fontanería' },
329
+ ],
330
+ }
331
+
332
+ it('renders discount and countdown only when the host supplies them', () => {
333
+ const withEvidence = renderToStaticMarkup(
334
+ <StorefrontHero
335
+ {...promotional}
336
+ deals={[{ id: 'deal1', title: 'Semana eléctrica', discount: '15%', endTime: '2d : 15h' }]}
337
+ />,
338
+ )
339
+ expect(withEvidence).toContain('Semana eléctrica')
340
+ expect(withEvidence).toContain('15%')
341
+ expect(withEvidence).toContain('2d : 15h')
342
+
343
+ const bare = renderToStaticMarkup(
344
+ <StorefrontHero {...promotional} deals={[{ id: 'deal1', title: 'Semana eléctrica' }]} />,
345
+ )
346
+ expect(bare).toContain('Semana eléctrica')
347
+ expect(bare).not.toContain('Termina en')
348
+ })
349
+
350
+ it('renders the deals-bar shop-now button only with its handler', () => {
351
+ const bare = renderToStaticMarkup(
352
+ <StorefrontHero {...promotional} deals={[{ id: 'deal1', title: 'Semana eléctrica' }]} />,
353
+ )
354
+ expect(bare).not.toContain('Comprar ahora')
355
+
356
+ const withHandler = renderToStaticMarkup(
357
+ <StorefrontHero {...promotional} deals={[{ id: 'deal1', title: 'Semana eléctrica' }]} onShopNow={() => {}} />,
358
+ )
359
+ expect(withHandler).toContain('Comprar ahora')
360
+ })
361
+
362
+ it('renders category counts and arrows only with evidence', () => {
363
+ const html = renderToStaticMarkup(<StorefrontHero {...promotional} />)
364
+ expect(html).toContain('2.5k+')
365
+ expect(html).toContain('Electricidad')
366
+ expect(html).toContain('Fontanería')
367
+ })
368
+
369
+ it('renders the first deal when rotation has not advanced', () => {
370
+ const html = renderToStaticMarkup(
371
+ <StorefrontHero
372
+ {...promotional}
373
+ deals={[
374
+ { id: 'deal1', title: 'Semana eléctrica' },
375
+ { id: 'deal2', title: 'Mes de medición' },
376
+ ]}
377
+ />,
378
+ )
379
+ expect(html).toContain('Semana eléctrica')
380
+ })
381
+ })
382
+
383
+ describe('storefrontHeroPropsSchema — serializable contract', () => {
384
+ it('parses the canonical props subset across presentations', () => {
385
+ const parsed = storefrontHeroPropsSchema.parse({
386
+ title: 'Potencia para cada proyecto',
387
+ description: 'Herramientas profesionales',
388
+ presentation: 'promotional',
389
+ currency: 'EUR',
390
+ products: [fullProduct],
391
+ heroImage: { src: '/hero-tools.png', alt: 'Taladro inalámbrico' },
392
+ departments: [{ id: 'd1', name: 'Herramientas', count: '120 productos' }],
393
+ featuredDeals: [{ id: 'f1', name: 'Oferta', price: 49.9, originalPrice: 79.9 }],
394
+ deals: [{ id: 'deal1', title: 'Semana eléctrica', endTime: '2026-11-30' }],
395
+ categories: [{ id: 'c1', name: 'Taladros', count: '35' }],
396
+ rotationInterval: 3000,
397
+ })
398
+ expect(parsed.products).toHaveLength(1)
399
+ expect(parsed.presentation).toBe('promotional')
400
+ })
401
+
402
+ it('rejects unknown presentation values', () => {
403
+ expect(() =>
404
+ storefrontHeroPropsSchema.parse({
405
+ title: 'x',
406
+ description: 'y',
407
+ presentation: 'mega',
408
+ products: [],
409
+ }),
410
+ ).toThrow()
411
+ })
412
+ })