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