@thorprovider/blocks 0.1.2 → 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 (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  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/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. 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
+ })