@thorprovider/blocks 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +108 -23
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
- package/src/index.ts +93 -46
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +395 -13
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -37
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1584
- package/dist/contracts.d.ts +0 -1584
- package/dist/contracts.js +0 -390
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -334
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -2786
- package/dist/index.d.ts +0 -2786
- package/dist/index.js +0 -17139
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17059
- package/dist/index.mjs.map +0 -1
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import * as React from 'react'
|
|
4
|
-
import { useEffect, useState } from 'react'
|
|
5
|
-
|
|
6
|
-
import { cn } from '../../../lib/utils'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @title Avatar group
|
|
10
|
-
* @description Overlapping avatar stack whose neighbours lift with a distance falloff on hover.
|
|
11
|
-
*
|
|
12
|
-
* Adapted from Transitions.dev "Avatar group hover".
|
|
13
|
-
*
|
|
14
|
-
* A dock, not a switch. Lifting only the hovered item makes the row twitch — one element jumps
|
|
15
|
-
* while the rest sit rigid — so the response falls off with distance: the neighbour rises about
|
|
16
|
-
* half as far, the next barely at all. The falloff is what makes the row read as one soft object.
|
|
17
|
-
*
|
|
18
|
-
* The return is deliberately springier than the approach: a strong overshoot curve on the way back
|
|
19
|
-
* reads as elastic rather than mechanical.
|
|
20
|
-
*/
|
|
21
|
-
const LIFT = -4
|
|
22
|
-
const FALLOFF = 0.45
|
|
23
|
-
const ACTIVE_SCALE = 1.05
|
|
24
|
-
const DURATION = 320
|
|
25
|
-
const EASE_IN = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
|
26
|
-
const EASE_OUT = 'cubic-bezier(0.34, 3.85, 0.64, 1)'
|
|
27
|
-
|
|
28
|
-
export function AvatarGroup({
|
|
29
|
-
children,
|
|
30
|
-
className,
|
|
31
|
-
overlap = '-ml-2',
|
|
32
|
-
}: {
|
|
33
|
-
children: React.ReactNode
|
|
34
|
-
className?: string
|
|
35
|
-
/** Negative margin applied to every item but the first. Set to `''` for a non-overlapping row. */
|
|
36
|
-
overlap?: string
|
|
37
|
-
}) {
|
|
38
|
-
const items = React.Children.toArray(children)
|
|
39
|
-
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
|
40
|
-
const [reducedMotion, setReducedMotion] = useState(false)
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
44
|
-
const sync = () => setReducedMotion(query.matches)
|
|
45
|
-
|
|
46
|
-
sync()
|
|
47
|
-
query.addEventListener('change', sync)
|
|
48
|
-
return () => query.removeEventListener('change', sync)
|
|
49
|
-
}, [])
|
|
50
|
-
|
|
51
|
-
const itemStyle = (index: number): React.CSSProperties | undefined => {
|
|
52
|
-
if (reducedMotion) {
|
|
53
|
-
return undefined
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const isActive = index === activeIndex
|
|
57
|
-
const shift = activeIndex === null ? 0 : LIFT * FALLOFF ** Math.abs(index - activeIndex)
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
// translateY before scale, so scaling never amplifies the lift distance.
|
|
61
|
-
transform: `translateY(${shift.toFixed(3)}px) scale(${isActive ? ACTIVE_SCALE : 1})`,
|
|
62
|
-
transition: `transform ${DURATION}ms ${activeIndex === null ? EASE_OUT : EASE_IN}`,
|
|
63
|
-
willChange: 'transform',
|
|
64
|
-
// A lifted item has to sit above the neighbour overlapping it, or the lift happens underneath.
|
|
65
|
-
zIndex: isActive ? 1 : undefined,
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<div className={cn('flex', className)} onMouseLeave={() => setActiveIndex(null)}>
|
|
71
|
-
{items.map((child, index) => (
|
|
72
|
-
<div
|
|
73
|
-
// eslint-disable-next-line react/no-array-index-key -- position is the identity here
|
|
74
|
-
key={index}
|
|
75
|
-
className={cn('relative', index > 0 && overlap)}
|
|
76
|
-
style={itemStyle(index)}
|
|
77
|
-
onMouseEnter={() => setActiveIndex(index)}
|
|
78
|
-
>
|
|
79
|
-
{child}
|
|
80
|
-
</div>
|
|
81
|
-
))}
|
|
82
|
-
</div>
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export default AvatarGroup
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { Button } from '../../../ui/button'
|
|
2
|
-
import { Card, CardContent } from '../../../ui/card'
|
|
3
|
-
import { cn } from '../../../lib/utils'
|
|
4
|
-
import {
|
|
5
|
-
ArrowRight,
|
|
6
|
-
BatteryCharging,
|
|
7
|
-
Cable,
|
|
8
|
-
Camera,
|
|
9
|
-
Lightbulb,
|
|
10
|
-
ShieldCheck,
|
|
11
|
-
Smartphone,
|
|
12
|
-
Sparkles,
|
|
13
|
-
Wrench,
|
|
14
|
-
X,
|
|
15
|
-
} from 'lucide-react'
|
|
16
|
-
import type { CTA } from '../../../lib/types'
|
|
17
|
-
|
|
18
|
-
const categoryIcons = {
|
|
19
|
-
Smartphone,
|
|
20
|
-
BatteryCharging,
|
|
21
|
-
Wrench,
|
|
22
|
-
Camera,
|
|
23
|
-
Cable,
|
|
24
|
-
ShieldCheck,
|
|
25
|
-
Sparkles,
|
|
26
|
-
Lightbulb,
|
|
27
|
-
} as const
|
|
28
|
-
|
|
29
|
-
export type ProductCategory1CategoryIcon =
|
|
30
|
-
| 'Smartphone'
|
|
31
|
-
| 'BatteryCharging'
|
|
32
|
-
| 'Wrench'
|
|
33
|
-
| 'Camera'
|
|
34
|
-
| 'Cable'
|
|
35
|
-
| 'ShieldCheck'
|
|
36
|
-
| 'Sparkles'
|
|
37
|
-
| 'Lightbulb'
|
|
38
|
-
|
|
39
|
-
export interface ProductCategory1Category {
|
|
40
|
-
id: string
|
|
41
|
-
name: string
|
|
42
|
-
subtitle: string
|
|
43
|
-
icon: ProductCategory1CategoryIcon
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface ProductCategory1Props {
|
|
47
|
-
badge?: string
|
|
48
|
-
title: string
|
|
49
|
-
description?: string
|
|
50
|
-
cta?: CTA
|
|
51
|
-
categories: ProductCategory1Category[]
|
|
52
|
-
className?: string
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function ProductCategory1({
|
|
56
|
-
badge,
|
|
57
|
-
title,
|
|
58
|
-
description,
|
|
59
|
-
cta,
|
|
60
|
-
categories,
|
|
61
|
-
className,
|
|
62
|
-
}: ProductCategory1Props) {
|
|
63
|
-
return (
|
|
64
|
-
<section className={cn('relative w-full py-12 md:py-16 lg:py-20', className)}>
|
|
65
|
-
<div className='relative z-10 mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
66
|
-
<header className='mx-auto mb-12 max-w-5xl'>
|
|
67
|
-
<div className='flex flex-col items-center justify-between gap-6 md:flex-row md:items-start md:text-start'>
|
|
68
|
-
<div className='flex flex-col items-center gap-4 text-center md:items-start md:text-start'>
|
|
69
|
-
{badge ? (
|
|
70
|
-
<div className='inline-flex'>
|
|
71
|
-
<span className='bg-secondary/80 text-secondary-foreground rounded-full px-4 py-1.5 text-sm font-medium'>
|
|
72
|
-
{badge}
|
|
73
|
-
</span>
|
|
74
|
-
</div>
|
|
75
|
-
) : null}
|
|
76
|
-
<div className='flex flex-col gap-2'>
|
|
77
|
-
<h2 className='text-foreground text-3xl font-bold tracking-tight md:text-4xl'>{title}</h2>
|
|
78
|
-
{description ? (
|
|
79
|
-
<p className='text-muted-foreground max-w-2xl'>{description}</p>
|
|
80
|
-
) : null}
|
|
81
|
-
</div>
|
|
82
|
-
</div>
|
|
83
|
-
{cta ? (
|
|
84
|
-
<Button
|
|
85
|
-
className='h-10 cursor-pointer whitespace-nowrap px-4'
|
|
86
|
-
size='lg'
|
|
87
|
-
asChild
|
|
88
|
-
>
|
|
89
|
-
<a href={cta.href}>
|
|
90
|
-
{cta.label}
|
|
91
|
-
<ArrowRight />
|
|
92
|
-
</a>
|
|
93
|
-
</Button>
|
|
94
|
-
) : null}
|
|
95
|
-
</div>
|
|
96
|
-
</header>
|
|
97
|
-
|
|
98
|
-
<div className='mx-auto grid max-w-5xl grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
|
99
|
-
{categories.map(category => {
|
|
100
|
-
const Icon = categoryIcons[category.icon] ?? X
|
|
101
|
-
return (
|
|
102
|
-
<Card
|
|
103
|
-
key={category.id}
|
|
104
|
-
className='group relative overflow-hidden py-6 px-2 shadow-xs transition-all hover:shadow-md'
|
|
105
|
-
>
|
|
106
|
-
<CardContent>
|
|
107
|
-
<div className='flex flex-col gap-4'>
|
|
108
|
-
<div className='bg-secondary/80 text-secondary-foreground flex size-12 items-center justify-center rounded-lg'>
|
|
109
|
-
<Icon className='size-5' strokeWidth={1.5} />
|
|
110
|
-
</div>
|
|
111
|
-
<div>
|
|
112
|
-
<h3 className='text-foreground text-lg font-semibold'>{category.name}</h3>
|
|
113
|
-
<p className='text-muted-foreground text-sm'>{category.subtitle}</p>
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
</CardContent>
|
|
117
|
-
</Card>
|
|
118
|
-
)
|
|
119
|
-
})}
|
|
120
|
-
</div>
|
|
121
|
-
</div>
|
|
122
|
-
</section>
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export default ProductCategory1
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import {
|
|
4
|
-
ProductCategory2,
|
|
5
|
-
type ProductCategory2Category,
|
|
6
|
-
} from './product-category-2'
|
|
7
|
-
|
|
8
|
-
const base: ProductCategory2Category = {
|
|
9
|
-
id: 'cat-1',
|
|
10
|
-
name: 'Herramientas',
|
|
11
|
-
description: 'Taladros y accesorios',
|
|
12
|
-
image: '/products/drill.png',
|
|
13
|
-
trending: false,
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const render = (category: ProductCategory2Category) =>
|
|
17
|
-
renderToStaticMarkup(<ProductCategory2 categories={[category]} />)
|
|
18
|
-
|
|
19
|
-
// The count is the only text node that ends in "items" inside a span (the
|
|
20
|
-
// `items-center` utility lives in a class attribute and is never followed by
|
|
21
|
-
// `</span>`).
|
|
22
|
-
const hasItemCount = (html: string) => html.includes('items</span>')
|
|
23
|
-
|
|
24
|
-
describe('ProductCategory2 — optional itemCount', () => {
|
|
25
|
-
it('renders no count when itemCount is omitted (unknown projection)', () => {
|
|
26
|
-
const html = render(base)
|
|
27
|
-
expect(hasItemCount(html)).toBe(false)
|
|
28
|
-
// The tile itself still renders.
|
|
29
|
-
expect(html).toContain('Herramientas')
|
|
30
|
-
expect(html).toContain('Browse')
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('preserves a real zero count', () => {
|
|
34
|
-
const html = render({ ...base, itemCount: 0 })
|
|
35
|
-
expect(html).toContain('0 items</span>')
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('renders a positive count', () => {
|
|
39
|
-
const html = render({ ...base, itemCount: 7 })
|
|
40
|
-
expect(html).toContain('7 items</span>')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('omits a negative count', () => {
|
|
44
|
-
expect(hasItemCount(render({ ...base, itemCount: -1 }))).toBe(false)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('omits a non-finite count', () => {
|
|
48
|
-
expect(hasItemCount(render({ ...base, itemCount: Number.NaN }))).toBe(false)
|
|
49
|
-
expect(hasItemCount(render({ ...base, itemCount: Number.POSITIVE_INFINITY }))).toBe(false)
|
|
50
|
-
})
|
|
51
|
-
})
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { Card } from '../../../ui/card'
|
|
2
|
-
import { Badge } from '../../../ui/badge'
|
|
3
|
-
import { Button } from '../../../ui/button'
|
|
4
|
-
import { ArrowRight, ShoppingBag } from 'lucide-react'
|
|
5
|
-
import type { CTA } from '../../../lib/types'
|
|
6
|
-
|
|
7
|
-
export interface ProductCategory2Category {
|
|
8
|
-
id: string
|
|
9
|
-
handle?: string
|
|
10
|
-
name: string
|
|
11
|
-
description: string
|
|
12
|
-
/**
|
|
13
|
-
* Number of items in the category. Optional: when omitted, or when it is not
|
|
14
|
-
* a finite non-negative number, no count is rendered — an unknown projection
|
|
15
|
-
* must not be shown as a fabricated `0`. A real `0` is preserved.
|
|
16
|
-
*/
|
|
17
|
-
itemCount?: number
|
|
18
|
-
image: string
|
|
19
|
-
trending: boolean
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface ProductCategory2Props {
|
|
23
|
-
title?: string
|
|
24
|
-
description?: string
|
|
25
|
-
categories: ProductCategory2Category[]
|
|
26
|
-
cta?: CTA
|
|
27
|
-
onBrowse?: (category: ProductCategory2Category) => void
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Formats a category item count only when it is a supplied, finite,
|
|
32
|
-
* non-negative number. Returns `undefined` for unknown or invalid values so the
|
|
33
|
-
* caller omits the count entirely (a real `0` is preserved).
|
|
34
|
-
*/
|
|
35
|
-
function formatItemCount(itemCount: number | undefined): string | undefined {
|
|
36
|
-
if (itemCount === undefined || !Number.isFinite(itemCount) || itemCount < 0) {
|
|
37
|
-
return undefined
|
|
38
|
-
}
|
|
39
|
-
return `${itemCount.toLocaleString()} items`
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function ProductCategory2({
|
|
43
|
-
title,
|
|
44
|
-
description,
|
|
45
|
-
categories,
|
|
46
|
-
cta,
|
|
47
|
-
onBrowse,
|
|
48
|
-
}: ProductCategory2Props) {
|
|
49
|
-
return (
|
|
50
|
-
<section className='py-12'>
|
|
51
|
-
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
52
|
-
{(title || description) && (
|
|
53
|
-
<div className='mb-12 text-center'>
|
|
54
|
-
{title ? <h2 className='text-3xl font-bold tracking-tight text-balance'>{title}</h2> : null}
|
|
55
|
-
{description ? (
|
|
56
|
-
<p className='text-muted-foreground mt-4 text-lg'>{description}</p>
|
|
57
|
-
) : null}
|
|
58
|
-
</div>
|
|
59
|
-
)}
|
|
60
|
-
|
|
61
|
-
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
|
|
62
|
-
{categories.map(category => {
|
|
63
|
-
const itemCountLabel = formatItemCount(category.itemCount)
|
|
64
|
-
return (
|
|
65
|
-
<Card
|
|
66
|
-
key={category.id}
|
|
67
|
-
className='group cursor-pointer overflow-hidden py-0 transition-all duration-500 hover:shadow-lg'
|
|
68
|
-
>
|
|
69
|
-
<div className='relative aspect-[5/4] overflow-hidden'>
|
|
70
|
-
<img
|
|
71
|
-
src={category.image || undefined}
|
|
72
|
-
alt={category.name}
|
|
73
|
-
className='size-full object-cover transition-transform duration-500 group-hover:scale-105'
|
|
74
|
-
/>
|
|
75
|
-
<div className='absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent' />
|
|
76
|
-
|
|
77
|
-
{category.trending ? (
|
|
78
|
-
<Badge className='absolute top-4 left-4 rounded-sm px-2.5 py-0.5 font-semibold'>
|
|
79
|
-
Trending
|
|
80
|
-
</Badge>
|
|
81
|
-
) : null}
|
|
82
|
-
|
|
83
|
-
<div className='absolute right-0 bottom-0 left-0 p-6 text-white'>
|
|
84
|
-
<h3 className='mb-1 text-xl font-bold'>{category.name}</h3>
|
|
85
|
-
<p className='mb-3 text-sm text-white/90'>{category.description}</p>
|
|
86
|
-
<div className='flex items-center justify-between'>
|
|
87
|
-
{itemCountLabel ? <span className='text-sm'>{itemCountLabel}</span> : null}
|
|
88
|
-
<Button
|
|
89
|
-
size='sm'
|
|
90
|
-
variant='secondary'
|
|
91
|
-
className='ms-auto h-8 cursor-pointer border-white/30 bg-white/20 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/30'
|
|
92
|
-
onClick={onBrowse ? () => onBrowse(category) : undefined}
|
|
93
|
-
>
|
|
94
|
-
Browse
|
|
95
|
-
<ArrowRight />
|
|
96
|
-
</Button>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
</Card>
|
|
101
|
-
)
|
|
102
|
-
})}
|
|
103
|
-
</div>
|
|
104
|
-
|
|
105
|
-
{cta ? (
|
|
106
|
-
<div className='mt-12 text-center'>
|
|
107
|
-
<Button size='lg' className='h-10 cursor-pointer gap-2 px-4' asChild>
|
|
108
|
-
<a href={cta.href}>
|
|
109
|
-
<ShoppingBag className='size-5' />
|
|
110
|
-
{cta.label}
|
|
111
|
-
</a>
|
|
112
|
-
</Button>
|
|
113
|
-
</div>
|
|
114
|
-
) : null}
|
|
115
|
-
</div>
|
|
116
|
-
</section>
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export default ProductCategory2
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import type { LucideIcon } from 'lucide-react'
|
|
4
|
-
import { Users, ShoppingBag } from 'lucide-react'
|
|
5
|
-
|
|
6
|
-
import { Card, CardContent } from '../../../ui/card'
|
|
7
|
-
import { Badge } from '../../../ui/badge'
|
|
8
|
-
import { Avatar, AvatarImage, AvatarFallback } from '../../../ui/avatar'
|
|
9
|
-
import { AvatarGroup } from './avatar-group'
|
|
10
|
-
|
|
11
|
-
export interface ProductCategory4Category {
|
|
12
|
-
id: string
|
|
13
|
-
title: string
|
|
14
|
-
description: string
|
|
15
|
-
icon: LucideIcon
|
|
16
|
-
customers: string
|
|
17
|
-
products: number
|
|
18
|
-
avatarImages?: string[]
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ProductCategory4Props {
|
|
22
|
-
title?: string
|
|
23
|
-
description?: string
|
|
24
|
-
categories: ProductCategory4Category[]
|
|
25
|
-
productsLabel?: string
|
|
26
|
-
customersLabel?: string
|
|
27
|
-
onCategoryClick?: (category: ProductCategory4Category) => void
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function getInitials(title: string): string {
|
|
31
|
-
return title
|
|
32
|
-
.split(/\s+/)
|
|
33
|
-
.filter(Boolean)
|
|
34
|
-
.slice(0, 2)
|
|
35
|
-
.map(word => word.charAt(0).toUpperCase())
|
|
36
|
-
.join('')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function ProductCategory4({
|
|
40
|
-
title = 'Compra por categoría',
|
|
41
|
-
description = 'Explora nuestra selección de repuestos y accesorios',
|
|
42
|
-
categories,
|
|
43
|
-
productsLabel = 'productos',
|
|
44
|
-
customersLabel = 'clientes',
|
|
45
|
-
onCategoryClick,
|
|
46
|
-
}: ProductCategory4Props) {
|
|
47
|
-
return (
|
|
48
|
-
<section className='bg-background w-full py-12 md:py-16'>
|
|
49
|
-
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
50
|
-
<div className='mx-auto mb-12 max-w-3xl text-center'>
|
|
51
|
-
<h2 className='text-foreground mb-4 text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl'>
|
|
52
|
-
{title}
|
|
53
|
-
</h2>
|
|
54
|
-
<p className='text-muted-foreground text-lg md:text-xl'>{description}</p>
|
|
55
|
-
</div>
|
|
56
|
-
|
|
57
|
-
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3'>
|
|
58
|
-
{categories.map(category => {
|
|
59
|
-
const Icon = category.icon
|
|
60
|
-
const clickable = Boolean(onCategoryClick)
|
|
61
|
-
return (
|
|
62
|
-
<Card
|
|
63
|
-
key={category.id}
|
|
64
|
-
className={`group relative overflow-hidden transition-all hover:shadow-md ${
|
|
65
|
-
clickable ? 'cursor-pointer' : ''
|
|
66
|
-
}`}
|
|
67
|
-
onClick={onCategoryClick ? () => onCategoryClick(category) : undefined}
|
|
68
|
-
aria-label={clickable ? category.title : undefined}
|
|
69
|
-
>
|
|
70
|
-
<CardContent>
|
|
71
|
-
<div className='mb-4 flex items-center justify-between'>
|
|
72
|
-
<div className='bg-accent text-primary rounded-lg p-2.5' aria-hidden='true'>
|
|
73
|
-
<Icon className='size-5' />
|
|
74
|
-
</div>
|
|
75
|
-
<Badge
|
|
76
|
-
variant='secondary'
|
|
77
|
-
className='bg-muted/50 rounded-sm px-2 py-1 font-semibold'
|
|
78
|
-
aria-label={`${category.products} ${productsLabel}`}
|
|
79
|
-
>
|
|
80
|
-
<ShoppingBag className='me-1 size-3' />
|
|
81
|
-
{category.products} {productsLabel}
|
|
82
|
-
</Badge>
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
<h3 className='text-foreground mb-2 text-lg font-semibold'>{category.title}</h3>
|
|
86
|
-
<p className='text-muted-foreground mb-4 text-sm'>{category.description}</p>
|
|
87
|
-
|
|
88
|
-
<div className='flex items-center justify-between'>
|
|
89
|
-
<div className='flex items-center gap-2'>
|
|
90
|
-
{category.avatarImages?.length ? (
|
|
91
|
-
<>
|
|
92
|
-
<AvatarGroup>
|
|
93
|
-
{category.avatarImages.slice(0, 3).map((src, i) => (
|
|
94
|
-
<Avatar key={i} className='bg-muted size-6 border'>
|
|
95
|
-
<AvatarImage src={src || undefined} alt='' />
|
|
96
|
-
<AvatarFallback>{getInitials(category.title)}</AvatarFallback>
|
|
97
|
-
</Avatar>
|
|
98
|
-
))}
|
|
99
|
-
</AvatarGroup>
|
|
100
|
-
<span
|
|
101
|
-
className='text-muted-foreground flex items-center gap-1 text-sm'
|
|
102
|
-
aria-label={`${category.customers} ${customersLabel}`}
|
|
103
|
-
>
|
|
104
|
-
<Users className='h-3.5 w-3.5' aria-hidden='true' />
|
|
105
|
-
{category.customers} {customersLabel}
|
|
106
|
-
</span>
|
|
107
|
-
</>
|
|
108
|
-
) : (
|
|
109
|
-
<span
|
|
110
|
-
className='text-muted-foreground flex items-center gap-1 text-sm'
|
|
111
|
-
aria-label={`${category.customers} ${customersLabel}`}
|
|
112
|
-
>
|
|
113
|
-
<Users className='h-3.5 w-3.5' aria-hidden='true' />
|
|
114
|
-
{category.customers} {customersLabel}
|
|
115
|
-
</span>
|
|
116
|
-
)}
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
</CardContent>
|
|
120
|
-
</Card>
|
|
121
|
-
)
|
|
122
|
-
})}
|
|
123
|
-
</div>
|
|
124
|
-
</div>
|
|
125
|
-
</section>
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export default ProductCategory4
|