@thorprovider/blocks 0.1.3 → 2.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 (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  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/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -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