@fayz-ai/storefront 0.6.0 → 0.8.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 (102) hide show
  1. package/README.md +3 -1
  2. package/dist/auth.d.ts +12 -2
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/blocks.d.ts +3 -3
  5. package/dist/blocks.d.ts.map +1 -1
  6. package/dist/component-contracts.d.ts +124 -0
  7. package/dist/component-contracts.d.ts.map +1 -0
  8. package/dist/component-selectors.d.ts +61 -0
  9. package/dist/component-selectors.d.ts.map +1 -0
  10. package/dist/components/CartDrawer.d.ts.map +1 -1
  11. package/dist/components/ProductCard.d.ts +2 -5
  12. package/dist/components/ProductCard.d.ts.map +1 -1
  13. package/dist/components/ProductEnquiryForm.d.ts +8 -0
  14. package/dist/components/ProductEnquiryForm.d.ts.map +1 -0
  15. package/dist/components/ProductGrid.d.ts.map +1 -1
  16. package/dist/components/RelatedProducts.d.ts +1 -1
  17. package/dist/components/RelatedProducts.d.ts.map +1 -1
  18. package/dist/components/SmoothImage.d.ts +7 -0
  19. package/dist/components/SmoothImage.d.ts.map +1 -0
  20. package/dist/components/StorefrontFooter.d.ts.map +1 -1
  21. package/dist/components/StorefrontHeader.d.ts.map +1 -1
  22. package/dist/components/sections/CategoryShowcase.d.ts.map +1 -1
  23. package/dist/components/sections/HeroSection.d.ts.map +1 -1
  24. package/dist/components/sections/MediaCarousel.d.ts +11 -0
  25. package/dist/components/sections/MediaCarousel.d.ts.map +1 -0
  26. package/dist/components/sections/MiscSections.d.ts.map +1 -1
  27. package/dist/components/sections/ProductRail.d.ts.map +1 -1
  28. package/dist/components/sections/ProductSlider.d.ts +15 -0
  29. package/dist/components/sections/ProductSlider.d.ts.map +1 -0
  30. package/dist/config.d.ts +38 -21
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/createStorefrontApp.d.ts +12 -0
  33. package/dist/createStorefrontApp.d.ts.map +1 -1
  34. package/dist/define.d.ts +17 -0
  35. package/dist/define.d.ts.map +1 -0
  36. package/dist/hooks/index.d.ts +1 -0
  37. package/dist/hooks/index.d.ts.map +1 -1
  38. package/dist/hooks/useStorefront.d.ts +35 -0
  39. package/dist/hooks/useStorefront.d.ts.map +1 -0
  40. package/dist/index.cjs +1113 -277
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.d.ts +21 -11
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1006 -188
  45. package/dist/index.js.map +1 -1
  46. package/dist/pages/CheckoutPage.d.ts.map +1 -1
  47. package/dist/pages/HomePage.d.ts +5 -2
  48. package/dist/pages/HomePage.d.ts.map +1 -1
  49. package/dist/pages/MyPurchasesPage.d.ts.map +1 -1
  50. package/dist/pages/OrderConfirmationPage.d.ts.map +1 -1
  51. package/dist/pages/ProductDetailPage.d.ts.map +1 -1
  52. package/dist/presets.d.ts +5 -0
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/sections.d.ts +45 -2
  55. package/dist/sections.d.ts.map +1 -1
  56. package/dist/stores/cart.store.d.ts +5 -0
  57. package/dist/stores/cart.store.d.ts.map +1 -1
  58. package/dist/testids.d.ts +21 -0
  59. package/dist/testids.d.ts.map +1 -1
  60. package/dist/theme.d.ts +21 -1
  61. package/dist/theme.d.ts.map +1 -1
  62. package/dist/workflows/checkout.d.ts.map +1 -1
  63. package/package.json +10 -6
  64. package/src/auth.ts +47 -10
  65. package/src/blocks.tsx +7 -3
  66. package/src/component-contracts.ts +140 -0
  67. package/src/component-selectors.ts +31 -0
  68. package/src/components/CartDrawer.tsx +2 -1
  69. package/src/components/ProductCard.tsx +39 -23
  70. package/src/components/ProductEnquiryForm.tsx +175 -0
  71. package/src/components/ProductGrid.tsx +12 -3
  72. package/src/components/RelatedProducts.tsx +13 -4
  73. package/src/components/SmoothImage.tsx +59 -0
  74. package/src/components/StorefrontFooter.tsx +24 -14
  75. package/src/components/StorefrontHeader.tsx +74 -42
  76. package/src/components/sections/CategoryShowcase.tsx +3 -2
  77. package/src/components/sections/HeroSection.tsx +18 -2
  78. package/src/components/sections/MediaCarousel.tsx +141 -0
  79. package/src/components/sections/MiscSections.tsx +2 -1
  80. package/src/components/sections/ProductRail.tsx +11 -3
  81. package/src/components/sections/ProductSlider.tsx +119 -0
  82. package/src/config.ts +65 -24
  83. package/src/createStorefrontApp.tsx +80 -10
  84. package/src/define.ts +34 -0
  85. package/src/hooks/index.ts +1 -0
  86. package/src/hooks/useStorefront.ts +58 -0
  87. package/src/index.ts +62 -11
  88. package/src/pages/CheckoutPage.tsx +2 -1
  89. package/src/pages/HomePage.tsx +6 -2
  90. package/src/pages/MyPurchasesPage.tsx +2 -1
  91. package/src/pages/OrderConfirmationPage.tsx +2 -1
  92. package/src/pages/ProductDetailPage.tsx +138 -62
  93. package/src/presets.ts +66 -0
  94. package/src/scaffold.tsx +1 -1
  95. package/src/sections.ts +49 -2
  96. package/src/stores/cart.store.ts +16 -1
  97. package/src/testids.ts +21 -0
  98. package/src/theme.ts +36 -2
  99. package/src/workflows/checkout.ts +5 -2
  100. package/dist/slot-contracts.d.ts +0 -34
  101. package/dist/slot-contracts.d.ts.map +0 -1
  102. package/src/slot-contracts.ts +0 -20
@@ -99,24 +99,48 @@ function UtilityBar() {
99
99
  )
100
100
  }
101
101
 
102
- function SearchInput({ className }: { className?: string }) {
102
+ function SearchInput({ className, iconOnly = false }: { className?: string; iconOnly?: boolean }) {
103
103
  const config = useStorefrontConfig()
104
104
  const search = useCatalogStore((s) => s.search)
105
105
  const setSearch = useCatalogStore((s) => s.setSearch)
106
106
  const path = useHashPath()
107
+ const [open, setOpen] = useState(false)
108
+
109
+ // Icon-only: a compact search button that expands into the input on click and
110
+ // collapses again on Escape / blur-when-empty.
111
+ if (iconOnly && !open) {
112
+ return (
113
+ <button
114
+ type="button"
115
+ aria-label="Buscar produtos"
116
+ data-testid={TID.headerSearchToggle}
117
+ onClick={() => setOpen(true)}
118
+ className="rounded-full p-2.5 transition-opacity hover:opacity-70"
119
+ >
120
+ <Search className="h-5 w-5" />
121
+ </button>
122
+ )
123
+ }
107
124
 
108
125
  return (
109
- <div className={`relative ${className ?? ''}`}>
126
+ <div className={`relative ${iconOnly ? 'w-full max-w-xs' : (className ?? '')}`}>
110
127
  <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 opacity-60" />
111
128
  <input
112
129
  data-testid={TID.headerSearch}
113
130
  type="search"
114
131
  placeholder="Buscar produtos..."
115
132
  value={search}
133
+ autoFocus={iconOnly}
116
134
  onChange={(e) => {
117
135
  setSearch(e.target.value)
118
136
  if (path !== config.catalogPath) navigateTo(config.catalogPath)
119
137
  }}
138
+ onKeyDown={(e) => {
139
+ if (e.key === 'Escape' && iconOnly) setOpen(false)
140
+ }}
141
+ onBlur={() => {
142
+ if (iconOnly && !search) setOpen(false)
143
+ }}
120
144
  className="w-full border bg-white/10 py-2 pl-9 pr-4 text-sm outline-none transition-colors placeholder:opacity-60 focus:border-primary focus:bg-white focus:text-gray-900"
121
145
  style={{
122
146
  borderRadius: 'var(--sf-radius-input)',
@@ -206,25 +230,27 @@ function HeaderActions() {
206
230
  )}
207
231
  </div>
208
232
  )}
209
- <button
210
- type="button"
211
- data-testid={TID.cartButton}
212
- aria-label="Carrinho"
213
- onClick={openDrawer}
214
- className="relative rounded-full p-2.5 transition-opacity hover:opacity-70"
215
- >
216
- <ShoppingBag className="h-5 w-5" />
217
- {count > 0 && (
218
- <span
219
- data-testid={TID.cartCount}
220
- className={`absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-xs font-semibold text-primary-foreground ${
221
- pop ? 'animate-badge-pop' : ''
222
- }`}
223
- >
224
- {count}
225
- </span>
226
- )}
227
- </button>
233
+ {config.features.cart && (
234
+ <button
235
+ type="button"
236
+ data-testid={TID.cartButton}
237
+ aria-label="Carrinho"
238
+ onClick={openDrawer}
239
+ className="relative rounded-full p-2.5 transition-opacity hover:opacity-70"
240
+ >
241
+ <ShoppingBag className="h-5 w-5" />
242
+ {count > 0 && (
243
+ <span
244
+ data-testid={TID.cartCount}
245
+ className={`absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-xs font-semibold text-primary-foreground ${
246
+ pop ? 'animate-badge-pop' : ''
247
+ }`}
248
+ >
249
+ {count}
250
+ </span>
251
+ )}
252
+ </button>
253
+ )}
228
254
  </nav>
229
255
  )
230
256
  }
@@ -233,6 +259,7 @@ function NavLinks({ className }: { className?: string }) {
233
259
  const config = useStorefrontConfig()
234
260
  const { categories } = useCategories()
235
261
  const setCategoryId = useCatalogStore((s) => s.setCategoryId)
262
+ const showCategories = config.theme?.header?.showCategories !== false
236
263
 
237
264
  const goCategory = (categoryId: string) => {
238
265
  useCatalogStore.getState().reset()
@@ -241,7 +268,7 @@ function NavLinks({ className }: { className?: string }) {
241
268
  }
242
269
 
243
270
  return (
244
- <nav className={`flex items-center gap-6 ${className ?? ''}`}>
271
+ <nav className={`flex min-w-max items-center gap-6 ${className ?? ''}`}>
245
272
  {config.nav.map((link) => (
246
273
  <Link
247
274
  key={link.to}
@@ -252,18 +279,19 @@ function NavLinks({ className }: { className?: string }) {
252
279
  {link.label}
253
280
  </Link>
254
281
  ))}
255
- {categories.slice(0, 6).map((c) => (
256
- <button
257
- key={c.id}
258
- type="button"
259
- data-testid={TID.navCategory(c.slug)}
260
- onClick={() => goCategory(c.id)}
261
- className="sf-nav-link group relative hidden text-sm font-medium transition-opacity hover:opacity-80 lg:inline"
262
- >
263
- {c.name}
264
- <span className="absolute -bottom-1 left-0 h-0.5 w-0 bg-primary transition-all duration-300 group-hover:w-full" />
265
- </button>
266
- ))}
282
+ {showCategories &&
283
+ categories.slice(0, 6).map((c) => (
284
+ <button
285
+ key={c.id}
286
+ type="button"
287
+ data-testid={TID.navCategory(c.slug)}
288
+ onClick={() => goCategory(c.id)}
289
+ className="sf-nav-link group relative hidden text-sm font-medium transition-opacity hover:opacity-80 lg:inline"
290
+ >
291
+ {c.name}
292
+ <span className="absolute -bottom-1 left-0 h-0.5 w-0 bg-primary transition-all duration-300 group-hover:w-full" />
293
+ </button>
294
+ ))}
267
295
  </nav>
268
296
  )
269
297
  }
@@ -272,6 +300,8 @@ export function StorefrontHeader() {
272
300
  const config = useStorefrontConfig()
273
301
  const variant = config.theme?.header?.variant ?? 'classic'
274
302
  const scrolled = useScrolled()
303
+ const showSearch = config.theme?.header?.showSearch !== false
304
+ const iconSearch = config.theme?.header?.searchStyle === 'icon'
275
305
  const logo = (
276
306
  <Link to="/" className="sf-heading shrink-0 text-xl font-bold tracking-tight">
277
307
  {config.logo ?? config.name}
@@ -290,13 +320,13 @@ export function StorefrontHeader() {
290
320
  // Rio/Flex pattern: centered logo row, nav row below
291
321
  <>
292
322
  <div className="mx-auto grid h-16 max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
293
- <SearchInput className="hidden w-full max-w-xs sm:block" />
323
+ {showSearch ? <SearchInput iconOnly={iconSearch} className="hidden w-full max-w-xs sm:block" /> : <div />}
294
324
  <div className="text-center">{logo}</div>
295
325
  <div className="justify-self-end">
296
326
  <HeaderActions />
297
327
  </div>
298
328
  </div>
299
- <div className="hidden justify-center border-t py-2.5 sm:flex" style={headerDivider}>
329
+ <div className="flex overflow-x-auto border-t py-2.5 sm:justify-center" style={headerDivider}>
300
330
  <NavLinks />
301
331
  </div>
302
332
  </>
@@ -304,13 +334,13 @@ export function StorefrontHeader() {
304
334
  // Brasília pattern: prominent search left, logo center, actions right; nav row below
305
335
  <>
306
336
  <div className="mx-auto grid h-16 max-w-7xl grid-cols-[1fr_auto_1fr] items-center gap-6 px-4 sm:px-6">
307
- <SearchInput className="hidden w-full max-w-sm sm:block" />
337
+ {showSearch ? <SearchInput iconOnly={iconSearch} className="hidden w-full max-w-sm sm:block" /> : <div />}
308
338
  <div className="text-center">{logo}</div>
309
339
  <div className="justify-self-end">
310
340
  <HeaderActions />
311
341
  </div>
312
342
  </div>
313
- <div className="hidden border-t sm:block" style={headerDivider}>
343
+ <div className="overflow-x-auto border-t" style={headerDivider}>
314
344
  <div className="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
315
345
  <NavLinks />
316
346
  </div>
@@ -324,17 +354,19 @@ export function StorefrontHeader() {
324
354
  </div>
325
355
  <div className="text-center">{logo}</div>
326
356
  <div className="flex items-center justify-end gap-3">
327
- <SearchInput className="hidden w-44 lg:block" />
357
+ {showSearch && <SearchInput iconOnly={iconSearch} className="hidden w-44 lg:block" />}
328
358
  <HeaderActions />
329
359
  </div>
330
360
  </div>
331
361
  ) : (
332
- // classic: logo left, search center-right, actions right (original layout)
362
+ // classic: logo left, nav, then a right-aligned search + actions cluster
333
363
  <div className="mx-auto flex h-16 max-w-7xl items-center gap-6 px-4 sm:px-6">
334
364
  {logo}
335
365
  <NavLinks className="hidden md:flex" />
336
- <SearchInput className="ml-auto hidden w-full max-w-sm sm:block" />
337
- <HeaderActions />
366
+ <div className="ml-auto flex items-center gap-2 sm:gap-3">
367
+ {showSearch && <SearchInput iconOnly={iconSearch} className={iconSearch ? '' : 'hidden w-full max-w-sm sm:block'} />}
368
+ <HeaderActions />
369
+ </div>
338
370
  </div>
339
371
  )}
340
372
  </header>
@@ -6,6 +6,7 @@ import { bannerPlaceholder } from '../../sections'
6
6
  import { Reveal } from '../../motion'
7
7
  import { navigateTo } from '../../router'
8
8
  import { TID } from '../../testids'
9
+ import { SmoothImage } from '../SmoothImage'
9
10
 
10
11
  export function CategoryShowcase({ style, title }: { style: 'bubbles' | 'tiles'; title?: string }) {
11
12
  const { categories } = useCategories()
@@ -33,7 +34,7 @@ export function CategoryShowcase({ style, title }: { style: 'bubbles' | 'tiles';
33
34
  <Reveal key={c.id} delay={i * 80}>
34
35
  <button type="button" onClick={() => go(c.id)} className="group flex w-28 flex-col items-center gap-3">
35
36
  <span className="block h-24 w-24 overflow-hidden rounded-full border-2 border-transparent shadow-sm transition-all duration-300 group-hover:scale-105 group-hover:border-primary group-hover:shadow-md">
36
- <img
37
+ <SmoothImage
37
38
  src={c.imageUrl ?? bannerPlaceholder(c.name, 30 + i * 70, 70 + i * 70, 200, 200)}
38
39
  alt={c.name}
39
40
  className="h-full w-full object-cover"
@@ -59,7 +60,7 @@ export function CategoryShowcase({ style, title }: { style: 'bubbles' | 'tiles';
59
60
  {categories.map((c, i) => (
60
61
  <Reveal key={c.id} delay={i * 90}>
61
62
  <button type="button" onClick={() => go(c.id)} className="group relative block aspect-[4/5] w-full overflow-hidden" style={{ borderRadius: 'var(--sf-radius-card)' }}>
62
- <img
63
+ <SmoothImage
63
64
  src={c.imageUrl ?? bannerPlaceholder(c.name, 30 + i * 70, 70 + i * 70, 480, 600)}
64
65
  alt={c.name}
65
66
  className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
@@ -3,9 +3,11 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'
3
3
  import type { HeroSlide } from '../../sections'
4
4
  import type { HeroVariant } from '../../theme'
5
5
  import { bannerPlaceholder } from '../../sections'
6
+ import { useStorefrontConfig } from '../../config'
6
7
  import { prefersReducedMotion } from '../../motion'
7
8
  import { Link } from '../../router'
8
9
  import { TID } from '../../testids'
10
+ import { SmoothImage } from '../SmoothImage'
9
11
 
10
12
  function slideImage(slide: HeroSlide, w = 1600, h = 700): string {
11
13
  return slide.image ?? bannerPlaceholder(slide.title, slide.hue ?? 200, (slide.hue ?? 200) + 40, w, h)
@@ -56,8 +58,18 @@ export function HeroSection({
56
58
  height?: 'tall' | 'medium'
57
59
  }) {
58
60
  const [index, setIndex] = useState(0)
61
+ const [loadedSlides, setLoadedSlides] = useState<Record<number, boolean>>({})
62
+ const config = useStorefrontConfig()
59
63
  const interacted = useRef(false)
60
64
  const hClass = height === 'tall' ? 'h-[72vh] min-h-[440px]' : 'h-[48vh] min-h-[340px]'
65
+ const revealImages = config.imageLoading.mode !== 'none'
66
+ const revealStyle = (loaded: boolean): React.CSSProperties | undefined =>
67
+ revealImages
68
+ ? {
69
+ filter: config.imageLoading.blur && !loaded ? 'blur(10px)' : 'blur(0px)',
70
+ transition: `opacity 700ms ease, filter ${config.imageLoading.durationMs}ms ${config.imageLoading.easing}`,
71
+ }
72
+ : undefined
61
73
 
62
74
  // Auto-advance until the visitor takes over; never under reduced motion.
63
75
  useEffect(() => {
@@ -84,7 +96,7 @@ export function HeroSection({
84
96
  to={slide.href ?? '/catalog'}
85
97
  className="group relative block h-[42vh] min-h-[320px] overflow-hidden"
86
98
  >
87
- <img
99
+ <SmoothImage
88
100
  src={slideImage(slide, 900, 700)}
89
101
  alt={slide.title}
90
102
  className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
@@ -122,8 +134,12 @@ export function HeroSection({
122
134
  src={slideImage(slide)}
123
135
  alt={slide.title}
124
136
  aria-hidden={i !== index}
137
+ loading={i === 0 ? 'eager' : 'lazy'}
138
+ decoding="async"
139
+ onLoad={() => setLoadedSlides((current) => ({ ...current, [i]: true }))}
140
+ style={revealStyle(Boolean(loadedSlides[i]))}
125
141
  className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ${
126
- i === index ? 'animate-kenburns opacity-100' : 'opacity-0'
142
+ i === index && (!revealImages || loadedSlides[i]) ? 'animate-kenburns opacity-100' : 'opacity-0'
127
143
  }`}
128
144
  />
129
145
  ))}
@@ -0,0 +1,141 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
3
+ import { Link } from '../../router'
4
+ import { TID } from '../../testids'
5
+ import { prefersReducedMotion } from '../../motion'
6
+ import type { MediaCarouselItem } from '../../sections'
7
+ import { useStorefrontConfig } from '../../config'
8
+
9
+ export interface MediaCarouselProps {
10
+ items: MediaCarouselItem[]
11
+ height?: 'medium' | 'tall' | 'screen'
12
+ autoplayMs?: number
13
+ overlay?: 'dark' | 'soft' | 'none'
14
+ className?: string
15
+ }
16
+
17
+ const heightClass = {
18
+ medium: 'h-[58vh] min-h-[420px]',
19
+ tall: 'h-[74vh] min-h-[520px]',
20
+ screen: 'min-h-[calc(100vh-64px)]',
21
+ }
22
+
23
+ const alignClass = {
24
+ start: 'items-start text-left',
25
+ center: 'items-center text-center',
26
+ end: 'items-end text-right',
27
+ }
28
+
29
+ export function MediaCarousel({
30
+ items,
31
+ height = 'tall',
32
+ autoplayMs = 7000,
33
+ overlay = 'dark',
34
+ className = '',
35
+ }: MediaCarouselProps) {
36
+ const [index, setIndex] = useState(0)
37
+ const [loadedItems, setLoadedItems] = useState<Record<number, boolean>>({})
38
+ const config = useStorefrontConfig()
39
+ const interacted = useRef(false)
40
+ const revealImages = config.imageLoading.mode !== 'none'
41
+ const revealStyle = (loaded: boolean): React.CSSProperties | undefined =>
42
+ revealImages
43
+ ? {
44
+ filter: config.imageLoading.blur && !loaded ? 'blur(10px)' : 'blur(0px)',
45
+ transition: `opacity 700ms ease, filter ${config.imageLoading.durationMs}ms ${config.imageLoading.easing}`,
46
+ }
47
+ : undefined
48
+
49
+ useEffect(() => {
50
+ if (items.length < 2 || autoplayMs <= 0 || prefersReducedMotion()) return
51
+ const timer = setInterval(() => {
52
+ if (!interacted.current) setIndex((i) => (i + 1) % items.length)
53
+ }, autoplayMs)
54
+ return () => clearInterval(timer)
55
+ }, [autoplayMs, items.length])
56
+
57
+ if (items.length === 0) return null
58
+
59
+ const active = items[Math.min(index, items.length - 1)]!
60
+ const goTo = (next: number) => {
61
+ interacted.current = true
62
+ setIndex((next + items.length) % items.length)
63
+ }
64
+ const overlayClass =
65
+ overlay === 'dark'
66
+ ? 'bg-gradient-to-r from-black/70 via-black/25 to-black/25'
67
+ : overlay === 'soft'
68
+ ? 'bg-gradient-to-t from-black/45 via-black/10 to-transparent'
69
+ : ''
70
+
71
+ return (
72
+ <section data-testid={TID.mediaCarousel} className={`relative isolate overflow-hidden ${heightClass[height]} ${className}`}>
73
+ {items.map((item, i) => (
74
+ <img
75
+ key={`${item.title}-${i}`}
76
+ src={item.image}
77
+ alt={item.imageAlt ?? item.title}
78
+ aria-hidden={i !== index}
79
+ loading={i === 0 ? 'eager' : 'lazy'}
80
+ decoding="async"
81
+ onLoad={() => setLoadedItems((current) => ({ ...current, [i]: true }))}
82
+ style={revealStyle(Boolean(loadedItems[i]))}
83
+ className={`absolute inset-0 -z-10 h-full w-full object-cover transition-opacity duration-700 ${
84
+ i === index && (!revealImages || loadedItems[i]) ? 'opacity-100' : 'opacity-0'
85
+ }`}
86
+ />
87
+ ))}
88
+ <div className={`absolute inset-0 -z-10 ${overlayClass}`} />
89
+ <div className={`flex h-full px-5 py-16 sm:px-10 lg:px-16 ${alignClass[active.align ?? 'start']}`}>
90
+ <div className="my-auto max-w-2xl text-white drop-shadow-md">
91
+ {active.eyebrow && (
92
+ <p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-white/80">{active.eyebrow}</p>
93
+ )}
94
+ <h1 className="sf-heading text-5xl font-normal leading-[0.95] sm:text-7xl lg:text-8xl">{active.title}</h1>
95
+ {active.subtitle && <p className="mt-5 max-w-xl text-base leading-8 text-white/88 sm:text-lg">{active.subtitle}</p>}
96
+ {active.cta && (
97
+ <Link
98
+ to={active.href ?? '/catalog'}
99
+ className="mt-8 inline-flex border border-white/75 px-6 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-white transition hover:bg-white hover:text-black"
100
+ >
101
+ {active.cta}
102
+ </Link>
103
+ )}
104
+ </div>
105
+ </div>
106
+ {items.length > 1 && (
107
+ <>
108
+ <button
109
+ type="button"
110
+ aria-label="Previous slide"
111
+ data-testid={TID.mediaCarouselPrev}
112
+ onClick={() => goTo(index - 1)}
113
+ className="absolute left-4 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center border border-white/60 bg-black/20 text-white backdrop-blur transition hover:bg-white hover:text-black"
114
+ >
115
+ <ChevronLeft className="h-5 w-5" />
116
+ </button>
117
+ <button
118
+ type="button"
119
+ aria-label="Next slide"
120
+ data-testid={TID.mediaCarouselNext}
121
+ onClick={() => goTo(index + 1)}
122
+ className="absolute right-4 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center border border-white/60 bg-black/20 text-white backdrop-blur transition hover:bg-white hover:text-black"
123
+ >
124
+ <ChevronRight className="h-5 w-5" />
125
+ </button>
126
+ <div className="absolute bottom-5 left-1/2 flex -translate-x-1/2 gap-2">
127
+ {items.map((item, i) => (
128
+ <button
129
+ key={`${item.title}-dot-${i}`}
130
+ type="button"
131
+ aria-label={`Go to slide ${i + 1}`}
132
+ onClick={() => goTo(i)}
133
+ className={`h-2 rounded-full transition-all ${i === index ? 'w-8 bg-white' : 'w-2 bg-white/55 hover:bg-white'}`}
134
+ />
135
+ ))}
136
+ </div>
137
+ </>
138
+ )}
139
+ </section>
140
+ )
141
+ }
@@ -4,6 +4,7 @@ import { bannerPlaceholder } from '../../sections'
4
4
  import { Reveal } from '../../motion'
5
5
  import { Link } from '../../router'
6
6
  import { TID } from '../../testids'
7
+ import { SmoothImage } from '../SmoothImage'
7
8
 
8
9
  function Icon({ name, className }: { name: string; className?: string }) {
9
10
  const C = (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[name]
@@ -49,7 +50,7 @@ export function PromoBanner({
49
50
  }) {
50
51
  return (
51
52
  <section data-testid={TID.promoBanner} className="group relative my-12 overflow-hidden">
52
- <img
53
+ <SmoothImage
53
54
  src={image ?? bannerPlaceholder(title, hue, hue + 50, 1600, 420)}
54
55
  alt={title}
55
56
  className="h-80 w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
@@ -1,6 +1,7 @@
1
1
  import React, { useMemo } from 'react'
2
2
  import { useProducts } from '../../hooks/useProducts'
3
- import { useStorefrontConfig } from '../../config'
3
+ import { getStorefrontComponents, useStorefrontConfig } from '../../config'
4
+ import { useStorefrontActions } from '../../hooks/useStorefront'
4
5
  import { ProductCard } from '../ProductCard'
5
6
  import { Reveal } from '../../motion'
6
7
  import { Link } from '../../router'
@@ -20,7 +21,9 @@ export function ProductRail({
20
21
  limit?: number
21
22
  }) {
22
23
  const config = useStorefrontConfig()
23
- const ProductCardComponent = config.slots?.ProductCard ?? ProductCard
24
+ const actions = useStorefrontActions()
25
+ const components = getStorefrontComponents(config)
26
+ const ProductCardComponent = components.ProductCard ?? ProductCard
24
27
  const { products, loading } = useProducts(
25
28
  filter === 'new'
26
29
  ? { status: 'active', orderBy: 'created_at', order: 'desc' }
@@ -54,7 +57,12 @@ export function ProductRail({
54
57
  <div className="grid grid-cols-2 gap-5 md:grid-cols-4">
55
58
  {visible.map((p, i) => (
56
59
  <Reveal key={p.id} delay={i * 90}>
57
- <ProductCardComponent product={p} />
60
+ <ProductCardComponent
61
+ product={p}
62
+ config={config}
63
+ commerceMode={config.commerceMode}
64
+ actions={actions}
65
+ />
58
66
  </Reveal>
59
67
  ))}
60
68
  </div>
@@ -0,0 +1,119 @@
1
+ import React, { useMemo, useRef } from 'react'
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
3
+ import { getStorefrontComponents, useStorefrontConfig } from '../../config'
4
+ import { useProducts } from '../../hooks/useProducts'
5
+ import { useStorefrontActions } from '../../hooks/useStorefront'
6
+ import { ProductCard } from '../ProductCard'
7
+ import { Link } from '../../router'
8
+ import { TID } from '../../testids'
9
+
10
+ export interface ProductSliderProps {
11
+ title: string
12
+ subtitle?: string
13
+ eyebrow?: string
14
+ filter?: 'sale' | 'new' | 'all'
15
+ categoryName?: string
16
+ collection?: string
17
+ limit?: number
18
+ cta?: string
19
+ href?: string
20
+ className?: string
21
+ }
22
+
23
+ export function ProductSlider({
24
+ title,
25
+ subtitle,
26
+ eyebrow,
27
+ filter = 'all',
28
+ categoryName,
29
+ collection,
30
+ limit = 10,
31
+ cta = 'View all',
32
+ href,
33
+ className = '',
34
+ }: ProductSliderProps) {
35
+ const config = useStorefrontConfig()
36
+ const actions = useStorefrontActions()
37
+ const viewportRef = useRef<HTMLDivElement>(null)
38
+ const components = getStorefrontComponents(config)
39
+ const ProductCardComponent = components.ProductCard ?? ProductCard
40
+ const { products, loading } = useProducts(
41
+ filter === 'new'
42
+ ? { status: 'active', orderBy: 'created_at', order: 'desc' }
43
+ : { status: 'active' },
44
+ )
45
+
46
+ const visible = useMemo(() => {
47
+ let list = products
48
+ if (filter === 'sale') list = list.filter((p) => p.compareAtPrice != null && p.compareAtPrice > p.price)
49
+ if (categoryName) list = list.filter((p) => p.categoryName === categoryName)
50
+ if (collection) list = list.filter((p) => p.metadata?.collection === collection)
51
+ return list.slice(0, limit)
52
+ }, [categoryName, collection, filter, limit, products])
53
+
54
+ const scroll = (direction: -1 | 1) => {
55
+ viewportRef.current?.scrollBy({ left: direction * Math.max(320, viewportRef.current.clientWidth * 0.75), behavior: 'smooth' })
56
+ }
57
+
58
+ if (!loading && visible.length === 0) return null
59
+
60
+ return (
61
+ <section data-testid={TID.productSlider} className={`mx-auto max-w-7xl px-4 py-14 sm:px-6 ${className}`}>
62
+ <div className="mb-8 flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
63
+ <div className="max-w-2xl">
64
+ {eyebrow && <p className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-primary">{eyebrow}</p>}
65
+ <h2 className="sf-heading text-3xl font-normal leading-tight tracking-normal sm:text-5xl">{title}</h2>
66
+ {subtitle && <p className="mt-3 leading-7 text-muted-foreground">{subtitle}</p>}
67
+ </div>
68
+ <div className="flex items-center gap-2">
69
+ <button
70
+ type="button"
71
+ aria-label="Previous products"
72
+ data-testid={TID.productSliderPrev}
73
+ onClick={() => scroll(-1)}
74
+ className="inline-flex h-10 w-10 items-center justify-center border border-border bg-background transition hover:bg-foreground hover:text-background"
75
+ >
76
+ <ChevronLeft className="h-5 w-5" />
77
+ </button>
78
+ <button
79
+ type="button"
80
+ aria-label="Next products"
81
+ data-testid={TID.productSliderNext}
82
+ onClick={() => scroll(1)}
83
+ className="inline-flex h-10 w-10 items-center justify-center border border-border bg-background transition hover:bg-foreground hover:text-background"
84
+ >
85
+ <ChevronRight className="h-5 w-5" />
86
+ </button>
87
+ </div>
88
+ </div>
89
+ <div
90
+ ref={viewportRef}
91
+ data-testid={TID.productSliderViewport}
92
+ className="flex snap-x gap-5 overflow-x-auto pb-5 [scrollbar-width:thin]"
93
+ >
94
+ {loading
95
+ ? Array.from({ length: Math.min(limit, 6) }, (_, i) => (
96
+ <div key={i} className="h-[360px] min-w-[260px] snap-start animate-pulse bg-muted" />
97
+ ))
98
+ : visible.map((product) => (
99
+ <div key={product.id} className="min-w-[260px] max-w-[300px] flex-[0_0_72vw] snap-start sm:flex-[0_0_300px]">
100
+ <ProductCardComponent
101
+ product={product}
102
+ config={config}
103
+ commerceMode={config.commerceMode}
104
+ actions={actions}
105
+ />
106
+ </div>
107
+ ))}
108
+ </div>
109
+ {cta && (
110
+ <Link
111
+ to={href ?? config.catalogPath}
112
+ className="mt-4 inline-flex border border-foreground/30 px-6 py-3 text-xs font-semibold uppercase tracking-[0.16em] transition hover:bg-foreground hover:text-background"
113
+ >
114
+ {cta}
115
+ </Link>
116
+ )}
117
+ </section>
118
+ )
119
+ }