@thorprovider/blocks 1.0.0 → 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 (91) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +156 -40
  3. package/package.json +1 -1
  4. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  5. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  6. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  7. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  8. package/src/blocks/application/verification/verify-1.tsx +2 -2
  9. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  10. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  11. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  12. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  13. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  14. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  15. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  16. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  17. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  19. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  20. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  21. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  22. package/src/blocks/{marketing/navbars/navbar-4-account-menu.test.tsx → e-commerce/navigation/storefront-navbar-account-menu.test.tsx} +8 -8
  23. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  24. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  25. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  27. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +40 -40
  29. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +17 -17
  30. package/src/blocks/e-commerce/product-list/product-card.tsx +63 -40
  31. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +2 -2
  32. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  33. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  34. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  35. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  36. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  37. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  38. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  39. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  40. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  41. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  42. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  43. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  44. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  45. package/src/index.ts +80 -81
  46. package/src/lib/contracts-curated.test.ts +5 -5
  47. package/src/lib/contracts.test.ts +55 -0
  48. package/src/lib/contracts.ts +64 -2
  49. package/tsup.config.ts +1 -1
  50. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  51. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  52. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  53. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  54. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  55. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  56. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  57. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  58. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  59. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  60. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  61. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  62. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  63. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  67. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  68. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  69. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  70. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  71. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  72. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  73. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  74. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  75. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  76. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  77. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  78. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  79. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  80. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  81. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  82. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  83. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  84. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  85. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  86. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  87. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  88. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  89. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  90. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  91. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -1,19 +1,29 @@
1
1
  import type { ReactElement, ReactNode } from 'react'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
+ import type { ProductPresentation } from '../../../lib/contracts'
4
5
  import {
5
- CategoryFilter2,
6
- CategoryFilter2StatusView,
6
+ CategoryListing,
7
+ CategoryListingStatusView,
7
8
  buildFilterChange,
8
9
  reconcileSearchDraft,
9
10
  toggleFilterValue,
10
- type CategoryFilter2ActiveFilter,
11
- type FilterProduct,
12
- } from './category-filter-2'
13
-
14
- const products: FilterProduct[] = [
15
- { id: 'pantalla-samsung-a54', name: 'Pantalla Samsung Galaxy A54 OLED', price: '$49.99', image: '/demo/pantalla.svg' },
16
- { id: 'bateria-iphone-13', name: 'Batería iPhone 13', price: '$29.99' },
11
+ type CategoryListingActiveFilter,
12
+ type CategoryListingProps,
13
+ } from './category-listing'
14
+
15
+ const products: ProductPresentation[] = [
16
+ {
17
+ id: 'pantalla-samsung-a54',
18
+ title: 'Pantalla Samsung Galaxy A54 OLED',
19
+ image: '/demo/pantalla.svg',
20
+ price: { amount: '49.99', currencyCode: 'USD' },
21
+ },
22
+ {
23
+ id: 'bateria-iphone-13',
24
+ title: 'Batería iPhone 13',
25
+ price: { amount: '29.99', currencyCode: 'USD' },
26
+ },
17
27
  ]
18
28
 
19
29
  const advertisedSorts = [
@@ -22,7 +32,7 @@ const advertisedSorts = [
22
32
  { value: 'newest', label: 'Más recientes' },
23
33
  ]
24
34
 
25
- const activeChips: CategoryFilter2ActiveFilter[] = [
35
+ const activeChips: CategoryListingActiveFilter[] = [
26
36
  { id: 'text', kind: 'text', label: 'pantalla', params: ['q'], value: 'pantalla' },
27
37
  { id: 'category:pantallas', kind: 'category', label: 'Pantallas', params: ['categories'], value: 'pantallas' },
28
38
  { id: 'price', kind: 'price', label: '10 – 40', params: ['priceMin', 'priceMax'] },
@@ -30,8 +40,29 @@ const activeChips: CategoryFilter2ActiveFilter[] = [
30
40
 
31
41
  const countOccurrences = (html: string, needle: string) => html.split(needle).length - 1
32
42
 
43
+ function render(overrides: Partial<CategoryListingProps> = {}): string {
44
+ return renderToStaticMarkup(
45
+ <CategoryListing
46
+ products={products}
47
+ pagination={[]}
48
+ status={{ state: 'ready' }}
49
+ activeFilters={[]}
50
+ searchValue=''
51
+ selectedCategories={[]}
52
+ selectedCollections={[]}
53
+ selectedSort='featured'
54
+ selectedPage={1}
55
+ selectedPageSize={10}
56
+ showSearch={false}
57
+ showFilters
58
+ showSort
59
+ {...overrides}
60
+ />,
61
+ )
62
+ }
63
+
33
64
  /** Find every element whose prop `key` equals `value`, walking the returned
34
- * element tree (no DOM needed — `CategoryFilter2StatusView` is hooks-free). */
65
+ * element tree (no DOM needed — `CategoryListingStatusView` is hooks-free). */
35
66
  function findAllByProp(
36
67
  node: ReactNode,
37
68
  key: string,
@@ -61,173 +92,232 @@ function findAllByProp(
61
92
  return found
62
93
  }
63
94
 
64
- describe('CategoryFilter2 — controlled canonical query', () => {
95
+ describe('CategoryListing — controlled canonical query', () => {
65
96
  it('renders the query-selected search, category, sort and page values', () => {
66
- const html = renderToStaticMarkup(
67
- <CategoryFilter2
68
- products={products}
69
- categories={[
70
- { id: 'pantallas', label: 'Pantallas' },
71
- { id: 'baterias', label: 'Baterías' },
72
- ]}
73
- sortOptions={advertisedSorts}
74
- pagination={[1, 2, 3]}
75
- showSearch
76
- searchValue='pantalla'
77
- selectedCategories={['pantallas']}
78
- selectedSort='name'
79
- selectedPage={2}
80
- />,
81
- )
97
+ const html = render({
98
+ categories: [
99
+ { id: 'pantallas', label: 'Pantallas' },
100
+ { id: 'baterias', label: 'Baterías' },
101
+ ],
102
+ sortOptions: advertisedSorts,
103
+ pagination: [1, 2, 3],
104
+ showSearch: true,
105
+ searchValue: 'pantalla',
106
+ selectedCategories: ['pantallas'],
107
+ selectedSort: 'name',
108
+ selectedPage: 2,
109
+ })
82
110
 
83
111
  expect(html).toContain('value="pantalla"')
84
- // The sort control renders (its options are portaled client-side, so the
85
- // advertised label set is asserted by the mapper test instead).
86
112
  expect(html).toContain('aria-label="Ordenar por"')
87
113
  expect(html).toContain('aria-checked="true"')
88
114
  expect(html).toContain('aria-current="page"')
89
- // The selected page is page 2; only one link is active.
90
115
  expect(countOccurrences(html, 'aria-current="page"')).toBe(1)
91
116
  })
92
117
 
93
118
  it('hides the search control by default and shows it when requested', () => {
94
- const hidden = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
119
+ const hidden = render()
95
120
  expect(hidden).not.toContain('type="search"')
96
121
 
97
- const shown = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} showSearch />)
122
+ const shown = render({ showSearch: true })
98
123
  expect(shown).toContain('type="search"')
99
124
  expect(shown).toContain('aria-label="Buscar"')
100
125
  })
101
126
 
102
127
  it('renders collection controls when collections are supplied', () => {
103
- const html = renderToStaticMarkup(
104
- <CategoryFilter2
105
- products={products}
106
- collections={[{ id: 'ofertas', label: 'Ofertas' }]}
107
- selectedCollections={['ofertas']}
108
- />,
109
- )
128
+ const html = render({
129
+ collections: [{ id: 'ofertas', label: 'Ofertas' }],
130
+ selectedCollections: ['ofertas'],
131
+ })
110
132
  expect(html).toContain('Colección')
111
133
  expect(html).toContain('Ofertas')
112
134
  expect(html).toContain('aria-checked="true"')
113
135
  })
136
+
137
+ it('does not own private query state: the controlled values always win', () => {
138
+ const html = render({ selectedCategories: ['pantallas'], categories: [{ id: 'pantallas', label: 'Pantallas' }] })
139
+ expect(html).toContain('aria-checked="true"')
140
+
141
+ const cleared = render({ selectedCategories: [], categories: [{ id: 'pantallas', label: 'Pantallas' }] })
142
+ expect(cleared).not.toContain('aria-checked="true"')
143
+ })
114
144
  })
115
145
 
116
- describe('CategoryFilter2 — no fabricated price or sort choices', () => {
146
+ describe('CategoryListing — no fabricated price or sort choices', () => {
117
147
  it('omits the price control without a real backend range', () => {
118
- const html = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
119
- expect(html).not.toContain('Rango de precio')
148
+ expect(render({ sortOptions: advertisedSorts })).not.toContain('Rango de precio')
120
149
  })
121
150
 
122
151
  it('shows the price control only from a real range', () => {
123
- const html = renderToStaticMarkup(
124
- <CategoryFilter2 products={products} priceRange={{ min: 0, max: 100, step: 5 }} selectedPriceRange={[10, 40]} />,
125
- )
152
+ const html = render({
153
+ priceRange: { min: 0, max: 100, step: 5 },
154
+ selectedPriceRange: [10, 40],
155
+ })
126
156
  expect(html).toContain('Rango de precio')
127
157
  expect(html).toContain('value="10"')
128
158
  expect(html).toContain('value="40"')
129
159
  })
130
160
 
131
- it('renders the sort control only when advertised options are supplied', () => {
132
- const withOptions = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
133
- expect(withOptions).toContain('aria-label="Ordenar por"')
134
-
135
- const withoutOptions = renderToStaticMarkup(<CategoryFilter2 products={products} />)
136
- expect(withoutOptions).not.toContain('aria-label="Ordenar por"')
161
+ it('hides the price control when showPrice is false even with a real range', () => {
162
+ const html = render({
163
+ showPrice: false,
164
+ priceRange: { min: 0, max: 100, step: 5 },
165
+ selectedPriceRange: [10, 40],
166
+ })
167
+ expect(html).not.toContain('Rango de precio')
137
168
  })
138
169
 
139
- it('renders no sort control when no options are advertised', () => {
140
- const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
141
- expect(html).not.toContain('Ordenar por')
170
+ it('renders the sort control only when advertised options are supplied', () => {
171
+ expect(render({ sortOptions: advertisedSorts })).toContain('aria-label="Ordenar por"')
172
+ expect(render()).not.toContain('aria-label="Ordenar por"')
142
173
  })
143
174
  })
144
175
 
145
- describe('CategoryFilter2 — provider-paged products are not re-paginated', () => {
176
+ describe('CategoryListing — provider-paged products are not re-paginated', () => {
146
177
  it('renders exactly the supplied page of products with backend page links', () => {
147
- const html = renderToStaticMarkup(
148
- <CategoryFilter2 products={products} pagination={[1, 2, 3]} selectedPage={2} sortOptions={advertisedSorts} />,
149
- )
178
+ const html = render({ pagination: [1, 2, 3], selectedPage: 2, sortOptions: advertisedSorts })
150
179
  expect(html).toContain('Pantalla Samsung Galaxy A54 OLED')
151
180
  expect(html).toContain('Batería iPhone 13')
152
181
  expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(3)
153
- // The second product has no image; the block omits src rather than emitting "".
154
182
  expect(html).not.toContain('src=""')
155
183
  })
156
184
  })
157
185
 
158
- describe('CategoryFilter2 — product cards link and placeholder (tasks 3.1/3.2)', () => {
159
- it('renders each product card as a real anchor when href is present', () => {
160
- const linked: FilterProduct[] = [
161
- { id: 'p1', name: 'Pantalla', price: '$49.99', href: '/productos/pantalla-samsung-a54' },
162
- ]
163
- const html = renderToStaticMarkup(<CategoryFilter2 products={linked} />)
164
- expect(html).toMatch(/<a[^>]*href="\/productos\/pantalla-samsung-a54"/)
165
- expect(html).toContain('Pantalla')
166
- // The card is not rendered as an article when it is a link.
186
+ describe('CategoryListing — canonical ProductCard reuse', () => {
187
+ it('renders results through the canonical card, not private card markup', () => {
188
+ const html = render()
189
+ expect(countOccurrences(html, 'data-slot="card"')).toBe(products.length)
167
190
  expect(html).not.toContain('<article')
168
- })
169
-
170
- it('keeps the onClick path (article, no anchor) when href is absent', () => {
171
- const html = renderToStaticMarkup(
172
- <CategoryFilter2 products={products} onProductClick={() => {}} />,
173
- )
174
- expect(html).not.toMatch(/<a[^>]*href="\/productos\//)
175
- expect(html).toContain('<article')
176
- })
177
-
178
- it('links only the cards that carry an href', () => {
179
- const mixed: FilterProduct[] = [
180
- { id: 'p1', name: 'Pantalla', price: '$49.99', href: '/productos/p1' },
181
- { id: 'p2', name: 'Batería', price: '$29.99' },
182
- ]
183
- const html = renderToStaticMarkup(<CategoryFilter2 products={mixed} />)
184
- expect(countOccurrences(html, '<a ')).toBe(1)
185
- expect(html).toMatch(/<a[^>]*href="\/productos\/p1"/)
186
- expect(html).toContain('Batería')
191
+ expect(html).not.toContain('data-slot="category-listing-product-card"')
187
192
  })
188
193
 
189
194
  it('renders a neutral placeholder and no <img> when the image is absent', () => {
190
- const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
195
+ const html = render()
191
196
  expect(html).toContain('data-slot="product-image-placeholder"')
192
- // The product without an image never emits an <img> without src.
193
197
  expect(html).not.toContain('src=""')
194
- // One product has an image (Pantalla), one does not (Batería): exactly one img.
195
198
  expect(countOccurrences(html, '<img')).toBe(1)
196
199
  expect(html).toMatch(/<img[^>]*src="\/demo\/pantalla.svg"/)
197
200
  })
201
+
202
+ it('does not render an inert add-to-cart control when the listing owns no cart action', () => {
203
+ const html = render()
204
+ expect(html).not.toContain('Agregar al carrito')
205
+ })
206
+ })
207
+
208
+ describe('CategoryListing — bounded presentations', () => {
209
+ it('renders the same products and controls in both presentations', () => {
210
+ for (const presentation of ['sidebar', 'toolbar'] as const) {
211
+ const html = render({
212
+ presentation,
213
+ categories: [{ id: 'pantallas', label: 'Pantallas' }],
214
+ sortOptions: advertisedSorts,
215
+ searchValue: 'pantalla',
216
+ showSearch: true,
217
+ selectedPage: 2,
218
+ pagination: [1, 2],
219
+ })
220
+ expect(html).toContain('Pantalla Samsung Galaxy A54 OLED')
221
+ expect(html).toContain('Batería iPhone 13')
222
+ expect(html).toContain('value="pantalla"')
223
+ expect(html).toContain('aria-label="Ordenar por"')
224
+ expect(html).toContain('aria-current="page"')
225
+ }
226
+ })
227
+
228
+ it('keeps the persistent sidebar only in the sidebar presentation', () => {
229
+ expect(render({ presentation: 'sidebar' })).toContain('<aside')
230
+ expect(render({ presentation: 'toolbar' })).not.toContain('<aside')
231
+ })
232
+
233
+ it('renders the toolbar filter trigger and sort sheet controls on mobile', () => {
234
+ const html = render({
235
+ presentation: 'toolbar',
236
+ sortOptions: advertisedSorts,
237
+ selectedCategories: ['pantallas'],
238
+ categories: [{ id: 'pantallas', label: 'Pantallas' }],
239
+ })
240
+ expect(html).toContain('Filtros')
241
+ expect(html).toContain('aria-label="Ordenar por"')
242
+ expect(html).toContain('aria-expanded="false"')
243
+ })
198
244
  })
199
245
 
200
- describe('CategoryFilter2 — emitted changes (pure reducer)', () => {
246
+ describe('CategoryListing — generic facets and honest counts', () => {
247
+ const facetGroup = {
248
+ id: 'connectivity',
249
+ label: 'Conectividad',
250
+ options: [
251
+ { value: 'wifi', label: 'Wi-Fi', count: 12 },
252
+ { value: 'bluetooth', label: 'Bluetooth', count: 0 },
253
+ { value: 'wired', label: 'Cable', count: Number.NaN },
254
+ { value: 'other', label: 'Otro', count: -3 },
255
+ ],
256
+ }
257
+
258
+ it('renders generic facet groups with their real counts', () => {
259
+ const html = render({
260
+ facets: [facetGroup],
261
+ selectedFacets: { connectivity: ['wifi'] },
262
+ })
263
+ expect(html).toContain('data-slot="facet-group"')
264
+ expect(html).toContain('Conectividad')
265
+ expect(html).toContain('Wi-Fi')
266
+ expect(html).toContain('aria-checked="true"')
267
+ })
268
+
269
+ it('preserves a real zero count and omits invalid counts', () => {
270
+ const html = render({ facets: [facetGroup] })
271
+ const counts = html.split('data-slot="facet-count"').slice(1)
272
+ expect(counts).toHaveLength(2)
273
+ expect(counts[0]).toContain('12')
274
+ expect(counts[1]).toContain('0')
275
+ })
276
+
277
+ it('renders no facet chrome without provider metadata', () => {
278
+ const html = render()
279
+ expect(html).not.toContain('data-slot="facet-group"')
280
+ expect(html).not.toContain('facet-count')
281
+ })
282
+
283
+ it('omits a supplied group with no options', () => {
284
+ const html = render({ facets: [{ id: 'empty', label: 'Vacío', options: [] }] })
285
+ expect(html).not.toContain('data-facet-id="empty"')
286
+ })
287
+ })
288
+
289
+ describe('CategoryListing — emitted changes (pure reducer)', () => {
201
290
  it('adds and removes a selection without duplicates', () => {
202
291
  expect(toggleFilterValue([], 'a', true)).toEqual(['a'])
203
292
  expect(toggleFilterValue(['a'], 'b', true)).toEqual(['a', 'b'])
204
293
  expect(toggleFilterValue(['a', 'b'], 'a', false)).toEqual(['b'])
205
294
  })
206
295
 
207
- it('emits the canonical filter change including collections', () => {
296
+ it('emits the absolute canonical filter change including facets and price', () => {
208
297
  expect(
209
298
  buildFilterChange({
210
299
  categories: ['pantallas'],
211
300
  collections: ['ofertas'],
212
301
  priceRange: [10, 40],
213
- size: null,
214
- color: null,
302
+ facets: { connectivity: ['wifi'] },
215
303
  }),
216
304
  ).toEqual({
217
305
  categories: ['pantallas'],
218
306
  collections: ['ofertas'],
219
307
  priceRange: [10, 40],
220
- size: null,
221
- color: null,
308
+ facets: { connectivity: ['wifi'] },
309
+ })
310
+ })
311
+
312
+ it('omits price and facets when the host supplied none', () => {
313
+ expect(buildFilterChange({ categories: [], collections: [] })).toEqual({
314
+ categories: [],
315
+ collections: [],
222
316
  })
223
317
  })
224
318
  })
225
319
 
226
- describe('CategoryFilter2 — controlled search draft (E2 task 8.2 corrective)', () => {
227
- // Simulates the real client loop: the shopper types into the controlled input
228
- // (mutating the draft immediately and emitting the full value), while the
229
- // URL-backed `searchValue` prop arrives later from the server. The draft must
230
- // keep every typed character and never be reverted by a stale response.
320
+ describe('CategoryListing — controlled search draft', () => {
231
321
  function type(seq: string) {
232
322
  let draft = ''
233
323
  let prevSearchValue: string | undefined = ''
@@ -243,12 +333,8 @@ describe('CategoryFilter2 — controlled search draft (E2 task 8.2 corrective)',
243
333
  const before = draft
244
334
  draft += ch
245
335
  emitted.push(draft)
246
- // A fast server round-trip can deliver the response to the PREVIOUS
247
- // keystroke after the shopper has already typed past it; each delivery
248
- // must leave the draft intact.
249
336
  deliver(before)
250
337
  }
251
- // The final server response reflects the whole committed query.
252
338
  deliver(seq)
253
339
  return { draft, emitted }
254
340
  }
@@ -260,21 +346,21 @@ describe('CategoryFilter2 — controlled search draft (E2 task 8.2 corrective)',
260
346
  })
261
347
 
262
348
  it('ignores a stale (unchanged) response that would revert the draft', () => {
263
- expect(
264
- reconcileSearchDraft({ searchValue: 'al', prevSearchValue: 'al', draft: 'alp' }),
265
- ).toBeNull()
349
+ expect(reconcileSearchDraft({ searchValue: 'al', prevSearchValue: 'al', draft: 'alp' })).toBeNull()
266
350
  })
267
351
 
268
352
  it('records a stale older response without reverting the draft', () => {
269
- expect(
270
- reconcileSearchDraft({ searchValue: 'a', prevSearchValue: 'al', draft: 'alp' }),
271
- ).toEqual({ draft: 'alp', prevSearchValue: 'a' })
353
+ expect(reconcileSearchDraft({ searchValue: 'a', prevSearchValue: 'al', draft: 'alp' })).toEqual({
354
+ draft: 'alp',
355
+ prevSearchValue: 'a',
356
+ })
272
357
  })
273
358
 
274
359
  it('adopts an external navigation value when no local edit is pending', () => {
275
- expect(
276
- reconcileSearchDraft({ searchValue: 'beta', prevSearchValue: '', draft: '' }),
277
- ).toEqual({ draft: 'beta', prevSearchValue: 'beta' })
360
+ expect(reconcileSearchDraft({ searchValue: 'beta', prevSearchValue: '', draft: '' })).toEqual({
361
+ draft: 'beta',
362
+ prevSearchValue: 'beta',
363
+ })
278
364
  })
279
365
 
280
366
  it('does not adopt while an uncommitted edit is pending', () => {
@@ -282,95 +368,76 @@ describe('CategoryFilter2 — controlled search draft (E2 task 8.2 corrective)',
282
368
  reconcileSearchDraft({ searchValue: 'other', prevSearchValue: 'alp', draft: 'alpha' }),
283
369
  ).toEqual({ draft: 'alpha', prevSearchValue: 'other' })
284
370
  })
285
-
286
- it('is a no-op for an unchanged prop', () => {
287
- expect(reconcileSearchDraft({ searchValue: 'q', prevSearchValue: 'q', draft: 'q' })).toBeNull()
288
- })
289
371
  })
290
372
 
291
- describe('CategoryFilter2 — observable listing states (task 4.5)', () => {
373
+ describe('CategoryListing — observable listing states', () => {
292
374
  it('shows skeleton placeholders while loading instead of products', () => {
293
- const html = renderToStaticMarkup(<CategoryFilter2 products={products} status={{ state: 'loading' }} />)
375
+ const html = render({ status: { state: 'loading' } })
294
376
  expect(html).toContain('data-slot="skeleton"')
295
377
  expect(html).toContain('aria-busy="true"')
296
378
  expect(html).not.toContain('Pantalla Samsung Galaxy A54 OLED')
297
379
  })
298
380
 
299
381
  it('renders a clearable empty state, distinct from an error', () => {
300
- const html = renderToStaticMarkup(
301
- <CategoryFilter2 products={[]} status={{ state: 'empty' }} onClearFilters={() => {}} />,
302
- )
382
+ const html = render({ products: [], status: { state: 'empty' }, onClearFilters: () => {} })
303
383
  expect(html).toContain('Sin resultados')
304
384
  expect(html).toContain('Limpiar filtros')
305
385
  expect(html).not.toContain('Reintentar')
306
386
  })
307
387
 
308
388
  it('renders an invalid-query repair state naming the invalid fields', () => {
309
- const html = renderToStaticMarkup(
310
- <CategoryFilter2
311
- products={[]}
312
- status={{ state: 'invalid-query', error: { code: 'invalid-query', fields: ['page'] } }}
313
- onRepair={() => {}}
314
- />,
315
- )
389
+ const html = render({
390
+ products: [],
391
+ status: { state: 'invalid-query', error: { code: 'invalid-query', fields: ['page'] } },
392
+ onRepair: () => {},
393
+ })
316
394
  expect(html).toContain('Consulta no válida')
317
395
  expect(html).toContain('page')
318
396
  expect(html).toContain('Corregir búsqueda')
319
397
  })
320
398
 
321
399
  it('renders an unsupported-query repair state naming the unsupported fields', () => {
322
- const html = renderToStaticMarkup(
323
- <CategoryFilter2
324
- products={[]}
325
- status={{ state: 'unsupported-query', error: { code: 'unsupported-query', fields: ['priceMin'] } }}
326
- onRepair={() => {}}
327
- />,
328
- )
400
+ const html = render({
401
+ products: [],
402
+ status: { state: 'unsupported-query', error: { code: 'unsupported-query', fields: ['priceMin'] } },
403
+ onRepair: () => {},
404
+ })
329
405
  expect(html).toContain('Filtro no disponible')
330
406
  expect(html).toContain('priceMin')
331
407
  })
332
408
 
333
409
  it('renders an unmounted-capability repair state naming the missing feature', () => {
334
- const html = renderToStaticMarkup(
335
- <CategoryFilter2
336
- products={[]}
337
- status={{
338
- state: 'unmounted-capability',
339
- error: { code: 'unmounted-capability', fields: ['q'], feature: 'catalog-search' },
340
- }}
341
- onRepair={() => {}}
342
- />,
343
- )
410
+ const html = render({
411
+ products: [],
412
+ status: {
413
+ state: 'unmounted-capability',
414
+ error: { code: 'unmounted-capability', fields: ['q'], feature: 'catalog-search' },
415
+ },
416
+ onRepair: () => {},
417
+ })
344
418
  expect(html).toContain('Función no disponible')
345
419
  expect(html).toContain('catalog-search')
346
420
  })
347
421
 
348
422
  it('renders an error state with a retry action and the failure message', () => {
349
- const html = renderToStaticMarkup(
350
- <CategoryFilter2 products={[]} status={{ state: 'error', message: 'Network down' }} onRetry={() => {}} />,
351
- )
423
+ const html = render({ products: [], status: { state: 'error', message: 'Network down' }, onRetry: () => {} })
352
424
  expect(html).toContain('Network down')
353
425
  expect(html).toContain('Reintentar')
354
426
  })
355
427
 
356
428
  it('renders an accessible removable chip per active filter', () => {
357
- const html = renderToStaticMarkup(<CategoryFilter2 products={products} activeFilters={activeChips} />)
429
+ const html = render({ activeFilters: activeChips })
358
430
  expect(html).toContain('aria-label="Filtros activos"')
359
431
  expect(html).toContain('pantalla')
360
432
  expect(html).toContain('Pantallas')
361
433
  expect(html).toContain('aria-label="Quitar filtro: Pantallas"')
362
434
  })
363
-
364
- it('keeps the legacy ready surface when no status is supplied', () => {
365
- const html = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
366
- expect(html).toContain('Pantalla Samsung Galaxy A54 OLED')
367
- })
368
435
  })
369
436
 
370
- describe('CategoryFilter2StatusView — state and chip interactions (task 4.5)', () => {
437
+ describe('CategoryListingStatusView — state and chip interactions', () => {
371
438
  it('removes exactly the clicked chip and preserves the others', () => {
372
439
  const onRemoveFilter = vi.fn()
373
- const tree = CategoryFilter2StatusView({
440
+ const tree = CategoryListingStatusView({
374
441
  status: { state: 'ready' },
375
442
  activeFilters: activeChips,
376
443
  onRemoveFilter,
@@ -388,7 +455,7 @@ describe('CategoryFilter2StatusView — state and chip interactions (task 4.5)',
388
455
 
389
456
  it('repairs by emitting the exact bad fields of the typed error', () => {
390
457
  const onRepair = vi.fn()
391
- const tree = CategoryFilter2StatusView({
458
+ const tree = CategoryListingStatusView({
392
459
  status: {
393
460
  state: 'unsupported-query',
394
461
  error: { code: 'unsupported-query', fields: ['priceMin', 'inStock'], message: 'Unsupported' },
@@ -405,7 +472,7 @@ describe('CategoryFilter2StatusView — state and chip interactions (task 4.5)',
405
472
 
406
473
  it('retries through the callback without emitting any query mutation', () => {
407
474
  const onRetry = vi.fn()
408
- const tree = CategoryFilter2StatusView({ status: { state: 'error' }, onRetry })
475
+ const tree = CategoryListingStatusView({ status: { state: 'error' }, onRetry })
409
476
 
410
477
  const retry = findAllByProp(tree, 'data-action', 'retry')
411
478
  retry[0].props.onClick?.()
@@ -416,7 +483,7 @@ describe('CategoryFilter2StatusView — state and chip interactions (task 4.5)',
416
483
 
417
484
  it('clears active filters from the empty state', () => {
418
485
  const onClearFilters = vi.fn()
419
- const tree = CategoryFilter2StatusView({
486
+ const tree = CategoryListingStatusView({
420
487
  status: { state: 'empty' },
421
488
  activeFilters: activeChips,
422
489
  onClearFilters,
@@ -430,56 +497,44 @@ describe('CategoryFilter2StatusView — state and chip interactions (task 4.5)',
430
497
  })
431
498
 
432
499
  it('offers no repair/retry action when the host binds no callback', () => {
433
- const tree = CategoryFilter2StatusView({
500
+ const tree = CategoryListingStatusView({
434
501
  status: { state: 'invalid-query', error: { code: 'invalid-query', fields: ['page'] } },
435
502
  })
436
503
  expect(findAllByProp(tree, 'data-action', 'repair')).toHaveLength(0)
437
504
  })
438
505
  })
439
506
 
440
- describe('CategoryFilter2 — keyboard, labels and mobile sheet (task 4.6)', () => {
441
- it('renders pagination as native buttons (keyboard-operable) with aria-current, not href-less anchors', () => {
442
- const html = renderToStaticMarkup(
443
- <CategoryFilter2 products={products} pagination={[1, 2, 3]} selectedPage={2} sortOptions={advertisedSorts} />,
444
- )
445
- // A bare `<a>` without href is not focusable and not a link role; the control
446
- // must be a native button so it is reachable and operable by keyboard.
507
+ describe('CategoryListing — keyboard, labels and mobile controls', () => {
508
+ it('renders pagination as native buttons with aria-current, not href-less anchors', () => {
509
+ const html = render({ pagination: [1, 2, 3], selectedPage: 2 })
447
510
  expect(html).toMatch(/<button[^>]*data-slot="pagination-link"/)
448
511
  expect(html).not.toMatch(/<a[^>]*data-slot="pagination-link"/)
449
512
  expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(3)
450
513
  expect(countOccurrences(html, 'aria-current="page"')).toBe(1)
451
514
  })
452
515
 
453
- it('shows no pagination and no product count when the backend supplies no total (task 7.6)', () => {
454
- // `pagination` is derived by the runtime seam from the backend `total`; an
455
- // absent total yields `[]`, and the block renders no page links and no
456
- // manufactured count.
457
- const html = renderToStaticMarkup(
458
- <CategoryFilter2 products={products} pagination={[]} sortOptions={advertisedSorts} />,
459
- )
516
+ it('shows no pagination and no product count when the backend supplies no total', () => {
517
+ const html = render({ pagination: [] })
460
518
  expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(0)
461
519
  expect(html).not.toContain('aria-label="Paginación"')
462
520
  expect(html).not.toMatch(/\d+\s*(producto|resultado)/i)
463
521
  })
464
522
 
465
523
  it('wires the mobile filter trigger as a dialog trigger so focus can return on close', () => {
466
- const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
524
+ const html = render()
467
525
  expect(html).toContain('aria-haspopup="dialog"')
468
526
  expect(html).toContain('aria-expanded="false"')
469
527
  expect(html).toMatch(/<button[^>]*lg:hidden[^>]*>[\s\S]*Filtros/)
470
528
  })
471
529
 
472
530
  it('labels the search, sort, view and chip controls', () => {
473
- const html = renderToStaticMarkup(
474
- <CategoryFilter2
475
- products={products}
476
- sortOptions={advertisedSorts}
477
- viewOptions={[10, 25]}
478
- showSearch
479
- searchValue='pantalla'
480
- activeFilters={activeChips}
481
- />,
482
- )
531
+ const html = render({
532
+ sortOptions: advertisedSorts,
533
+ viewOptions: [10, 25],
534
+ showSearch: true,
535
+ searchValue: 'pantalla',
536
+ activeFilters: activeChips,
537
+ })
483
538
  expect(html).toContain('aria-label="Buscar"')
484
539
  expect(html).toContain('aria-label="Ordenar por"')
485
540
  expect(html).toContain('aria-label="Ver"')
@@ -488,14 +543,11 @@ describe('CategoryFilter2 — keyboard, labels and mobile sheet (task 4.6)', ()
488
543
  })
489
544
 
490
545
  it('labels the filter-panel inputs and checkboxes it renders', () => {
491
- const html = renderToStaticMarkup(
492
- <CategoryFilter2
493
- products={products}
494
- categories={[{ id: 'pantallas', label: 'Pantallas' }]}
495
- collections={[{ id: 'ofertas', label: 'Ofertas' }]}
496
- priceRange={{ min: 0, max: 100, step: 5 }}
497
- />,
498
- )
546
+ const html = render({
547
+ categories: [{ id: 'pantallas', label: 'Pantallas' }],
548
+ collections: [{ id: 'ofertas', label: 'Ofertas' }],
549
+ priceRange: { min: 0, max: 100, step: 5 },
550
+ })
499
551
  expect(html).toContain('>Mínimo</label>')
500
552
  expect(html).toContain('>Máximo</label>')
501
553
  expect(html).toMatch(/<label[^>]*for="[^"]*"[^>]*>\s*Pantallas\s*<\/label>/)
@@ -503,11 +555,47 @@ describe('CategoryFilter2 — keyboard, labels and mobile sheet (task 4.6)', ()
503
555
  })
504
556
 
505
557
  it('adds no positive tabindex and keeps search and pagination in the natural tab order', () => {
506
- const html = renderToStaticMarkup(
507
- <CategoryFilter2 products={products} sortOptions={advertisedSorts} pagination={[1, 2]} showSearch />,
508
- )
558
+ const html = render({ sortOptions: advertisedSorts, pagination: [1, 2], showSearch: true })
509
559
  expect(html).not.toMatch(/tabindex="[1-9]/)
510
560
  expect(html).not.toMatch(/<input[^>]*type="search"[^>]*tabindex="-1"/)
511
561
  expect(html).not.toMatch(/<button[^>]*data-slot="pagination-link"[^>]*tabindex="-1"/)
512
562
  })
513
563
  })
564
+
565
+ describe('CategoryListing — one contract, two sectors', () => {
566
+ it('renders home goods through the same contract with its own real facets and range', () => {
567
+ const homeGoods = render({
568
+ products: [
569
+ {
570
+ id: 'jarron',
571
+ title: 'Jarrón de cerámica',
572
+ price: { amount: '42.00', currencyCode: 'USD' },
573
+ },
574
+ ],
575
+ categories: [{ id: 'decoracion', label: 'Decoración' }],
576
+ collections: [{ id: 'hogar', label: 'Hogar' }],
577
+ priceRange: { min: 0, max: 200, step: 5 },
578
+ selectedPriceRange: [0, 200],
579
+ facets: [
580
+ {
581
+ id: 'material',
582
+ label: 'Material',
583
+ options: [
584
+ { value: 'ceramic', label: 'Cerámica', count: 2 },
585
+ { value: 'wood', label: 'Madera', count: 0 },
586
+ ],
587
+ },
588
+ ],
589
+ showSearch: false,
590
+ })
591
+
592
+ expect(homeGoods).toContain('Jarrón de cerámica')
593
+ expect(homeGoods).toContain('Decoración')
594
+ expect(homeGoods).toContain('Hogar')
595
+ expect(homeGoods).toContain('Material')
596
+ expect(homeGoods).toContain('Madera')
597
+ expect(homeGoods).toContain('data-slot="facet-count"')
598
+ expect(homeGoods).not.toContain('Auriculares')
599
+ expect(homeGoods).not.toContain('Condición')
600
+ })
601
+ })