@thorprovider/blocks 0.1.3 → 2.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 (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  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/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -0,0 +1,263 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ import { ProductCard, type ProductCardProps } from './product-card'
8
+ import type { ProductPresentation } from '../../../lib/contracts'
9
+
10
+ ;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
11
+
12
+ // jsdom lacks the browser APIs the classic presentation's motion layer touches.
13
+ if (!window.matchMedia) {
14
+ window.matchMedia = ((query: string) => ({
15
+ matches: false,
16
+ media: query,
17
+ onchange: null,
18
+ addEventListener: () => {},
19
+ removeEventListener: () => {},
20
+ addListener: () => {},
21
+ removeListener: () => {},
22
+ dispatchEvent: () => false,
23
+ })) as unknown as typeof window.matchMedia
24
+ }
25
+ if (!('IntersectionObserver' in window)) {
26
+ ;(window as Record<string, unknown>).IntersectionObserver = class {
27
+ observe() {}
28
+ unobserve() {}
29
+ disconnect() {}
30
+ takeRecords() {
31
+ return []
32
+ }
33
+ }
34
+ }
35
+
36
+ const baseItem: ProductPresentation = {
37
+ id: 'p1',
38
+ title: 'Llave de impacto',
39
+ image: '/demo/impact.svg',
40
+ imageAlt: 'Llave de impacto inalámbrica',
41
+ price: { amount: '129.50', currencyCode: 'EUR' },
42
+ compareAtPrice: { amount: '159.00', currencyCode: 'EUR' },
43
+ rating: 4.5,
44
+ badge: 'Nuevo',
45
+ }
46
+
47
+ const renderMarkup = (props: ProductCardProps) => renderToStaticMarkup(<ProductCard {...props} />)
48
+
49
+ describe('ProductCard — renders from canonical data', () => {
50
+ it('renders one item cell from canonical Money, requiring no demo-only fields', () => {
51
+ const html = renderMarkup({ item: baseItem })
52
+ expect(html).toContain('Llave de impacto')
53
+ expect(html).toContain('129')
54
+ expect(html).not.toContain('$')
55
+ })
56
+
57
+ it('renders exactly one cell, with no section wrapper or heading', () => {
58
+ const html = renderMarkup({ item: baseItem })
59
+ expect(html.match(/<h3/g)?.length).toBe(1)
60
+ expect(html).not.toContain('<section')
61
+ expect(html).not.toContain('<h2')
62
+ })
63
+ })
64
+
65
+ describe('ProductCard — optional fields are omitted, not emptied', () => {
66
+ it('renders without empty src, stars, or chip when optionals are absent', () => {
67
+ const html = renderMarkup({
68
+ item: { ...baseItem, image: undefined, imageAlt: undefined, rating: undefined, badge: undefined },
69
+ })
70
+ expect(html).not.toContain('src=""')
71
+ expect(html).not.toContain('4.5')
72
+ expect(html).not.toContain('Nuevo')
73
+ })
74
+
75
+ it('falls back to the title as the image alt text', () => {
76
+ const html = renderMarkup({ item: { ...baseItem, imageAlt: undefined } })
77
+ expect(html).toContain('alt="Llave de impacto"')
78
+ })
79
+ })
80
+
81
+ describe('ProductCard — missing price is unavailable, never zero', () => {
82
+ it('shows the unavailable label, disables add-to-cart, and never renders a zero', () => {
83
+ const html = renderMarkup({
84
+ item: { ...baseItem, price: { amount: '', currencyCode: '' }, compareAtPrice: undefined },
85
+ })
86
+ expect(html).toContain('Precio no disponible')
87
+ expect(html).not.toContain('$0')
88
+ expect(html).toContain('disabled')
89
+ })
90
+
91
+ it('treats a valid zero as a real price', () => {
92
+ const html = renderMarkup({
93
+ item: { ...baseItem, price: { amount: '0.00', currencyCode: 'USD' }, compareAtPrice: undefined },
94
+ })
95
+ expect(html).toContain('$0.00')
96
+ expect(html).not.toContain('Precio no disponible')
97
+ })
98
+ })
99
+
100
+ describe('ProductCard — no evidence is invented', () => {
101
+ it('shows no stars, chip, scarcity copy, or crossed-out price when none is supplied', () => {
102
+ const html = renderMarkup({
103
+ item: { id: 'p1', title: 'Producto 1', price: { amount: '10.00', currencyCode: 'EUR' } },
104
+ })
105
+ expect(html).not.toContain('line-through')
106
+ expect(html).not.toContain('Nuevo')
107
+ expect(html).not.toContain('¡Solo')
108
+ })
109
+ })
110
+
111
+ describe('ProductCard — compare-at only when valid and higher', () => {
112
+ it('renders a comparison price only when strictly higher', () => {
113
+ expect(renderMarkup({ item: baseItem })).toContain('159')
114
+ const equal = renderMarkup({
115
+ item: { ...baseItem, compareAtPrice: { amount: '129.50', currencyCode: 'EUR' } },
116
+ })
117
+ const lower = renderMarkup({
118
+ item: { ...baseItem, compareAtPrice: { amount: '99.00', currencyCode: 'EUR' } },
119
+ })
120
+ const invalid = renderMarkup({
121
+ item: { ...baseItem, compareAtPrice: { amount: '', currencyCode: '' } },
122
+ })
123
+ expect(equal).not.toContain('159')
124
+ expect(lower).not.toContain('99')
125
+ expect(invalid).not.toContain('line-through')
126
+ })
127
+ })
128
+
129
+ describe('ProductCard — bounded presentation surface', () => {
130
+ it('changes structure across presentations while keeping the same contract', () => {
131
+ const classic = renderMarkup({ item: baseItem })
132
+ const minimal = renderMarkup({ item: baseItem, presentation: 'minimal' })
133
+ const rich = renderMarkup({ item: baseItem, presentation: 'rich' })
134
+ for (const html of [classic, minimal, rich]) {
135
+ expect(html).toContain('129')
136
+ expect(html).toContain('Llave de impacto')
137
+ expect(html).toContain('Agregar al carrito')
138
+ }
139
+ expect(classic).toContain('>Agregar al carrito<')
140
+ expect(minimal).not.toContain('>Agregar al carrito<')
141
+ expect(minimal).toContain('aria-label="Agregar al carrito"')
142
+ expect(rich).toContain('aria-pressed')
143
+ expect(rich).toContain('>Agregar al carrito<')
144
+ })
145
+ })
146
+
147
+ describe('ProductCard — rich presentation degrades honestly', () => {
148
+ const heartTag = (html: string) => html.match(/<button[^>]*aria-pressed[^>]*>/)?.[0]
149
+ const isDisabled = (tag: string | undefined) => /\sdisabled(?:="")?(?=[\s>])/.test(tag ?? '')
150
+
151
+ it('reflects the wishlisted prop and disables the heart without wiring', () => {
152
+ const unwired = renderMarkup({ item: baseItem, presentation: 'rich' })
153
+ expect(unwired).toContain('aria-pressed="false"')
154
+ expect(isDisabled(heartTag(unwired))).toBe(true)
155
+ expect(unwired).not.toContain('Ver similares')
156
+ expect(renderMarkup({ item: baseItem, presentation: 'rich', wishlisted: true })).toContain(
157
+ 'aria-pressed="true"',
158
+ )
159
+ const wired = renderMarkup({ item: baseItem, presentation: 'rich', onToggleWishlist: () => {} })
160
+ expect(isDisabled(heartTag(wired))).toBe(false)
161
+ })
162
+ })
163
+
164
+ describe('ProductCard — labels are overridable', () => {
165
+ it('uses supplied labels for actions and availability copy', () => {
166
+ const html = renderMarkup({
167
+ item: { ...baseItem, price: { amount: '', currencyCode: '' } },
168
+ addToCartLabel: 'Comprar ahora',
169
+ unavailablePriceLabel: 'Consultar precio',
170
+ addToWishlistLabel: 'Guardar',
171
+ viewSimilarLabel: 'Ver parecidos',
172
+ presentation: 'rich',
173
+ onViewSimilar: () => {},
174
+ })
175
+ expect(html).toContain('Comprar ahora')
176
+ expect(html).toContain('Consultar precio')
177
+ expect(html).toContain('Guardar')
178
+ expect(html).toContain('Ver parecidos')
179
+ expect(html).not.toContain('Agregar al carrito')
180
+ })
181
+ })
182
+
183
+ describe('ProductCard — actions are reported, not owned', () => {
184
+ let container: HTMLDivElement
185
+ let root: Root
186
+ let mounted: Root | undefined
187
+
188
+ const mount = (props: ProductCardProps) => {
189
+ root = createRoot(container)
190
+ mounted = root
191
+ act(() => root.render(<ProductCard {...props} />))
192
+ }
193
+
194
+ const click = (element: Element | undefined | null) => {
195
+ expect(element).toBeDefined()
196
+ act(() => {
197
+ element!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
198
+ })
199
+ }
200
+
201
+ beforeEach(() => {
202
+ container = document.createElement('div')
203
+ document.body.appendChild(container)
204
+ })
205
+
206
+ afterEach(() => {
207
+ const root = mounted
208
+ if (root) act(() => root.unmount())
209
+ container.remove()
210
+ })
211
+
212
+ it('reports add-to-cart and product clicks with the item identity', () => {
213
+ const onAddToCart = vi.fn()
214
+ const onProductClick = vi.fn()
215
+ mount({ item: baseItem, onAddToCart, onProductClick })
216
+
217
+ const addToCart = Array.from(container.querySelectorAll('button')).find((b) =>
218
+ b.textContent?.includes('Agregar al carrito'),
219
+ )
220
+ click(addToCart)
221
+ expect(onAddToCart).toHaveBeenCalledWith(baseItem)
222
+
223
+ click(container.querySelector('h3 button'))
224
+ expect(onProductClick).toHaveBeenCalledWith(baseItem)
225
+ })
226
+
227
+ it('reports the wishlist toggle with the next state and reflects the prop', () => {
228
+ const onToggleWishlist = vi.fn()
229
+ mount({ item: baseItem, presentation: 'rich', wishlisted: false, onToggleWishlist })
230
+
231
+ const heart = () => container.querySelector('button[aria-pressed]')
232
+ expect(heart()!.getAttribute('aria-pressed')).toBe('false')
233
+ click(heart())
234
+ expect(onToggleWishlist).toHaveBeenCalledWith(baseItem, true)
235
+ expect(heart()!.getAttribute('aria-pressed')).toBe('false')
236
+ })
237
+
238
+ it('keeps an unwired heart disabled and inert', () => {
239
+ mount({ item: baseItem, presentation: 'rich', wishlisted: false })
240
+ const heart = () => container.querySelector('button[aria-pressed]') as HTMLButtonElement
241
+ expect(heart().disabled).toBe(true)
242
+ click(heart())
243
+ expect(heart().getAttribute('aria-pressed')).toBe('false')
244
+ })
245
+
246
+ it('reflects host-owned wishlist membership from the prop', () => {
247
+ const onToggleWishlist = vi.fn()
248
+ mount({ item: baseItem, presentation: 'rich', wishlisted: true, onToggleWishlist })
249
+
250
+ const heart = () => container.querySelector('button[aria-pressed]')
251
+ expect(heart()!.getAttribute('aria-pressed')).toBe('true')
252
+ click(heart())
253
+ expect(onToggleWishlist).toHaveBeenCalledWith(baseItem, false)
254
+ expect(heart()!.getAttribute('aria-pressed')).toBe('true')
255
+ })
256
+
257
+ it('reports view-similar through its callback and omits the affordance without it', () => {
258
+ const onViewSimilar = vi.fn()
259
+ mount({ item: baseItem, presentation: 'rich', onViewSimilar })
260
+ click(container.querySelector('button[aria-label="Ver similares"]'))
261
+ expect(onViewSimilar).toHaveBeenCalledWith(baseItem)
262
+ })
263
+ })