@thorprovider/blocks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/.turbo/turbo-test.log +10 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/README.md +161 -0
  6. package/components.json +33 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/contracts.d.mts +563 -0
  9. package/dist/contracts.d.ts +563 -0
  10. package/dist/contracts.js +207 -0
  11. package/dist/contracts.js.map +1 -0
  12. package/dist/contracts.mjs +168 -0
  13. package/dist/contracts.mjs.map +1 -0
  14. package/dist/index.d.mts +2408 -0
  15. package/dist/index.d.ts +2408 -0
  16. package/dist/index.js +16422 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.mjs +16352 -0
  19. package/dist/index.mjs.map +1 -0
  20. package/docs/standards/data-contracts.md +70 -0
  21. package/docs/standards/theming.md +85 -0
  22. package/package.json +62 -0
  23. package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
  24. package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
  25. package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
  26. package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
  27. package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
  28. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
  29. package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
  30. package/src/blocks/application/verification/verify-1.tsx +307 -0
  31. package/src/blocks/application/verification/verify-confetti.tsx +192 -0
  32. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
  33. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
  34. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
  35. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
  36. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
  37. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
  38. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
  39. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
  40. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
  41. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
  42. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
  43. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
  44. package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
  45. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
  46. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
  47. package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
  48. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
  49. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
  50. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
  51. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
  52. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
  53. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
  54. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
  55. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
  56. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
  57. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
  58. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
  59. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
  60. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
  61. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
  62. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
  63. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
  67. package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
  68. package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
  69. package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
  70. package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
  71. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
  72. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
  73. package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
  74. package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
  75. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
  76. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
  77. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
  78. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
  79. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
  80. package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
  81. package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
  82. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
  83. package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
  84. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
  85. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
  86. package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
  87. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
  88. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
  89. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
  90. package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
  91. package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
  92. package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
  93. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
  94. package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
  95. package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
  96. package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
  97. package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
  98. package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
  99. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
  105. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
  106. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
  107. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
  108. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
  109. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
  110. package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
  111. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
  112. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
  113. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
  114. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
  115. package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
  116. package/src/blocks/marketing/banners/banner-1.tsx +188 -0
  117. package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
  118. package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
  119. package/src/blocks/marketing/features/feature-1.tsx +97 -0
  120. package/src/blocks/marketing/features/feature-2.tsx +82 -0
  121. package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
  122. package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
  123. package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
  124. package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
  125. package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
  126. package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
  127. package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
  128. package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
  129. package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
  130. package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
  131. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
  132. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
  133. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
  134. package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
  135. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
  136. package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
  137. package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
  138. package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
  139. package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
  140. package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
  141. package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
  142. package/src/blocks/shared/reaction-toggle.tsx +145 -0
  143. package/src/hooks/use-mobile.ts +19 -0
  144. package/src/index.ts +200 -0
  145. package/src/lib/contracts.test.ts +32 -0
  146. package/src/lib/contracts.ts +235 -0
  147. package/src/lib/types/menu.ts +43 -0
  148. package/src/lib/types.ts +4 -0
  149. package/src/lib/utils.ts +10 -0
  150. package/src/ui/accordion.tsx +65 -0
  151. package/src/ui/avatar.tsx +108 -0
  152. package/src/ui/badge.tsx +47 -0
  153. package/src/ui/breadcrumb.tsx +108 -0
  154. package/src/ui/button-group.tsx +83 -0
  155. package/src/ui/button.tsx +63 -0
  156. package/src/ui/card.tsx +91 -0
  157. package/src/ui/carousel.tsx +241 -0
  158. package/src/ui/dialog.tsx +158 -0
  159. package/src/ui/drawer.tsx +134 -0
  160. package/src/ui/dropdown-menu.tsx +256 -0
  161. package/src/ui/empty.tsx +103 -0
  162. package/src/ui/field.tsx +248 -0
  163. package/src/ui/input-group.tsx +170 -0
  164. package/src/ui/input-otp.tsx +76 -0
  165. package/src/ui/input.tsx +20 -0
  166. package/src/ui/label.tsx +23 -0
  167. package/src/ui/navigation-menu.tsx +167 -0
  168. package/src/ui/pagination.tsx +127 -0
  169. package/src/ui/progress.tsx +30 -0
  170. package/src/ui/radio-group.tsx +44 -0
  171. package/src/ui/select.tsx +189 -0
  172. package/src/ui/separator.tsx +27 -0
  173. package/src/ui/sheet.tsx +142 -0
  174. package/src/ui/sidebar.tsx +726 -0
  175. package/src/ui/skeleton.tsx +13 -0
  176. package/src/ui/table.tsx +115 -0
  177. package/src/ui/tabs.tsx +90 -0
  178. package/src/ui/textarea.tsx +17 -0
  179. package/src/ui/toggle-group.tsx +83 -0
  180. package/src/ui/toggle.tsx +46 -0
  181. package/src/ui/tooltip.tsx +56 -0
  182. package/tsconfig.json +20 -0
  183. package/tsup.config.ts +16 -0
  184. package/vitest.config.ts +9 -0
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import type {
4
+ CategoryFilter4Facets,
5
+ CategoryFilter4FilterState,
6
+ } from './category-filter-4'
7
+ import { CategoryFilter4Fields } from './category-filter-4-fields'
8
+
9
+ interface CategoryFilter4SidebarProps {
10
+ filters: CategoryFilter4FilterState
11
+ onChange: (next: CategoryFilter4FilterState) => void
12
+ countWith: (partial: Partial<CategoryFilter4FilterState>) => number
13
+ facets: CategoryFilter4Facets
14
+ colourNames: Record<string, string>
15
+ modelTypeLabel?: string
16
+ rangeLabel?: string
17
+ frameSizeLabel?: string
18
+ colourLabel?: string
19
+ featuresLabel?: string
20
+ }
21
+
22
+ export function CategoryFilter4Sidebar({
23
+ filters,
24
+ onChange,
25
+ countWith,
26
+ facets,
27
+ colourNames,
28
+ modelTypeLabel,
29
+ rangeLabel,
30
+ frameSizeLabel,
31
+ colourLabel,
32
+ featuresLabel,
33
+ }: CategoryFilter4SidebarProps) {
34
+ return (
35
+ <aside className="w-70 shrink-0">
36
+ <CategoryFilter4Fields
37
+ filters={filters}
38
+ onChange={onChange}
39
+ countWith={countWith}
40
+ facets={facets}
41
+ colourNames={colourNames}
42
+ modelTypeLabel={modelTypeLabel}
43
+ rangeLabel={rangeLabel}
44
+ frameSizeLabel={frameSizeLabel}
45
+ colourLabel={colourLabel}
46
+ featuresLabel={featuresLabel}
47
+ />
48
+ </aside>
49
+ )
50
+ }
51
+
52
+ export default CategoryFilter4Sidebar
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+
3
+ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '../../../ui/drawer'
4
+ import { Label } from '../../../ui/label'
5
+ import { RadioGroup, RadioGroupItem } from '../../../ui/radio-group'
6
+ import type { CategoryFilter4SortOption } from './category-filter-4'
7
+
8
+ interface CategoryFilter4SortSheetProps {
9
+ open: boolean
10
+ onOpenChange: (open: boolean) => void
11
+ sort: string
12
+ onSortChange: (value: string) => void
13
+ sortOptions: CategoryFilter4SortOption[]
14
+ sortByLabel?: string
15
+ }
16
+
17
+ export function CategoryFilter4SortSheet({
18
+ open,
19
+ onOpenChange,
20
+ sort,
21
+ onSortChange,
22
+ sortOptions,
23
+ sortByLabel = 'Ordenar por',
24
+ }: CategoryFilter4SortSheetProps) {
25
+ return (
26
+ <Drawer open={open} onOpenChange={onOpenChange}>
27
+ <DrawerContent>
28
+ <DrawerHeader>
29
+ <DrawerTitle>{sortByLabel}</DrawerTitle>
30
+ </DrawerHeader>
31
+ <div className="px-4 pb-6">
32
+ <RadioGroup
33
+ value={sort}
34
+ onValueChange={value => {
35
+ if (typeof value === 'string') {
36
+ onSortChange(value)
37
+ onOpenChange(false)
38
+ }
39
+ }}
40
+ className="gap-3"
41
+ >
42
+ {sortOptions.map(option => (
43
+ <div key={option.value} className="flex items-center gap-2">
44
+ <RadioGroupItem value={option.value} id={`sort-${option.value}`} />
45
+ <Label htmlFor={`sort-${option.value}`} className="min-h-11 font-normal">
46
+ {option.label}
47
+ </Label>
48
+ </div>
49
+ ))}
50
+ </RadioGroup>
51
+ </div>
52
+ </DrawerContent>
53
+ </Drawer>
54
+ )
55
+ }
56
+
57
+ export default CategoryFilter4SortSheet
@@ -0,0 +1,68 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import type {
5
+ CategoryFilter4Facets,
6
+ CategoryFilter4FilterState,
7
+ CategoryFilter4Product,
8
+ CategoryFilter4RangeBand,
9
+ } from './category-filter-4'
10
+
11
+ export function emptyFilters(maxPrice: number): CategoryFilter4FilterState {
12
+ return {
13
+ modelTypes: [],
14
+ priceMax: maxPrice,
15
+ rangeBands: [],
16
+ frameSizes: [],
17
+ colourways: [],
18
+ features: [],
19
+ }
20
+ }
21
+
22
+ function matchesFilters(
23
+ product: CategoryFilter4Product,
24
+ filters: CategoryFilter4FilterState,
25
+ rangeBands: CategoryFilter4RangeBand[]
26
+ ): boolean {
27
+ if (filters.modelTypes.length > 0 && !filters.modelTypes.includes(product.modelType)) return false
28
+ if (product.price > filters.priceMax) return false
29
+ if (filters.rangeBands.length > 0) {
30
+ const inBand = filters.rangeBands.some(value => {
31
+ const band = rangeBands.find(b => b.value === value)
32
+ return band && product.rangeKm >= band.min && product.rangeKm < band.max
33
+ })
34
+ if (!inBand) return false
35
+ }
36
+ if (filters.frameSizes.length > 0 && !filters.frameSizes.includes(product.frameSize)) return false
37
+ if (filters.colourways.length > 0 && !filters.colourways.includes(product.colourway)) return false
38
+ if (filters.features.length > 0 && !filters.features.every(f => product.features.includes(f))) return false
39
+ return true
40
+ }
41
+
42
+ /** The shared filter hook — counts are derived from the catalogue by this
43
+ * predicate, never typed, so an option count can never disagree with the grid. */
44
+ export function useCategoryFilter4State(
45
+ products: CategoryFilter4Product[],
46
+ facets: CategoryFilter4Facets,
47
+ maxPrice: number
48
+ ) {
49
+ const [filters, setFilters] = useState<CategoryFilter4FilterState>(() => emptyFilters(maxPrice))
50
+ const [sort, setSort] = useState(() => facets.sortOptions[0]?.value ?? 'newest')
51
+
52
+ const filtered = useMemo(() => {
53
+ const matches = products.filter(product => matchesFilters(product, filters, facets.rangeBands))
54
+ const sorted = [...matches]
55
+ if (sort === 'price-asc') sorted.sort((a, b) => a.price - b.price)
56
+ if (sort === 'price-desc') sorted.sort((a, b) => b.price - a.price)
57
+ if (sort === 'range') sorted.sort((a, b) => b.rangeKm - a.rangeKm)
58
+ if (sort === 'rating') sorted.sort((a, b) => b.rating - a.rating)
59
+ return sorted
60
+ }, [products, filters, sort, facets.rangeBands])
61
+
62
+ function countWith(partial: Partial<CategoryFilter4FilterState>): number {
63
+ const next = { ...filters, ...partial }
64
+ return products.filter(product => matchesFilters(product, next, facets.rangeBands)).length
65
+ }
66
+
67
+ return { filters, setFilters, sort, setSort, filtered, countWith }
68
+ }
@@ -0,0 +1,298 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { SlidersHorizontal } from 'lucide-react'
5
+
6
+ import { Badge } from '../../../ui/badge'
7
+ import { Button } from '../../../ui/button'
8
+ import {
9
+ Empty,
10
+ EmptyContent,
11
+ EmptyDescription,
12
+ EmptyHeader,
13
+ EmptyTitle,
14
+ } from '../../../ui/empty'
15
+
16
+ import { CategoryFilter4ChipRail } from './category-filter-4-chip-rail'
17
+ import { CategoryFilter4Drawer } from './category-filter-4-drawer'
18
+ import { CategoryFilter4Sidebar } from './category-filter-4-sidebar'
19
+ import { CategoryFilter4SortSheet } from './category-filter-4-sort-sheet'
20
+ import { emptyFilters, useCategoryFilter4State } from './category-filter-4-state'
21
+
22
+ export interface CategoryFilter4SortOption {
23
+ value: string
24
+ label: string
25
+ }
26
+
27
+ export interface CategoryFilter4FacetOption {
28
+ value: string
29
+ label: string
30
+ }
31
+
32
+ export interface CategoryFilter4RangeBand {
33
+ value: string
34
+ label: string
35
+ min: number
36
+ max: number
37
+ }
38
+
39
+ export interface CategoryFilter4Facets {
40
+ sortOptions: CategoryFilter4SortOption[]
41
+ frameSizes: string[]
42
+ modelTypes: CategoryFilter4FacetOption[]
43
+ rangeBands: CategoryFilter4RangeBand[]
44
+ featureOptions: CategoryFilter4FacetOption[]
45
+ }
46
+
47
+ export interface CategoryFilter4FilterState {
48
+ modelTypes: string[]
49
+ priceMax: number
50
+ rangeBands: string[]
51
+ frameSizes: string[]
52
+ colourways: string[]
53
+ features: string[]
54
+ }
55
+
56
+ export interface CategoryFilter4Product {
57
+ id: string
58
+ name: string
59
+ modelType: string
60
+ price: number
61
+ rangeKm: number
62
+ frameSize: string
63
+ colourway: string
64
+ features: string[]
65
+ rating: number
66
+ }
67
+
68
+ export interface CategoryFilter4Props {
69
+ products: CategoryFilter4Product[]
70
+ facets: CategoryFilter4Facets
71
+ colourNames: Record<string, string>
72
+ fallbackImage: string
73
+ maxPrice: number
74
+ filtersLabel?: string
75
+ sortLabel?: string
76
+ sortByLabel?: string
77
+ modelTypeLabel?: string
78
+ rangeLabel?: string
79
+ frameSizeLabel?: string
80
+ colourLabel?: string
81
+ featuresLabel?: string
82
+ resultsCountLabel?: (count: number) => string
83
+ clearAllLabel?: string
84
+ showResultsLabel?: (count: number) => string
85
+ noResultsLabel?: string
86
+ noResultsTitle?: string
87
+ noResultsDescription?: string
88
+ noResultsCtaLabel?: string
89
+ onFiltersChange?: (filters: CategoryFilter4FilterState) => void
90
+ onSortChange?: (sort: string) => void
91
+ onProductSelect?: (product: CategoryFilter4Product) => void
92
+ }
93
+
94
+ function activeFilterCount(
95
+ filters: CategoryFilter4FilterState,
96
+ empty: CategoryFilter4FilterState
97
+ ): number {
98
+ return (
99
+ filters.modelTypes.length +
100
+ filters.rangeBands.length +
101
+ filters.frameSizes.length +
102
+ filters.colourways.length +
103
+ filters.features.length +
104
+ (filters.priceMax < empty.priceMax ? 1 : 0)
105
+ )
106
+ }
107
+
108
+ export function CategoryFilter4({
109
+ products,
110
+ facets,
111
+ colourNames,
112
+ fallbackImage,
113
+ maxPrice,
114
+ filtersLabel = 'Filtros',
115
+ sortLabel = 'Ordenar',
116
+ sortByLabel = 'Ordenar por',
117
+ modelTypeLabel = 'Tipo de modelo',
118
+ rangeLabel = 'Autonomía',
119
+ frameSizeLabel = 'Talla de cuadro',
120
+ colourLabel = 'Color',
121
+ featuresLabel = 'Características',
122
+ resultsCountLabel = count => `${count} ${count === 1 ? 'producto' : 'productos'}`,
123
+ clearAllLabel = 'Borrar todos',
124
+ showResultsLabel = count => `Mostrar ${count} ${count === 1 ? 'producto' : 'productos'}`,
125
+ noResultsLabel = 'Sin coincidencias',
126
+ noResultsTitle = 'No hay productos que coincidan con estos filtros',
127
+ noResultsDescription = 'Prueba a relajar alguno de los filtros aplicados.',
128
+ noResultsCtaLabel = 'Borrar todos los filtros',
129
+ onFiltersChange,
130
+ onSortChange,
131
+ onProductSelect,
132
+ }: CategoryFilter4Props) {
133
+ const empty = emptyFilters(maxPrice)
134
+ const { filters, setFilters, sort, setSort, filtered, countWith } = useCategoryFilter4State(
135
+ products,
136
+ facets,
137
+ maxPrice
138
+ )
139
+ const [drawerOpen, setDrawerOpen] = useState(false)
140
+ const [sortOpen, setSortOpen] = useState(false)
141
+
142
+ const handleFiltersChange = (next: CategoryFilter4FilterState) => {
143
+ setFilters(next)
144
+ onFiltersChange?.(next)
145
+ }
146
+
147
+ const handleSortChange = (next: string) => {
148
+ setSort(next)
149
+ onSortChange?.(next)
150
+ }
151
+
152
+ const count = activeFilterCount(filters, empty)
153
+ const selectedSortLabel = facets.sortOptions.find(option => option.value === sort)?.label ?? sortByLabel
154
+
155
+ return (
156
+ <div className="w-full">
157
+ <div className="bg-background/95 sticky top-0 z-10 flex items-center justify-between gap-2 border-b px-4 py-2 backdrop-blur sm:px-6 lg:hidden">
158
+ <Button variant="outline" size="sm" onClick={() => setDrawerOpen(true)}>
159
+ <SlidersHorizontal className="size-3.5" />
160
+ {filtersLabel}
161
+ {count > 0 ? <Badge className="ml-1 size-4 justify-center rounded-full p-0 text-[10px]">{count}</Badge> : null}
162
+ </Button>
163
+ <Button variant="ghost" size="sm" onClick={() => setSortOpen(true)}>
164
+ {sortLabel}: {selectedSortLabel}
165
+ </Button>
166
+ <span aria-live="polite" className="text-muted-foreground text-xs tabular-nums">
167
+ {resultsCountLabel(filtered.length)}
168
+ </span>
169
+ </div>
170
+
171
+ <div className="px-4 py-2 sm:px-6 lg:hidden">
172
+ <CategoryFilter4ChipRail
173
+ filters={filters}
174
+ onChange={handleFiltersChange}
175
+ empty={empty}
176
+ clearAllLabel={clearAllLabel}
177
+ />
178
+ </div>
179
+
180
+ <div className="flex gap-8 px-4 py-6 sm:px-6">
181
+ <div className="hidden lg:block">
182
+ <CategoryFilter4Sidebar
183
+ filters={filters}
184
+ onChange={handleFiltersChange}
185
+ countWith={countWith}
186
+ facets={facets}
187
+ colourNames={colourNames}
188
+ modelTypeLabel={modelTypeLabel}
189
+ rangeLabel={rangeLabel}
190
+ frameSizeLabel={frameSizeLabel}
191
+ colourLabel={colourLabel}
192
+ featuresLabel={featuresLabel}
193
+ />
194
+ </div>
195
+
196
+ <div className="flex-1">
197
+ <div className="hidden items-center justify-between gap-2 lg:flex">
198
+ <CategoryFilter4ChipRail
199
+ filters={filters}
200
+ onChange={setFilters}
201
+ empty={empty}
202
+ clearAllLabel={clearAllLabel}
203
+ />
204
+ <span aria-live="polite" className="text-muted-foreground text-xs tabular-nums">
205
+ {resultsCountLabel(filtered.length)}
206
+ </span>
207
+ </div>
208
+
209
+ {filtered.length === 0 ? (
210
+ <Empty className="mt-6">
211
+ <EmptyHeader>
212
+ <EmptyTitle>{noResultsTitle}</EmptyTitle>
213
+ <EmptyDescription>{noResultsDescription}</EmptyDescription>
214
+ </EmptyHeader>
215
+ <EmptyContent>
216
+ <Button size="sm" onClick={() => handleFiltersChange(empty)}>
217
+ {noResultsCtaLabel}
218
+ </Button>
219
+ </EmptyContent>
220
+ </Empty>
221
+ ) : (
222
+ <div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3">
223
+ {filtered.map(product => {
224
+ const card = (
225
+ <>
226
+ {/*
227
+ An empty grey square told the reader nothing and made a filtered grid look
228
+ broken rather than filtered. The alt is empty on purpose: the name and price
229
+ directly beneath already carry everything the picture would say.
230
+ */}
231
+ <img
232
+ src={fallbackImage || undefined}
233
+ alt=""
234
+ loading="lazy"
235
+ decoding="async"
236
+ className="bg-muted aspect-square w-full rounded-lg object-cover"
237
+ />
238
+ <p className="text-sm font-medium">{product.name}</p>
239
+ <p className="text-muted-foreground text-xs tabular-nums">
240
+ ${product.price.toLocaleString()} · {product.rangeKm} km
241
+ </p>
242
+ </>
243
+ )
244
+
245
+ return onProductSelect ? (
246
+ <button
247
+ key={product.id}
248
+ type="button"
249
+ onClick={() => onProductSelect(product)}
250
+ className="flex cursor-pointer flex-col gap-1.5 text-left"
251
+ >
252
+ {card}
253
+ </button>
254
+ ) : (
255
+ <div key={product.id} className="flex flex-col gap-1.5">
256
+ {card}
257
+ </div>
258
+ )
259
+ })}
260
+ </div>
261
+ )}
262
+ </div>
263
+ </div>
264
+
265
+ <CategoryFilter4Drawer
266
+ open={drawerOpen}
267
+ onOpenChange={setDrawerOpen}
268
+ filters={filters}
269
+ onChange={handleFiltersChange}
270
+ countWith={countWith}
271
+ resultCount={filtered.length}
272
+ empty={empty}
273
+ facets={facets}
274
+ colourNames={colourNames}
275
+ filtersLabel={filtersLabel}
276
+ modelTypeLabel={modelTypeLabel}
277
+ rangeLabel={rangeLabel}
278
+ frameSizeLabel={frameSizeLabel}
279
+ colourLabel={colourLabel}
280
+ featuresLabel={featuresLabel}
281
+ clearAllLabel={clearAllLabel}
282
+ showResultsLabel={showResultsLabel}
283
+ noResultsLabel={noResultsLabel}
284
+ noResultsDescription={noResultsDescription}
285
+ />
286
+ <CategoryFilter4SortSheet
287
+ open={sortOpen}
288
+ onOpenChange={setSortOpen}
289
+ sort={sort}
290
+ onSortChange={handleSortChange}
291
+ sortOptions={facets.sortOptions}
292
+ sortByLabel={sortByLabel}
293
+ />
294
+ </div>
295
+ )
296
+ }
297
+
298
+ export default CategoryFilter4
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatCardNumber, formatExpiryDate } from './card-formatting'
3
+
4
+ describe('formatCardNumber', () => {
5
+ it('strips non-digit characters', () => {
6
+ expect(formatCardNumber('4242 4242 4242 4242')).toBe('4242 4242 4242 4242')
7
+ })
8
+
9
+ it('groups digits in blocks of four', () => {
10
+ expect(formatCardNumber('4242424242424242')).toBe('4242 4242 4242 4242')
11
+ })
12
+
13
+ it('caps at sixteen digits', () => {
14
+ expect(formatCardNumber('42424242424242421234')).toBe('4242 4242 4242 4242')
15
+ })
16
+
17
+ it('does not add a trailing group separator', () => {
18
+ expect(formatCardNumber('42424242')).toBe('4242 4242')
19
+ })
20
+
21
+ it('returns an empty string for no input', () => {
22
+ expect(formatCardNumber('')).toBe('')
23
+ })
24
+ })
25
+
26
+ describe('formatExpiryDate', () => {
27
+ it('masks two-digit months without a slash', () => {
28
+ expect(formatExpiryDate('12')).toBe('12')
29
+ })
30
+
31
+ it('masks MM/YY once a third digit is typed', () => {
32
+ expect(formatExpiryDate('122')).toBe('12/2')
33
+ expect(formatExpiryDate('1226')).toBe('12/26')
34
+ })
35
+
36
+ it('strips non-digit characters', () => {
37
+ expect(formatExpiryDate('12/26')).toBe('12/26')
38
+ })
39
+
40
+ it('caps at four digits', () => {
41
+ expect(formatExpiryDate('12261234')).toBe('12/26')
42
+ })
43
+
44
+ it('returns an empty string for no input', () => {
45
+ expect(formatExpiryDate('')).toBe('')
46
+ })
47
+ })
@@ -0,0 +1,9 @@
1
+ export function formatCardNumber(value: string, maxDigits = 16): string {
2
+ const digits = value.replace(/\D/g, '').slice(0, maxDigits)
3
+ return digits.replace(/(\d{4})(?=\d)/g, '$1 ')
4
+ }
5
+
6
+ export function formatExpiryDate(value: string, maxDigits = 4): string {
7
+ const digits = value.replace(/\D/g, '').slice(0, maxDigits)
8
+ return digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits
9
+ }
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import { useLayoutEffect, useRef, useState } from 'react'
4
+ import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
5
+
6
+ import { cn } from '../../../lib/utils'
7
+
8
+ const BOX_MS = 150
9
+ const DRAW_MS = 350
10
+ const UNCHECK_MS = 150
11
+ const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
12
+
13
+ export function CheckboxCheck({ className, indeterminate, ...props }: CheckboxPrimitive.Root.Props) {
14
+ const pathRef = useRef<SVGPathElement>(null)
15
+ const [length, setLength] = useState(15)
16
+
17
+ useLayoutEffect(() => {
18
+ const path = pathRef.current
19
+ if (path) {
20
+ setLength(Math.ceil(path.getTotalLength()))
21
+ }
22
+ }, [])
23
+
24
+ const mark = indeterminate ? 'M2.12 5.08H8.47' : 'M1.69 5.08L3.81 7.2L8.47 2.54'
25
+
26
+ return (
27
+ <CheckboxPrimitive.Root
28
+ data-slot="checkbox"
29
+ className={cn(
30
+ 'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary',
31
+ 'motion-reduce:transition-none',
32
+ className
33
+ )}
34
+ style={{ transition: `background-color ${BOX_MS}ms ${EASE}, border-color ${BOX_MS}ms ${EASE}` }}
35
+ indeterminate={indeterminate}
36
+ {...props}
37
+ >
38
+ <CheckboxPrimitive.Indicator
39
+ keepMounted
40
+ data-slot="checkbox-indicator"
41
+ className={cn(
42
+ 'grid size-3.5 place-content-center text-current',
43
+ '[&_path]:[transition:stroke-dashoffset_var(--check-uncheck)_var(--check-ease)]',
44
+ '[&_path]:[stroke-dashoffset:var(--check-len)]',
45
+ 'data-checked:[&_path]:[stroke-dashoffset:0]',
46
+ 'data-checked:[&_path]:[transition-duration:var(--check-draw)]',
47
+ 'motion-reduce:[&_path]:transition-none'
48
+ )}
49
+ style={
50
+ {
51
+ '--check-len': length,
52
+ '--check-draw': `${DRAW_MS}ms`,
53
+ '--check-uncheck': `${UNCHECK_MS}ms`,
54
+ '--check-ease': EASE,
55
+ } as React.CSSProperties
56
+ }
57
+ >
58
+ <svg viewBox="0 0 10.1668 10.1668" fill="none" aria-hidden className="size-full overflow-visible">
59
+ <path
60
+ ref={pathRef}
61
+ d={mark}
62
+ stroke="currentColor"
63
+ strokeWidth={0.9}
64
+ strokeLinecap="round"
65
+ strokeLinejoin="round"
66
+ style={{ strokeDasharray: length }}
67
+ />
68
+ </svg>
69
+ </CheckboxPrimitive.Indicator>
70
+ </CheckboxPrimitive.Root>
71
+ )
72
+ }
73
+
74
+ export default CheckboxCheck