@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
@@ -15,7 +15,6 @@ import {
15
15
  type CarouselApi,
16
16
  } from '../../../ui/carousel'
17
17
  import { Progress } from '../../../ui/progress'
18
- import { ProductCard3ReactionToggle } from '../product-list/product-card-3-reaction-toggle'
19
18
 
20
19
  export interface Review {
21
20
  id: string
@@ -194,11 +193,11 @@ export function ReviewRating2({
194
193
  className="h-8 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary cursor-pointer"
195
194
  aria-label={likesLabel}
196
195
  >
197
- <ProductCard3ReactionToggle
198
- active={!!isLiked[review.id]}
199
- icon={ThumbsUp}
200
- iconClassName='size-4'
201
- activeColorClassName='text-primary'
196
+ <ThumbsUp
197
+ className={cn(
198
+ 'size-4',
199
+ isLiked[review.id] && 'text-primary',
200
+ )}
202
201
  />
203
202
  {formatNumber((review.likes ?? 0) + (isLiked[review.id] ? 1 : 0))}
204
203
  </Button>
@@ -0,0 +1,399 @@
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 userEvent from '@testing-library/user-event'
6
+ import { Zap } from 'lucide-react'
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ import { ShoppingCart, type ShoppingCartProps } from './shopping-cart'
10
+ import type { CartTotals, ShoppingCartItem } from '../../../lib/contracts'
11
+
12
+ ;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
13
+
14
+ // jsdom lacks browser APIs some shadcn/ui primitives probe on mount.
15
+ if (!window.matchMedia) {
16
+ window.matchMedia = ((query: string) => ({
17
+ matches: false,
18
+ media: query,
19
+ onchange: null,
20
+ addEventListener: () => {},
21
+ removeEventListener: () => {},
22
+ addListener: () => {},
23
+ removeListener: () => {},
24
+ dispatchEvent: () => false,
25
+ })) as unknown as typeof window.matchMedia
26
+ }
27
+ if (!('IntersectionObserver' in window)) {
28
+ ;(window as Record<string, unknown>).IntersectionObserver = class {
29
+ observe() {}
30
+ unobserve() {}
31
+ disconnect() {}
32
+ takeRecords() {
33
+ return []
34
+ }
35
+ }
36
+ }
37
+
38
+ const usd = (amount: string) => ({ amount, currencyCode: 'USD' })
39
+
40
+ const items: ShoppingCartItem[] = [
41
+ {
42
+ id: 'line_1',
43
+ name: 'Taladro',
44
+ quantity: 2,
45
+ image: 'https://cdn.example/taladro.jpg',
46
+ unitPrice: usd('45.00'),
47
+ lineTotal: usd('90.00'),
48
+ },
49
+ { id: 'line_2', name: 'Batería', quantity: 1, unitPrice: usd('20.00'), lineTotal: usd('20.00') },
50
+ ]
51
+
52
+ const totals: CartTotals = {
53
+ subtotal: usd('110.00'),
54
+ discount: usd('10.00'),
55
+ tax: usd('8.00'),
56
+ shipping: usd('0'),
57
+ total: usd('108.00'),
58
+ }
59
+
60
+ const formatMoney = (money: { amount: string; currencyCode: string }) =>
61
+ `${money.currencyCode} ${money.amount}`
62
+
63
+ const renderMarkup = (props: ShoppingCartProps) => renderToStaticMarkup(<ShoppingCart {...props} />)
64
+
65
+ describe('ShoppingCart — renders from authoritative cart data', () => {
66
+ it('renders supplied backend totals verbatim (no local arithmetic)', () => {
67
+ const html = renderMarkup({ items, promotions: [], totals, formatMoney })
68
+ expect(html).toContain('USD 110.00')
69
+ expect(html).toContain('USD 10.00')
70
+ expect(html).toContain('USD 8.00')
71
+ expect(html).toContain('Gratis')
72
+ expect(html).toContain('USD 108.00')
73
+ })
74
+
75
+ it('does not invent subtotal or total when none are supplied', () => {
76
+ const html = renderMarkup({ items, promotions: [], formatMoney })
77
+ expect(html).not.toContain('Subtotal')
78
+ expect(html).not.toContain('Total')
79
+ })
80
+
81
+ it('renders a controlled pending state', () => {
82
+ const html = renderMarkup({ items, promotions: [], totalsState: 'pending' })
83
+ expect(html).toContain('Actualizando totales')
84
+ })
85
+
86
+ it('renders a controlled error state with a recovery action', () => {
87
+ const html = renderMarkup({ items, promotions: [], totalsState: 'error', onRetryTotals: () => {} })
88
+ expect(html).toContain('No se pudieron actualizar los totales')
89
+ expect(html).toContain('Reintentar')
90
+ })
91
+
92
+ it('never renders a fake zero for absent or malformed required totals', () => {
93
+ const html = renderMarkup({
94
+ items,
95
+ promotions: [],
96
+ totals: { subtotal: { amount: '', currencyCode: 'USD' }, total: { amount: 'abc', currencyCode: 'USD' } },
97
+ formatMoney,
98
+ })
99
+ expect(html).not.toContain('USD 0')
100
+ expect(html).toContain('Totales no disponibles')
101
+ })
102
+
103
+ it('preserves a legitimate zero total', () => {
104
+ const html = renderMarkup({
105
+ items,
106
+ promotions: [],
107
+ totals: { subtotal: usd('0'), total: usd('0') },
108
+ formatMoney,
109
+ })
110
+ expect(html).toContain('USD 0')
111
+ expect(html).not.toContain('Totales no disponibles')
112
+ })
113
+
114
+ it('renders the authoritative line total verbatim, never unit price × quantity', () => {
115
+ const html = renderMarkup({
116
+ items: [
117
+ { id: 'line_x', name: 'Pieza', quantity: 1, unitPrice: usd('10.00'), lineTotal: usd('10.00') },
118
+ { id: 'line_y', name: 'Set', quantity: 3, unitPrice: usd('10.00'), lineTotal: usd('25.00') },
119
+ ],
120
+ promotions: [],
121
+ totals,
122
+ formatMoney,
123
+ })
124
+ expect(html).toContain('>USD 10.00<')
125
+ expect(html).toContain('>USD 25.00<')
126
+ expect(html).not.toContain('>USD 30')
127
+ expect(html).not.toContain('—')
128
+ })
129
+
130
+ it('shows an unavailable marker (not zero) for a malformed line total', () => {
131
+ const html = renderMarkup({
132
+ items: [{ id: 'line_x', name: 'Pieza', quantity: 1, unitPrice: usd('10.00'), lineTotal: { amount: '', currencyCode: 'USD' } }],
133
+ promotions: [],
134
+ totals,
135
+ formatMoney,
136
+ })
137
+ expect(html).toContain('—')
138
+ expect(html).not.toContain('USD 0')
139
+ })
140
+
141
+ it('renders absolute quantities from props (cart contract, not a delta)', () => {
142
+ const html = renderMarkup({ items, promotions: [], totals, formatMoney })
143
+ expect(html).toContain('>2<')
144
+ expect(html).toContain('>1<')
145
+ })
146
+ })
147
+
148
+ describe('ShoppingCart — optional fields are omitted, not emptied', () => {
149
+ it('omits an absent line image without emitting an empty src', () => {
150
+ const html = renderMarkup({ items, promotions: [], totals, formatMoney })
151
+ expect(html).not.toMatch(/src=""/)
152
+ })
153
+
154
+ it('renders no delivery estimate, express controls, or suggestions when absent', () => {
155
+ const html = renderMarkup({ items, promotions: [], totals, formatMoney })
156
+ expect(html).not.toContain('Entrega estimada')
157
+ expect(html).not.toContain('Apple Pay')
158
+ expect(html).not.toContain('Google Pay')
159
+ expect(html).not.toContain('Compra rápida')
160
+ expect(html).not.toContain('Vistos recientemente')
161
+ })
162
+
163
+ it('omits the promo entry affordance when no callback is supplied', () => {
164
+ const html = renderMarkup({ items, promotions: [], totals, formatMoney })
165
+ expect(html).not.toContain('código de descuento')
166
+ })
167
+ })
168
+
169
+ describe('ShoppingCart — truthful evidence only', () => {
170
+ it('renders host-supplied suggestions with Money prices and omits absent fields', () => {
171
+ const html = renderMarkup({
172
+ items,
173
+ promotions: [],
174
+ totals,
175
+ formatMoney,
176
+ recentlyViewed: [
177
+ { id: 'p1', name: 'Mouse', image: '/demo/mouse.svg', unitPrice: usd('5.00') },
178
+ { id: 'p2', name: 'Cable' },
179
+ ],
180
+ })
181
+ expect(html).toContain('Vistos recientemente')
182
+ expect(html).toContain('USD 5.00')
183
+ expect(html).not.toMatch(/src=""/)
184
+ expect(html).not.toContain('$')
185
+ })
186
+
187
+ it('derives the display currency from the authoritative Money subtotal', () => {
188
+ const eur = (amount: string) => ({ amount, currencyCode: 'EUR' })
189
+ const html = renderMarkup({
190
+ items,
191
+ promotions: [],
192
+ totals: { subtotal: eur('110.00'), discount: eur('10.00'), tax: eur('8.00'), shipping: eur('0'), total: eur('108.00') },
193
+ })
194
+ expect(html).toMatch(/(?:EUR|€)\s?110\.00/)
195
+ expect(html).toMatch(/(?:EUR|€)\s?108\.00/)
196
+ })
197
+
198
+ it('renders host-supplied promotion progress and activated copy from data', () => {
199
+ const html = renderMarkup({
200
+ items,
201
+ promotions: [{ threshold: 200, reward: 'Envío gratis', icon: Zap }],
202
+ totals,
203
+ formatMoney,
204
+ })
205
+ expect(html).toContain('Te faltan')
206
+ expect(html).toContain('Envío gratis')
207
+ })
208
+ })
209
+
210
+ describe('ShoppingCart — actions are reported, not owned', () => {
211
+ let container: HTMLElement
212
+ let root: Root
213
+ let user: ReturnType<typeof userEvent.setup>
214
+
215
+ beforeEach(() => {
216
+ container = document.createElement('div')
217
+ document.body.appendChild(container)
218
+ root = createRoot(container)
219
+ user = userEvent.setup()
220
+ })
221
+
222
+ afterEach(() => {
223
+ act(() => root.unmount())
224
+ container.remove()
225
+ })
226
+
227
+ const mount = (props: ShoppingCartProps) => {
228
+ act(() => {
229
+ root.render(<ShoppingCart {...props} />)
230
+ })
231
+ }
232
+
233
+ it('reports absolute quantity changes keyed by the backend line id', async () => {
234
+ const onQuantityChange = vi.fn()
235
+ mount({ items, promotions: [], totals, onQuantityChange })
236
+ const increase = container.querySelector('button[aria-label="Aumentar cantidad, Taladro"]')!
237
+ await user.click(increase)
238
+ expect(onQuantityChange).toHaveBeenCalledWith('line_1', 3)
239
+ const decrease = container.querySelector('button[aria-label="Disminuir cantidad, Taladro"]')!
240
+ await user.click(decrease)
241
+ expect(onQuantityChange).toHaveBeenCalledWith('line_1', 1)
242
+ })
243
+
244
+ it('reports removals keyed by the backend line id', async () => {
245
+ const onRemove = vi.fn()
246
+ mount({ items, promotions: [], totals, onRemove })
247
+ await user.click(container.querySelector('button[aria-label="Quitar Taladro"]')!)
248
+ expect(onRemove).toHaveBeenCalledWith('line_1')
249
+ })
250
+
251
+ it('reports the promo code to the host and never resolves it locally', async () => {
252
+ const onApplyPromo = vi.fn()
253
+ mount({ items, promotions: [], totals, onApplyPromo })
254
+ const input = container.querySelector('input')!
255
+ await user.type(input, 'X10')
256
+ await user.click(container.querySelector('button[type="submit"]')!)
257
+ expect(onApplyPromo).toHaveBeenCalledWith('X10')
258
+ expect((input as HTMLInputElement).value).toBe('')
259
+ })
260
+
261
+ it('does not invent an applied-discount row from the entered code', async () => {
262
+ const onApplyPromo = vi.fn()
263
+ mount({ items, promotions: [], totals, onApplyPromo, formatMoney })
264
+ const input = container.querySelector('input')!
265
+ await user.type(input, 'X10')
266
+ await user.click(container.querySelector('button[type="submit"]')!)
267
+ expect(onApplyPromo).toHaveBeenCalledTimes(1)
268
+ expect(container.querySelector('input')!.value).toBe('')
269
+ })
270
+ })
271
+
272
+ describe('ShoppingCart — presentation and action availability', () => {
273
+ it('defaults to the compact collapsible card', () => {
274
+ const html = renderMarkup({ items, promotions: [], totals, formatMoney })
275
+ expect(html).toContain('max-w-md')
276
+ expect(html).toContain('Contraer carrito')
277
+ expect(html).toContain('max-h-64')
278
+ })
279
+
280
+ it('renders page presentation as a wide, always-expanded review with a heading', () => {
281
+ const html = renderMarkup({ items, promotions: [], totals, formatMoney, presentation: 'page' })
282
+ expect(html).toContain('max-w-3xl')
283
+ expect(html).toContain('<h1')
284
+ expect(html).not.toContain('Contraer carrito')
285
+ expect(html).not.toContain('max-h-64')
286
+ })
287
+
288
+ it('keeps the same data and action contract across presentations', () => {
289
+ for (const presentation of ['compact', 'page'] as const) {
290
+ const html = renderMarkup({
291
+ items,
292
+ promotions: [],
293
+ totals,
294
+ formatMoney,
295
+ presentation,
296
+ onApplyPromo: () => {},
297
+ })
298
+ expect(html).toContain('USD 108.00')
299
+ expect(html).toContain('Taladro')
300
+ expect(html).toContain('código de descuento')
301
+ }
302
+ })
303
+
304
+ it('offers a continue-shopping action in page presentation', () => {
305
+ const html = renderMarkup({
306
+ items,
307
+ promotions: [],
308
+ totals,
309
+ formatMoney,
310
+ presentation: 'page',
311
+ onContinueShopping: () => {},
312
+ })
313
+ expect(html).toContain('Seguir comprando')
314
+ })
315
+
316
+ it('suppresses checkout when the checkout feature is unavailable', () => {
317
+ const html = renderMarkup({
318
+ items,
319
+ promotions: [],
320
+ totals,
321
+ formatMoney,
322
+ presentation: 'page',
323
+ checkoutUnavailable: true,
324
+ })
325
+ expect(html).not.toContain('Ver carrito y finalizar')
326
+ })
327
+
328
+ it('disables checkout while the cart is not ready', () => {
329
+ const html = renderMarkup({
330
+ items,
331
+ promotions: [],
332
+ totals,
333
+ formatMoney,
334
+ presentation: 'page',
335
+ checkoutDisabled: true,
336
+ })
337
+ expect(html).toMatch(/<button[^>]*disabled[^>]*>[^<]*Ver carrito y finalizar/)
338
+ })
339
+ })
340
+
341
+ describe('ShoppingCart — no hardcoded currency or locale copy', () => {
342
+ it('formats every amount through the canonical Money path', () => {
343
+ const html = renderMarkup({
344
+ items,
345
+ promotions: [],
346
+ totals,
347
+ formatMoney,
348
+ recentlyViewed: [{ id: 'p1', name: 'Mouse', unitPrice: usd('5.00') }],
349
+ })
350
+ expect(html).toContain('USD 110.00')
351
+ expect(html).toContain('USD 5.00')
352
+ expect(html).not.toMatch(/\$\d/)
353
+ })
354
+
355
+ it('uses overridable labels for actions, states, and summary copy', () => {
356
+ const html = renderMarkup({
357
+ items,
358
+ promotions: [],
359
+ totals,
360
+ titleLabel: 'Mi carrito',
361
+ emptyLabel: 'Nada por aquí',
362
+ checkoutLabel: 'Pagar',
363
+ })
364
+ expect(html).toContain('Mi carrito')
365
+ expect(html).toContain('Pagar')
366
+ const empty = renderMarkup({
367
+ items: [],
368
+ promotions: [],
369
+ emptyLabel: 'Nada por aquí',
370
+ titleLabel: 'Mi carrito',
371
+ })
372
+ expect(empty).toContain('Nada por aquí')
373
+ expect(empty).not.toContain('Taladro')
374
+ })
375
+ })
376
+
377
+ describe('ShoppingCart — accessibility', () => {
378
+ it('names every line mutation control', () => {
379
+ const html = renderMarkup({ items, promotions: [], totals, formatMoney })
380
+ expect(html).toContain('aria-label="Disminuir cantidad, Taladro"')
381
+ expect(html).toContain('aria-label="Aumentar cantidad, Taladro"')
382
+ expect(html).toContain('aria-label="Quitar Taladro"')
383
+ })
384
+
385
+ it('names the promo input and apply action', () => {
386
+ const html = renderMarkup({ items, promotions: [], totals, onApplyPromo: () => {} })
387
+ expect(html).toContain('código de descuento')
388
+ expect(html).toMatch(/<label[^>]*for=/)
389
+ expect(html).toMatch(/<input[^>]*id=/)
390
+ })
391
+
392
+ it('announces pending totals politely and errors as an alert', () => {
393
+ const pending = renderMarkup({ items, promotions: [], totalsState: 'pending' })
394
+ expect(pending).toContain('aria-live="polite"')
395
+ expect(pending).toContain('Actualizando totales')
396
+ const error = renderMarkup({ items, promotions: [], totalsState: 'error' })
397
+ expect(error).toMatch(/role="alert"/)
398
+ })
399
+ })