@thorprovider/blocks 0.1.1 → 0.1.2
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.
- package/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +32 -5
- package/AGENTS.md +1 -1
- package/README.md +52 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1111 -90
- package/dist/contracts.d.ts +1111 -90
- package/dist/contracts.js +189 -6
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +173 -7
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +467 -89
- package/dist/index.d.ts +467 -89
- package/dist/index.js +2967 -2250
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2989 -2282
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +2 -2
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
- package/src/index.ts +38 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +182 -4
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/tsup.config.ts +0 -1
- package/vitest.config.ts +9 -1
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react'
|
|
4
|
-
import { SlidersHorizontal } from 'lucide-react'
|
|
3
|
+
import { useEffect, useId, useState, type ReactNode } from 'react'
|
|
4
|
+
import { Search, SlidersHorizontal, X } from 'lucide-react'
|
|
5
5
|
|
|
6
|
+
import { Badge } from '../../../ui/badge'
|
|
6
7
|
import { Button } from '../../../ui/button'
|
|
8
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '../../../ui/empty'
|
|
9
|
+
import { Input } from '../../../ui/input'
|
|
7
10
|
import {
|
|
8
11
|
Pagination,
|
|
9
12
|
PaginationContent,
|
|
10
13
|
PaginationEllipsis,
|
|
11
14
|
PaginationItem,
|
|
12
|
-
PaginationLink,
|
|
13
15
|
} from '../../../ui/pagination'
|
|
14
16
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
|
|
15
|
-
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../../../ui/sheet'
|
|
17
|
+
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../../ui/sheet'
|
|
18
|
+
import { Skeleton } from '../../../ui/skeleton'
|
|
16
19
|
import { cn } from '../../../lib/utils'
|
|
17
20
|
|
|
18
21
|
import { CategoryFilter2FilterPanel } from './category-filter-2-filter-panel'
|
|
@@ -21,7 +24,15 @@ export interface FilterProduct {
|
|
|
21
24
|
id: string
|
|
22
25
|
name: string
|
|
23
26
|
price: string
|
|
24
|
-
|
|
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
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
export interface CategoryFilter2Category {
|
|
@@ -29,6 +40,11 @@ export interface CategoryFilter2Category {
|
|
|
29
40
|
label: string
|
|
30
41
|
}
|
|
31
42
|
|
|
43
|
+
export interface CategoryFilter2Collection {
|
|
44
|
+
id: string
|
|
45
|
+
label: string
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
export interface CategoryFilter2Color {
|
|
33
49
|
id: string
|
|
34
50
|
color: string
|
|
@@ -47,20 +63,108 @@ export interface CategoryFilter2SortOption {
|
|
|
47
63
|
|
|
48
64
|
export interface CategoryFilter2Filters {
|
|
49
65
|
categories: string[]
|
|
66
|
+
/** Collections requested by the `category-filters` feature (E2). */
|
|
67
|
+
collections?: string[]
|
|
50
68
|
priceRange: number[]
|
|
51
69
|
size: string | null
|
|
52
70
|
color: string | null
|
|
53
71
|
}
|
|
54
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
|
+
|
|
55
131
|
export interface CategoryFilter2Props {
|
|
56
132
|
products: FilterProduct[]
|
|
57
133
|
categories?: CategoryFilter2Category[]
|
|
134
|
+
collections?: CategoryFilter2Collection[]
|
|
58
135
|
sizes?: string[]
|
|
59
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
|
+
*/
|
|
60
142
|
priceRange?: CategoryFilter2PriceRange
|
|
61
143
|
viewOptions?: (number | string)[]
|
|
144
|
+
/** Adapter-advertised sort options only. When absent/empty no sort control is rendered. */
|
|
62
145
|
sortOptions?: CategoryFilter2SortOption[]
|
|
63
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
|
+
|
|
64
168
|
filtersTitle?: string
|
|
65
169
|
selectedText?: string
|
|
66
170
|
clearAllLabel?: string
|
|
@@ -68,6 +172,7 @@ export interface CategoryFilter2Props {
|
|
|
68
172
|
minLabel?: string
|
|
69
173
|
maxLabel?: string
|
|
70
174
|
categoriesTitle?: string
|
|
175
|
+
collectionsTitle?: string
|
|
71
176
|
sizesTitle?: string
|
|
72
177
|
colorsTitle?: string
|
|
73
178
|
viewLabel?: string
|
|
@@ -75,30 +180,327 @@ export interface CategoryFilter2Props {
|
|
|
75
180
|
sortPlaceholder?: string
|
|
76
181
|
productsTitle?: string
|
|
77
182
|
currentLabel?: string
|
|
183
|
+
searchLabel?: string
|
|
184
|
+
searchPlaceholder?: string
|
|
78
185
|
onProductClick?: (product: FilterProduct) => void
|
|
79
186
|
onPageChange?: (page: number | string) => void
|
|
80
187
|
onViewChange?: (view: number | string) => void
|
|
188
|
+
onPageSizeChange?: (pageSize: number | string) => void
|
|
81
189
|
onSortChange?: (sort: string) => void
|
|
82
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
|
|
83
224
|
}
|
|
84
225
|
|
|
85
226
|
const DEFAULT_VIEW_OPTIONS: (number | string)[] = [10, 25, 50, 100]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|
|
92
482
|
|
|
93
483
|
export function CategoryFilter2({
|
|
94
484
|
products,
|
|
95
485
|
categories = [],
|
|
486
|
+
collections = [],
|
|
96
487
|
sizes = [],
|
|
97
488
|
colors = [],
|
|
98
|
-
priceRange: priceRangeConfig
|
|
489
|
+
priceRange: priceRangeConfig,
|
|
99
490
|
viewOptions = DEFAULT_VIEW_OPTIONS,
|
|
100
|
-
sortOptions =
|
|
491
|
+
sortOptions = [],
|
|
101
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,
|
|
102
504
|
filtersTitle = 'Filtros',
|
|
103
505
|
selectedText = 'Filtros',
|
|
104
506
|
clearAllLabel = 'Restablecer',
|
|
@@ -106,6 +508,7 @@ export function CategoryFilter2({
|
|
|
106
508
|
minLabel = 'Mínimo',
|
|
107
509
|
maxLabel = 'Máximo',
|
|
108
510
|
categoriesTitle = 'Categoría',
|
|
511
|
+
collectionsTitle = 'Colección',
|
|
109
512
|
sizesTitle = 'Tamaño',
|
|
110
513
|
colorsTitle = 'Color',
|
|
111
514
|
viewLabel = 'Ver',
|
|
@@ -113,21 +516,75 @@ export function CategoryFilter2({
|
|
|
113
516
|
sortPlaceholder = 'Ordenar por',
|
|
114
517
|
productsTitle = 'Productos',
|
|
115
518
|
currentLabel = '(actual)',
|
|
519
|
+
searchLabel = 'Buscar',
|
|
520
|
+
searchPlaceholder = 'Buscar',
|
|
116
521
|
onProductClick,
|
|
117
522
|
onPageChange,
|
|
118
523
|
onViewChange,
|
|
524
|
+
onPageSizeChange,
|
|
119
525
|
onSortChange,
|
|
120
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,
|
|
121
547
|
}: CategoryFilter2Props) {
|
|
122
|
-
const
|
|
123
|
-
const [
|
|
124
|
-
const [
|
|
125
|
-
const [
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const [
|
|
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)
|
|
129
567
|
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
|
130
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
|
+
|
|
131
588
|
// Close sheet when screen size becomes large using CSS media query
|
|
132
589
|
useEffect(() => {
|
|
133
590
|
const mediaQuery = window.matchMedia('(min-width: 1024px)')
|
|
@@ -142,71 +599,110 @@ export function CategoryFilter2({
|
|
|
142
599
|
return () => mediaQuery.removeEventListener('change', handleChange)
|
|
143
600
|
}, [])
|
|
144
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
|
+
|
|
145
615
|
const handleCategoryChange = (category: string, checked: boolean) => {
|
|
146
|
-
const next =
|
|
147
|
-
|
|
148
|
-
|
|
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 })
|
|
149
625
|
}
|
|
150
626
|
|
|
151
627
|
const handlePriceChange = (next: number[]) => {
|
|
152
|
-
|
|
153
|
-
|
|
628
|
+
setInternalPriceRange(next)
|
|
629
|
+
emitFilters({ priceRange: next })
|
|
154
630
|
}
|
|
155
631
|
|
|
156
632
|
const handleSizeChange = (size: string | null) => {
|
|
157
|
-
|
|
158
|
-
|
|
633
|
+
setInternalSize(size)
|
|
634
|
+
emitFilters({ size })
|
|
159
635
|
}
|
|
160
636
|
|
|
161
637
|
const handleColorChange = (color: string | null) => {
|
|
162
|
-
|
|
163
|
-
|
|
638
|
+
setInternalColor(color)
|
|
639
|
+
emitFilters({ color })
|
|
164
640
|
}
|
|
165
641
|
|
|
166
642
|
const handleClearAll = () => {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
)
|
|
172
658
|
}
|
|
173
659
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
onViewChange?.(
|
|
660
|
+
const handlePageSizeChange = (size: number | string) => {
|
|
661
|
+
setInternalView(size)
|
|
662
|
+
;(onPageSizeChange ?? onViewChange)?.(size)
|
|
177
663
|
}
|
|
178
664
|
|
|
179
665
|
const handleSortChange = (sort: string) => {
|
|
180
|
-
|
|
666
|
+
setInternalSort(sort)
|
|
181
667
|
onSortChange?.(sort)
|
|
182
668
|
}
|
|
183
669
|
|
|
184
670
|
const handlePageChange = (page: number | string) => {
|
|
185
671
|
if (page === '...') return
|
|
186
|
-
|
|
672
|
+
setInternalPage(page)
|
|
187
673
|
onPageChange?.(page)
|
|
188
674
|
}
|
|
189
675
|
|
|
676
|
+
const handleSearchChange = (text: string) => {
|
|
677
|
+
if (searchValue === undefined) setInternalSearch(text)
|
|
678
|
+
else setDraftSearch(text)
|
|
679
|
+
onSearchChange?.(text)
|
|
680
|
+
}
|
|
681
|
+
|
|
190
682
|
const filterPanelProps = {
|
|
191
683
|
selectedText,
|
|
192
684
|
clearAllLabel,
|
|
193
685
|
priceRangeTitle,
|
|
194
|
-
priceRange,
|
|
195
|
-
priceRangeConfig,
|
|
686
|
+
priceRange: priceRangeValue,
|
|
687
|
+
priceRangeConfig: showPrice ? priceRangeConfig : undefined,
|
|
196
688
|
minLabel,
|
|
197
689
|
maxLabel,
|
|
690
|
+
onPriceChange: handlePriceChange,
|
|
198
691
|
categoriesTitle,
|
|
199
692
|
categories,
|
|
200
|
-
selectedCategories,
|
|
693
|
+
selectedCategories: categoriesValue,
|
|
201
694
|
onCategoryChange: handleCategoryChange,
|
|
202
|
-
|
|
695
|
+
collectionsTitle,
|
|
696
|
+
collections,
|
|
697
|
+
selectedCollections: collectionsValue,
|
|
698
|
+
onCollectionChange: handleCollectionChange,
|
|
203
699
|
sizesTitle,
|
|
204
700
|
sizes,
|
|
205
|
-
selectedSize,
|
|
701
|
+
selectedSize: internalSize,
|
|
206
702
|
onSizeChange: handleSizeChange,
|
|
207
703
|
colorsTitle,
|
|
208
704
|
colors,
|
|
209
|
-
selectedColor,
|
|
705
|
+
selectedColor: internalColor,
|
|
210
706
|
onColorChange: handleColorChange,
|
|
211
707
|
onClearAll: handleClearAll,
|
|
212
708
|
}
|
|
@@ -214,128 +710,212 @@ export function CategoryFilter2({
|
|
|
214
710
|
return (
|
|
215
711
|
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
216
712
|
<div className="flex flex-col gap-8 lg:flex-row">
|
|
217
|
-
|
|
218
|
-
<
|
|
219
|
-
<div className="
|
|
220
|
-
<
|
|
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>
|
|
221
719
|
</div>
|
|
222
|
-
</
|
|
223
|
-
|
|
720
|
+
</aside>
|
|
721
|
+
) : null}
|
|
224
722
|
|
|
225
723
|
<main className="flex min-w-0 flex-1 flex-col gap-6">
|
|
226
724
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
227
725
|
<div className="flex items-center gap-4">
|
|
228
|
-
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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>
|
|
245
746
|
</div>
|
|
246
|
-
</
|
|
247
|
-
</
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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} />
|
|
257
796
|
</SelectTrigger>
|
|
258
797
|
<SelectContent>
|
|
259
|
-
{
|
|
260
|
-
<SelectItem key={option} value={
|
|
261
|
-
{option}
|
|
798
|
+
{sortOptions.map(option => (
|
|
799
|
+
<SelectItem key={option.value} value={option.value}>
|
|
800
|
+
{option.label}
|
|
262
801
|
</SelectItem>
|
|
263
802
|
))}
|
|
264
803
|
</SelectContent>
|
|
265
804
|
</Select>
|
|
266
805
|
</div>
|
|
267
|
-
|
|
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>
|
|
806
|
+
) : null}
|
|
282
807
|
</div>
|
|
283
808
|
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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}
|
|
324
877
|
>
|
|
325
|
-
{
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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}
|
|
335
915
|
</main>
|
|
336
916
|
</div>
|
|
337
917
|
</div>
|
|
338
918
|
)
|
|
339
919
|
}
|
|
340
920
|
|
|
341
|
-
export default CategoryFilter2
|
|
921
|
+
export default CategoryFilter2
|