@thorprovider/blocks 0.1.3 → 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 (99) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +73 -29
  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/product-categories/category-card-grid.test.tsx +122 -0
  7. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  8. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  9. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  10. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  11. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  12. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  13. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  14. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  15. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  16. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  17. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  18. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  19. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  20. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  21. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  22. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  23. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  24. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  25. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  26. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  27. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  28. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  29. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  30. package/src/blocks/marketing/features/feature.tsx +195 -0
  31. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +27 -9
  32. package/src/blocks/marketing/navbars/navbar-4.test.tsx +33 -2
  33. package/src/blocks/marketing/navbars/navbar-4.tsx +29 -11
  34. package/src/index.ts +77 -48
  35. package/src/lib/contracts-curated.test.ts +42 -24
  36. package/src/lib/contracts.test.ts +143 -2
  37. package/src/lib/contracts.ts +346 -14
  38. package/src/ui/button-group.tsx +1 -1
  39. package/src/ui/carousel.tsx +1 -1
  40. package/src/ui/dialog.tsx +1 -1
  41. package/src/ui/field.tsx +2 -2
  42. package/src/ui/input-group.tsx +3 -3
  43. package/src/ui/pagination.tsx +1 -1
  44. package/src/ui/sidebar.tsx +7 -7
  45. package/src/ui/toggle-group.tsx +1 -1
  46. package/tsconfig.build.json +12 -0
  47. package/tsup.config.ts +11 -7
  48. package/.turbo/turbo-build.log +0 -24
  49. package/.turbo/turbo-test.log +0 -47
  50. package/.turbo/turbo-type-check.log +0 -0
  51. package/dist/.tsbuildinfo +0 -1
  52. package/dist/contracts.d.mts +0 -1732
  53. package/dist/contracts.d.ts +0 -1732
  54. package/dist/contracts.js +0 -423
  55. package/dist/contracts.js.map +0 -1
  56. package/dist/contracts.mjs +0 -365
  57. package/dist/contracts.mjs.map +0 -1
  58. package/dist/index.d.mts +0 -3068
  59. package/dist/index.d.ts +0 -3068
  60. package/dist/index.js +0 -17834
  61. package/dist/index.js.map +0 -1
  62. package/dist/index.mjs +0 -17740
  63. package/dist/index.mjs.map +0 -1
  64. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  65. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  66. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  67. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  68. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  69. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  70. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  71. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  72. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  73. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  74. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  75. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  76. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  77. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  78. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  79. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  80. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  81. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  82. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  83. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  84. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  85. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  86. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  87. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  88. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  89. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  90. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  91. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  92. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  93. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  94. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  95. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  96. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  97. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  98. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  99. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -0,0 +1,1283 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { motion, useReducedMotion } from 'motion/react'
5
+ import { ArrowRight, MapPin, Search, ShoppingBag, Star } from 'lucide-react'
6
+ import type { ComponentProps, ReactNode } from 'react'
7
+
8
+ import { Badge } from '../../../ui/badge'
9
+ import { Button } from '../../../ui/button'
10
+ import { Card, CardContent } from '../../../ui/card'
11
+ import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '../../../ui/carousel'
12
+ import { Input } from '../../../ui/input'
13
+ import { cn } from '../../../lib/utils'
14
+ import type { CTA } from '../../../lib/types'
15
+
16
+ /**
17
+ * Display contract for products featured in the hero. Every popularity,
18
+ * review, or availability field is host-supplied evidence: omitted fields
19
+ * render nothing (honest omission) instead of fabricated defaults.
20
+ */
21
+ export interface FeaturedProduct {
22
+ id: string
23
+ name: string
24
+ /** Current selling price. */
25
+ price: number
26
+ /** Real compare-at price. Discount treatments render only from a real `originalPrice > price` pair. */
27
+ originalPrice?: number
28
+ /** Image URL or path; omitted entirely when absent or blank (never an empty `src`). */
29
+ image?: string
30
+ /** Accessible image description (falls back to `name`). */
31
+ imageAlt?: string
32
+ /** Host-supplied brand label; the row renders only when supplied. */
33
+ brand?: string
34
+ /** Host-supplied swatch colors; the row renders only when supplied. */
35
+ colors?: string[]
36
+ /** Host-supplied rating (0–5); rendered only when supplied. */
37
+ rating?: number
38
+ /** Host-supplied review count; rendered only when supplied. */
39
+ reviewCount?: number
40
+ /** Host-supplied availability. Absence makes no stock claim; an explicit `false` disables purchase. */
41
+ inStock?: boolean
42
+ /** Host-supplied "new" flag; the badge renders only when true. */
43
+ isNew?: boolean
44
+ /** Host-supplied badge label for showcase slides (e.g. a category); rendered only when supplied. */
45
+ tag?: string
46
+ }
47
+
48
+ export interface HeroImage {
49
+ src: string
50
+ alt: string
51
+ }
52
+
53
+ export interface HeroDepartment {
54
+ id: string
55
+ name: string
56
+ /** Image URL; omitted entirely when absent or blank. */
57
+ image?: string
58
+ /** Accessible image description (falls back to `name`). */
59
+ imageAlt?: string
60
+ /** Host-supplied count label; rendered only when supplied. */
61
+ count?: string
62
+ /** Link destination; renders the chip as a link when present. */
63
+ href?: string
64
+ }
65
+
66
+ export interface HeroFeaturedDeal {
67
+ id: string
68
+ name: string
69
+ /** Host-supplied supporting description; rendered only when supplied. */
70
+ description?: string
71
+ /** Image URL; omitted entirely when absent or blank. */
72
+ image?: string
73
+ /** Accessible image description (falls back to `name`). */
74
+ imageAlt?: string
75
+ price: number
76
+ /** Real compare-at price; rendered struck-through only when greater than `price`. */
77
+ originalPrice?: number
78
+ /** Optional call to action shown on the card. */
79
+ cta?: CTA
80
+ }
81
+
82
+ export interface HeroStoreLocator {
83
+ title: string
84
+ description: string
85
+ ctaLabel: string
86
+ /** Link destination for the locator button; falls back to `onLocateStores`. */
87
+ ctaHref?: string
88
+ }
89
+
90
+ export interface HeroDeal {
91
+ id: string
92
+ /** Deal name shown in the deals bar badge, e.g. a campaign name. */
93
+ title: string
94
+ /** Host-supplied discount label (from a real compare-at price); rendered only when supplied. */
95
+ discount?: string
96
+ /** Host-supplied countdown text; rendered only when supplied — the block never invents one. */
97
+ endTime?: string
98
+ }
99
+
100
+ export interface HeroCategory {
101
+ id: string
102
+ name: string
103
+ /** Image URL; omitted entirely when absent or blank. */
104
+ image?: string
105
+ /** Accessible image description (falls back to `name`). */
106
+ imageAlt?: string
107
+ /** Host-supplied count label; rendered only when supplied. */
108
+ count?: string
109
+ /** Link destination; renders the card arrow as a link when present. */
110
+ href?: string
111
+ }
112
+
113
+ /**
114
+ * The bounded presentation surface, reusing the registry's archetype intent
115
+ * vocabulary: `compact` (text-forward hero + product grid), `showcase`
116
+ * (search affordance + rotating product carousel), `editorial` (department
117
+ * navigation + optional deals/locator), `promotional` (deals bar + category
118
+ * grid). The value changes structure, never the data contract.
119
+ */
120
+ export type StorefrontHeroPresentation = 'compact' | 'showcase' | 'editorial' | 'promotional'
121
+
122
+ export interface StorefrontHeroProps {
123
+ title: string
124
+ description: string
125
+ /** Bounded presentation option; defaults to `compact`. */
126
+ presentation?: StorefrontHeroPresentation
127
+ /** ISO 4217 code used to format prices via `Intl.NumberFormat`; never hardcoded. */
128
+ currency?: string
129
+ /** Featured products. An empty list renders the hero text-forward with no empty grid chrome. */
130
+ products: FeaturedProduct[]
131
+ badge?: string
132
+ titleAccent?: string
133
+ primaryCta?: CTA
134
+ secondaryCta?: CTA
135
+ /**
136
+ * Optional editorial image. Rendered as a native `<img>` when `src` is a
137
+ * non-empty string; omitted entirely when absent or blank (never an empty
138
+ * `src`).
139
+ */
140
+ heroImage?: HeroImage
141
+ /** `showcase` only. The search input renders only when a handler is supplied. */
142
+ onSearch?: (query: string) => void
143
+ /** `editorial` only: department navigation grid. */
144
+ departments?: HeroDepartment[]
145
+ /** `editorial` only: featured deal cards. */
146
+ featuredDeals?: HeroFeaturedDeal[]
147
+ /** `editorial` only: store locator strip. */
148
+ storeLocator?: HeroStoreLocator
149
+ /** `editorial` only: shipping banner text under the CTAs. */
150
+ shippingInfo?: string
151
+ /** `promotional` only: rotating deals bar. */
152
+ deals?: HeroDeal[]
153
+ /** `promotional` only: category card grid. */
154
+ categories?: HeroCategory[]
155
+ /** Milliseconds between rotations (showcase carousel and promotional deals bar; default 3000). */
156
+ rotationInterval?: number
157
+ onAddToCart?: (product: FeaturedProduct) => void
158
+ onBuyNow?: (product: FeaturedProduct) => void
159
+ onSelectColor?: (product: FeaturedProduct, index: number, color: string) => void
160
+ onDepartmentClick?: (department: HeroDepartment) => void
161
+ onShopNow?: (item: HeroFeaturedDeal | HeroDeal) => void
162
+ onLocateStores?: () => void
163
+ onCategoryClick?: (category: HeroCategory) => void
164
+ /** Detail/purchase affordance on product cards and showcase slides. */
165
+ addToCartLabel?: string
166
+ buyNowLabel?: string
167
+ newLabel?: string
168
+ outOfStockLabel?: string
169
+ colorsLabel?: string
170
+ /** Suffix appended to the computed discount percentage (default `% OFF`). */
171
+ discountSuffixLabel?: string
172
+ /** `showcase` labels. */
173
+ searchPlaceholder?: string
174
+ searchLabel?: string
175
+ /** `showcase` copy under each slide's product name. */
176
+ featuredDescription?: string
177
+ /** `editorial`/`promotional` labels. */
178
+ shopNowLabel?: string
179
+ endsInLabel?: string
180
+ productsLabel?: string
181
+ viewLabel?: string
182
+ }
183
+
184
+ function hasRealDiscount(item: Pick<FeaturedProduct, 'price' | 'originalPrice'>): boolean {
185
+ return (
186
+ typeof item.originalPrice === 'number' &&
187
+ Number.isFinite(item.originalPrice) &&
188
+ item.originalPrice > item.price &&
189
+ item.price >= 0
190
+ )
191
+ }
192
+
193
+ function discountPercent(price: number, originalPrice: number): number {
194
+ return Math.round(((originalPrice - price) / originalPrice) * 100)
195
+ }
196
+
197
+ function formatAmount(amount: number, currency?: string): string {
198
+ if (currency) {
199
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
200
+ }
201
+ return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(amount)
202
+ }
203
+
204
+ function CtaButton({
205
+ cta,
206
+ variant,
207
+ className = 'group relative cursor-pointer overflow-hidden px-8 py-6 text-base font-medium',
208
+ children,
209
+ }: {
210
+ cta: CTA
211
+ variant?: ComponentProps<typeof Button>['variant']
212
+ className?: string
213
+ children: ReactNode
214
+ }) {
215
+ if (cta.href) {
216
+ return (
217
+ <Button variant={variant} size='lg' className={className} asChild>
218
+ <a href={cta.href}>{children}</a>
219
+ </Button>
220
+ )
221
+ }
222
+
223
+ return (
224
+ <Button variant={variant} size='lg' className={className}>
225
+ {children}
226
+ </Button>
227
+ )
228
+ }
229
+
230
+ function HeroHeading({
231
+ title,
232
+ titleAccent,
233
+ badge,
234
+ }: {
235
+ title: string
236
+ titleAccent?: string
237
+ badge?: string
238
+ }) {
239
+ return (
240
+ <>
241
+ {badge && (
242
+ <div className='border-primary/20 bg-primary/5 text-primary inline-flex items-center rounded-full border px-4 py-1.5 text-sm font-medium backdrop-blur-sm'>
243
+ {badge}
244
+ </div>
245
+ )}
246
+ <h1 className='text-foreground mt-8 text-4xl font-bold tracking-tight sm:text-5xl md:text-7xl'>
247
+ {title}{' '}
248
+ {titleAccent && (
249
+ <span className='from-primary to-primary/70 bg-gradient-to-r bg-clip-text text-transparent'>
250
+ {titleAccent}
251
+ </span>
252
+ )}
253
+ </h1>
254
+ </>
255
+ )
256
+ }
257
+
258
+ function EditorialHeading({
259
+ title,
260
+ titleAccent,
261
+ badge,
262
+ }: {
263
+ title: string
264
+ titleAccent?: string
265
+ badge?: string
266
+ }) {
267
+ return (
268
+ <>
269
+ {badge && (
270
+ <div className='border-primary/20 bg-primary/5 text-primary inline-flex items-center justify-center rounded-md border px-4 py-1.5 text-sm'>
271
+ {badge}
272
+ </div>
273
+ )}
274
+ <h1 className='mt-6 text-4xl font-bold tracking-tight text-balance sm:text-5xl md:text-6xl'>
275
+ {title} {titleAccent && <span className='text-primary'>{titleAccent}</span>}
276
+ </h1>
277
+ </>
278
+ )
279
+ }
280
+
281
+ interface HeroLabels {
282
+ addToCartLabel: string
283
+ buyNowLabel: string
284
+ newLabel: string
285
+ outOfStockLabel: string
286
+ colorsLabel: string
287
+ discountSuffixLabel: string
288
+ searchPlaceholder: string
289
+ searchLabel: string
290
+ featuredDescription?: string
291
+ shopNowLabel: string
292
+ endsInLabel: string
293
+ productsLabel: string
294
+ viewLabel: string
295
+ }
296
+
297
+ function CompactProductCard({
298
+ product,
299
+ currency,
300
+ labels,
301
+ onAddToCart,
302
+ onBuyNow,
303
+ onSelectColor,
304
+ hasCustomBuyNowLabel,
305
+ }: {
306
+ product: FeaturedProduct
307
+ currency?: string
308
+ labels: HeroLabels
309
+ onAddToCart?: (product: FeaturedProduct) => void
310
+ onBuyNow?: (product: FeaturedProduct) => void
311
+ onSelectColor?: (product: FeaturedProduct, index: number, color: string) => void
312
+ hasCustomBuyNowLabel: boolean
313
+ }) {
314
+ const outOfStock = product.inStock === false
315
+ const onSale = hasRealDiscount(product)
316
+ // A host-supplied custom action label is a purchase affordance and requires
317
+ // its handler; the block's default label is a detail affordance that may
318
+ // render without one. Never infer this from the label's language.
319
+ const showBuyNow = Boolean(onBuyNow) || !hasCustomBuyNowLabel
320
+
321
+ return (
322
+ <div className='bg-card relative h-full overflow-hidden rounded-lg border shadow-sm transition-shadow hover:shadow-md'>
323
+ <div className='absolute top-4 left-4 z-10 flex flex-col gap-2'>
324
+ {product.isNew && (
325
+ <Badge variant='secondary' className='rounded-full'>{labels.newLabel}</Badge>
326
+ )}
327
+ {onSale && (
328
+ <Badge variant='destructive' className='rounded-full'>
329
+ {discountPercent(product.price, product.originalPrice as number)}
330
+ {labels.discountSuffixLabel}
331
+ </Badge>
332
+ )}
333
+ </div>
334
+
335
+ <div className='relative aspect-square overflow-hidden'>
336
+ {product.image && product.image.trim().length > 0 ? (
337
+ <img
338
+ src={product.image}
339
+ alt={product.imageAlt ?? product.name}
340
+ className='size-full object-cover'
341
+ loading='lazy'
342
+ />
343
+ ) : null}
344
+ {outOfStock && (
345
+ <div className='absolute inset-0 flex items-center justify-center bg-black/50'>
346
+ <span className='rounded bg-white/90 px-3 py-1 text-sm font-medium'>{labels.outOfStockLabel}</span>
347
+ </div>
348
+ )}
349
+ </div>
350
+
351
+ <div className='p-4'>
352
+ {product.brand && product.brand.trim().length > 0 && (
353
+ <div className='text-muted-foreground mb-1 text-sm font-medium'>{product.brand}</div>
354
+ )}
355
+ <h3 className='mb-2 font-medium'>{product.name}</h3>
356
+
357
+ <div className='mb-3'>
358
+ <span className='text-lg font-bold'>{formatAmount(product.price, currency)}</span>
359
+ {onSale && (
360
+ <span className='text-muted-foreground ms-2 text-sm line-through'>
361
+ {formatAmount(product.originalPrice as number, currency)}
362
+ </span>
363
+ )}
364
+ </div>
365
+
366
+ {product.colors && product.colors.length > 0 && (
367
+ <div className='mb-4 flex items-center'>
368
+ <span className='text-muted-foreground me-2 text-sm'>{labels.colorsLabel}</span>
369
+ <div className='flex gap-1'>
370
+ {product.colors.map((color, i) => (
371
+ <button
372
+ key={i}
373
+ className='size-5 rounded-full border'
374
+ style={{ backgroundColor: color }}
375
+ aria-label={`Opción de color ${i + 1}`}
376
+ onClick={onSelectColor ? () => onSelectColor(product, i, color) : undefined}
377
+ />
378
+ ))}
379
+ </div>
380
+ </div>
381
+ )}
382
+
383
+ {typeof product.rating === 'number' && (
384
+ <div className='mb-4 flex items-center'>
385
+ <div className='flex'>
386
+ {[...Array(5)].map((_, i) => (
387
+ <Star
388
+ key={i}
389
+ className={`size-4 ${i < Math.floor(product.rating ?? 0) ? 'fill-foreground text-foreground' : 'text-muted-foreground'}`}
390
+ />
391
+ ))}
392
+ </div>
393
+ {typeof product.reviewCount === 'number' && (
394
+ <span className='text-muted-foreground ms-2 text-sm'>({product.reviewCount.toLocaleString()})</span>
395
+ )}
396
+ </div>
397
+ )}
398
+
399
+ <div className='flex flex-col gap-2'>
400
+ {onAddToCart && (
401
+ <Button
402
+ variant='outline'
403
+ className='h-9 cursor-pointer px-4 py-2'
404
+ disabled={outOfStock}
405
+ onClick={() => onAddToCart(product)}
406
+ >
407
+ <ShoppingBag />
408
+ {labels.addToCartLabel}
409
+ </Button>
410
+ )}
411
+ {showBuyNow && (
412
+ <Button
413
+ className='h-9 cursor-pointer px-4 py-2'
414
+ disabled={outOfStock}
415
+ onClick={onBuyNow ? () => onBuyNow(product) : undefined}
416
+ >
417
+ {labels.buyNowLabel}
418
+ </Button>
419
+ )}
420
+ </div>
421
+ </div>
422
+ </div>
423
+ )
424
+ }
425
+
426
+ function CompactPresentation({
427
+ title,
428
+ description,
429
+ products,
430
+ badge,
431
+ titleAccent,
432
+ primaryCta,
433
+ secondaryCta,
434
+ heroImage,
435
+ currency,
436
+ labels,
437
+ onAddToCart,
438
+ onBuyNow,
439
+ onSelectColor,
440
+ hasCustomBuyNowLabel,
441
+ }: {
442
+ title: string
443
+ description: string
444
+ products: FeaturedProduct[]
445
+ badge?: string
446
+ titleAccent?: string
447
+ primaryCta?: CTA
448
+ secondaryCta?: CTA
449
+ heroImage?: HeroImage
450
+ currency?: string
451
+ labels: HeroLabels
452
+ onAddToCart?: (product: FeaturedProduct) => void
453
+ onBuyNow?: (product: FeaturedProduct) => void
454
+ onSelectColor?: (product: FeaturedProduct, index: number, color: string) => void
455
+ hasCustomBuyNowLabel: boolean
456
+ }) {
457
+ const reduceMotion = useReducedMotion()
458
+
459
+ return (
460
+ <div className='from-primary/5 to-background relative overflow-hidden bg-gradient-to-br'>
461
+ <div className='relative z-10 mx-auto w-full max-w-7xl flex flex-col items-center justify-between gap-12 px-4 sm:px-6 lg:px-8 py-16 lg:py-20 xl:flex-row xl:items-start'>
462
+ <motion.div
463
+ initial={reduceMotion ? false : { opacity: 0, y: 24 }}
464
+ animate={{ opacity: 1, y: 0 }}
465
+ transition={{ duration: 0.5, ease: 'easeOut' }}
466
+ className='mx-auto max-w-xl text-center lg:mx-0 xl:text-left'
467
+ >
468
+ <HeroHeading title={title} titleAccent={titleAccent} badge={badge} />
469
+ <p className='text-muted-foreground mt-8 text-lg'>{description}</p>
470
+ {(primaryCta || secondaryCta) && (
471
+ <div className='mt-10 flex flex-col gap-4 sm:flex-row sm:justify-center xl:justify-start'>
472
+ {primaryCta && (
473
+ <CtaButton cta={primaryCta}>
474
+ <span className='relative z-10 flex items-center gap-2'>
475
+ {primaryCta.label}
476
+ <ArrowRight className='transition-transform group-hover:translate-x-1' />
477
+ </span>
478
+ <span className='bg-primary/5 absolute inset-0 -z-0 opacity-0 transition-opacity group-hover:opacity-100'></span>
479
+ </CtaButton>
480
+ )}
481
+ {secondaryCta && (
482
+ <CtaButton cta={secondaryCta} variant='outline'>
483
+ <span className='relative z-10 flex items-center gap-2'>
484
+ <ShoppingBag />
485
+ {secondaryCta.label}
486
+ </span>
487
+ </CtaButton>
488
+ )}
489
+ </div>
490
+ )}
491
+ {heroImage && heroImage.src.trim().length > 0 ? (
492
+ <img
493
+ src={heroImage.src}
494
+ alt={heroImage.alt}
495
+ className='mt-10 aspect-video w-full rounded-lg object-cover'
496
+ />
497
+ ) : null}
498
+ </motion.div>
499
+
500
+ {products.length > 0 && (
501
+ <div className='w-full max-w-2xl lg:mt-0'>
502
+ <div className='grid gap-6 sm:grid-cols-2'>
503
+ {products.map((product, index) => (
504
+ <motion.div
505
+ key={product.id}
506
+ initial={reduceMotion ? false : { opacity: 0, y: 24 }}
507
+ whileInView={{ opacity: 1, y: 0 }}
508
+ viewport={{ once: true, margin: '-40px' }}
509
+ transition={{
510
+ duration: 0.45,
511
+ delay: reduceMotion ? 0 : index * 0.1,
512
+ ease: 'easeOut',
513
+ }}
514
+ >
515
+ <CompactProductCard
516
+ product={product}
517
+ currency={currency}
518
+ labels={labels}
519
+ onAddToCart={onAddToCart}
520
+ onBuyNow={onBuyNow}
521
+ onSelectColor={onSelectColor}
522
+ hasCustomBuyNowLabel={hasCustomBuyNowLabel}
523
+ />
524
+ </motion.div>
525
+ ))}
526
+ </div>
527
+ </div>
528
+ )}
529
+ </div>
530
+
531
+ <div className='bg-primary/10 absolute -top-32 -right-32 size-96 rounded-full blur-3xl' />
532
+ <div className='bg-secondary/30 absolute -bottom-32 -left-32 size-96 rounded-full blur-3xl' />
533
+ </div>
534
+ )
535
+ }
536
+
537
+ function ShowcaseSlide({
538
+ product,
539
+ currency,
540
+ labels,
541
+ onBuyNow,
542
+ }: {
543
+ product: FeaturedProduct
544
+ currency?: string
545
+ labels: HeroLabels
546
+ onBuyNow?: (product: FeaturedProduct) => void
547
+ }) {
548
+ const outOfStock = product.inStock === false
549
+
550
+ return (
551
+ <Card className='relative size-full overflow-hidden border-1 py-4'>
552
+ <CardContent className='px-4'>
553
+ <div className='relative size-full overflow-hidden rounded-md'>
554
+ {product.image && product.image.trim().length > 0 ? (
555
+ <img
556
+ src={product.image}
557
+ alt={product.imageAlt ?? product.name}
558
+ className='h-[500px] w-full object-cover'
559
+ loading='lazy'
560
+ />
561
+ ) : null}
562
+ </div>
563
+ <div className='from-background/90 via-background/30 absolute inset-0 bg-linear-to-t to-transparent' />
564
+
565
+ <div className='text-background-foreground absolute inset-0 flex flex-col justify-end p-8'>
566
+ <div className='relative z-10 flex max-w-md flex-col gap-4'>
567
+ {product.tag && (
568
+ <Badge className='w-fit rounded-full px-2.5 py-0.5 font-semibold'>{product.tag}</Badge>
569
+ )}
570
+ <h2 className='text-4xl font-bold'>{product.name}</h2>
571
+ {labels.featuredDescription && (
572
+ <p className='text-background-foreground/80 text-lg'>{labels.featuredDescription}</p>
573
+ )}
574
+ <span className='text-3xl font-bold'>{formatAmount(product.price, currency)}</span>
575
+ <div className='flex items-center gap-4 pt-2'>
576
+ {onBuyNow && (
577
+ <Button
578
+ size='lg'
579
+ className='h-10 cursor-pointer rounded-full px-8'
580
+ disabled={outOfStock}
581
+ onClick={() => onBuyNow(product)}
582
+ >
583
+ {labels.buyNowLabel}
584
+ </Button>
585
+ )}
586
+ {typeof product.rating === 'number' && (
587
+ <div className='text-foreground flex items-center gap-1'>
588
+ <Star className='fill-foreground size-5' />
589
+ <span className='font-medium'>{product.rating}</span>
590
+ {typeof product.reviewCount === 'number' && (
591
+ <span className='text-foreground/80'>({product.reviewCount})</span>
592
+ )}
593
+ </div>
594
+ )}
595
+ </div>
596
+ </div>
597
+ </div>
598
+ </CardContent>
599
+ </Card>
600
+ )
601
+ }
602
+
603
+ function ShowcasePresentation({
604
+ title,
605
+ description,
606
+ products,
607
+ badge,
608
+ primaryCta,
609
+ secondaryCta,
610
+ currency,
611
+ labels,
612
+ onSearch,
613
+ onBuyNow,
614
+ rotationInterval,
615
+ }: {
616
+ title: string
617
+ description: string
618
+ products: FeaturedProduct[]
619
+ badge?: string
620
+ primaryCta?: CTA
621
+ secondaryCta?: CTA
622
+ currency?: string
623
+ labels: HeroLabels
624
+ onSearch?: (query: string) => void
625
+ onBuyNow?: (product: FeaturedProduct) => void
626
+ rotationInterval: number
627
+ }) {
628
+ const reduceMotion = useReducedMotion()
629
+ const [searchQuery, setSearchQuery] = useState('')
630
+ const [api, setApi] = useState<CarouselApi>()
631
+ const [currentSlide, setCurrentSlide] = useState(0)
632
+
633
+ useEffect(() => {
634
+ if (!api || products.length <= 1 || reduceMotion) return
635
+
636
+ const interval = setInterval(() => {
637
+ const nextSlide = (currentSlide + 1) % products.length
638
+ api.scrollTo(nextSlide)
639
+ setCurrentSlide(nextSlide)
640
+ }, rotationInterval)
641
+
642
+ return () => clearInterval(interval)
643
+ }, [api, currentSlide, products.length, reduceMotion, rotationInterval])
644
+
645
+ const handleSearch = () => {
646
+ if (onSearch) onSearch(searchQuery)
647
+ }
648
+
649
+ return (
650
+ <section className='from-background to-accent/20 relative bg-linear-to-b'>
651
+ <div className='relative mx-auto w-full max-w-7xl px-4 py-16 sm:px-6 lg:px-8 lg:py-20'>
652
+ <div className='grid grid-cols-1 items-center gap-12 lg:grid-cols-2'>
653
+ <header className='flex flex-col gap-8'>
654
+ {badge && (
655
+ <Badge variant='outline' className='flex h-auto w-fit items-center rounded-full px-4 py-2 font-semibold'>
656
+ {badge}
657
+ </Badge>
658
+ )}
659
+
660
+ <h1 className='text-5xl leading-tight font-bold text-balance md:text-6xl lg:text-7xl'>{title}</h1>
661
+
662
+ <p className='text-muted-foreground max-w-lg text-xl text-balance'>{description}</p>
663
+
664
+ {onSearch && (
665
+ <div className='relative max-w-md'>
666
+ <Input
667
+ type='search'
668
+ placeholder={labels.searchPlaceholder}
669
+ value={searchQuery}
670
+ onChange={e => setSearchQuery(e.target.value)}
671
+ onKeyDown={e => {
672
+ if (e.key === 'Enter') handleSearch()
673
+ }}
674
+ className='h-14 rounded-full pe-4 pl-12 text-lg'
675
+ aria-label={labels.searchPlaceholder}
676
+ />
677
+ <Search className='text-muted-foreground absolute top-1/2 start-4 size-5 -translate-y-1/2' />
678
+ <Button
679
+ size='lg'
680
+ className='absolute top-1/2 end-2 h-10 -translate-y-1/2 cursor-pointer rounded-full px-6'
681
+ onClick={handleSearch}
682
+ >
683
+ {labels.searchLabel}
684
+ </Button>
685
+ </div>
686
+ )}
687
+
688
+ {(primaryCta || secondaryCta) && (
689
+ <div className='flex items-center gap-4'>
690
+ {primaryCta && (
691
+ <CtaButton cta={primaryCta} className='h-10 cursor-pointer rounded-full px-4'>
692
+ <span className='flex items-center gap-2'>
693
+ {primaryCta.label}
694
+ <ArrowRight />
695
+ </span>
696
+ </CtaButton>
697
+ )}
698
+ {secondaryCta && (
699
+ <CtaButton cta={secondaryCta} variant='outline' className='h-10 cursor-pointer rounded-full px-4'>
700
+ <span className='flex items-center gap-2'>
701
+ <ShoppingBag />
702
+ {secondaryCta.label}
703
+ </span>
704
+ </CtaButton>
705
+ )}
706
+ </div>
707
+ )}
708
+ </header>
709
+
710
+ {products.length > 0 && (
711
+ <div className='flex flex-col gap-4'>
712
+ <div className='relative h-[500px] w-full border-0'>
713
+ <Carousel
714
+ className='group size-full'
715
+ setApi={setApi}
716
+ opts={{
717
+ align: 'start',
718
+ loop: true,
719
+ duration: 20,
720
+ skipSnaps: true,
721
+ }}
722
+ >
723
+ <CarouselContent className='h-full'>
724
+ {products.map(product => (
725
+ <CarouselItem key={product.id} className='h-full'>
726
+ <ShowcaseSlide product={product} currency={currency} labels={labels} onBuyNow={onBuyNow} />
727
+ </CarouselItem>
728
+ ))}
729
+ </CarouselContent>
730
+ </Carousel>
731
+ </div>
732
+
733
+ <div className='relative mt-8 flex justify-center gap-3'>
734
+ {products.map((product, index) => (
735
+ <button
736
+ key={product.id}
737
+ type='button'
738
+ onClick={() => {
739
+ api?.scrollTo(index)
740
+ setCurrentSlide(index)
741
+ }}
742
+ className={cn(
743
+ 'relative size-3 rounded-full transition-all',
744
+ currentSlide === index ? 'bg-primary' : 'bg-foreground/20 hover:bg-foreground/40'
745
+ )}
746
+ aria-label={`Ir a la diapositiva ${index + 1}`}
747
+ aria-current={currentSlide === index ? 'step' : undefined}
748
+ />
749
+ ))}
750
+ </div>
751
+ </div>
752
+ )}
753
+ </div>
754
+ </div>
755
+ </section>
756
+ )
757
+ }
758
+
759
+ function EditorialPresentation({
760
+ title,
761
+ description,
762
+ badge,
763
+ titleAccent,
764
+ primaryCta,
765
+ secondaryCta,
766
+ shippingInfo,
767
+ departments,
768
+ featuredDeals,
769
+ storeLocator,
770
+ currency,
771
+ labels,
772
+ onDepartmentClick,
773
+ onShopNow,
774
+ onLocateStores,
775
+ }: {
776
+ title: string
777
+ description: string
778
+ badge?: string
779
+ titleAccent?: string
780
+ primaryCta?: CTA
781
+ secondaryCta?: CTA
782
+ shippingInfo?: string
783
+ departments: HeroDepartment[]
784
+ featuredDeals: HeroFeaturedDeal[]
785
+ storeLocator?: HeroStoreLocator
786
+ currency?: string
787
+ labels: HeroLabels
788
+ onDepartmentClick?: (department: HeroDepartment) => void
789
+ onShopNow?: (item: HeroFeaturedDeal | HeroDeal) => void
790
+ onLocateStores?: () => void
791
+ }) {
792
+ const departmentChipClasses = cn(
793
+ 'flex flex-col items-center gap-4 rounded-lg p-2 text-center outline-none',
794
+ 'transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring',
795
+ )
796
+
797
+ return (
798
+ <div className='bg-background relative overflow-hidden'>
799
+ <div className='relative z-10'>
800
+ <div className='mx-auto w-full max-w-7xl px-4 py-16 sm:px-6 lg:px-8 lg:py-20'>
801
+ <div className='mx-auto max-w-4xl text-center'>
802
+ <EditorialHeading title={title} titleAccent={titleAccent} badge={badge} />
803
+
804
+ <p className='text-muted-foreground mx-auto mt-6 max-w-2xl text-lg'>{description}</p>
805
+
806
+ {(primaryCta || secondaryCta) && (
807
+ <div className='mt-8 flex flex-wrap justify-center gap-4'>
808
+ {primaryCta && (
809
+ <CtaButton cta={primaryCta} className='group h-10 cursor-pointer px-4 text-base'>
810
+ {primaryCta.label}
811
+ <ArrowRight className='transition-transform group-hover:translate-x-1' />
812
+ </CtaButton>
813
+ )}
814
+ {secondaryCta && (
815
+ <CtaButton cta={secondaryCta} variant='outline' className='group h-10 cursor-pointer px-4 text-base'>
816
+ {secondaryCta.label}
817
+ </CtaButton>
818
+ )}
819
+ </div>
820
+ )}
821
+
822
+ {shippingInfo && (
823
+ <div className='mt-6'>
824
+ <div className='bg-background/80 text-muted-foreground inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm backdrop-blur-sm'>
825
+ <MapPin className='text-primary size-4' aria-hidden='true' />
826
+ <span>{shippingInfo}</span>
827
+ </div>
828
+ </div>
829
+ )}
830
+
831
+ {departments.length > 0 && (
832
+ <div className='mt-12 grid grid-cols-3 gap-8'>
833
+ {departments.map(department => {
834
+ const chip = (
835
+ <>
836
+ {department.image && department.image.trim().length > 0 ? (
837
+ <img
838
+ src={department.image}
839
+ alt={department.imageAlt ?? department.name}
840
+ className='bg-card border-border size-16 rounded-full border object-cover'
841
+ loading='lazy'
842
+ />
843
+ ) : null}
844
+ <div>
845
+ <div className='text-lg font-medium'>{department.name}</div>
846
+ {department.count && (
847
+ <div className='text-muted-foreground mt-1 text-sm'>{department.count}</div>
848
+ )}
849
+ </div>
850
+ </>
851
+ )
852
+
853
+ if (department.href) {
854
+ return (
855
+ <a
856
+ key={department.id}
857
+ href={department.href}
858
+ className={cn(departmentChipClasses, 'cursor-pointer')}
859
+ >
860
+ {chip}
861
+ </a>
862
+ )
863
+ }
864
+
865
+ return (
866
+ <button
867
+ key={department.id}
868
+ type='button'
869
+ onClick={onDepartmentClick ? () => onDepartmentClick(department) : undefined}
870
+ className={cn(departmentChipClasses, 'cursor-pointer')}
871
+ >
872
+ {chip}
873
+ </button>
874
+ )
875
+ })}
876
+ </div>
877
+ )}
878
+
879
+ {featuredDeals.length > 0 && (
880
+ <div className='mt-16 grid gap-6 sm:grid-cols-2'>
881
+ {featuredDeals.map(deal => {
882
+ const onSale = hasRealDiscount(deal)
883
+
884
+ return (
885
+ <Card key={deal.id} className='mx-auto h-full w-full overflow-hidden border-0 py-0 shadow-lg'>
886
+ <CardContent className='p-0'>
887
+ <div className='relative aspect-[3/2] overflow-hidden'>
888
+ {deal.image && deal.image.trim().length > 0 ? (
889
+ <img
890
+ src={deal.image}
891
+ alt={deal.imageAlt ?? deal.name}
892
+ className='size-full object-cover object-center transition-transform duration-700 hover:scale-105'
893
+ loading='lazy'
894
+ />
895
+ ) : null}
896
+ <div className='from-background/90 via-background/30 absolute inset-0 bg-gradient-to-t to-transparent' />
897
+ <div className='absolute inset-0 flex items-end p-8'>
898
+ <div className='w-full'>
899
+ <div className='bg-background/80 flex flex-col items-center justify-between gap-6 rounded-lg p-6 backdrop-blur-sm sm:flex-row sm:gap-8'>
900
+ <div className='text-center sm:text-left'>
901
+ <h3 className='text-2xl font-bold tracking-tight sm:text-3xl'>{deal.name}</h3>
902
+ {deal.description && (
903
+ <p className='text-muted-foreground mt-1'>{deal.description}</p>
904
+ )}
905
+ <div className='mt-2 flex items-baseline justify-center gap-2 sm:justify-start'>
906
+ <span className='text-lg font-bold'>{formatAmount(deal.price, currency)}</span>
907
+ {onSale && (
908
+ <span className='text-muted-foreground text-sm line-through'>
909
+ {formatAmount(deal.originalPrice as number, currency)}
910
+ </span>
911
+ )}
912
+ </div>
913
+ </div>
914
+ {(deal.cta?.href || onShopNow) && (
915
+ <div className='flex-shrink-0'>
916
+ {deal.cta?.href ? (
917
+ <Button size='lg' className='group h-10 cursor-pointer px-4' asChild>
918
+ <a href={deal.cta.href}>
919
+ {deal.cta.label}
920
+ <ArrowRight className='transition-transform group-hover:translate-x-1' />
921
+ </a>
922
+ </Button>
923
+ ) : (
924
+ <Button
925
+ size='lg'
926
+ className='group h-10 cursor-pointer px-4'
927
+ onClick={() => onShopNow?.(deal)}
928
+ >
929
+ {deal.cta?.label ?? labels.shopNowLabel}
930
+ <ArrowRight className='transition-transform group-hover:translate-x-1' />
931
+ </Button>
932
+ )}
933
+ </div>
934
+ )}
935
+ </div>
936
+ </div>
937
+ </div>
938
+ </div>
939
+ </CardContent>
940
+ </Card>
941
+ )
942
+ })}
943
+ </div>
944
+ )}
945
+
946
+ {storeLocator && (
947
+ <div className='border-border/50 bg-background/50 mt-8 rounded-lg border p-6 backdrop-blur-sm'>
948
+ <div className='flex flex-col items-center gap-6 sm:flex-row sm:justify-between'>
949
+ <div className='flex items-center gap-4'>
950
+ <div className='bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full'>
951
+ <MapPin className='size-6' aria-hidden='true' />
952
+ </div>
953
+ <div className='text-start'>
954
+ <h4 className='text-lg font-medium'>{storeLocator.title}</h4>
955
+ <p className='text-muted-foreground'>{storeLocator.description}</p>
956
+ </div>
957
+ </div>
958
+ {(storeLocator.ctaHref || onLocateStores) && (
959
+ <div>
960
+ {storeLocator.ctaHref ? (
961
+ <Button variant='outline' className='h-9 cursor-pointer px-4 py-2 whitespace-nowrap' asChild>
962
+ <a href={storeLocator.ctaHref}>{storeLocator.ctaLabel}</a>
963
+ </Button>
964
+ ) : (
965
+ <Button
966
+ variant='outline'
967
+ className='h-9 cursor-pointer px-4 py-2 whitespace-nowrap'
968
+ onClick={onLocateStores}
969
+ >
970
+ {storeLocator.ctaLabel}
971
+ </Button>
972
+ )}
973
+ </div>
974
+ )}
975
+ </div>
976
+ </div>
977
+ )}
978
+ </div>
979
+ </div>
980
+ </div>
981
+ </div>
982
+ )
983
+ }
984
+
985
+ function PromotionalPresentation({
986
+ title,
987
+ description,
988
+ badge,
989
+ titleAccent,
990
+ deals,
991
+ categories,
992
+ rotationInterval,
993
+ labels,
994
+ onShopNow,
995
+ onCategoryClick,
996
+ }: {
997
+ title: string
998
+ description: string
999
+ badge?: string
1000
+ titleAccent?: string
1001
+ deals: HeroDeal[]
1002
+ categories: HeroCategory[]
1003
+ rotationInterval: number
1004
+ labels: HeroLabels
1005
+ onShopNow?: (item: HeroFeaturedDeal | HeroDeal) => void
1006
+ onCategoryClick?: (category: HeroCategory) => void
1007
+ }) {
1008
+ const reduceMotion = useReducedMotion()
1009
+ const [currentDeal, setCurrentDeal] = useState(0)
1010
+
1011
+ useEffect(() => {
1012
+ if (deals.length <= 1 || reduceMotion) return
1013
+
1014
+ const interval = setInterval(() => {
1015
+ setCurrentDeal(prev => (prev + 1) % deals.length)
1016
+ }, rotationInterval)
1017
+
1018
+ return () => clearInterval(interval)
1019
+ }, [deals.length, reduceMotion, rotationInterval])
1020
+
1021
+ const deal = deals[currentDeal]
1022
+
1023
+ return (
1024
+ <div className='relative'>
1025
+ {deal && (
1026
+ <header className='bg-primary text-primary-foreground sticky top-0 z-50 shadow-md'>
1027
+ <div className='mx-auto w-full max-w-7xl px-4 py-2 sm:px-6 lg:px-8'>
1028
+ <div className='flex items-center justify-center'>
1029
+ <div className='flex w-full max-w-3xl items-center justify-center gap-4'>
1030
+ <div className='flex items-center gap-2'>
1031
+ <Badge
1032
+ variant='secondary'
1033
+ className='bg-primary-foreground text-primary px-2.5 py-0.5 font-semibold'
1034
+ >
1035
+ {deal.title}
1036
+ </Badge>
1037
+ {deal.discount && <span className='font-semibold'>{deal.discount}</span>}
1038
+ </div>
1039
+ {deal.endTime && (
1040
+ <span className='hidden sm:inline'>
1041
+ {labels.endsInLabel} {deal.endTime}
1042
+ </span>
1043
+ )}
1044
+ {onShopNow && (
1045
+ <Button
1046
+ variant='link'
1047
+ size='sm'
1048
+ className='text-primary-foreground hover:text-primary-foreground/80 ml-auto h-8 cursor-pointer px-3 text-xs'
1049
+ onClick={() => onShopNow(deal)}
1050
+ >
1051
+ {labels.shopNowLabel} <ArrowRight />
1052
+ </Button>
1053
+ )}
1054
+ </div>
1055
+ </div>
1056
+ </div>
1057
+ </header>
1058
+ )}
1059
+
1060
+ <main className='mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8'>
1061
+ <div className='mb-12 text-center'>
1062
+ {badge && (
1063
+ <div className='relative inline-block'>
1064
+ <Badge
1065
+ variant='outline'
1066
+ className='mb-4 flex h-auto items-center rounded-full px-3.5 py-1.5 text-sm font-normal'
1067
+ >
1068
+ {badge}
1069
+ </Badge>
1070
+ </div>
1071
+ )}
1072
+ <h1 className='text-4xl font-bold tracking-tight text-balance sm:text-5xl md:text-6xl'>
1073
+ {title}
1074
+ {titleAccent && (
1075
+ <>
1076
+ <br />
1077
+ {titleAccent}
1078
+ </>
1079
+ )}
1080
+ </h1>
1081
+ <p className='text-muted-foreground mx-auto mt-4 max-w-2xl text-lg'>{description}</p>
1082
+ </div>
1083
+
1084
+ {categories.length > 0 && (
1085
+ <div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
1086
+ {categories.map(category => (
1087
+ <Card key={category.id} className='group relative overflow-hidden py-0 transition-all duration-500'>
1088
+ {category.image && category.image.trim().length > 0 ? (
1089
+ <img
1090
+ src={category.image}
1091
+ alt={category.imageAlt ?? category.name}
1092
+ className='aspect-4/5 size-full object-cover grayscale transition-all duration-500 hover:grayscale-0'
1093
+ width={400}
1094
+ height={500}
1095
+ loading='lazy'
1096
+ />
1097
+ ) : (
1098
+ <div className='bg-muted aspect-4/5 size-full' />
1099
+ )}
1100
+ <CardContent className='absolute inset-x-0 bottom-0 p-4'>
1101
+ <div className='mt-1 flex items-center justify-between'>
1102
+ <div>
1103
+ <h2 className='text-background dark:text-foreground text-lg font-medium'>{category.name}</h2>
1104
+ {category.count && (
1105
+ <span className='text-background/80 dark:text-foreground/80 text-sm'>
1106
+ {category.count} {labels.productsLabel}
1107
+ </span>
1108
+ )}
1109
+ </div>
1110
+ {category.href ? (
1111
+ <Button
1112
+ variant='secondary'
1113
+ size='icon'
1114
+ className='h-9 w-9 cursor-pointer rounded-full backdrop-blur-xs'
1115
+ aria-label={`${labels.viewLabel} ${category.name}`}
1116
+ asChild
1117
+ >
1118
+ <a href={category.href}>
1119
+ <ArrowRight />
1120
+ </a>
1121
+ </Button>
1122
+ ) : onCategoryClick ? (
1123
+ <Button
1124
+ variant='secondary'
1125
+ size='icon'
1126
+ className='h-9 w-9 cursor-pointer rounded-full backdrop-blur-xs'
1127
+ aria-label={`${labels.viewLabel} ${category.name}`}
1128
+ onClick={() => onCategoryClick(category)}
1129
+ >
1130
+ <ArrowRight />
1131
+ </Button>
1132
+ ) : null}
1133
+ </div>
1134
+ </CardContent>
1135
+ </Card>
1136
+ ))}
1137
+ </div>
1138
+ )}
1139
+ </main>
1140
+ </div>
1141
+ )
1142
+ }
1143
+
1144
+ export function StorefrontHero({
1145
+ title,
1146
+ description,
1147
+ presentation = 'compact',
1148
+ currency,
1149
+ products,
1150
+ badge,
1151
+ titleAccent,
1152
+ primaryCta,
1153
+ secondaryCta,
1154
+ heroImage,
1155
+ onSearch,
1156
+ departments = [],
1157
+ featuredDeals = [],
1158
+ storeLocator,
1159
+ shippingInfo,
1160
+ deals = [],
1161
+ categories = [],
1162
+ rotationInterval = 3000,
1163
+ onAddToCart,
1164
+ onBuyNow,
1165
+ onSelectColor,
1166
+ onDepartmentClick,
1167
+ onShopNow,
1168
+ onLocateStores,
1169
+ onCategoryClick,
1170
+ addToCartLabel = 'Agregar al carrito',
1171
+ buyNowLabel,
1172
+ newLabel = 'Nuevo',
1173
+ outOfStockLabel = 'Agotado',
1174
+ colorsLabel = 'Colores:',
1175
+ discountSuffixLabel = '% OFF',
1176
+ searchPlaceholder = 'Buscar productos...',
1177
+ searchLabel = 'Buscar',
1178
+ featuredDescription,
1179
+ shopNowLabel = 'Comprar ahora',
1180
+ endsInLabel = 'Termina en',
1181
+ productsLabel = 'productos',
1182
+ viewLabel = 'Ver',
1183
+ }: StorefrontHeroProps) {
1184
+ // Presence-based, never label-language based: a host-supplied custom action
1185
+ // label is a purchase affordance that must be handler-gated, while the
1186
+ // block's own default label is a non-purchase detail affordance that may
1187
+ // render without a handler (preserving the accepted default detail).
1188
+ const resolvedBuyNowLabel = buyNowLabel ?? 'Ver producto'
1189
+ const hasCustomBuyNowLabel = buyNowLabel !== undefined
1190
+ const labels: HeroLabels = {
1191
+ addToCartLabel,
1192
+ buyNowLabel: resolvedBuyNowLabel,
1193
+ newLabel,
1194
+ outOfStockLabel,
1195
+ colorsLabel,
1196
+ discountSuffixLabel,
1197
+ searchPlaceholder,
1198
+ searchLabel,
1199
+ featuredDescription,
1200
+ shopNowLabel,
1201
+ endsInLabel,
1202
+ productsLabel,
1203
+ viewLabel,
1204
+ }
1205
+
1206
+ if (presentation === 'showcase') {
1207
+ return (
1208
+ <ShowcasePresentation
1209
+ title={title}
1210
+ description={description}
1211
+ products={products}
1212
+ badge={badge}
1213
+ primaryCta={primaryCta}
1214
+ secondaryCta={secondaryCta}
1215
+ currency={currency}
1216
+ labels={labels}
1217
+ onSearch={onSearch}
1218
+ onBuyNow={onBuyNow}
1219
+ rotationInterval={rotationInterval}
1220
+ />
1221
+ )
1222
+ }
1223
+
1224
+ if (presentation === 'editorial') {
1225
+ return (
1226
+ <EditorialPresentation
1227
+ title={title}
1228
+ description={description}
1229
+ badge={badge}
1230
+ titleAccent={titleAccent}
1231
+ primaryCta={primaryCta}
1232
+ secondaryCta={secondaryCta}
1233
+ shippingInfo={shippingInfo}
1234
+ departments={departments}
1235
+ featuredDeals={featuredDeals}
1236
+ storeLocator={storeLocator}
1237
+ currency={currency}
1238
+ labels={labels}
1239
+ onDepartmentClick={onDepartmentClick}
1240
+ onShopNow={onShopNow}
1241
+ onLocateStores={onLocateStores}
1242
+ />
1243
+ )
1244
+ }
1245
+
1246
+ if (presentation === 'promotional') {
1247
+ return (
1248
+ <PromotionalPresentation
1249
+ title={title}
1250
+ description={description}
1251
+ badge={badge}
1252
+ titleAccent={titleAccent}
1253
+ deals={deals}
1254
+ categories={categories}
1255
+ rotationInterval={rotationInterval}
1256
+ labels={labels}
1257
+ onShopNow={onShopNow}
1258
+ onCategoryClick={onCategoryClick}
1259
+ />
1260
+ )
1261
+ }
1262
+
1263
+ return (
1264
+ <CompactPresentation
1265
+ title={title}
1266
+ description={description}
1267
+ products={products}
1268
+ badge={badge}
1269
+ titleAccent={titleAccent}
1270
+ primaryCta={primaryCta}
1271
+ secondaryCta={secondaryCta}
1272
+ heroImage={heroImage}
1273
+ currency={currency}
1274
+ labels={labels}
1275
+ onAddToCart={onAddToCart}
1276
+ onBuyNow={onBuyNow}
1277
+ onSelectColor={onSelectColor}
1278
+ hasCustomBuyNowLabel={hasCustomBuyNowLabel}
1279
+ />
1280
+ )
1281
+ }
1282
+
1283
+ export default StorefrontHero