@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
@@ -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\s110\.00/)
195
+ expect(html).toMatch(/EUR\s108\.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
+ })