@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
@@ -1,921 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useId, useState, type ReactNode } from 'react'
4
- import { Search, SlidersHorizontal, X } from 'lucide-react'
5
-
6
- import { Badge } from '../../../ui/badge'
7
- import { Button } from '../../../ui/button'
8
- import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '../../../ui/empty'
9
- import { Input } from '../../../ui/input'
10
- import {
11
- Pagination,
12
- PaginationContent,
13
- PaginationEllipsis,
14
- PaginationItem,
15
- } from '../../../ui/pagination'
16
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
17
- import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../../ui/sheet'
18
- import { Skeleton } from '../../../ui/skeleton'
19
- import { cn } from '../../../lib/utils'
20
-
21
- import { CategoryFilter2FilterPanel } from './category-filter-2-filter-panel'
22
-
23
- export interface FilterProduct {
24
- id: string
25
- name: string
26
- price: string
27
- /** Optional: an absent image is omitted so the block never renders `src=""`. */
28
- image?: string
29
- /**
30
- * Optional product-detail link. When present the card renders as a real
31
- * `<a href>` (keyboard focus, middle-click, crawlers); when absent the block
32
- * falls back to the `onProductClick` path. The href is produced by the
33
- * presentation layer — the block never builds a route.
34
- */
35
- href?: string
36
- }
37
-
38
- export interface CategoryFilter2Category {
39
- id: string
40
- label: string
41
- }
42
-
43
- export interface CategoryFilter2Collection {
44
- id: string
45
- label: string
46
- }
47
-
48
- export interface CategoryFilter2Color {
49
- id: string
50
- color: string
51
- }
52
-
53
- export interface CategoryFilter2PriceRange {
54
- min: number
55
- max: number
56
- step: number
57
- }
58
-
59
- export interface CategoryFilter2SortOption {
60
- value: string
61
- label: string
62
- }
63
-
64
- export interface CategoryFilter2Filters {
65
- categories: string[]
66
- /** Collections requested by the `category-filters` feature (E2). */
67
- collections?: string[]
68
- priceRange: number[]
69
- size: string | null
70
- color: string | null
71
- }
72
-
73
- /**
74
- * A typed catalog-query error the listing renders for repair. It is a structural
75
- * mirror of the runtime boundary error (`CatalogQueryError`/`CatalogQueryFailure`):
76
- * `fields` names the exact raw parameters to remove and `feature` is present only
77
- * for `unmounted-capability`. The block never builds a host URL — it emits the
78
- * fields through `onRepair` (E2 task 4.5).
79
- */
80
- export interface CategoryFilter2QueryError {
81
- code: 'invalid-query' | 'unsupported-query' | 'unmounted-capability'
82
- /** Exact raw URL parameter names that must be removed to repair the query. */
83
- fields: string[]
84
- message?: string
85
- feature?: 'catalog-search' | 'category-filters'
86
- }
87
-
88
- /**
89
- * The observable listing status. The host derives it from the URL boundary and
90
- * the provider outcome (`mapCatalogOutcomeToStatus`) and passes it down; the
91
- * block only renders it. `loading` is in flight; `ready` shows products; `empty`
92
- * is a valid zero-result query; `invalid-query`/`unsupported-query`/
93
- * `unmounted-capability` are typed repair states; `error` is a query/network
94
- * failure with retry. Distinct states stay distinct — a valid empty result is
95
- * never shown as an error and a raw failure is never swallowed as empty.
96
- */
97
- export type CategoryFilter2Status =
98
- | { state: 'loading' }
99
- | { state: 'ready' }
100
- | { state: 'empty' }
101
- | { state: 'invalid-query'; error: CategoryFilter2QueryError }
102
- | { state: 'unsupported-query'; error: CategoryFilter2QueryError }
103
- | { state: 'unmounted-capability'; error: CategoryFilter2QueryError }
104
- | { state: 'error'; message?: string }
105
-
106
- /** The kind of canonical filter an active chip represents. */
107
- export type CategoryFilter2ActiveFilterKind =
108
- | 'text'
109
- | 'category'
110
- | 'collection'
111
- | 'price'
112
- | 'attribute'
113
-
114
- /**
115
- * A removable active-filter chip. Built only from the actual canonical query
116
- * (never from claimed provider filtering): `params` are the raw URL parameter
117
- * names carrying the filter and `value` is the specific list value to drop.
118
- */
119
- export interface CategoryFilter2ActiveFilter {
120
- /** Stable identity for React keys and removal callbacks. */
121
- id: string
122
- kind: CategoryFilter2ActiveFilterKind
123
- /** Human-readable label shown on the chip. */
124
- label: string
125
- /** Raw URL parameter names that carry this filter. */
126
- params: string[]
127
- /** The specific list value this chip removes; absent for scalar filters. */
128
- value?: string
129
- }
130
-
131
- export interface CategoryFilter2Props {
132
- products: FilterProduct[]
133
- categories?: CategoryFilter2Category[]
134
- collections?: CategoryFilter2Collection[]
135
- sizes?: string[]
136
- colors?: CategoryFilter2Color[]
137
- /**
138
- * Real backend-provided price range only. When absent the price control is
139
- * omitted; the block never falls back to a fabricated `0..1000` default
140
- * (E2 design Decision 8 / spec "Price controls require real price metadata").
141
- */
142
- priceRange?: CategoryFilter2PriceRange
143
- viewOptions?: (number | string)[]
144
- /** Adapter-advertised sort options only. When absent/empty no sort control is rendered. */
145
- sortOptions?: CategoryFilter2SortOption[]
146
- pagination?: (number | string)[]
147
-
148
- // ── Controlled canonical-query values (E2) ────────────────────────────────
149
- // When supplied these win over the block's internal state, making the block
150
- // controlled by the URL-backed canonical query. When absent the block stays
151
- // uncontrolled for legacy consumers.
152
- searchValue?: string
153
- selectedCategories?: string[]
154
- selectedCollections?: string[]
155
- selectedPriceRange?: number[]
156
- selectedSort?: string
157
- selectedPage?: number | string
158
- selectedPageSize?: number | string
159
-
160
- // ── Per-control visibility (selected from the installed features) ─────────
161
- // Defaults preserve the legacy surface: filters shown, search hidden, price
162
- // shown when a real range is supplied, sort shown when options are supplied.
163
- showSearch?: boolean
164
- showFilters?: boolean
165
- showPrice?: boolean
166
- showSort?: boolean
167
-
168
- filtersTitle?: string
169
- selectedText?: string
170
- clearAllLabel?: string
171
- priceRangeTitle?: string
172
- minLabel?: string
173
- maxLabel?: string
174
- categoriesTitle?: string
175
- collectionsTitle?: string
176
- sizesTitle?: string
177
- colorsTitle?: string
178
- viewLabel?: string
179
- viewPlaceholder?: string
180
- sortPlaceholder?: string
181
- productsTitle?: string
182
- currentLabel?: string
183
- searchLabel?: string
184
- searchPlaceholder?: string
185
- onProductClick?: (product: FilterProduct) => void
186
- onPageChange?: (page: number | string) => void
187
- onViewChange?: (view: number | string) => void
188
- onPageSizeChange?: (pageSize: number | string) => void
189
- onSortChange?: (sort: string) => void
190
- onFilterChange?: (filters: CategoryFilter2Filters) => void
191
- onSearchChange?: (text: string) => void
192
-
193
- // ── Observable listing states + active-filter chips (E2 task 4.5) ─────────
194
- /** Observable listing status. Defaults to `ready` so legacy consumers are unchanged. */
195
- status?: CategoryFilter2Status
196
- /** Removable active-filter chips built from the canonical query. */
197
- activeFilters?: CategoryFilter2ActiveFilter[]
198
-
199
- // State / chip labels (Spanish defaults match the rest of the block).
200
- loadingLabel?: string
201
- emptyTitle?: string
202
- emptyDescription?: string
203
- clearFiltersLabel?: string
204
- invalidQueryTitle?: string
205
- unsupportedQueryTitle?: string
206
- unmountedCapabilityTitle?: string
207
- errorTitle?: string
208
- errorDescription?: string
209
- repairLabel?: string
210
- retryLabel?: string
211
- activeFiltersTitle?: string
212
- removeFilterLabel?: string
213
-
214
- // Emit-only callbacks: the block never fetches or rewrites the URL. The host
215
- // (the generated route's `updateURL` action, task 5.2) binds these.
216
- /** Empty-state clear: drops the active filters but never base scope/sort/pageSize. */
217
- onClearFilters?: () => void
218
- /** Invalid/unsupported/unmounted repair: receives the exact bad fields to remove. */
219
- onRepair?: (remove: string[]) => void
220
- /** Query/network failure retry: preserves the current query/input. */
221
- onRetry?: () => void
222
- /** Removes a single active-filter chip. */
223
- onRemoveFilter?: (filter: CategoryFilter2ActiveFilter) => void
224
- }
225
-
226
- const DEFAULT_VIEW_OPTIONS: (number | string)[] = [10, 25, 50, 100]
227
-
228
- /**
229
- * Pure selection reducer shared by the controlled checkbox handlers. Exported
230
- * so the emitted-change contract is unit-testable without a DOM (this package's
231
- * tests render with `react-dom/server`).
232
- */
233
- export function toggleFilterValue(
234
- current: readonly string[],
235
- value: string,
236
- checked: boolean,
237
- ): string[] {
238
- return checked ? [...current, value] : current.filter(candidate => candidate !== value)
239
- }
240
-
241
- /** Assemble the typed filter change emitted upward. */
242
- export function buildFilterChange(input: {
243
- categories: string[]
244
- collections: string[]
245
- priceRange: number[]
246
- size: string | null
247
- color: string | null
248
- }): CategoryFilter2Filters {
249
- return {
250
- categories: input.categories,
251
- collections: input.collections,
252
- priceRange: input.priceRange,
253
- size: input.size,
254
- color: input.color,
255
- }
256
- }
257
-
258
- /**
259
- * Reconcile the controlled search draft with the URL-backed `searchValue`
260
- * (E2 corrective, task 8.2).
261
- *
262
- * The category listing's search input is controlled by the canonical URL query
263
- * (the URL is authoritative), but the URL only updates after an async server
264
- * navigation. Binding the input's DOM value directly to that lagging prop makes
265
- * React reset it to the previous server value on every re-render, so fast typing
266
- * loses every character but the last. This pure function keeps a local draft
267
- * authoritative while the shopper is typing and adopts an external `searchValue`
268
- * (navigation / reload / back-forward) otherwise.
269
- *
270
- * Adoption is allowed only when the draft has not diverged from the previously
271
- * seen server value — i.e. there is no uncommitted local edit. A stale or
272
- * out-of-order response therefore cannot revert the draft. Returns the state
273
- * adjustment to apply, or `null` when nothing changes.
274
- */
275
- export function reconcileSearchDraft(input: {
276
- searchValue?: string
277
- prevSearchValue?: string
278
- draft: string
279
- }): { draft: string; prevSearchValue?: string } | null {
280
- const { searchValue, prevSearchValue, draft } = input
281
- if (searchValue === undefined || searchValue === prevSearchValue) return null
282
- const clean = prevSearchValue === undefined || draft === prevSearchValue
283
- return { draft: clean ? searchValue : draft, prevSearchValue: searchValue }
284
- }
285
-
286
- export interface CategoryFilter2StatusViewProps {
287
- status: CategoryFilter2Status
288
- activeFilters?: CategoryFilter2ActiveFilter[]
289
- activeFiltersTitle?: string
290
- removeFilterLabel?: string
291
- loadingLabel?: string
292
- emptyTitle?: string
293
- emptyDescription?: string
294
- clearFiltersLabel?: string
295
- invalidQueryTitle?: string
296
- unsupportedQueryTitle?: string
297
- unmountedCapabilityTitle?: string
298
- errorTitle?: string
299
- errorDescription?: string
300
- repairLabel?: string
301
- retryLabel?: string
302
- onClearFilters?: () => void
303
- onRepair?: (remove: string[]) => void
304
- onRetry?: () => void
305
- onRemoveFilter?: (filter: CategoryFilter2ActiveFilter) => void
306
- }
307
-
308
- /** Human-readable description naming the exact offending parameters (and, for
309
- * `unmounted-capability`, the missing feature). Never a raw stack/error dump. */
310
- function describeQueryError(
311
- error: CategoryFilter2QueryError,
312
- state: 'invalid-query' | 'unsupported-query' | 'unmounted-capability',
313
- ): string {
314
- const fields = error.fields.join(', ')
315
- if (state === 'unmounted-capability' && error.feature) {
316
- return `${fields} pertenece a la función «${error.feature}», que no está instalada.`
317
- }
318
- return error.message ?? `Parámetros: ${fields}`
319
- }
320
-
321
- /**
322
- * Hooks-free presentation of the observable listing states and the removable
323
- * active-filter chips (E2 task 4.5). Kept separate from `CategoryFilter2` so the
324
- * interaction contract (clear / repair / retry / chip removal) is testable by
325
- * invoking the rendered handlers without a DOM (this package's tests render with
326
- * `react-dom/server`). It renders no products and performs no fetch — it only
327
- * emits the host callbacks. When `ready` with no chips it renders nothing.
328
- */
329
- export function CategoryFilter2StatusView({
330
- status,
331
- activeFilters = [],
332
- activeFiltersTitle = 'Filtros activos',
333
- removeFilterLabel = 'Quitar filtro',
334
- loadingLabel = 'Cargando productos',
335
- emptyTitle = 'Sin resultados',
336
- emptyDescription = 'Ningún producto coincide con los filtros activos.',
337
- clearFiltersLabel = 'Limpiar filtros',
338
- invalidQueryTitle = 'Consulta no válida',
339
- unsupportedQueryTitle = 'Filtro no disponible',
340
- unmountedCapabilityTitle = 'Función no disponible',
341
- errorTitle = 'No se pudieron cargar los productos',
342
- errorDescription = 'Inténtalo de nuevo.',
343
- repairLabel = 'Corregir búsqueda',
344
- retryLabel = 'Reintentar',
345
- onClearFilters,
346
- onRepair,
347
- onRetry,
348
- onRemoveFilter,
349
- }: CategoryFilter2StatusViewProps) {
350
- const chips =
351
- activeFilters.length > 0 ? (
352
- <div
353
- role="group"
354
- aria-label={activeFiltersTitle}
355
- className="flex flex-wrap items-center gap-2"
356
- >
357
- {activeFilters.map(filter => (
358
- <Badge key={filter.id} variant="secondary" className="gap-1 pr-1">
359
- <span>{filter.label}</span>
360
- <Button
361
- type="button"
362
- variant="ghost"
363
- size="icon-xs"
364
- className="size-4 cursor-pointer rounded-full"
365
- aria-label={`${removeFilterLabel}: ${filter.label}`}
366
- data-action="remove-filter"
367
- data-filter-id={filter.id}
368
- onClick={() => onRemoveFilter?.(filter)}
369
- >
370
- <X aria-hidden="true" />
371
- </Button>
372
- </Badge>
373
- ))}
374
- </div>
375
- ) : null
376
-
377
- if (status.state === 'ready') {
378
- return chips
379
- }
380
-
381
- let body: ReactNode
382
- switch (status.state) {
383
- case 'loading':
384
- body = (
385
- <div role="status" aria-live="polite" aria-busy="true" className="flex flex-col gap-4">
386
- <span className="sr-only">{loadingLabel}</span>
387
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3">
388
- {Array.from({ length: 6 }, (_, index) => (
389
- <div key={index} className="flex flex-col gap-2">
390
- <Skeleton className="aspect-3/4 w-full rounded-lg" />
391
- <Skeleton className="h-4 w-3/4" />
392
- <Skeleton className="h-4 w-1/3" />
393
- </div>
394
- ))}
395
- </div>
396
- </div>
397
- )
398
- break
399
- case 'empty':
400
- body = (
401
- <Empty role="status">
402
- <EmptyHeader>
403
- <EmptyTitle>{emptyTitle}</EmptyTitle>
404
- <EmptyDescription>{emptyDescription}</EmptyDescription>
405
- </EmptyHeader>
406
- {onClearFilters ? (
407
- <EmptyContent>
408
- <Button
409
- type="button"
410
- variant="outline"
411
- data-action="clear-filters"
412
- onClick={() => onClearFilters()}
413
- >
414
- {clearFiltersLabel}
415
- </Button>
416
- </EmptyContent>
417
- ) : null}
418
- </Empty>
419
- )
420
- break
421
- case 'invalid-query':
422
- case 'unsupported-query':
423
- case 'unmounted-capability':
424
- body = (
425
- <Empty role="alert">
426
- <EmptyHeader>
427
- <EmptyTitle>
428
- {status.state === 'invalid-query'
429
- ? invalidQueryTitle
430
- : status.state === 'unsupported-query'
431
- ? unsupportedQueryTitle
432
- : unmountedCapabilityTitle}
433
- </EmptyTitle>
434
- <EmptyDescription>{describeQueryError(status.error, status.state)}</EmptyDescription>
435
- </EmptyHeader>
436
- {onRepair ? (
437
- <EmptyContent>
438
- <Button
439
- type="button"
440
- variant="outline"
441
- data-action="repair"
442
- onClick={() => onRepair(status.error.fields)}
443
- >
444
- {repairLabel}
445
- </Button>
446
- </EmptyContent>
447
- ) : null}
448
- </Empty>
449
- )
450
- break
451
- case 'error':
452
- body = (
453
- <Empty role="alert">
454
- <EmptyHeader>
455
- <EmptyTitle>{errorTitle}</EmptyTitle>
456
- <EmptyDescription>{status.message ?? errorDescription}</EmptyDescription>
457
- </EmptyHeader>
458
- {onRetry ? (
459
- <EmptyContent>
460
- <Button
461
- type="button"
462
- variant="default"
463
- data-action="retry"
464
- onClick={() => onRetry()}
465
- >
466
- {retryLabel}
467
- </Button>
468
- </EmptyContent>
469
- ) : null}
470
- </Empty>
471
- )
472
- break
473
- }
474
-
475
- return (
476
- <div className="flex flex-col gap-4">
477
- {chips}
478
- {body}
479
- </div>
480
- )
481
- }
482
-
483
- export function CategoryFilter2({
484
- products,
485
- categories = [],
486
- collections = [],
487
- sizes = [],
488
- colors = [],
489
- priceRange: priceRangeConfig,
490
- viewOptions = DEFAULT_VIEW_OPTIONS,
491
- sortOptions = [],
492
- pagination = [],
493
- searchValue,
494
- selectedCategories,
495
- selectedCollections,
496
- selectedPriceRange,
497
- selectedSort,
498
- selectedPage,
499
- selectedPageSize,
500
- showSearch = false,
501
- showFilters = true,
502
- showPrice = true,
503
- showSort = true,
504
- filtersTitle = 'Filtros',
505
- selectedText = 'Filtros',
506
- clearAllLabel = 'Restablecer',
507
- priceRangeTitle = 'Rango de precio',
508
- minLabel = 'Mínimo',
509
- maxLabel = 'Máximo',
510
- categoriesTitle = 'Categoría',
511
- collectionsTitle = 'Colección',
512
- sizesTitle = 'Tamaño',
513
- colorsTitle = 'Color',
514
- viewLabel = 'Ver',
515
- viewPlaceholder = 'Seleccionar',
516
- sortPlaceholder = 'Ordenar por',
517
- productsTitle = 'Productos',
518
- currentLabel = '(actual)',
519
- searchLabel = 'Buscar',
520
- searchPlaceholder = 'Buscar',
521
- onProductClick,
522
- onPageChange,
523
- onViewChange,
524
- onPageSizeChange,
525
- onSortChange,
526
- onFilterChange,
527
- onSearchChange,
528
- status = { state: 'ready' },
529
- activeFilters = [],
530
- loadingLabel,
531
- emptyTitle,
532
- emptyDescription,
533
- clearFiltersLabel,
534
- invalidQueryTitle,
535
- unsupportedQueryTitle,
536
- unmountedCapabilityTitle,
537
- errorTitle,
538
- errorDescription,
539
- repairLabel,
540
- retryLabel,
541
- activeFiltersTitle,
542
- removeFilterLabel,
543
- onClearFilters,
544
- onRepair,
545
- onRetry,
546
- onRemoveFilter,
547
- }: CategoryFilter2Props) {
548
- const searchInputId = useId()
549
- const [internalCategories, setInternalCategories] = useState<string[]>([])
550
- const [internalCollections, setInternalCollections] = useState<string[]>([])
551
- const [internalPriceRange, setInternalPriceRange] = useState<number[]>(
552
- priceRangeConfig ? [priceRangeConfig.min, priceRangeConfig.max] : [],
553
- )
554
- const [internalSize, setInternalSize] = useState<string | null>(null)
555
- const [internalColor, setInternalColor] = useState<string | null>(null)
556
- const [internalView, setInternalView] = useState<number | string>(viewOptions[0] ?? 10)
557
- const [internalSort, setInternalSort] = useState<string>(sortOptions[0]?.value ?? '')
558
- const [internalPage, setInternalPage] = useState<number | string | null>(pagination.length > 0 ? pagination[0] : null)
559
- const [internalSearch, setInternalSearch] = useState('')
560
- // Local draft of the controlled search input (E2 corrective, task 8.2): the
561
- // URL stays authoritative for the canonical query and the listing, but the
562
- // input displays this draft so fast typing is never reset by a lagging server
563
- // response. Adopt the external `searchValue` only when no uncommitted edit is
564
- // pending (see `reconcileSearchDraft`).
565
- const [draftSearch, setDraftSearch] = useState(searchValue ?? '')
566
- const [prevSearchValue, setPrevSearchValue] = useState(searchValue)
567
- const [isSheetOpen, setIsSheetOpen] = useState(false)
568
-
569
- const reconciledSearch = reconcileSearchDraft({
570
- searchValue,
571
- prevSearchValue,
572
- draft: draftSearch,
573
- })
574
- if (reconciledSearch) {
575
- setPrevSearchValue(reconciledSearch.prevSearchValue)
576
- if (reconciledSearch.draft !== draftSearch) setDraftSearch(reconciledSearch.draft)
577
- }
578
-
579
- // Controlled-when-supplied values: the canonical query wins over local state.
580
- const categoriesValue = selectedCategories ?? internalCategories
581
- const collectionsValue = selectedCollections ?? internalCollections
582
- const priceRangeValue = selectedPriceRange ?? internalPriceRange
583
- const sortValue = selectedSort ?? internalSort
584
- const pageValue = selectedPage ?? internalPage
585
- const pageSizeValue = selectedPageSize ?? internalView
586
- const searchText = searchValue === undefined ? internalSearch : draftSearch
587
-
588
- // Close sheet when screen size becomes large using CSS media query
589
- useEffect(() => {
590
- const mediaQuery = window.matchMedia('(min-width: 1024px)')
591
-
592
- const handleChange = (e: MediaQueryListEvent) => {
593
- if (e.matches) {
594
- setIsSheetOpen(false)
595
- }
596
- }
597
-
598
- mediaQuery.addEventListener('change', handleChange)
599
- return () => mediaQuery.removeEventListener('change', handleChange)
600
- }, [])
601
-
602
- const emitFilters = (overrides: Partial<CategoryFilter2Filters> = {}) => {
603
- onFilterChange?.(
604
- buildFilterChange({
605
- categories: categoriesValue,
606
- collections: collectionsValue,
607
- priceRange: priceRangeValue,
608
- size: internalSize,
609
- color: internalColor,
610
- ...overrides,
611
- }),
612
- )
613
- }
614
-
615
- const handleCategoryChange = (category: string, checked: boolean) => {
616
- const next = toggleFilterValue(categoriesValue, category, checked)
617
- setInternalCategories(next)
618
- emitFilters({ categories: next })
619
- }
620
-
621
- const handleCollectionChange = (collection: string, checked: boolean) => {
622
- const next = toggleFilterValue(collectionsValue, collection, checked)
623
- setInternalCollections(next)
624
- emitFilters({ collections: next })
625
- }
626
-
627
- const handlePriceChange = (next: number[]) => {
628
- setInternalPriceRange(next)
629
- emitFilters({ priceRange: next })
630
- }
631
-
632
- const handleSizeChange = (size: string | null) => {
633
- setInternalSize(size)
634
- emitFilters({ size })
635
- }
636
-
637
- const handleColorChange = (color: string | null) => {
638
- setInternalColor(color)
639
- emitFilters({ color })
640
- }
641
-
642
- const handleClearAll = () => {
643
- const resetPriceRange = priceRangeConfig ? [priceRangeConfig.min, priceRangeConfig.max] : []
644
- setInternalCategories([])
645
- setInternalCollections([])
646
- setInternalSize(null)
647
- setInternalColor(null)
648
- setInternalPriceRange(resetPriceRange)
649
- onFilterChange?.(
650
- buildFilterChange({
651
- categories: [],
652
- collections: [],
653
- priceRange: resetPriceRange,
654
- size: null,
655
- color: null,
656
- }),
657
- )
658
- }
659
-
660
- const handlePageSizeChange = (size: number | string) => {
661
- setInternalView(size)
662
- ;(onPageSizeChange ?? onViewChange)?.(size)
663
- }
664
-
665
- const handleSortChange = (sort: string) => {
666
- setInternalSort(sort)
667
- onSortChange?.(sort)
668
- }
669
-
670
- const handlePageChange = (page: number | string) => {
671
- if (page === '...') return
672
- setInternalPage(page)
673
- onPageChange?.(page)
674
- }
675
-
676
- const handleSearchChange = (text: string) => {
677
- if (searchValue === undefined) setInternalSearch(text)
678
- else setDraftSearch(text)
679
- onSearchChange?.(text)
680
- }
681
-
682
- const filterPanelProps = {
683
- selectedText,
684
- clearAllLabel,
685
- priceRangeTitle,
686
- priceRange: priceRangeValue,
687
- priceRangeConfig: showPrice ? priceRangeConfig : undefined,
688
- minLabel,
689
- maxLabel,
690
- onPriceChange: handlePriceChange,
691
- categoriesTitle,
692
- categories,
693
- selectedCategories: categoriesValue,
694
- onCategoryChange: handleCategoryChange,
695
- collectionsTitle,
696
- collections,
697
- selectedCollections: collectionsValue,
698
- onCollectionChange: handleCollectionChange,
699
- sizesTitle,
700
- sizes,
701
- selectedSize: internalSize,
702
- onSizeChange: handleSizeChange,
703
- colorsTitle,
704
- colors,
705
- selectedColor: internalColor,
706
- onColorChange: handleColorChange,
707
- onClearAll: handleClearAll,
708
- }
709
-
710
- return (
711
- <div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
712
- <div className="flex flex-col gap-8 lg:flex-row">
713
- {showFilters ? (
714
- <aside className="hidden w-full flex-none lg:block lg:w-72">
715
- <div className="max-h-100vh sticky top-4 overflow-y-auto">
716
- <div className="flex flex-col gap-6 pb-6">
717
- <CategoryFilter2FilterPanel {...filterPanelProps} />
718
- </div>
719
- </div>
720
- </aside>
721
- ) : null}
722
-
723
- <main className="flex min-w-0 flex-1 flex-col gap-6">
724
- <div className="flex flex-wrap items-center justify-between gap-4">
725
- <div className="flex items-center gap-4">
726
- {showFilters ? (
727
- <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
728
- <SheetTrigger asChild>
729
- <Button
730
- variant="outline"
731
- size="sm"
732
- className="h-8 cursor-pointer px-3 text-xs lg:hidden"
733
- >
734
- <SlidersHorizontal />
735
- {filtersTitle}
736
- </Button>
737
- </SheetTrigger>
738
- <SheetContent side="left" className="w-full p-0 sm:w-[380px] lg:hidden">
739
- <SheetHeader className="border-b px-6 py-4">
740
- <SheetTitle className="text-lg">{filtersTitle}</SheetTitle>
741
- </SheetHeader>
742
- <div className="h-[calc(100vh-4rem)] overflow-y-auto p-6">
743
- <div className="flex flex-col gap-6">
744
- <CategoryFilter2FilterPanel {...filterPanelProps} />
745
- </div>
746
- </div>
747
- </SheetContent>
748
- </Sheet>
749
- ) : null}
750
- {showSearch ? (
751
- <div className="relative w-full sm:w-64">
752
- <label htmlFor={searchInputId} className="sr-only">
753
- {searchLabel}
754
- </label>
755
- <Search
756
- aria-hidden="true"
757
- className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
758
- />
759
- <Input
760
- id={searchInputId}
761
- type="search"
762
- value={searchText}
763
- onChange={event => handleSearchChange(event.target.value)}
764
- placeholder={searchPlaceholder}
765
- aria-label={searchLabel}
766
- className="h-8 pl-8"
767
- />
768
- </div>
769
- ) : null}
770
- {viewOptions.length > 0 ? (
771
- <div className="hidden items-center gap-2 sm:flex">
772
- <span className="text-sm">{viewLabel}</span>
773
- <Select
774
- value={String(pageSizeValue)}
775
- onValueChange={value => handlePageSizeChange(viewOptions.find(option => String(option) === value) ?? value)}
776
- >
777
- <SelectTrigger className="h-8 w-[100px]" aria-label={viewLabel}>
778
- <SelectValue placeholder={viewPlaceholder} />
779
- </SelectTrigger>
780
- <SelectContent>
781
- {viewOptions.map(option => (
782
- <SelectItem key={option} value={String(option)}>
783
- {option}
784
- </SelectItem>
785
- ))}
786
- </SelectContent>
787
- </Select>
788
- </div>
789
- ) : null}
790
- </div>
791
- {showSort && sortOptions.length > 0 ? (
792
- <div className="flex items-center gap-4">
793
- <Select value={sortValue} onValueChange={handleSortChange}>
794
- <SelectTrigger className="h-8 w-[180px]" aria-label={sortPlaceholder}>
795
- <SelectValue placeholder={sortPlaceholder} />
796
- </SelectTrigger>
797
- <SelectContent>
798
- {sortOptions.map(option => (
799
- <SelectItem key={option.value} value={option.value}>
800
- {option.label}
801
- </SelectItem>
802
- ))}
803
- </SelectContent>
804
- </Select>
805
- </div>
806
- ) : null}
807
- </div>
808
-
809
- <CategoryFilter2StatusView
810
- status={status}
811
- activeFilters={activeFilters}
812
- loadingLabel={loadingLabel}
813
- emptyTitle={emptyTitle}
814
- emptyDescription={emptyDescription}
815
- clearFiltersLabel={clearFiltersLabel}
816
- invalidQueryTitle={invalidQueryTitle}
817
- unsupportedQueryTitle={unsupportedQueryTitle}
818
- unmountedCapabilityTitle={unmountedCapabilityTitle}
819
- errorTitle={errorTitle}
820
- errorDescription={errorDescription}
821
- repairLabel={repairLabel}
822
- retryLabel={retryLabel}
823
- activeFiltersTitle={activeFiltersTitle}
824
- removeFilterLabel={removeFilterLabel}
825
- onClearFilters={onClearFilters}
826
- onRepair={onRepair}
827
- onRetry={onRetry}
828
- onRemoveFilter={onRemoveFilter}
829
- />
830
-
831
- {status.state === 'ready' ? (
832
- <section aria-labelledby="products-heading" className="flex flex-col gap-6">
833
- <h2 id="products-heading" className="sr-only">
834
- {productsTitle}
835
- </h2>
836
- <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3">
837
- {products.map(product => {
838
- const card = (
839
- <>
840
- <div className="relative aspect-3/4 overflow-hidden rounded-lg">
841
- {product.image ? (
842
- <img
843
- src={product.image}
844
- alt={product.name}
845
- className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
846
- width={300}
847
- height={400}
848
- loading="lazy"
849
- />
850
- ) : (
851
- <div
852
- data-slot="product-image-placeholder"
853
- aria-hidden="true"
854
- className="size-full bg-muted"
855
- />
856
- )}
857
- </div>
858
- <h3 className="text-sm font-medium text-balance">{product.name}</h3>
859
- <p className="text-sm font-semibold">{product.price}</p>
860
- </>
861
- )
862
- return (
863
- <li key={product.id} className="group">
864
- {product.href ? (
865
- // A real anchor keeps keyboard focus, middle-click and
866
- // crawlers working without a JS emit path.
867
- <a href={product.href} className="flex h-full flex-col gap-2">
868
- {card}
869
- </a>
870
- ) : (
871
- <article
872
- className={cn(
873
- 'flex h-full flex-col gap-2',
874
- onProductClick && 'cursor-pointer',
875
- )}
876
- onClick={onProductClick ? () => onProductClick(product) : undefined}
877
- >
878
- {card}
879
- </article>
880
- )}
881
- </li>
882
- )
883
- })}
884
- </ul>
885
-
886
- {pagination.length > 0 ? (
887
- <Pagination aria-label="Paginación">
888
- <PaginationContent>
889
- {pagination.map((page, index) =>
890
- page === '...' ? (
891
- <PaginationEllipsis key={`${page}-${index}`} />
892
- ) : (
893
- <PaginationItem key={page}>
894
- <Button
895
- type="button"
896
- variant={pageValue === page ? 'outline' : 'ghost'}
897
- size="icon"
898
- data-slot="pagination-link"
899
- data-active={pageValue === page}
900
- aria-current={pageValue === page ? 'page' : undefined}
901
- className="size-8 cursor-pointer p-0"
902
- onClick={() => handlePageChange(page)}
903
- >
904
- {page}
905
- {pageValue === page && <span className="sr-only">{currentLabel}</span>}
906
- </Button>
907
- </PaginationItem>
908
- ),
909
- )}
910
- </PaginationContent>
911
- </Pagination>
912
- ) : null}
913
- </section>
914
- ) : null}
915
- </main>
916
- </div>
917
- </div>
918
- )
919
- }
920
-
921
- export default CategoryFilter2