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