@thorprovider/blocks 0.1.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 (184) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/.turbo/turbo-test.log +10 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/README.md +161 -0
  6. package/components.json +33 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/contracts.d.mts +563 -0
  9. package/dist/contracts.d.ts +563 -0
  10. package/dist/contracts.js +207 -0
  11. package/dist/contracts.js.map +1 -0
  12. package/dist/contracts.mjs +168 -0
  13. package/dist/contracts.mjs.map +1 -0
  14. package/dist/index.d.mts +2408 -0
  15. package/dist/index.d.ts +2408 -0
  16. package/dist/index.js +16422 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.mjs +16352 -0
  19. package/dist/index.mjs.map +1 -0
  20. package/docs/standards/data-contracts.md +70 -0
  21. package/docs/standards/theming.md +85 -0
  22. package/package.json +62 -0
  23. package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
  24. package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
  25. package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
  26. package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
  27. package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
  28. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
  29. package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
  30. package/src/blocks/application/verification/verify-1.tsx +307 -0
  31. package/src/blocks/application/verification/verify-confetti.tsx +192 -0
  32. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
  33. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
  34. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
  35. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
  36. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
  37. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
  38. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
  39. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
  40. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
  41. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
  42. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
  43. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
  44. package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
  45. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
  46. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
  47. package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
  48. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
  49. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
  50. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
  51. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
  52. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
  53. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
  54. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
  55. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
  56. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
  57. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
  58. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
  59. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
  60. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
  61. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
  62. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
  63. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
  67. package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
  68. package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
  69. package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
  70. package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
  71. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
  72. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
  73. package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
  74. package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
  75. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
  76. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
  77. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
  78. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
  79. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
  80. package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
  81. package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
  82. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
  83. package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
  84. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
  85. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
  86. package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
  87. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
  88. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
  89. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
  90. package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
  91. package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
  92. package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
  93. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
  94. package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
  95. package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
  96. package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
  97. package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
  98. package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
  99. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
  105. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
  106. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
  107. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
  108. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
  109. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
  110. package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
  111. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
  112. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
  113. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
  114. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
  115. package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
  116. package/src/blocks/marketing/banners/banner-1.tsx +188 -0
  117. package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
  118. package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
  119. package/src/blocks/marketing/features/feature-1.tsx +97 -0
  120. package/src/blocks/marketing/features/feature-2.tsx +82 -0
  121. package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
  122. package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
  123. package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
  124. package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
  125. package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
  126. package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
  127. package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
  128. package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
  129. package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
  130. package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
  131. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
  132. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
  133. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
  134. package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
  135. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
  136. package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
  137. package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
  138. package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
  139. package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
  140. package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
  141. package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
  142. package/src/blocks/shared/reaction-toggle.tsx +145 -0
  143. package/src/hooks/use-mobile.ts +19 -0
  144. package/src/index.ts +200 -0
  145. package/src/lib/contracts.test.ts +32 -0
  146. package/src/lib/contracts.ts +235 -0
  147. package/src/lib/types/menu.ts +43 -0
  148. package/src/lib/types.ts +4 -0
  149. package/src/lib/utils.ts +10 -0
  150. package/src/ui/accordion.tsx +65 -0
  151. package/src/ui/avatar.tsx +108 -0
  152. package/src/ui/badge.tsx +47 -0
  153. package/src/ui/breadcrumb.tsx +108 -0
  154. package/src/ui/button-group.tsx +83 -0
  155. package/src/ui/button.tsx +63 -0
  156. package/src/ui/card.tsx +91 -0
  157. package/src/ui/carousel.tsx +241 -0
  158. package/src/ui/dialog.tsx +158 -0
  159. package/src/ui/drawer.tsx +134 -0
  160. package/src/ui/dropdown-menu.tsx +256 -0
  161. package/src/ui/empty.tsx +103 -0
  162. package/src/ui/field.tsx +248 -0
  163. package/src/ui/input-group.tsx +170 -0
  164. package/src/ui/input-otp.tsx +76 -0
  165. package/src/ui/input.tsx +20 -0
  166. package/src/ui/label.tsx +23 -0
  167. package/src/ui/navigation-menu.tsx +167 -0
  168. package/src/ui/pagination.tsx +127 -0
  169. package/src/ui/progress.tsx +30 -0
  170. package/src/ui/radio-group.tsx +44 -0
  171. package/src/ui/select.tsx +189 -0
  172. package/src/ui/separator.tsx +27 -0
  173. package/src/ui/sheet.tsx +142 -0
  174. package/src/ui/sidebar.tsx +726 -0
  175. package/src/ui/skeleton.tsx +13 -0
  176. package/src/ui/table.tsx +115 -0
  177. package/src/ui/tabs.tsx +90 -0
  178. package/src/ui/textarea.tsx +17 -0
  179. package/src/ui/toggle-group.tsx +83 -0
  180. package/src/ui/toggle.tsx +46 -0
  181. package/src/ui/tooltip.tsx +56 -0
  182. package/tsconfig.json +20 -0
  183. package/tsup.config.ts +16 -0
  184. package/vitest.config.ts +9 -0
@@ -0,0 +1,341 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { SlidersHorizontal } from 'lucide-react'
5
+
6
+ import { Button } from '../../../ui/button'
7
+ import {
8
+ Pagination,
9
+ PaginationContent,
10
+ PaginationEllipsis,
11
+ PaginationItem,
12
+ PaginationLink,
13
+ } from '../../../ui/pagination'
14
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
15
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../../../ui/sheet'
16
+ import { cn } from '../../../lib/utils'
17
+
18
+ import { CategoryFilter2FilterPanel } from './category-filter-2-filter-panel'
19
+
20
+ export interface FilterProduct {
21
+ id: string
22
+ name: string
23
+ price: string
24
+ image: string
25
+ }
26
+
27
+ export interface CategoryFilter2Category {
28
+ id: string
29
+ label: string
30
+ }
31
+
32
+ export interface CategoryFilter2Color {
33
+ id: string
34
+ color: string
35
+ }
36
+
37
+ export interface CategoryFilter2PriceRange {
38
+ min: number
39
+ max: number
40
+ step: number
41
+ }
42
+
43
+ export interface CategoryFilter2SortOption {
44
+ value: string
45
+ label: string
46
+ }
47
+
48
+ export interface CategoryFilter2Filters {
49
+ categories: string[]
50
+ priceRange: number[]
51
+ size: string | null
52
+ color: string | null
53
+ }
54
+
55
+ export interface CategoryFilter2Props {
56
+ products: FilterProduct[]
57
+ categories?: CategoryFilter2Category[]
58
+ sizes?: string[]
59
+ colors?: CategoryFilter2Color[]
60
+ priceRange?: CategoryFilter2PriceRange
61
+ viewOptions?: (number | string)[]
62
+ sortOptions?: CategoryFilter2SortOption[]
63
+ pagination?: (number | string)[]
64
+ filtersTitle?: string
65
+ selectedText?: string
66
+ clearAllLabel?: string
67
+ priceRangeTitle?: string
68
+ minLabel?: string
69
+ maxLabel?: string
70
+ categoriesTitle?: string
71
+ sizesTitle?: string
72
+ colorsTitle?: string
73
+ viewLabel?: string
74
+ viewPlaceholder?: string
75
+ sortPlaceholder?: string
76
+ productsTitle?: string
77
+ currentLabel?: string
78
+ onProductClick?: (product: FilterProduct) => void
79
+ onPageChange?: (page: number | string) => void
80
+ onViewChange?: (view: number | string) => void
81
+ onSortChange?: (sort: string) => void
82
+ onFilterChange?: (filters: CategoryFilter2Filters) => void
83
+ }
84
+
85
+ const DEFAULT_VIEW_OPTIONS: (number | string)[] = [10, 25, 50, 100]
86
+ const DEFAULT_SORT_OPTIONS: CategoryFilter2SortOption[] = [
87
+ { value: 'newest', label: 'Más recientes' },
88
+ { value: 'price-asc', label: 'Precio: de menor a mayor' },
89
+ { value: 'price-desc', label: 'Precio: de mayor a menor' },
90
+ ]
91
+ const DEFAULT_PRICE_RANGE: CategoryFilter2PriceRange = { min: 0, max: 1000, step: 10 }
92
+
93
+ export function CategoryFilter2({
94
+ products,
95
+ categories = [],
96
+ sizes = [],
97
+ colors = [],
98
+ priceRange: priceRangeConfig = DEFAULT_PRICE_RANGE,
99
+ viewOptions = DEFAULT_VIEW_OPTIONS,
100
+ sortOptions = DEFAULT_SORT_OPTIONS,
101
+ pagination = [],
102
+ filtersTitle = 'Filtros',
103
+ selectedText = 'Filtros',
104
+ clearAllLabel = 'Restablecer',
105
+ priceRangeTitle = 'Rango de precio',
106
+ minLabel = 'Mínimo',
107
+ maxLabel = 'Máximo',
108
+ categoriesTitle = 'Categoría',
109
+ sizesTitle = 'Tamaño',
110
+ colorsTitle = 'Color',
111
+ viewLabel = 'Ver',
112
+ viewPlaceholder = 'Seleccionar',
113
+ sortPlaceholder = 'Ordenar por',
114
+ productsTitle = 'Productos',
115
+ currentLabel = '(actual)',
116
+ onProductClick,
117
+ onPageChange,
118
+ onViewChange,
119
+ onSortChange,
120
+ onFilterChange,
121
+ }: CategoryFilter2Props) {
122
+ const [selectedCategories, setSelectedCategories] = useState<string[]>([])
123
+ const [priceRange, setPriceRange] = useState<number[]>([priceRangeConfig.min, priceRangeConfig.max])
124
+ const [selectedSize, setSelectedSize] = useState<string | null>(null)
125
+ const [selectedColor, setSelectedColor] = useState<string | null>(null)
126
+ const [selectedView, setSelectedView] = useState<number | string>(viewOptions[0] ?? 10)
127
+ const [selectedSort, setSelectedSort] = useState<string>(sortOptions[0]?.value ?? 'newest')
128
+ const [activePage, setActivePage] = useState<number | string | null>(pagination.length > 0 ? pagination[0] : null)
129
+ const [isSheetOpen, setIsSheetOpen] = useState(false)
130
+
131
+ // Close sheet when screen size becomes large using CSS media query
132
+ useEffect(() => {
133
+ const mediaQuery = window.matchMedia('(min-width: 1024px)')
134
+
135
+ const handleChange = (e: MediaQueryListEvent) => {
136
+ if (e.matches) {
137
+ setIsSheetOpen(false)
138
+ }
139
+ }
140
+
141
+ mediaQuery.addEventListener('change', handleChange)
142
+ return () => mediaQuery.removeEventListener('change', handleChange)
143
+ }, [])
144
+
145
+ const handleCategoryChange = (category: string, checked: boolean) => {
146
+ const next = checked ? [...selectedCategories, category] : selectedCategories.filter(c => c !== category)
147
+ setSelectedCategories(next)
148
+ onFilterChange?.({ categories: next, priceRange, size: selectedSize, color: selectedColor })
149
+ }
150
+
151
+ const handlePriceChange = (next: number[]) => {
152
+ setPriceRange(next)
153
+ onFilterChange?.({ categories: selectedCategories, priceRange: next, size: selectedSize, color: selectedColor })
154
+ }
155
+
156
+ const handleSizeChange = (size: string | null) => {
157
+ setSelectedSize(size)
158
+ onFilterChange?.({ categories: selectedCategories, priceRange, size, color: selectedColor })
159
+ }
160
+
161
+ const handleColorChange = (color: string | null) => {
162
+ setSelectedColor(color)
163
+ onFilterChange?.({ categories: selectedCategories, priceRange, size: selectedSize, color })
164
+ }
165
+
166
+ const handleClearAll = () => {
167
+ setSelectedCategories([])
168
+ setSelectedSize(null)
169
+ setSelectedColor(null)
170
+ setPriceRange([priceRangeConfig.min, priceRangeConfig.max])
171
+ onFilterChange?.({ categories: [], priceRange: [priceRangeConfig.min, priceRangeConfig.max], size: null, color: null })
172
+ }
173
+
174
+ const handleViewChange = (view: number | string) => {
175
+ setSelectedView(view)
176
+ onViewChange?.(view)
177
+ }
178
+
179
+ const handleSortChange = (sort: string) => {
180
+ setSelectedSort(sort)
181
+ onSortChange?.(sort)
182
+ }
183
+
184
+ const handlePageChange = (page: number | string) => {
185
+ if (page === '...') return
186
+ setActivePage(page)
187
+ onPageChange?.(page)
188
+ }
189
+
190
+ const filterPanelProps = {
191
+ selectedText,
192
+ clearAllLabel,
193
+ priceRangeTitle,
194
+ priceRange,
195
+ priceRangeConfig,
196
+ minLabel,
197
+ maxLabel,
198
+ categoriesTitle,
199
+ categories,
200
+ selectedCategories,
201
+ onCategoryChange: handleCategoryChange,
202
+ onPriceChange: handlePriceChange,
203
+ sizesTitle,
204
+ sizes,
205
+ selectedSize,
206
+ onSizeChange: handleSizeChange,
207
+ colorsTitle,
208
+ colors,
209
+ selectedColor,
210
+ onColorChange: handleColorChange,
211
+ onClearAll: handleClearAll,
212
+ }
213
+
214
+ return (
215
+ <div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
216
+ <div className="flex flex-col gap-8 lg:flex-row">
217
+ <aside className="hidden w-full flex-none lg:block lg:w-72">
218
+ <div className="max-h-100vh sticky top-4 overflow-y-auto">
219
+ <div className="flex flex-col gap-6 pb-6">
220
+ <CategoryFilter2FilterPanel {...filterPanelProps} />
221
+ </div>
222
+ </div>
223
+ </aside>
224
+
225
+ <main className="flex min-w-0 flex-1 flex-col gap-6">
226
+ <div className="flex flex-wrap items-center justify-between gap-4">
227
+ <div className="flex items-center gap-4">
228
+ <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
229
+ <Button
230
+ variant="outline"
231
+ size="sm"
232
+ className="h-8 cursor-pointer px-3 text-xs lg:hidden"
233
+ onClick={() => setIsSheetOpen(true)}
234
+ >
235
+ <SlidersHorizontal />
236
+ {filtersTitle}
237
+ </Button>
238
+ <SheetContent side="left" className="w-full p-0 sm:w-[380px] lg:hidden">
239
+ <SheetHeader className="border-b px-6 py-4">
240
+ <SheetTitle className="text-lg">{filtersTitle}</SheetTitle>
241
+ </SheetHeader>
242
+ <div className="h-[calc(100vh-4rem)] overflow-y-auto p-6">
243
+ <div className="flex flex-col gap-6">
244
+ <CategoryFilter2FilterPanel {...filterPanelProps} />
245
+ </div>
246
+ </div>
247
+ </SheetContent>
248
+ </Sheet>
249
+ <div className="hidden items-center gap-2 sm:flex">
250
+ <span className="text-sm">{viewLabel}</span>
251
+ <Select
252
+ value={String(selectedView)}
253
+ onValueChange={value => handleViewChange(viewOptions.find(option => String(option) === value) ?? value)}
254
+ >
255
+ <SelectTrigger className="h-8 w-[100px]" aria-label={viewLabel}>
256
+ <SelectValue placeholder={viewPlaceholder} />
257
+ </SelectTrigger>
258
+ <SelectContent>
259
+ {viewOptions.map(option => (
260
+ <SelectItem key={option} value={String(option)}>
261
+ {option}
262
+ </SelectItem>
263
+ ))}
264
+ </SelectContent>
265
+ </Select>
266
+ </div>
267
+ </div>
268
+ <div className="flex items-center gap-4">
269
+ <Select value={selectedSort} onValueChange={handleSortChange}>
270
+ <SelectTrigger className="h-8 w-[180px]" aria-label={sortPlaceholder}>
271
+ <SelectValue placeholder={sortPlaceholder} />
272
+ </SelectTrigger>
273
+ <SelectContent>
274
+ {sortOptions.map(option => (
275
+ <SelectItem key={option.value} value={option.value}>
276
+ {option.label}
277
+ </SelectItem>
278
+ ))}
279
+ </SelectContent>
280
+ </Select>
281
+ </div>
282
+ </div>
283
+
284
+ <section aria-labelledby="products-heading" className="flex flex-col gap-6">
285
+ <h2 id="products-heading" className="sr-only">
286
+ {productsTitle}
287
+ </h2>
288
+ <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3">
289
+ {products.map(product => (
290
+ <li key={product.id} className="group">
291
+ <article
292
+ className={cn('flex h-full flex-col gap-2', onProductClick && 'cursor-pointer')}
293
+ onClick={onProductClick ? () => onProductClick(product) : undefined}
294
+ >
295
+ <div className="relative aspect-3/4 overflow-hidden rounded-lg">
296
+ <img
297
+ src={product.image || undefined}
298
+ alt={product.name}
299
+ className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
300
+ width={300}
301
+ height={400}
302
+ loading="lazy"
303
+ />
304
+ </div>
305
+ <h3 className="text-sm font-medium text-balance">{product.name}</h3>
306
+ <p className="text-sm font-semibold">{product.price}</p>
307
+ </article>
308
+ </li>
309
+ ))}
310
+ </ul>
311
+
312
+ {pagination.length > 0 ? (
313
+ <Pagination aria-label="Paginación">
314
+ <PaginationContent>
315
+ {pagination.map((page, index) =>
316
+ page === '...' ? (
317
+ <PaginationEllipsis key={`${page}-${index}`} />
318
+ ) : (
319
+ <PaginationItem key={page}>
320
+ <PaginationLink
321
+ isActive={activePage === page}
322
+ className="size-8 cursor-pointer p-0"
323
+ onClick={() => handlePageChange(page)}
324
+ >
325
+ {page}
326
+ {activePage === page && <span className="sr-only">{currentLabel}</span>}
327
+ </PaginationLink>
328
+ </PaginationItem>
329
+ )
330
+ )}
331
+ </PaginationContent>
332
+ </Pagination>
333
+ ) : null}
334
+ </section>
335
+ </main>
336
+ </div>
337
+ </div>
338
+ )
339
+ }
340
+
341
+ export default CategoryFilter2
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+
3
+ import { X } from 'lucide-react'
4
+
5
+ import { Badge } from '../../../ui/badge'
6
+ import { Button } from '../../../ui/button'
7
+ import type { CategoryFilter4FilterState } from './category-filter-4'
8
+
9
+ interface CategoryFilter4ChipRailProps {
10
+ filters: CategoryFilter4FilterState
11
+ onChange: (next: CategoryFilter4FilterState) => void
12
+ empty: CategoryFilter4FilterState
13
+ clearAllLabel?: string
14
+ }
15
+
16
+ export function CategoryFilter4ChipRail({
17
+ filters,
18
+ onChange,
19
+ empty,
20
+ clearAllLabel = 'Borrar todos',
21
+ }: CategoryFilter4ChipRailProps) {
22
+ type Chip = { label: string; clear: () => void }
23
+ const chips: Chip[] = [
24
+ ...filters.modelTypes.map(v => ({ label: v, clear: () => onChange({ ...filters, modelTypes: filters.modelTypes.filter(x => x !== v) }) })),
25
+ ...filters.rangeBands.map(v => ({ label: v, clear: () => onChange({ ...filters, rangeBands: filters.rangeBands.filter(x => x !== v) }) })),
26
+ ...filters.frameSizes.map(v => ({ label: `Talla ${v}`, clear: () => onChange({ ...filters, frameSizes: filters.frameSizes.filter(x => x !== v) }) })),
27
+ ...filters.colourways.map(v => ({ label: v, clear: () => onChange({ ...filters, colourways: filters.colourways.filter(x => x !== v) }) })),
28
+ ...filters.features.map(v => ({ label: v, clear: () => onChange({ ...filters, features: filters.features.filter(x => x !== v) }) })),
29
+ ]
30
+
31
+ if (filters.priceMax < empty.priceMax) {
32
+ chips.push({
33
+ label: `Menos de $${filters.priceMax.toLocaleString()}`,
34
+ clear: () => onChange({ ...filters, priceMax: empty.priceMax }),
35
+ })
36
+ }
37
+
38
+ if (chips.length === 0) return null
39
+
40
+ return (
41
+ <div className="flex items-center gap-1.5 overflow-x-auto pb-1" role="group" aria-label="Filtros aplicados">
42
+ {chips.map(chip => (
43
+ <Badge key={chip.label} variant="secondary" className="shrink-0 gap-1">
44
+ {chip.label}
45
+ <button type="button" aria-label={`Eliminar filtro: ${chip.label}`} onClick={chip.clear}>
46
+ <X className="size-3" />
47
+ </button>
48
+ </Badge>
49
+ ))}
50
+ <Button variant="ghost" size="sm" className="shrink-0" onClick={() => onChange(empty)}>
51
+ {clearAllLabel}
52
+ </Button>
53
+ </div>
54
+ )
55
+ }
56
+
57
+ export default CategoryFilter4ChipRail
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { animate, useReducedMotion } from 'motion/react'
5
+
6
+ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '../../../ui/drawer'
7
+ import { Button } from '../../../ui/button'
8
+ import type {
9
+ CategoryFilter4Facets,
10
+ CategoryFilter4FilterState,
11
+ } from './category-filter-4'
12
+ import { CategoryFilter4Fields } from './category-filter-4-fields'
13
+
14
+ interface CategoryFilter4DrawerProps {
15
+ open: boolean
16
+ onOpenChange: (open: boolean) => void
17
+ filters: CategoryFilter4FilterState
18
+ onChange: (next: CategoryFilter4FilterState) => void
19
+ countWith: (partial: Partial<CategoryFilter4FilterState>) => number
20
+ resultCount: number
21
+ empty: CategoryFilter4FilterState
22
+ facets: CategoryFilter4Facets
23
+ colourNames: Record<string, string>
24
+ filtersLabel?: string
25
+ modelTypeLabel?: string
26
+ rangeLabel?: string
27
+ frameSizeLabel?: string
28
+ colourLabel?: string
29
+ featuresLabel?: string
30
+ clearAllLabel?: string
31
+ showResultsLabel?: (count: number) => string
32
+ noResultsLabel?: string
33
+ noResultsDescription?: string
34
+ }
35
+
36
+ export function CategoryFilter4Drawer({
37
+ open,
38
+ onOpenChange,
39
+ filters,
40
+ onChange,
41
+ countWith,
42
+ resultCount,
43
+ empty,
44
+ facets,
45
+ colourNames,
46
+ filtersLabel = 'Filtros',
47
+ modelTypeLabel,
48
+ rangeLabel,
49
+ frameSizeLabel,
50
+ colourLabel,
51
+ featuresLabel,
52
+ clearAllLabel = 'Borrar todos',
53
+ showResultsLabel = count => `Mostrar ${count} ${count === 1 ? 'producto' : 'productos'}`,
54
+ noResultsLabel = 'Sin coincidencias',
55
+ noResultsDescription = 'Prueba a relajar alguno de los filtros aplicados.',
56
+ }: CategoryFilter4DrawerProps) {
57
+ const reduceMotion = useReducedMotion()
58
+ const [displayCount, setDisplayCount] = useState(resultCount)
59
+ const prevRef = useRef(resultCount)
60
+
61
+ useEffect(() => {
62
+ if (reduceMotion) {
63
+ setDisplayCount(resultCount)
64
+ return
65
+ }
66
+ const controls = animate(prevRef.current, resultCount, { duration: 0.3, onUpdate: v => setDisplayCount(Math.round(v)) })
67
+ prevRef.current = resultCount
68
+ return () => controls.stop()
69
+ }, [resultCount, reduceMotion])
70
+
71
+ return (
72
+ <Drawer open={open} onOpenChange={onOpenChange}>
73
+ {/*
74
+ No height override. `DrawerContent` already ships `h-auto` with a `max-h-[80vh]` cap for
75
+ a bottom drawer, which is exactly the behaviour wanted: grow to the filters, stop before
76
+ swallowing the screen. Forcing `h-[88vh]` made it that tall whatever it held, so a short
77
+ filter set left a half-screen of blank between the last checkbox and the footer.
78
+
79
+ `min-h-0` on the scroller because a flex child defaults to `min-height: auto` — it
80
+ refuses to shrink below its content, so without it a tall filter set pushes the footer
81
+ off the bottom instead of scrolling inside.
82
+ */}
83
+ <DrawerContent>
84
+ <DrawerHeader>
85
+ <DrawerTitle>{filtersLabel}</DrawerTitle>
86
+ </DrawerHeader>
87
+ <div className="min-h-0 flex-1 overflow-y-auto px-4">
88
+ <CategoryFilter4Fields
89
+ filters={filters}
90
+ onChange={onChange}
91
+ countWith={countWith}
92
+ facets={facets}
93
+ colourNames={colourNames}
94
+ modelTypeLabel={modelTypeLabel}
95
+ rangeLabel={rangeLabel}
96
+ frameSizeLabel={frameSizeLabel}
97
+ colourLabel={colourLabel}
98
+ featuresLabel={featuresLabel}
99
+ />
100
+ </div>
101
+ <div className="flex items-center gap-2 border-t p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
102
+ <Button variant="ghost" onClick={() => onChange(empty)}>
103
+ {clearAllLabel}
104
+ </Button>
105
+ <Button
106
+ className="flex-1"
107
+ disabled={resultCount === 0}
108
+ aria-describedby={resultCount === 0 ? 'category-filter-4-zero-reason' : undefined}
109
+ onClick={() => onOpenChange(false)}
110
+ >
111
+ {resultCount === 0 ? noResultsLabel : showResultsLabel(displayCount)}
112
+ </Button>
113
+ </div>
114
+ {resultCount === 0 ? (
115
+ <p id="category-filter-4-zero-reason" className="text-destructive px-4 pb-2 text-xs">
116
+ {noResultsDescription}
117
+ </p>
118
+ ) : null}
119
+ </DrawerContent>
120
+ </Drawer>
121
+ )
122
+ }
123
+
124
+ export default CategoryFilter4Drawer
@@ -0,0 +1,170 @@
1
+ 'use client'
2
+
3
+ import { Label } from '../../../ui/label'
4
+ import { CheckboxCheck } from '../checkout-forms/checkbox-check'
5
+ import type {
6
+ CategoryFilter4Facets,
7
+ CategoryFilter4FilterState,
8
+ } from './category-filter-4'
9
+
10
+ interface CategoryFilter4FieldsProps {
11
+ filters: CategoryFilter4FilterState
12
+ onChange: (next: CategoryFilter4FilterState) => void
13
+ countWith: (partial: Partial<CategoryFilter4FilterState>) => number
14
+ facets: CategoryFilter4Facets
15
+ colourNames: Record<string, string>
16
+ modelTypeLabel?: string
17
+ rangeLabel?: string
18
+ frameSizeLabel?: string
19
+ colourLabel?: string
20
+ featuresLabel?: string
21
+ }
22
+
23
+ export function CategoryFilter4Fields({
24
+ filters,
25
+ onChange,
26
+ countWith,
27
+ facets,
28
+ colourNames,
29
+ modelTypeLabel = 'Tipo de modelo',
30
+ rangeLabel = 'Autonomía',
31
+ frameSizeLabel = 'Talla de cuadro',
32
+ colourLabel = 'Color',
33
+ featuresLabel = 'Características',
34
+ }: CategoryFilter4FieldsProps) {
35
+ function toggle<K extends 'modelTypes' | 'rangeBands' | 'frameSizes' | 'colourways' | 'features'>(
36
+ key: K,
37
+ value: string
38
+ ) {
39
+ const list = filters[key] as string[]
40
+ const next = list.includes(value) ? list.filter(v => v !== value) : [...list, value]
41
+ onChange({ ...filters, [key]: next })
42
+ }
43
+
44
+ const colourways = Object.keys(colourNames)
45
+
46
+ return (
47
+ <div className="flex flex-col gap-6">
48
+ <fieldset>
49
+ <legend className="mb-2 text-sm font-medium">{modelTypeLabel}</legend>
50
+ <div className="flex flex-col gap-2">
51
+ {facets.modelTypes.map(option => {
52
+ const count = countWith({
53
+ modelTypes: filters.modelTypes.includes(option.value)
54
+ ? filters.modelTypes
55
+ : [...filters.modelTypes, option.value],
56
+ })
57
+ const zero = count === 0 && !filters.modelTypes.includes(option.value)
58
+ return (
59
+ <div key={option.value} className="flex items-center justify-between">
60
+ <div className="flex items-center gap-2">
61
+ <CheckboxCheck
62
+ id={`model-${option.value}`}
63
+ checked={filters.modelTypes.includes(option.value)}
64
+ disabled={zero}
65
+ onCheckedChange={() => toggle('modelTypes', option.value)}
66
+ />
67
+ <Label htmlFor={`model-${option.value}`} className="font-normal">
68
+ {option.label}
69
+ </Label>
70
+ </div>
71
+ <span className="text-muted-foreground text-xs tabular-nums">{count}</span>
72
+ </div>
73
+ )
74
+ })}
75
+ </div>
76
+ </fieldset>
77
+
78
+ <fieldset>
79
+ <legend className="mb-2 text-sm font-medium">{rangeLabel}</legend>
80
+ <div className="flex flex-col gap-2">
81
+ {facets.rangeBands.map(band => {
82
+ const count = countWith({
83
+ rangeBands: filters.rangeBands.includes(band.value)
84
+ ? filters.rangeBands
85
+ : [...filters.rangeBands, band.value],
86
+ })
87
+ const zero = count === 0 && !filters.rangeBands.includes(band.value)
88
+ return (
89
+ <div key={band.value} className="flex items-center justify-between">
90
+ <div className="flex items-center gap-2">
91
+ <CheckboxCheck
92
+ id={`range-${band.value}`}
93
+ checked={filters.rangeBands.includes(band.value)}
94
+ disabled={zero}
95
+ onCheckedChange={() => toggle('rangeBands', band.value)}
96
+ />
97
+ <Label htmlFor={`range-${band.value}`} className="font-normal">
98
+ {band.label}
99
+ </Label>
100
+ </div>
101
+ <span className="text-muted-foreground text-xs tabular-nums">{count}</span>
102
+ </div>
103
+ )
104
+ })}
105
+ </div>
106
+ </fieldset>
107
+
108
+ <fieldset>
109
+ <legend className="mb-2 text-sm font-medium">{frameSizeLabel}</legend>
110
+ <div className="flex flex-wrap gap-2">
111
+ {facets.frameSizes.map(size => {
112
+ const active = filters.frameSizes.includes(size)
113
+ return (
114
+ <button
115
+ key={size}
116
+ type="button"
117
+ aria-pressed={active}
118
+ onClick={() => toggle('frameSizes', size)}
119
+ className="focus-visible:ring-ring/50 min-h-11 min-w-11 rounded-full border px-3 text-sm font-medium transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground border-input hover:bg-accent/50"
120
+ >
121
+ {size}
122
+ </button>
123
+ )
124
+ })}
125
+ </div>
126
+ </fieldset>
127
+
128
+ <fieldset>
129
+ <legend className="mb-2 text-sm font-medium">{colourLabel}</legend>
130
+ <div className="flex flex-wrap gap-2">
131
+ {colourways.map(colour => {
132
+ const active = filters.colourways.includes(colour)
133
+ return (
134
+ <button
135
+ key={colour}
136
+ type="button"
137
+ aria-pressed={active}
138
+ onClick={() => toggle('colourways', colour)}
139
+ className="focus-visible:ring-ring/50 flex min-h-11 items-center gap-1.5 rounded-full border px-3 text-sm transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none aria-pressed:border-primary aria-pressed:bg-primary/10 border-input hover:bg-accent/50"
140
+ >
141
+ <span aria-hidden="true" className="bg-muted-foreground/30 size-3 rounded-full" />
142
+ {colourNames[colour]}
143
+ </button>
144
+ )
145
+ })}
146
+ </div>
147
+ </fieldset>
148
+
149
+ <fieldset>
150
+ <legend className="mb-2 text-sm font-medium">{featuresLabel}</legend>
151
+ <div className="flex flex-col gap-2">
152
+ {facets.featureOptions.map(option => (
153
+ <div key={option.value} className="flex items-center gap-2">
154
+ <CheckboxCheck
155
+ id={`feature-${option.value}`}
156
+ checked={filters.features.includes(option.value)}
157
+ onCheckedChange={() => toggle('features', option.value)}
158
+ />
159
+ <Label htmlFor={`feature-${option.value}`} className="font-normal">
160
+ {option.label}
161
+ </Label>
162
+ </div>
163
+ ))}
164
+ </div>
165
+ </fieldset>
166
+ </div>
167
+ )
168
+ }
169
+
170
+ export default CategoryFilter4Fields