@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
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ArrowRight,
|
|
5
|
+
Baby,
|
|
6
|
+
BookOpen,
|
|
7
|
+
Boxes,
|
|
8
|
+
Cable,
|
|
9
|
+
Camera,
|
|
10
|
+
Car,
|
|
11
|
+
Dumbbell,
|
|
12
|
+
Gamepad2,
|
|
13
|
+
Gift,
|
|
14
|
+
Heart,
|
|
15
|
+
Home,
|
|
16
|
+
Laptop,
|
|
17
|
+
LayoutGrid,
|
|
18
|
+
Lightbulb,
|
|
19
|
+
Package,
|
|
20
|
+
Search,
|
|
21
|
+
ShieldCheck,
|
|
22
|
+
Shirt,
|
|
23
|
+
ShoppingBag,
|
|
24
|
+
Smartphone,
|
|
25
|
+
Sparkles,
|
|
26
|
+
Store,
|
|
27
|
+
Tag,
|
|
28
|
+
Utensils,
|
|
29
|
+
Wrench,
|
|
30
|
+
type LucideIcon,
|
|
31
|
+
} from 'lucide-react'
|
|
32
|
+
|
|
33
|
+
import { Badge } from '../../../ui/badge'
|
|
34
|
+
import { Button } from '../../../ui/button'
|
|
35
|
+
import { Card, CardContent } from '../../../ui/card'
|
|
36
|
+
import { Input } from '../../../ui/input'
|
|
37
|
+
import { cn } from '../../../lib/utils'
|
|
38
|
+
import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
|
|
39
|
+
import type { CTA } from '../../../lib/types'
|
|
40
|
+
import type { ProductCategoryItem, ProductCategorySpecProps } from '../../../lib/contracts'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The bounded presentation surface. `grid` is image-forward, `icons` is a
|
|
44
|
+
* compact icon grid, and `list` is a described row list with subcategory chips
|
|
45
|
+
* and an optional controlled search. The value changes structure, never the
|
|
46
|
+
* data contract or the callbacks.
|
|
47
|
+
*/
|
|
48
|
+
export type ProductCategoryPresentation = NonNullable<ProductCategorySpecProps['presentation']>
|
|
49
|
+
|
|
50
|
+
export interface ProductCategoryProps {
|
|
51
|
+
/** Canonical category projection; absent optional fields degrade gracefully. */
|
|
52
|
+
categories: ProductCategoryItem[]
|
|
53
|
+
/** Bounded presentation option; defaults to `grid`. */
|
|
54
|
+
presentation?: ProductCategoryPresentation
|
|
55
|
+
title?: string
|
|
56
|
+
description?: string
|
|
57
|
+
/** Optional eyebrow label (`icons` presentation). */
|
|
58
|
+
badge?: string
|
|
59
|
+
/** Optional section-level link action. */
|
|
60
|
+
cta?: CTA
|
|
61
|
+
/** Controlled search value (`list` presentation); the host owns filtering. */
|
|
62
|
+
query?: string
|
|
63
|
+
onQueryChange?: (query: string) => void
|
|
64
|
+
onBrowse?: (category: ProductCategoryItem) => void
|
|
65
|
+
onViewAll?: () => void
|
|
66
|
+
// All user-facing copy is supplied by the host; the block ships no defaults.
|
|
67
|
+
searchPlaceholder?: string
|
|
68
|
+
searchLabel?: string
|
|
69
|
+
emptyLabel?: string
|
|
70
|
+
noResultsLabel?: string
|
|
71
|
+
browseLabel?: string
|
|
72
|
+
viewAllLabel?: string
|
|
73
|
+
trendingLabel?: string
|
|
74
|
+
featuredLabel?: string
|
|
75
|
+
itemsLabel?: string
|
|
76
|
+
/** Neutral placeholder for a missing image; defaults to `PRODUCT_IMAGE_FALLBACK`. */
|
|
77
|
+
imageFallback?: string
|
|
78
|
+
className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Bounded, neutral icon vocabulary. The prop is a plain string (never a named
|
|
83
|
+
* or sector-specific enum); an unknown or absent key renders a neutral fallback.
|
|
84
|
+
*/
|
|
85
|
+
const CATEGORY_ICONS: Record<string, LucideIcon> = {
|
|
86
|
+
grid: LayoutGrid,
|
|
87
|
+
boxes: Boxes,
|
|
88
|
+
smartphone: Smartphone,
|
|
89
|
+
laptop: Laptop,
|
|
90
|
+
camera: Camera,
|
|
91
|
+
cable: Cable,
|
|
92
|
+
wrench: Wrench,
|
|
93
|
+
shield: ShieldCheck,
|
|
94
|
+
sparkles: Sparkles,
|
|
95
|
+
lightbulb: Lightbulb,
|
|
96
|
+
package: Package,
|
|
97
|
+
tag: Tag,
|
|
98
|
+
store: Store,
|
|
99
|
+
home: Home,
|
|
100
|
+
shirt: Shirt,
|
|
101
|
+
book: BookOpen,
|
|
102
|
+
gift: Gift,
|
|
103
|
+
heart: Heart,
|
|
104
|
+
car: Car,
|
|
105
|
+
utensils: Utensils,
|
|
106
|
+
gamepad: Gamepad2,
|
|
107
|
+
baby: Baby,
|
|
108
|
+
dumbbell: Dumbbell,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const FALLBACK_ICON: LucideIcon = LayoutGrid
|
|
112
|
+
|
|
113
|
+
function resolveCategoryIcon(icon: string | undefined): LucideIcon {
|
|
114
|
+
if (!icon) return FALLBACK_ICON
|
|
115
|
+
return CATEGORY_ICONS[icon] ?? FALLBACK_ICON
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Formats a category item count only when it is a supplied, finite,
|
|
120
|
+
* non-negative number. Returns `undefined` for unknown or invalid values so the
|
|
121
|
+
* caller omits the count entirely (a real `0` is preserved). The optional
|
|
122
|
+
* `itemsLabel` is host-supplied copy — the block never hardcodes a unit.
|
|
123
|
+
*/
|
|
124
|
+
function formatCount(itemCount: number | undefined, itemsLabel: string | undefined): string | undefined {
|
|
125
|
+
if (itemCount === undefined || !Number.isFinite(itemCount) || itemCount < 0) {
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
const value = itemCount.toLocaleString()
|
|
129
|
+
return itemsLabel ? `${value} ${itemsLabel}` : value
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveImage(image: string | undefined, fallback: string): string {
|
|
133
|
+
return image && image.trim() !== '' ? image : fallback
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function SectionCta({ cta }: { cta: CTA | undefined }) {
|
|
137
|
+
if (!cta) return null
|
|
138
|
+
return (
|
|
139
|
+
<div className='mt-12 text-center'>
|
|
140
|
+
<Button size='lg' className='h-10 cursor-pointer gap-2 px-4' asChild>
|
|
141
|
+
<a href={cta.href}>
|
|
142
|
+
<ShoppingBag className='size-5' />
|
|
143
|
+
{cta.label}
|
|
144
|
+
</a>
|
|
145
|
+
</Button>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ViewAllButton({ onViewAll, viewAllLabel }: { onViewAll?: () => void; viewAllLabel?: string }) {
|
|
151
|
+
// Purpose-gated: no handler or no label means no affordance.
|
|
152
|
+
if (!onViewAll || !viewAllLabel) return null
|
|
153
|
+
return (
|
|
154
|
+
<div className='mt-12 text-center'>
|
|
155
|
+
<Button variant='outline' className='h-9 cursor-pointer px-4' onClick={onViewAll}>
|
|
156
|
+
{viewAllLabel}
|
|
157
|
+
<ArrowRight />
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function GridPresentation({
|
|
164
|
+
categories,
|
|
165
|
+
title,
|
|
166
|
+
description,
|
|
167
|
+
cta,
|
|
168
|
+
onBrowse,
|
|
169
|
+
browseLabel,
|
|
170
|
+
trendingLabel,
|
|
171
|
+
itemsLabel,
|
|
172
|
+
imageFallback,
|
|
173
|
+
className,
|
|
174
|
+
}: ProductCategoryProps) {
|
|
175
|
+
const fallback = imageFallback ?? PRODUCT_IMAGE_FALLBACK
|
|
176
|
+
return (
|
|
177
|
+
<section className={cn('py-12', className)}>
|
|
178
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
179
|
+
{title || description ? (
|
|
180
|
+
<div className='mb-12 text-center'>
|
|
181
|
+
{title ? <h2 className='text-3xl font-bold tracking-tight text-balance'>{title}</h2> : null}
|
|
182
|
+
{description ? <p className='text-muted-foreground mt-4 text-lg'>{description}</p> : null}
|
|
183
|
+
</div>
|
|
184
|
+
) : null}
|
|
185
|
+
|
|
186
|
+
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
|
|
187
|
+
{categories.map(category => {
|
|
188
|
+
const countLabel = formatCount(category.itemCount, itemsLabel)
|
|
189
|
+
return (
|
|
190
|
+
<Card
|
|
191
|
+
key={category.id}
|
|
192
|
+
className='group overflow-hidden py-0 transition-all duration-500 hover:shadow-lg'
|
|
193
|
+
>
|
|
194
|
+
<div className='relative aspect-[5/4] overflow-hidden'>
|
|
195
|
+
<img
|
|
196
|
+
src={resolveImage(category.image, fallback)}
|
|
197
|
+
alt={category.name}
|
|
198
|
+
className='size-full object-cover transition-transform duration-500 group-hover:scale-105'
|
|
199
|
+
/>
|
|
200
|
+
<div className='absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent' />
|
|
201
|
+
|
|
202
|
+
{category.trending && trendingLabel ? (
|
|
203
|
+
<Badge className='absolute top-4 left-4 rounded-sm px-2.5 py-0.5 font-semibold'>
|
|
204
|
+
{trendingLabel}
|
|
205
|
+
</Badge>
|
|
206
|
+
) : null}
|
|
207
|
+
|
|
208
|
+
<div className='absolute right-0 bottom-0 left-0 p-6 text-white'>
|
|
209
|
+
<h3 className='mb-1 text-xl font-bold'>{category.name}</h3>
|
|
210
|
+
{category.description ? (
|
|
211
|
+
<p className='mb-3 text-sm text-white/90'>{category.description}</p>
|
|
212
|
+
) : null}
|
|
213
|
+
<div className='flex items-center justify-between'>
|
|
214
|
+
{countLabel ? <span className='text-sm'>{countLabel}</span> : null}
|
|
215
|
+
{onBrowse && browseLabel ? (
|
|
216
|
+
<Button
|
|
217
|
+
size='sm'
|
|
218
|
+
variant='secondary'
|
|
219
|
+
aria-label={`${browseLabel} ${category.name}`}
|
|
220
|
+
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'
|
|
221
|
+
onClick={() => onBrowse(category)}
|
|
222
|
+
>
|
|
223
|
+
{browseLabel}
|
|
224
|
+
<ArrowRight />
|
|
225
|
+
</Button>
|
|
226
|
+
) : null}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</Card>
|
|
231
|
+
)
|
|
232
|
+
})}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<SectionCta cta={cta} />
|
|
236
|
+
</div>
|
|
237
|
+
</section>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function IconsPresentation({
|
|
242
|
+
categories,
|
|
243
|
+
title,
|
|
244
|
+
description,
|
|
245
|
+
badge,
|
|
246
|
+
cta,
|
|
247
|
+
onBrowse,
|
|
248
|
+
onViewAll,
|
|
249
|
+
viewAllLabel,
|
|
250
|
+
className,
|
|
251
|
+
}: ProductCategoryProps) {
|
|
252
|
+
return (
|
|
253
|
+
<section className={cn('py-12', className)}>
|
|
254
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
255
|
+
{badge || title || description ? (
|
|
256
|
+
<div className='mx-auto mb-12 max-w-3xl text-center'>
|
|
257
|
+
{badge ? (
|
|
258
|
+
<div className='mb-4 inline-flex'>
|
|
259
|
+
<span className='bg-secondary/80 text-secondary-foreground rounded-full px-4 py-1.5 text-sm font-medium'>
|
|
260
|
+
{badge}
|
|
261
|
+
</span>
|
|
262
|
+
</div>
|
|
263
|
+
) : null}
|
|
264
|
+
{title ? <h2 className='text-3xl font-bold tracking-tight text-balance'>{title}</h2> : null}
|
|
265
|
+
{description ? <p className='text-muted-foreground mt-4'>{description}</p> : null}
|
|
266
|
+
</div>
|
|
267
|
+
) : null}
|
|
268
|
+
|
|
269
|
+
<div className='mx-auto grid max-w-5xl grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
|
|
270
|
+
{categories.map(category => {
|
|
271
|
+
const Icon = resolveCategoryIcon(category.icon)
|
|
272
|
+
const clickable = Boolean(onBrowse)
|
|
273
|
+
return (
|
|
274
|
+
<Card
|
|
275
|
+
key={category.id}
|
|
276
|
+
className={cn(
|
|
277
|
+
'group relative overflow-hidden px-2 py-6 shadow-xs transition-all hover:shadow-md',
|
|
278
|
+
clickable && 'cursor-pointer',
|
|
279
|
+
)}
|
|
280
|
+
>
|
|
281
|
+
{clickable ? (
|
|
282
|
+
// A stretched native control keeps the whole card clickable
|
|
283
|
+
// while focus, activation, and the accessible name come from
|
|
284
|
+
// the platform. The Card stays non-interactive so no
|
|
285
|
+
// block-level element is nested inside the button, and with
|
|
286
|
+
// no browse handler there is no control and no focus target.
|
|
287
|
+
<button
|
|
288
|
+
type='button'
|
|
289
|
+
onClick={() => onBrowse?.(category)}
|
|
290
|
+
aria-label={category.name}
|
|
291
|
+
className='absolute inset-0 z-10 rounded-xl focus-visible:inset-ring-2 focus-visible:inset-ring-ring focus-visible:outline-none'
|
|
292
|
+
/>
|
|
293
|
+
) : null}
|
|
294
|
+
<CardContent>
|
|
295
|
+
<div className='flex flex-col gap-4'>
|
|
296
|
+
<div className='bg-secondary/80 text-secondary-foreground flex size-12 items-center justify-center rounded-lg'>
|
|
297
|
+
<Icon className='size-5' strokeWidth={1.5} aria-hidden='true' />
|
|
298
|
+
</div>
|
|
299
|
+
<div>
|
|
300
|
+
<h3 className='text-foreground text-lg font-semibold'>{category.name}</h3>
|
|
301
|
+
{category.description ? (
|
|
302
|
+
<p className='text-muted-foreground text-sm'>{category.description}</p>
|
|
303
|
+
) : null}
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</CardContent>
|
|
307
|
+
</Card>
|
|
308
|
+
)
|
|
309
|
+
})}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<SectionCta cta={cta} />
|
|
313
|
+
<ViewAllButton onViewAll={onViewAll} viewAllLabel={viewAllLabel} />
|
|
314
|
+
</div>
|
|
315
|
+
</section>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function ListPresentation({
|
|
320
|
+
categories,
|
|
321
|
+
title,
|
|
322
|
+
description,
|
|
323
|
+
query,
|
|
324
|
+
onQueryChange,
|
|
325
|
+
searchPlaceholder,
|
|
326
|
+
searchLabel,
|
|
327
|
+
emptyLabel,
|
|
328
|
+
noResultsLabel,
|
|
329
|
+
featuredLabel,
|
|
330
|
+
browseLabel,
|
|
331
|
+
itemsLabel,
|
|
332
|
+
onBrowse,
|
|
333
|
+
onViewAll,
|
|
334
|
+
viewAllLabel,
|
|
335
|
+
className,
|
|
336
|
+
}: ProductCategoryProps) {
|
|
337
|
+
const hasQuery = Boolean(query && query.trim() !== '')
|
|
338
|
+
// The host owns filtering: this presentation renders exactly the categories it
|
|
339
|
+
// receives. Only the empty copy depends on whether a query is active.
|
|
340
|
+
const emptyMessage = hasQuery ? noResultsLabel ?? emptyLabel : emptyLabel
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<section className={cn('py-12', className)}>
|
|
344
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
345
|
+
{title || description ? (
|
|
346
|
+
<div className='mb-8 text-center'>
|
|
347
|
+
{title ? (
|
|
348
|
+
<h2 className='mb-4 text-3xl font-bold tracking-tight text-balance'>{title}</h2>
|
|
349
|
+
) : null}
|
|
350
|
+
{description ? <p className='text-muted-foreground text-lg'>{description}</p> : null}
|
|
351
|
+
</div>
|
|
352
|
+
) : null}
|
|
353
|
+
|
|
354
|
+
{onQueryChange ? (
|
|
355
|
+
<div className='relative mx-auto mb-8 max-w-md'>
|
|
356
|
+
<Search
|
|
357
|
+
className='text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2'
|
|
358
|
+
aria-hidden='true'
|
|
359
|
+
/>
|
|
360
|
+
<Input
|
|
361
|
+
type='search'
|
|
362
|
+
placeholder={searchPlaceholder}
|
|
363
|
+
value={query ?? ''}
|
|
364
|
+
onChange={event => onQueryChange(event.target.value)}
|
|
365
|
+
className='ps-9 h-9'
|
|
366
|
+
aria-label={searchLabel ?? searchPlaceholder}
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
) : null}
|
|
370
|
+
|
|
371
|
+
{categories.length === 0 ? (
|
|
372
|
+
emptyMessage ? (
|
|
373
|
+
<p className='text-muted-foreground py-10 text-center text-sm'>{emptyMessage}</p>
|
|
374
|
+
) : null
|
|
375
|
+
) : (
|
|
376
|
+
<div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
|
|
377
|
+
{categories.map(category => {
|
|
378
|
+
const Icon = resolveCategoryIcon(category.icon)
|
|
379
|
+
const countLabel = formatCount(category.itemCount, itemsLabel)
|
|
380
|
+
return (
|
|
381
|
+
<Card
|
|
382
|
+
key={category.id}
|
|
383
|
+
className={cn(
|
|
384
|
+
'group hover:border-primary relative py-0 transition-all duration-200 hover:shadow-md',
|
|
385
|
+
)}
|
|
386
|
+
>
|
|
387
|
+
<CardContent className='p-6'>
|
|
388
|
+
<div className='flex items-start gap-4'>
|
|
389
|
+
<div className='flex-shrink-0'>
|
|
390
|
+
<div className='bg-primary/10 group-hover:bg-primary/20 flex size-12 items-center justify-center rounded-lg transition-colors'>
|
|
391
|
+
<Icon className='text-primary size-6' aria-hidden='true' />
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<div className='min-w-0 flex-1'>
|
|
396
|
+
<div className='mb-2 flex items-center gap-2'>
|
|
397
|
+
<h3 className='text-lg font-semibold'>{category.name}</h3>
|
|
398
|
+
{category.featured && featuredLabel ? (
|
|
399
|
+
<Badge variant='secondary' className='rounded-sm px-2 py-0.5 font-semibold'>
|
|
400
|
+
{featuredLabel}
|
|
401
|
+
</Badge>
|
|
402
|
+
) : null}
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
{category.description ? (
|
|
406
|
+
<p className='text-muted-foreground mb-3 line-clamp-2 text-sm'>
|
|
407
|
+
{category.description}
|
|
408
|
+
</p>
|
|
409
|
+
) : null}
|
|
410
|
+
|
|
411
|
+
{category.subcategories && category.subcategories.length > 0 ? (
|
|
412
|
+
<div className='mb-3 flex flex-wrap gap-2'>
|
|
413
|
+
{category.subcategories.map((sub, index) => (
|
|
414
|
+
<span
|
|
415
|
+
key={`${category.id}:sub:${index}`}
|
|
416
|
+
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs'
|
|
417
|
+
>
|
|
418
|
+
{sub}
|
|
419
|
+
</span>
|
|
420
|
+
))}
|
|
421
|
+
</div>
|
|
422
|
+
) : null}
|
|
423
|
+
|
|
424
|
+
{countLabel ? (
|
|
425
|
+
<div className='text-muted-foreground text-sm'>{countLabel}</div>
|
|
426
|
+
) : null}
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
{onBrowse && browseLabel ? (
|
|
431
|
+
<Button
|
|
432
|
+
variant='ghost'
|
|
433
|
+
size='sm'
|
|
434
|
+
aria-label={`${browseLabel} ${category.name}`}
|
|
435
|
+
className='absolute end-4 bottom-4 h-8 cursor-pointer px-3 text-xs opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
|
|
436
|
+
onClick={() => onBrowse(category)}
|
|
437
|
+
>
|
|
438
|
+
{browseLabel}
|
|
439
|
+
<ArrowRight />
|
|
440
|
+
</Button>
|
|
441
|
+
) : null}
|
|
442
|
+
</CardContent>
|
|
443
|
+
</Card>
|
|
444
|
+
)
|
|
445
|
+
})}
|
|
446
|
+
</div>
|
|
447
|
+
)}
|
|
448
|
+
|
|
449
|
+
<ViewAllButton onViewAll={onViewAll} viewAllLabel={viewAllLabel} />
|
|
450
|
+
</div>
|
|
451
|
+
</section>
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function ProductCategory(props: ProductCategoryProps) {
|
|
456
|
+
const presentation = props.presentation ?? 'grid'
|
|
457
|
+
if (presentation === 'icons') return <IconsPresentation {...props} />
|
|
458
|
+
if (presentation === 'list') return <ListPresentation {...props} />
|
|
459
|
+
return <GridPresentation {...props} />
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export default ProductCategory
|