@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. 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