@thorprovider/blocks 0.1.0 → 0.1.2

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