@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.
- package/.turbo/turbo-build.log +24 -0
- package/.turbo/turbo-test.log +10 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -0
- package/README.md +161 -0
- package/components.json +33 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/contracts.d.mts +563 -0
- package/dist/contracts.d.ts +563 -0
- package/dist/contracts.js +207 -0
- package/dist/contracts.js.map +1 -0
- package/dist/contracts.mjs +168 -0
- package/dist/contracts.mjs.map +1 -0
- package/dist/index.d.mts +2408 -0
- package/dist/index.d.ts +2408 -0
- package/dist/index.js +16422 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +16352 -0
- package/dist/index.mjs.map +1 -0
- package/docs/standards/data-contracts.md +70 -0
- package/docs/standards/theming.md +85 -0
- package/package.json +62 -0
- package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
- package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
- package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
- package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
- package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
- package/src/blocks/application/verification/verify-1.tsx +307 -0
- package/src/blocks/application/verification/verify-confetti.tsx +192 -0
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
- package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
- package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
- package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
- package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
- package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
- package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
- package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
- package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
- package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
- package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
- package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
- package/src/blocks/marketing/banners/banner-1.tsx +188 -0
- package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
- package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
- package/src/blocks/marketing/features/feature-1.tsx +97 -0
- package/src/blocks/marketing/features/feature-2.tsx +82 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
- package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
- package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
- package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
- package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
- package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
- package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
- package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
- package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
- package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
- package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
- package/src/blocks/shared/reaction-toggle.tsx +145 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +200 -0
- package/src/lib/contracts.test.ts +32 -0
- package/src/lib/contracts.ts +235 -0
- package/src/lib/types/menu.ts +43 -0
- package/src/lib/types.ts +4 -0
- package/src/lib/utils.ts +10 -0
- package/src/ui/accordion.tsx +65 -0
- package/src/ui/avatar.tsx +108 -0
- package/src/ui/badge.tsx +47 -0
- package/src/ui/breadcrumb.tsx +108 -0
- package/src/ui/button-group.tsx +83 -0
- package/src/ui/button.tsx +63 -0
- package/src/ui/card.tsx +91 -0
- package/src/ui/carousel.tsx +241 -0
- package/src/ui/dialog.tsx +158 -0
- package/src/ui/drawer.tsx +134 -0
- package/src/ui/dropdown-menu.tsx +256 -0
- package/src/ui/empty.tsx +103 -0
- package/src/ui/field.tsx +248 -0
- package/src/ui/input-group.tsx +170 -0
- package/src/ui/input-otp.tsx +76 -0
- package/src/ui/input.tsx +20 -0
- package/src/ui/label.tsx +23 -0
- package/src/ui/navigation-menu.tsx +167 -0
- package/src/ui/pagination.tsx +127 -0
- package/src/ui/progress.tsx +30 -0
- package/src/ui/radio-group.tsx +44 -0
- package/src/ui/select.tsx +189 -0
- package/src/ui/separator.tsx +27 -0
- package/src/ui/sheet.tsx +142 -0
- package/src/ui/sidebar.tsx +726 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/table.tsx +115 -0
- package/src/ui/tabs.tsx +90 -0
- package/src/ui/textarea.tsx +17 -0
- package/src/ui/toggle-group.tsx +83 -0
- package/src/ui/toggle.tsx +46 -0
- package/src/ui/tooltip.tsx +56 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +16 -0
- 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
|