@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.
- package/README.md +3 -1
- package/dist/auth.d.ts +12 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/blocks.d.ts +3 -3
- package/dist/blocks.d.ts.map +1 -1
- package/dist/component-contracts.d.ts +124 -0
- package/dist/component-contracts.d.ts.map +1 -0
- package/dist/component-selectors.d.ts +61 -0
- package/dist/component-selectors.d.ts.map +1 -0
- package/dist/components/CartDrawer.d.ts.map +1 -1
- package/dist/components/ProductCard.d.ts +2 -5
- package/dist/components/ProductCard.d.ts.map +1 -1
- package/dist/components/ProductEnquiryForm.d.ts +8 -0
- package/dist/components/ProductEnquiryForm.d.ts.map +1 -0
- package/dist/components/ProductGrid.d.ts.map +1 -1
- package/dist/components/RelatedProducts.d.ts +1 -1
- package/dist/components/RelatedProducts.d.ts.map +1 -1
- package/dist/components/SmoothImage.d.ts +7 -0
- package/dist/components/SmoothImage.d.ts.map +1 -0
- package/dist/components/StorefrontFooter.d.ts.map +1 -1
- package/dist/components/StorefrontHeader.d.ts.map +1 -1
- package/dist/components/sections/CategoryShowcase.d.ts.map +1 -1
- package/dist/components/sections/HeroSection.d.ts.map +1 -1
- package/dist/components/sections/MediaCarousel.d.ts +11 -0
- package/dist/components/sections/MediaCarousel.d.ts.map +1 -0
- package/dist/components/sections/MiscSections.d.ts.map +1 -1
- package/dist/components/sections/ProductRail.d.ts.map +1 -1
- package/dist/components/sections/ProductSlider.d.ts +15 -0
- package/dist/components/sections/ProductSlider.d.ts.map +1 -0
- package/dist/config.d.ts +38 -21
- package/dist/config.d.ts.map +1 -1
- package/dist/createStorefrontApp.d.ts +12 -0
- package/dist/createStorefrontApp.d.ts.map +1 -1
- package/dist/define.d.ts +17 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useStorefront.d.ts +35 -0
- package/dist/hooks/useStorefront.d.ts.map +1 -0
- package/dist/index.cjs +1113 -277
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +21 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1006 -188
- package/dist/index.js.map +1 -1
- package/dist/pages/CheckoutPage.d.ts.map +1 -1
- package/dist/pages/HomePage.d.ts +5 -2
- package/dist/pages/HomePage.d.ts.map +1 -1
- package/dist/pages/MyPurchasesPage.d.ts.map +1 -1
- package/dist/pages/OrderConfirmationPage.d.ts.map +1 -1
- package/dist/pages/ProductDetailPage.d.ts.map +1 -1
- package/dist/presets.d.ts +5 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/sections.d.ts +45 -2
- package/dist/sections.d.ts.map +1 -1
- package/dist/stores/cart.store.d.ts +5 -0
- package/dist/stores/cart.store.d.ts.map +1 -1
- package/dist/testids.d.ts +21 -0
- package/dist/testids.d.ts.map +1 -1
- package/dist/theme.d.ts +21 -1
- package/dist/theme.d.ts.map +1 -1
- package/dist/workflows/checkout.d.ts.map +1 -1
- package/package.json +10 -6
- package/src/auth.ts +47 -10
- package/src/blocks.tsx +7 -3
- package/src/component-contracts.ts +140 -0
- package/src/component-selectors.ts +31 -0
- package/src/components/CartDrawer.tsx +2 -1
- package/src/components/ProductCard.tsx +39 -23
- package/src/components/ProductEnquiryForm.tsx +175 -0
- package/src/components/ProductGrid.tsx +12 -3
- package/src/components/RelatedProducts.tsx +13 -4
- package/src/components/SmoothImage.tsx +59 -0
- package/src/components/StorefrontFooter.tsx +24 -14
- package/src/components/StorefrontHeader.tsx +74 -42
- package/src/components/sections/CategoryShowcase.tsx +3 -2
- package/src/components/sections/HeroSection.tsx +18 -2
- package/src/components/sections/MediaCarousel.tsx +141 -0
- package/src/components/sections/MiscSections.tsx +2 -1
- package/src/components/sections/ProductRail.tsx +11 -3
- package/src/components/sections/ProductSlider.tsx +119 -0
- package/src/config.ts +65 -24
- package/src/createStorefrontApp.tsx +80 -10
- package/src/define.ts +34 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useStorefront.ts +58 -0
- package/src/index.ts +62 -11
- package/src/pages/CheckoutPage.tsx +2 -1
- package/src/pages/HomePage.tsx +6 -2
- package/src/pages/MyPurchasesPage.tsx +2 -1
- package/src/pages/OrderConfirmationPage.tsx +2 -1
- package/src/pages/ProductDetailPage.tsx +138 -62
- package/src/presets.ts +66 -0
- package/src/scaffold.tsx +1 -1
- package/src/sections.ts +49 -2
- package/src/stores/cart.store.ts +16 -1
- package/src/testids.ts +21 -0
- package/src/theme.ts +36 -2
- package/src/workflows/checkout.ts +5 -2
- package/dist/slot-contracts.d.ts +0 -34
- package/dist/slot-contracts.d.ts.map +0 -1
- 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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
{
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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="
|
|
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="
|
|
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,
|
|
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
|
-
<
|
|
337
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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
|
+
}
|