@thorprovider/blocks 0.1.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -0,0 +1,122 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { CategoryCard } from './category-card'
7
+ import { CategoryGrid } from './product-category-grid'
8
+ import type { ProductCategoryItem } from '../../../lib/contracts'
9
+
10
+ beforeAll(() => {
11
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
12
+ })
13
+
14
+ const category: ProductCategoryItem = {
15
+ id: 'pcat_herramientas',
16
+ handle: 'herramientas',
17
+ name: 'Herramientas',
18
+ description: 'Taladros y accesorios',
19
+ image: '/products/drill.png',
20
+ itemCount: 12,
21
+ trending: true,
22
+ featured: true,
23
+ subcategories: ['Taladros', 'Accesorios'],
24
+ }
25
+
26
+ const categories: ProductCategoryItem[] = [
27
+ category,
28
+ { ...category, id: 'pcat_pintura', handle: 'pintura', name: 'Pintura', image: undefined },
29
+ { ...category, id: 'pcat_jardin', handle: 'jardin', name: 'Jardín' },
30
+ ]
31
+
32
+ const card = (extra: Record<string, unknown> = {}) =>
33
+ renderToStaticMarkup(<CategoryCard category={category} {...extra} />)
34
+
35
+ const grid = (extra: Record<string, unknown> = {}) =>
36
+ renderToStaticMarkup(<CategoryGrid categories={categories} {...extra} />)
37
+
38
+ describe('CategoryCard — item cell', () => {
39
+ it('renders a single category without section chrome', () => {
40
+ const html = card()
41
+ expect(html).toContain('Herramientas')
42
+ expect(html).not.toContain('<section')
43
+ expect(html).not.toContain('<h2')
44
+ })
45
+
46
+ it('renders the image for the grid presentation and omits it for icons', () => {
47
+ expect(card()).toContain('/products/drill.png')
48
+ expect(card({ presentation: 'icons' })).not.toContain('<img')
49
+ })
50
+
51
+ it('keeps counts honest (zero and unknown are not invented)', () => {
52
+ expect(card()).toContain('12')
53
+ expect(renderToStaticMarkup(<CategoryCard category={{ ...category, itemCount: undefined }} />)).not.toContain('items</span>')
54
+ expect(renderToStaticMarkup(<CategoryCard category={{ ...category, itemCount: 0 }} />)).not.toContain('items</span>')
55
+ })
56
+
57
+ it('only exposes browse when a handler exists', () => {
58
+ expect(card({ browseLabel: 'Ver' })).not.toContain('Ver Herramientas')
59
+ const html = card({ browseLabel: 'Ver', onBrowse: vi.fn() })
60
+ expect(html).toContain('aria-label="Ver Herramientas"')
61
+ })
62
+
63
+ it('renders the list cell with description, chips and featured badge', () => {
64
+ const html = card({ presentation: 'list', featuredLabel: 'Destacada', itemsLabel: 'productos' })
65
+ expect(html).toContain('Taladros y accesorios')
66
+ expect(html).toContain('Taladros')
67
+ expect(html).toContain('Destacada')
68
+ expect(html).toContain('productos')
69
+ })
70
+ })
71
+
72
+ describe('CategoryGrid — listing', () => {
73
+ it('renders one cell per category', () => {
74
+ const html = grid()
75
+ expect(html).toContain('Herramientas')
76
+ expect(html).toContain('Pintura')
77
+ expect(html).toContain('Jardín')
78
+ expect(html.match(/data-slot="card"/g) ?? []).toHaveLength(3)
79
+ })
80
+
81
+ it('caps the rendered collection with maxItems', () => {
82
+ const html = grid({ maxItems: 2 })
83
+ expect(html).toContain('Herramientas')
84
+ expect(html).toContain('Pintura')
85
+ expect(html).not.toContain('Jardín')
86
+ })
87
+
88
+ it('owns its loading state with skeletons and no cells', () => {
89
+ const html = grid({ loading: true, skeletonCount: 3 })
90
+ expect(html).toContain('aria-busy="true"')
91
+ expect(html).not.toContain('data-slot="card"')
92
+ })
93
+
94
+ it('owns its empty state', () => {
95
+ expect(renderToStaticMarkup(<CategoryGrid categories={[]} emptyLabel="Sin categorías" />)).toContain('Sin categorías')
96
+ })
97
+
98
+ it('renders the search control only in the list presentation', () => {
99
+ expect(grid({ presentation: 'grid', onQueryChange: vi.fn() })).not.toContain('type="search"')
100
+ expect(grid({ presentation: 'list', onQueryChange: vi.fn(), searchPlaceholder: 'Buscar' })).toContain('type="search"')
101
+ })
102
+
103
+ it('renders view-all only outside the grid and the section cta only outside the list', () => {
104
+ const withBoth = { cta: { label: 'Comprar', href: '/tienda' }, onViewAll: vi.fn(), viewAllLabel: 'Ver todo' }
105
+ const gridHtml = grid({ presentation: 'grid', ...withBoth })
106
+ expect(gridHtml).toContain('Comprar')
107
+ expect(gridHtml).not.toContain('Ver todo')
108
+ const listHtml = grid({ presentation: 'list', ...withBoth })
109
+ expect(listHtml).toContain('Ver todo')
110
+ expect(listHtml).not.toContain('Comprar')
111
+ })
112
+
113
+ it('forwards the controlled query to the search input', async () => {
114
+ const user = userEvent.setup()
115
+ const onQueryChange = vi.fn()
116
+ render(<CategoryGrid categories={categories} presentation="list" query="tal" onQueryChange={onQueryChange} searchLabel="Buscar categorías" />)
117
+ const input = screen.getByLabelText('Buscar categorías') as HTMLInputElement
118
+ expect(input.value).toBe('tal')
119
+ await user.type(input, 'a')
120
+ expect(onQueryChange).toHaveBeenCalled()
121
+ })
122
+ })
@@ -0,0 +1,68 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { CategoryCard } from './category-card'
7
+ import type { ProductCategoryItem } from '../../../lib/contracts'
8
+
9
+ const category: ProductCategoryItem = {
10
+ id: 'pcat_herramientas',
11
+ name: 'Herramientas',
12
+ description: 'Taladros y accesorios',
13
+ image: '/products/drill.png',
14
+ itemCount: 12,
15
+ trending: true,
16
+ featured: true,
17
+ subcategories: ['Taladros', 'Accesorios'],
18
+ }
19
+
20
+ describe('CategoryCard', () => {
21
+ it('renders one item without section-level chrome', () => {
22
+ const html = renderToStaticMarkup(<CategoryCard category={category} />)
23
+ expect(html).toContain('Herramientas')
24
+ expect(html).not.toContain('<section')
25
+ expect(html).not.toContain('<h2')
26
+ })
27
+
28
+ it('renders the selected presentation and forwards presentation-specific copy', () => {
29
+ expect(renderToStaticMarkup(<CategoryCard category={category} />)).toContain('/products/drill.png')
30
+ expect(renderToStaticMarkup(<CategoryCard category={category} presentation="icons" />)).not.toContain('<img')
31
+ const list = renderToStaticMarkup(
32
+ <CategoryCard category={category} presentation="list" itemsLabel="productos" featuredLabel="Destacada" />,
33
+ )
34
+ expect(list).toContain('Taladros y accesorios')
35
+ expect(list).toContain('Destacada')
36
+ expect(list).toContain('productos')
37
+ })
38
+
39
+ it('exposes an accessible browse name and invokes the category action by keyboard', async () => {
40
+ const user = userEvent.setup()
41
+ const onBrowse = vi.fn()
42
+ render(<CategoryCard category={category} presentation="icons" onBrowse={onBrowse} />)
43
+ const button = screen.getByRole('button', { name: 'Herramientas' })
44
+
45
+ button.focus()
46
+ await user.keyboard('{Enter}')
47
+ await user.keyboard(' ')
48
+
49
+ expect(onBrowse).toHaveBeenCalledTimes(2)
50
+ expect(onBrowse).toHaveBeenLastCalledWith(category)
51
+ })
52
+
53
+ it('does not add an inert focus target when browsing is unavailable', () => {
54
+ render(<CategoryCard category={category} presentation="icons" />)
55
+ expect(screen.queryByRole('button')).toBeNull()
56
+ })
57
+
58
+ it('gives grid and list browse buttons an accessible name containing the category', () => {
59
+ const grid = renderToStaticMarkup(
60
+ <CategoryCard category={category} onBrowse={() => undefined} browseLabel="Explorar" />,
61
+ )
62
+ const list = renderToStaticMarkup(
63
+ <CategoryCard category={category} presentation="list" onBrowse={() => undefined} browseLabel="Explorar" />,
64
+ )
65
+ expect(grid).toContain('aria-label="Explorar Herramientas"')
66
+ expect(list).toContain('aria-label="Explorar Herramientas"')
67
+ })
68
+ })
@@ -0,0 +1,172 @@
1
+ 'use client'
2
+
3
+ import { Badge } from '../../../ui/badge'
4
+ import { Button } from '../../../ui/button'
5
+ import { Card, CardContent } from '../../../ui/card'
6
+ import { cn } from '../../../lib/utils'
7
+ import type { CategoryCardSpecProps } from '../../../lib/contracts'
8
+ import type { ProductCategoryItem } from '../../../lib/contracts'
9
+ import {
10
+ formatCount,
11
+ resolveCategoryIcon,
12
+ resolveImage,
13
+ } from './category-shared'
14
+
15
+ export type CategoryCardPresentation = NonNullable<CategoryCardSpecProps['presentation']>
16
+
17
+ export interface CategoryCardProps extends CategoryCardSpecProps {
18
+ onBrowse?: (category: ProductCategoryItem) => void
19
+ }
20
+
21
+ function GridCell({
22
+ category,
23
+ browseLabel,
24
+ itemsLabel,
25
+ trendingLabel,
26
+ imageFallback,
27
+ onBrowse,
28
+ }: CategoryCardProps) {
29
+ const count = formatCount(category.itemCount, itemsLabel)
30
+ return (
31
+ <Card className="group overflow-hidden py-0 transition-all duration-500 hover:shadow-lg">
32
+ <div className="relative aspect-[5/4] overflow-hidden">
33
+ <img
34
+ src={resolveImage(category.image, imageFallback)}
35
+ alt={category.name}
36
+ className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
37
+ />
38
+ <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
39
+ {category.trending && trendingLabel ? (
40
+ <Badge className="absolute top-4 left-4 rounded-sm px-2.5 py-0.5 font-semibold">
41
+ {trendingLabel}
42
+ </Badge>
43
+ ) : null}
44
+ <div className="absolute right-0 bottom-0 left-0 p-6 text-white">
45
+ <h3 className="mb-1 text-xl font-bold">{category.name}</h3>
46
+ {category.description ? (
47
+ <p className="mb-3 text-sm text-white/90">{category.description}</p>
48
+ ) : null}
49
+ <div className="flex items-center justify-between">
50
+ {count ? <span className="text-sm">{count}</span> : null}
51
+ {onBrowse && browseLabel ? (
52
+ <Button
53
+ size="sm"
54
+ variant="secondary"
55
+ aria-label={`${browseLabel} ${category.name}`}
56
+ className="ms-auto h-8 cursor-pointer border-white/30 bg-white/20 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/30"
57
+ onClick={() => onBrowse(category)}
58
+ >
59
+ {browseLabel}
60
+ </Button>
61
+ ) : null}
62
+ </div>
63
+ </div>
64
+ </div>
65
+ </Card>
66
+ )
67
+ }
68
+
69
+ function IconsCell({ category, onBrowse }: CategoryCardProps) {
70
+ const Icon = resolveCategoryIcon(category.icon)
71
+ const clickable = Boolean(onBrowse)
72
+ return (
73
+ <Card
74
+ className={cn(
75
+ 'group relative overflow-hidden px-2 py-6 shadow-xs transition-all hover:shadow-md',
76
+ clickable && 'cursor-pointer',
77
+ )}
78
+ >
79
+ {clickable ? (
80
+ <button
81
+ type="button"
82
+ onClick={() => onBrowse?.(category)}
83
+ aria-label={category.name}
84
+ className="absolute inset-0 z-10 rounded-xl focus-visible:inset-ring-2 focus-visible:inset-ring-ring focus-visible:outline-none"
85
+ />
86
+ ) : null}
87
+ <CardContent>
88
+ <div className="flex flex-col gap-4">
89
+ <div className="bg-secondary/80 text-secondary-foreground flex size-12 items-center justify-center rounded-lg">
90
+ <Icon className="size-5" strokeWidth={1.5} aria-hidden="true" />
91
+ </div>
92
+ <div>
93
+ <h3 className="text-foreground text-lg font-semibold">{category.name}</h3>
94
+ {category.description ? (
95
+ <p className="text-muted-foreground text-sm">{category.description}</p>
96
+ ) : null}
97
+ </div>
98
+ </div>
99
+ </CardContent>
100
+ </Card>
101
+ )
102
+ }
103
+
104
+ function ListCell({
105
+ category,
106
+ browseLabel,
107
+ itemsLabel,
108
+ featuredLabel,
109
+ onBrowse,
110
+ }: CategoryCardProps) {
111
+ const Icon = resolveCategoryIcon(category.icon)
112
+ const count = formatCount(category.itemCount, itemsLabel)
113
+ return (
114
+ <Card className="group hover:border-primary relative py-0 transition-all duration-200 hover:shadow-md">
115
+ <CardContent className="p-6">
116
+ <div className="flex items-start gap-4">
117
+ <div className="flex-shrink-0">
118
+ <div className="bg-primary/10 group-hover:bg-primary/20 flex size-12 items-center justify-center rounded-lg transition-colors">
119
+ <Icon className="text-primary size-6" aria-hidden="true" />
120
+ </div>
121
+ </div>
122
+ <div className="min-w-0 flex-1">
123
+ <div className="mb-2 flex items-center gap-2">
124
+ <h3 className="text-lg font-semibold">{category.name}</h3>
125
+ {category.featured && featuredLabel ? (
126
+ <Badge variant="secondary" className="rounded-sm px-2 py-0.5 font-semibold">
127
+ {featuredLabel}
128
+ </Badge>
129
+ ) : null}
130
+ </div>
131
+ {category.description ? (
132
+ <p className="text-muted-foreground mb-3 line-clamp-2 text-sm">{category.description}</p>
133
+ ) : null}
134
+ {category.subcategories && category.subcategories.length > 0 ? (
135
+ <div className="mb-3 flex flex-wrap gap-2">
136
+ {category.subcategories.map((subcategory, index) => (
137
+ <span
138
+ key={`${category.id}:sub:${index}`}
139
+ className="bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs"
140
+ >
141
+ {subcategory}
142
+ </span>
143
+ ))}
144
+ </div>
145
+ ) : null}
146
+ {count ? <div className="text-muted-foreground text-sm">{count}</div> : null}
147
+ {onBrowse && browseLabel ? (
148
+ <Button
149
+ variant="ghost"
150
+ size="sm"
151
+ aria-label={`${browseLabel} ${category.name}`}
152
+ className="absolute end-4 bottom-4 h-8 cursor-pointer px-3 text-xs opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
153
+ onClick={() => onBrowse(category)}
154
+ >
155
+ {browseLabel}
156
+ </Button>
157
+ ) : null}
158
+ </div>
159
+ </div>
160
+ </CardContent>
161
+ </Card>
162
+ )
163
+ }
164
+
165
+ export function CategoryCard(props: CategoryCardProps) {
166
+ const presentation = props.presentation ?? 'grid'
167
+ if (presentation === 'icons') return <IconsCell {...props} />
168
+ if (presentation === 'list') return <ListCell {...props} />
169
+ return <GridCell {...props} />
170
+ }
171
+
172
+ export default CategoryCard
@@ -0,0 +1,67 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { CategoryGrid } from './product-category-grid'
7
+ import type { ProductCategoryItem } from '../../../lib/contracts'
8
+
9
+ const categories: ProductCategoryItem[] = [
10
+ { id: 'pcat_tools', name: 'Herramientas', icon: 'wrench' },
11
+ { id: 'pcat_paint', name: 'Pintura', icon: 'paintbrush' },
12
+ { id: 'pcat_garden', name: 'Jardín', icon: 'flower' },
13
+ ]
14
+
15
+ describe('CategoryGrid', () => {
16
+ it('renders one cell per category in source order and forwards the presentation', () => {
17
+ const html = renderToStaticMarkup(<CategoryGrid categories={categories} presentation="icons" />)
18
+ const names = categories.map(({ name }) => html.indexOf(name))
19
+ expect(html.match(/data-slot="card"/g) ?? []).toHaveLength(categories.length)
20
+ expect(names[0]).toBeLessThan(names[1])
21
+ expect(names[1]).toBeLessThan(names[2])
22
+ expect(html).not.toContain('<img')
23
+ })
24
+
25
+ it('caps displayed categories without reordering them', () => {
26
+ const html = renderToStaticMarkup(<CategoryGrid categories={categories} maxItems={2} />)
27
+ expect(html).toContain('Herramientas')
28
+ expect(html).toContain('Pintura')
29
+ expect(html).not.toContain('Jardín')
30
+ })
31
+
32
+ it('owns loading and empty states', () => {
33
+ const loading = renderToStaticMarkup(<CategoryGrid categories={categories} loading skeletonCount={2} />)
34
+ expect(loading).toContain('aria-busy="true"')
35
+ expect(loading.match(/animate-pulse/g) ?? []).toHaveLength(2)
36
+ expect(loading).not.toContain('data-slot="card"')
37
+ expect(renderToStaticMarkup(<CategoryGrid categories={[]} emptyLabel="Sin categorías" />)).toContain(
38
+ 'Sin categorías',
39
+ )
40
+ })
41
+
42
+ it('controls list search and exposes an accessible label', async () => {
43
+ const user = userEvent.setup()
44
+ const onQueryChange = vi.fn()
45
+ render(
46
+ <CategoryGrid
47
+ categories={categories}
48
+ presentation="list"
49
+ query="tal"
50
+ onQueryChange={onQueryChange}
51
+ searchLabel="Buscar categorías"
52
+ />,
53
+ )
54
+ const input = screen.getByRole('searchbox', { name: 'Buscar categorías' })
55
+ expect((input as HTMLInputElement).value).toBe('tal')
56
+ await user.type(input, 'a')
57
+ expect(onQueryChange).toHaveBeenCalledWith('tala')
58
+ })
59
+
60
+ it('bubbles a cell browse action with its category', async () => {
61
+ const user = userEvent.setup()
62
+ const onBrowse = vi.fn()
63
+ render(<CategoryGrid categories={categories} presentation="icons" onBrowse={onBrowse} />)
64
+ await user.click(screen.getByRole('button', { name: 'Herramientas' }))
65
+ expect(onBrowse).toHaveBeenCalledExactlyOnceWith(categories[0])
66
+ })
67
+ })
@@ -0,0 +1,109 @@
1
+ import {
2
+ ArrowRight,
3
+ Baby,
4
+ BookOpen,
5
+ Boxes,
6
+ Cable,
7
+ Camera,
8
+ Car,
9
+ Dumbbell,
10
+ Gamepad2,
11
+ Gift,
12
+ Heart,
13
+ Home,
14
+ Laptop,
15
+ LayoutGrid,
16
+ Lightbulb,
17
+ Package,
18
+ ShieldCheck,
19
+ Shirt,
20
+ ShoppingBag,
21
+ Smartphone,
22
+ Sparkles,
23
+ Store,
24
+ Tag,
25
+ Utensils,
26
+ Wrench,
27
+ type LucideIcon,
28
+ } from 'lucide-react'
29
+
30
+ import { Button } from '../../../ui/button'
31
+ import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
32
+ import type { CTA } from '../../../lib/types'
33
+
34
+ export const CATEGORY_ICONS: Record<string, LucideIcon> = {
35
+ grid: LayoutGrid,
36
+ boxes: Boxes,
37
+ smartphone: Smartphone,
38
+ laptop: Laptop,
39
+ camera: Camera,
40
+ cable: Cable,
41
+ wrench: Wrench,
42
+ shield: ShieldCheck,
43
+ sparkles: Sparkles,
44
+ lightbulb: Lightbulb,
45
+ package: Package,
46
+ tag: Tag,
47
+ store: Store,
48
+ home: Home,
49
+ shirt: Shirt,
50
+ book: BookOpen,
51
+ gift: Gift,
52
+ heart: Heart,
53
+ car: Car,
54
+ utensils: Utensils,
55
+ gamepad: Gamepad2,
56
+ baby: Baby,
57
+ dumbbell: Dumbbell,
58
+ }
59
+
60
+ export const FALLBACK_ICON = LayoutGrid
61
+
62
+ export function resolveCategoryIcon(icon?: string): LucideIcon {
63
+ if (!icon) return FALLBACK_ICON
64
+ return CATEGORY_ICONS[icon] ?? FALLBACK_ICON
65
+ }
66
+
67
+ export function formatCount(itemCount: number | undefined, itemsLabel?: string): string | undefined {
68
+ if (itemCount === undefined || !Number.isFinite(itemCount) || itemCount < 0) return undefined
69
+ const value = itemCount.toLocaleString()
70
+ return itemsLabel ? `${value} ${itemsLabel}` : value
71
+ }
72
+
73
+ export function resolveImage(image: string | undefined, fallback?: string): string {
74
+ const candidate = typeof image === 'string' ? image.trim() : ''
75
+ const fallbackCandidate = typeof fallback === 'string' ? fallback.trim() : ''
76
+ return candidate || fallbackCandidate || PRODUCT_IMAGE_FALLBACK
77
+ }
78
+
79
+ export function SectionCta({ cta }: { cta?: CTA }) {
80
+ if (!cta) return null
81
+ return (
82
+ <div className="mt-12 text-center">
83
+ <Button size="lg" className="h-10 cursor-pointer gap-2 px-4" asChild>
84
+ <a href={cta.href}>
85
+ <ShoppingBag className="size-5" />
86
+ {cta.label}
87
+ </a>
88
+ </Button>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export function ViewAllButton({
94
+ onViewAll,
95
+ viewAllLabel,
96
+ }: {
97
+ onViewAll?: () => void
98
+ viewAllLabel?: string
99
+ }) {
100
+ if (!onViewAll || !viewAllLabel) return null
101
+ return (
102
+ <div className="mt-12 text-center">
103
+ <Button variant="outline" className="h-9 cursor-pointer px-4" onClick={onViewAll}>
104
+ {viewAllLabel}
105
+ <ArrowRight />
106
+ </Button>
107
+ </div>
108
+ )
109
+ }