@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,170 @@
1
+ 'use client'
2
+
3
+ import { Search } from 'lucide-react'
4
+
5
+ import { Input } from '../../../ui/input'
6
+ import { cn } from '../../../lib/utils'
7
+ import type {
8
+ CategoryGridSpecProps,
9
+ ProductCategoryItem,
10
+ } from '../../../lib/contracts'
11
+ import { CategoryCard, type CategoryCardPresentation } from './category-card'
12
+ import { SectionCta, ViewAllButton } from './category-shared'
13
+
14
+ export type CategoryGridPresentation = NonNullable<CategoryGridSpecProps['presentation']>
15
+
16
+ export interface CategoryGridProps extends CategoryGridSpecProps {
17
+ onBrowse?: (category: ProductCategoryItem) => void
18
+ onViewAll?: () => void
19
+ onQueryChange?: (query: string) => void
20
+ }
21
+
22
+ const CONTAINER = 'mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'
23
+
24
+ const LAYOUT: Record<CategoryGridPresentation, string> = {
25
+ grid: 'grid gap-6 sm:grid-cols-2 lg:grid-cols-3',
26
+ icons: 'mx-auto grid max-w-5xl grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4',
27
+ list: 'grid grid-cols-1 gap-4 lg:grid-cols-2',
28
+ }
29
+
30
+ const SKELETON: Record<CategoryGridPresentation, string> = {
31
+ grid: 'aspect-[5/4]',
32
+ icons: 'h-36',
33
+ list: 'h-28',
34
+ }
35
+
36
+ function Header({
37
+ presentation,
38
+ title,
39
+ description,
40
+ badge,
41
+ }: {
42
+ presentation: CategoryGridPresentation
43
+ title?: string
44
+ description?: string
45
+ badge?: string
46
+ }) {
47
+ if (presentation === 'icons') {
48
+ if (!badge && !title && !description) return null
49
+ return (
50
+ <div className="mx-auto mb-12 max-w-3xl text-center">
51
+ {badge ? (
52
+ <div className="mb-4 inline-flex">
53
+ <span className="bg-secondary/80 text-secondary-foreground rounded-full px-4 py-1.5 text-sm font-medium">
54
+ {badge}
55
+ </span>
56
+ </div>
57
+ ) : null}
58
+ {title ? <h2 className="text-3xl font-bold tracking-tight text-balance">{title}</h2> : null}
59
+ {description ? <p className="text-muted-foreground mt-4 text-lg">{description}</p> : null}
60
+ </div>
61
+ )
62
+ }
63
+
64
+ if (!title && !description) return null
65
+ return (
66
+ <div className={cn('text-center', presentation === 'list' ? 'mb-8' : 'mb-12')}>
67
+ {title ? (
68
+ <h2 className={cn('text-3xl font-bold tracking-tight text-balance', presentation === 'list' && 'mb-4')}>
69
+ {title}
70
+ </h2>
71
+ ) : null}
72
+ {description ? (
73
+ <p className={cn('text-muted-foreground text-lg', presentation !== 'list' && 'mt-4')}>
74
+ {description}
75
+ </p>
76
+ ) : null}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export function CategoryGrid(props: CategoryGridProps) {
82
+ const presentation = props.presentation ?? 'grid'
83
+ const {
84
+ categories,
85
+ className,
86
+ title,
87
+ description,
88
+ badge,
89
+ cta,
90
+ query,
91
+ onQueryChange,
92
+ onBrowse,
93
+ onViewAll,
94
+ searchPlaceholder,
95
+ searchLabel,
96
+ emptyLabel,
97
+ noResultsLabel,
98
+ viewAllLabel,
99
+ } = props
100
+
101
+ const displayed =
102
+ props.maxItems !== undefined ? categories.slice(0, props.maxItems) : categories
103
+ const hasQuery = Boolean(query && query.trim() !== '')
104
+ const emptyMessage = hasQuery ? (noResultsLabel ?? emptyLabel) : emptyLabel
105
+ const skeletonCount = props.skeletonCount ?? 6
106
+ const cellPresentation = presentation as CategoryCardPresentation
107
+
108
+ return (
109
+ <section className={cn('py-12', className)}>
110
+ <div className={CONTAINER}>
111
+ <Header presentation={presentation} title={title} description={description} badge={badge} />
112
+
113
+ {presentation === 'list' && onQueryChange ? (
114
+ <div className="relative mx-auto mb-8 max-w-md">
115
+ <Search
116
+ className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2"
117
+ aria-hidden="true"
118
+ />
119
+ <Input
120
+ type="search"
121
+ placeholder={searchPlaceholder}
122
+ value={query ?? ''}
123
+ onChange={(event) => onQueryChange(event.target.value)}
124
+ className="ps-9 h-9"
125
+ aria-label={searchLabel ?? searchPlaceholder}
126
+ />
127
+ </div>
128
+ ) : null}
129
+
130
+ {props.loading ? (
131
+ <div className={LAYOUT[presentation]} aria-busy="true">
132
+ {Array.from({ length: skeletonCount }, (_, index) => (
133
+ <div
134
+ key={`skeleton-${index}`}
135
+ className={cn('bg-muted/50 animate-pulse rounded-xl', SKELETON[presentation])}
136
+ />
137
+ ))}
138
+ </div>
139
+ ) : displayed.length === 0 ? (
140
+ emptyMessage ? (
141
+ <p className="text-muted-foreground py-10 text-center text-sm">{emptyMessage}</p>
142
+ ) : null
143
+ ) : (
144
+ <div className={LAYOUT[presentation]}>
145
+ {displayed.map((category) => (
146
+ <CategoryCard
147
+ key={category.id}
148
+ category={category}
149
+ presentation={cellPresentation}
150
+ browseLabel={props.browseLabel}
151
+ itemsLabel={props.itemsLabel}
152
+ trendingLabel={props.trendingLabel}
153
+ featuredLabel={props.featuredLabel}
154
+ imageFallback={props.imageFallback}
155
+ onBrowse={onBrowse}
156
+ />
157
+ ))}
158
+ </div>
159
+ )}
160
+
161
+ {!props.loading && presentation !== 'list' ? <SectionCta cta={cta} /> : null}
162
+ {!props.loading && presentation !== 'grid' ? (
163
+ <ViewAllButton onViewAll={onViewAll} viewAllLabel={viewAllLabel} />
164
+ ) : null}
165
+ </div>
166
+ </section>
167
+ )
168
+ }
169
+
170
+ export default CategoryGrid
@@ -0,0 +1,266 @@
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 { ProductCategory } from './product-category'
7
+ import type { ProductCategoryItem } from '../../../lib/contracts'
8
+
9
+ beforeAll(() => {
10
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
11
+ })
12
+
13
+ const base: ProductCategoryItem = {
14
+ id: 'pcat_herramientas',
15
+ handle: 'herramientas',
16
+ name: 'Herramientas',
17
+ description: 'Taladros y accesorios',
18
+ image: '/products/drill.png',
19
+ trending: false,
20
+ }
21
+
22
+ const renderGrid = (category: ProductCategoryItem, extra: Record<string, unknown> = {}) =>
23
+ renderToStaticMarkup(<ProductCategory categories={[category]} {...extra} />)
24
+
25
+ // The count is the only text node that ends in "items" (the `items-center`
26
+ // utility lives in a class attribute and is never followed by `</span>`).
27
+ const hasGridCount = (html: string) => html.includes('items</span>')
28
+
29
+ describe('ProductCategory — optional itemCount (grid)', () => {
30
+ it('renders no count when itemCount is omitted (unknown projection)', () => {
31
+ const html = renderGrid(base, { itemsLabel: 'items' })
32
+ expect(hasGridCount(html)).toBe(false)
33
+ // The tile itself still renders.
34
+ expect(html).toContain('Herramientas')
35
+ })
36
+
37
+ it('preserves a real zero count', () => {
38
+ expect(renderGrid({ ...base, itemCount: 0 }, { itemsLabel: 'items' })).toContain('0 items</span>')
39
+ })
40
+
41
+ it('renders a positive count', () => {
42
+ expect(renderGrid({ ...base, itemCount: 7 }, { itemsLabel: 'items' })).toContain('7 items</span>')
43
+ })
44
+
45
+ it('renders the bare number when no items label is supplied', () => {
46
+ const html = renderGrid({ ...base, itemCount: 7 })
47
+ expect(html).toContain('>7<')
48
+ expect(html).not.toContain('items</span>')
49
+ })
50
+
51
+ it('omits a negative count', () => {
52
+ expect(hasGridCount(renderGrid({ ...base, itemCount: -1 }, { itemsLabel: 'items' }))).toBe(false)
53
+ })
54
+
55
+ it('omits a non-finite count', () => {
56
+ expect(hasGridCount(renderGrid({ ...base, itemCount: Number.NaN }, { itemsLabel: 'items' }))).toBe(false)
57
+ expect(hasGridCount(renderGrid({ ...base, itemCount: Number.POSITIVE_INFINITY }, { itemsLabel: 'items' }))).toBe(
58
+ false,
59
+ )
60
+ })
61
+ })
62
+
63
+ describe('ProductCategory — no demo defaults or hardcoded locale copy', () => {
64
+ it('ships no copy when the host supplies none', () => {
65
+ const html = renderGrid(base)
66
+ expect(html).toContain('Herramientas')
67
+ expect(html).not.toContain('Browse')
68
+ expect(html).not.toContain('Trending')
69
+ expect(html).not.toContain('items</span>')
70
+ })
71
+
72
+ it('renders the browse action only when a handler and label are supplied', () => {
73
+ expect(renderGrid({ ...base, trending: true })).not.toContain('Browse')
74
+ expect(renderGrid(base, { onBrowse: () => {}, browseLabel: 'Explorar' })).toContain('Explorar')
75
+ })
76
+
77
+ it('renders the trending badge only when a label is supplied', () => {
78
+ expect(renderGrid({ ...base, trending: true })).not.toContain('Trending')
79
+ expect(renderGrid({ ...base, trending: true }, { trendingLabel: 'Tendencia' })).toContain('Tendencia')
80
+ })
81
+ })
82
+
83
+ describe('ProductCategory — honest imagery', () => {
84
+ it('never emits src="" and falls back to a neutral placeholder', () => {
85
+ const html = renderGrid({ ...base, image: undefined })
86
+ expect(html).not.toContain('src=""')
87
+ expect(html).toContain('data:image/svg+xml')
88
+ })
89
+
90
+ it('treats a blank image as missing', () => {
91
+ expect(renderGrid({ ...base, image: ' ' })).toContain('data:image/svg+xml')
92
+ })
93
+ })
94
+
95
+ describe('ProductCategory — bounded presentations', () => {
96
+ it('icons presentation renders no image and a different structure than grid', () => {
97
+ const category: ProductCategoryItem = { id: 'pcat_x', name: 'Categoría', icon: 'shirt' }
98
+ const icons = renderToStaticMarkup(<ProductCategory categories={[category]} presentation='icons' />)
99
+ expect(icons).toContain('Categoría')
100
+ expect(icons).not.toContain('<img')
101
+ expect(icons).not.toContain('aspect-[5/4]')
102
+ })
103
+
104
+ it('icons presentation ignores category counts (no demo metrics)', () => {
105
+ const category: ProductCategoryItem = { id: 'pcat_x', name: 'Categoría', icon: 'shirt', itemCount: 42 }
106
+ const icons = renderToStaticMarkup(
107
+ <ProductCategory categories={[category]} presentation='icons' itemsLabel='items' />,
108
+ )
109
+ expect(icons).not.toContain('42')
110
+ expect(icons).not.toContain('items</span>')
111
+ expect(icons).not.toContain('items</div>')
112
+ })
113
+
114
+ it('falls back to a neutral icon for an unknown or absent key', () => {
115
+ const unknown = renderToStaticMarkup(
116
+ <ProductCategory categories={[{ id: 'pcat_x', name: 'X', icon: 'not-a-real-icon' }]} presentation='icons' />,
117
+ )
118
+ const absent = renderToStaticMarkup(
119
+ <ProductCategory categories={[{ id: 'pcat_y', name: 'Y' }]} presentation='icons' />,
120
+ )
121
+ expect(unknown).toContain('X')
122
+ expect(absent).toContain('Y')
123
+ })
124
+ })
125
+
126
+ describe('ProductCategory — icons: keyboard-operable interactive category', () => {
127
+ const category: ProductCategoryItem = { id: 'pcat_herramientas', name: 'Herramientas', icon: 'wrench' }
128
+
129
+ it('renders a native button whose accessible name is the category name', () => {
130
+ render(<ProductCategory categories={[category]} presentation='icons' onBrowse={() => {}} />)
131
+ const control = screen.getByRole('button', { name: 'Herramientas' })
132
+ expect(control.tagName).toBe('BUTTON')
133
+ expect(control.getAttribute('type')).toBe('button')
134
+ })
135
+
136
+ it('is focusable and activates with the Enter key, reporting onBrowse', async () => {
137
+ const user = userEvent.setup()
138
+ const onBrowse = vi.fn()
139
+ render(<ProductCategory categories={[category]} presentation='icons' onBrowse={onBrowse} />)
140
+ await user.tab()
141
+ const control = screen.getByRole('button', { name: 'Herramientas' })
142
+ expect(document.activeElement).toBe(control)
143
+ await user.keyboard('{Enter}')
144
+ expect(onBrowse).toHaveBeenCalledWith(category)
145
+ })
146
+
147
+ it('activates with the Space key, reporting onBrowse', async () => {
148
+ const user = userEvent.setup()
149
+ const onBrowse = vi.fn()
150
+ render(<ProductCategory categories={[category]} presentation='icons' onBrowse={onBrowse} />)
151
+ await user.tab()
152
+ await user.keyboard(' ')
153
+ expect(onBrowse).toHaveBeenCalledWith(category)
154
+ })
155
+
156
+ it('is not a focusable control when no browse handler is supplied', async () => {
157
+ const user = userEvent.setup()
158
+ render(<ProductCategory categories={[category]} presentation='icons' />)
159
+ expect(screen.queryByRole('button')).toBeNull()
160
+ await user.tab()
161
+ expect(document.activeElement).toBe(document.body)
162
+ expect(screen.getByText('Herramientas')).toBeTruthy()
163
+ })
164
+
165
+ it('uses an inset focus ring so the indicator is not clipped by the card', () => {
166
+ render(<ProductCategory categories={[category]} presentation='icons' onBrowse={() => {}} />)
167
+ const control = screen.getByRole('button', { name: 'Herramientas' })
168
+ expect(control.className).toContain('focus-visible:inset-ring-2')
169
+ expect(control.className).toContain('focus-visible:inset-ring-ring')
170
+ expect(control.className).not.toContain('focus-visible:ring-2')
171
+ })
172
+ })
173
+
174
+ describe('ProductCategory — grid/list browse controls expose the category name', () => {
175
+ const category: ProductCategoryItem = { id: 'pcat_herramientas', name: 'Herramientas', image: '/products/drill.png' }
176
+
177
+ it('names the grid browse control with the host label and the category name', () => {
178
+ render(
179
+ <ProductCategory categories={[category]} presentation='grid' onBrowse={() => {}} browseLabel='Explorar' />,
180
+ )
181
+ const control = screen.getByRole('button', { name: 'Explorar Herramientas' })
182
+ expect(control.textContent).toContain('Explorar')
183
+ })
184
+
185
+ it('names the list browse control with the host label and the category name', () => {
186
+ render(
187
+ <ProductCategory categories={[category]} presentation='list' onBrowse={() => {}} browseLabel='Explorar' />,
188
+ )
189
+ const control = screen.getByRole('button', { name: 'Explorar Herramientas' })
190
+ expect(control.textContent).toContain('Explorar')
191
+ })
192
+ })
193
+
194
+ describe('ProductCategory — list presentation owns no filtering', () => {
195
+ const categories: ProductCategoryItem[] = [
196
+ { id: 'pcat_shirts', name: 'Camisas', icon: 'shirt', subcategories: ['Manga larga', 'Manga corta'], itemCount: 12 },
197
+ { id: 'pcat_boots', name: 'Botas', icon: 'shirt', featured: true },
198
+ ]
199
+
200
+ it('renders only the categories the host supplies for the current query', () => {
201
+ const html = renderToStaticMarkup(
202
+ <ProductCategory categories={[categories[1]]} presentation='list' query='bot' onQueryChange={() => {}} />,
203
+ )
204
+ expect(html).toContain('Botas')
205
+ expect(html).not.toContain('Camisas')
206
+ })
207
+
208
+ it('renders subcategory chips and the count with the host label', () => {
209
+ const html = renderToStaticMarkup(
210
+ <ProductCategory categories={[categories[0]]} presentation='list' itemsLabel='productos' />,
211
+ )
212
+ expect(html).toContain('Manga larga')
213
+ expect(html).toContain('12 productos')
214
+ })
215
+
216
+ it('renders the featured badge only when a label is supplied', () => {
217
+ const without = renderToStaticMarkup(<ProductCategory categories={[categories[1]]} presentation='list' />)
218
+ const with_ = renderToStaticMarkup(
219
+ <ProductCategory categories={[categories[1]]} presentation='list' featuredLabel='Destacado' />,
220
+ )
221
+ expect(without).not.toContain('Destacado')
222
+ expect(with_).toContain('Destacado')
223
+ })
224
+
225
+ it('reflects the controlled query in the search input', () => {
226
+ const html = renderToStaticMarkup(
227
+ <ProductCategory categories={categories} presentation='list' query='cam' onQueryChange={() => {}} />,
228
+ )
229
+ expect(html).toContain('value="cam"')
230
+ })
231
+
232
+ it('shows the no-results copy for an empty searched list and the empty copy otherwise', () => {
233
+ const searched = renderToStaticMarkup(
234
+ <ProductCategory
235
+ categories={[]}
236
+ presentation='list'
237
+ query='zzz'
238
+ onQueryChange={() => {}}
239
+ noResultsLabel='Sin resultados'
240
+ emptyLabel='Sin categorías'
241
+ />,
242
+ )
243
+ const empty = renderToStaticMarkup(
244
+ <ProductCategory categories={[]} presentation='list' emptyLabel='Sin categorías' noResultsLabel='Sin resultados' />,
245
+ )
246
+ expect(searched).toContain('Sin resultados')
247
+ expect(empty).toContain('Sin categorías')
248
+ })
249
+ })
250
+
251
+ describe('ProductCategory — reusable across sectors without demo content', () => {
252
+ it('renders distinct sector datasets through the same component and contract', () => {
253
+ const fashion: ProductCategoryItem[] = [
254
+ { id: 'pcat_fashion_shirts', name: 'Camisas', icon: 'shirt' },
255
+ ]
256
+ const electronics: ProductCategoryItem[] = [
257
+ { id: 'pcat_electronics_laptops', name: 'Portátiles', icon: 'laptop' },
258
+ ]
259
+ const fashionHtml = renderToStaticMarkup(<ProductCategory categories={fashion} presentation='icons' />)
260
+ const electronicsHtml = renderToStaticMarkup(<ProductCategory categories={electronics} presentation='icons' />)
261
+ // Same component and contract; only the supplied data differs (no branch on sector).
262
+ expect(fashionHtml).toContain('Camisas')
263
+ expect(electronicsHtml).toContain('Portátiles')
264
+ expect(fashionHtml).not.toContain('Portátiles')
265
+ })
266
+ })