@fayz-ai/storefront 0.6.0 → 0.7.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/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 +904 -185
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +20 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +797 -95
- 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/sections.d.ts +45 -2
- package/dist/sections.d.ts.map +1 -1
- package/dist/testids.d.ts +20 -0
- package/dist/testids.d.ts.map +1 -1
- package/dist/theme.d.ts +4 -0
- package/dist/theme.d.ts.map +1 -1
- package/package.json +7 -9
- 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 +43 -38
- 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 +62 -9
- package/src/define.ts +34 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useStorefront.ts +58 -0
- package/src/index.ts +61 -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/scaffold.tsx +1 -1
- package/src/sections.ts +49 -2
- package/src/testids.ts +20 -0
- package/src/theme.ts +4 -0
- package/src/workflows/checkout.ts +1 -1
- package/dist/slot-contracts.d.ts +0 -34
- package/dist/slot-contracts.d.ts.map +0 -1
- package/src/slot-contracts.ts +0 -20
|
@@ -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
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import React, { createContext, useContext } from 'react'
|
|
2
|
-
import type { Product } from '@fayz-ai/shop/types'
|
|
3
2
|
import type { ShopProvider } from '@fayz-ai/shop/provider'
|
|
4
3
|
import type { MockShopSeed } from '@fayz-ai/shop/mock'
|
|
5
4
|
import type { StorefrontTheme } from './theme'
|
|
6
5
|
import type { HomeConfig, NavLink, FooterConfig } from './sections'
|
|
7
|
-
import type {
|
|
8
|
-
|
|
9
|
-
export interface ProductCardSlotProps {
|
|
10
|
-
product: Product
|
|
11
|
-
}
|
|
6
|
+
import type { StorefrontAuthConfig } from './auth'
|
|
7
|
+
import type { StorefrontComponents } from './component-contracts'
|
|
12
8
|
|
|
13
9
|
export type StorefrontRouteKind =
|
|
14
10
|
| 'home'
|
|
@@ -28,6 +24,15 @@ export interface StorefrontRouteComponentProps {
|
|
|
28
24
|
}
|
|
29
25
|
|
|
30
26
|
export type StorefrontRouteChrome = 'default' | 'focused'
|
|
27
|
+
export type StorefrontCommerceMode = 'checkout' | 'catalog' | 'enquiry'
|
|
28
|
+
export type StorefrontImageLoadingMode = 'fade' | 'none'
|
|
29
|
+
|
|
30
|
+
export interface StorefrontImageLoadingConfig {
|
|
31
|
+
mode?: StorefrontImageLoadingMode
|
|
32
|
+
durationMs?: number
|
|
33
|
+
easing?: string
|
|
34
|
+
blur?: boolean
|
|
35
|
+
}
|
|
31
36
|
|
|
32
37
|
export interface StorefrontRouteDefinition {
|
|
33
38
|
/**
|
|
@@ -54,22 +59,21 @@ export interface StorefrontRouteDefinition {
|
|
|
54
59
|
component: React.ComponentType<StorefrontRouteComponentProps>
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
export interface StorefrontSlots {
|
|
58
|
-
/**
|
|
59
|
-
* Product card renderer used by catalog grids and product rails.
|
|
60
|
-
*
|
|
61
|
-
* Custom renderers must preserve `productCardSlotContract` selectors so
|
|
62
|
-
* checkout smoke tests, QA, and agents can still operate the storefront.
|
|
63
|
-
*/
|
|
64
|
-
ProductCard?: React.ComponentType<ProductCardSlotProps>
|
|
65
|
-
}
|
|
66
|
-
|
|
67
62
|
export interface StorefrontDiscountConfig {
|
|
68
63
|
code: string
|
|
69
64
|
percent: number
|
|
70
65
|
title?: string
|
|
71
66
|
}
|
|
72
67
|
|
|
68
|
+
export interface StorefrontEnquiryConfig {
|
|
69
|
+
label?: string
|
|
70
|
+
successMessage?: string
|
|
71
|
+
subjectPrefix?: string
|
|
72
|
+
email?: string
|
|
73
|
+
whatsappUrl?: string
|
|
74
|
+
requirePhone?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
73
77
|
export interface StorefrontConfig {
|
|
74
78
|
/** Store display name (header, page title) */
|
|
75
79
|
name: string
|
|
@@ -81,6 +85,8 @@ export interface StorefrontConfig {
|
|
|
81
85
|
locale?: string
|
|
82
86
|
/** Visual identity: colors, fonts, radius, header/card personality */
|
|
83
87
|
theme?: StorefrontTheme
|
|
88
|
+
/** Checkout-first default, catalog-only browsing, or product enquiry flow. */
|
|
89
|
+
commerceMode?: StorefrontCommerceMode
|
|
84
90
|
/** Promo message above the header (e.g. 'FRETE GRÁTIS ACIMA DE R$ 300') */
|
|
85
91
|
announcement?: string
|
|
86
92
|
/** Home page blueprint. When set, `/` renders it and the catalog moves to /catalog. */
|
|
@@ -108,7 +114,7 @@ export interface StorefrontConfig {
|
|
|
108
114
|
*/
|
|
109
115
|
legal?: { privacy?: string; terms?: string; returns?: string }
|
|
110
116
|
/** Customer auth — same AuthAdapter contract as createSaasApp. Default: mock. */
|
|
111
|
-
auth?:
|
|
117
|
+
auth?: StorefrontAuthConfig
|
|
112
118
|
/** Shipping pricing — default { flatRate: 0 } */
|
|
113
119
|
shipping?: { flatRate?: number; freeAbove?: number }
|
|
114
120
|
/**
|
|
@@ -117,12 +123,16 @@ export interface StorefrontConfig {
|
|
|
117
123
|
* a real payment webhook confirms settlement.
|
|
118
124
|
*/
|
|
119
125
|
payments?: { mode?: 'mock' | 'pix-mercadopago' }
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
|
|
126
|
+
/** Product enquiry behavior for catalog/enquiry stores. */
|
|
127
|
+
enquiry?: StorefrontEnquiryConfig
|
|
128
|
+
/** Feature toggles — defaults depend on commerceMode. */
|
|
129
|
+
features?: { discounts?: boolean; accounts?: boolean; cart?: boolean; checkout?: boolean }
|
|
130
|
+
/** Global storefront image reveal behavior. Default is a soft fade. */
|
|
131
|
+
imageLoading?: StorefrontImageLoadingConfig
|
|
132
|
+
/** Code-level component replacements. Kept out of serialized manifests. */
|
|
133
|
+
components?: StorefrontComponents
|
|
124
134
|
/** Code-level route overrides/custom routes. Kept out of serialized manifests. */
|
|
125
|
-
routes?: StorefrontRouteDefinition[]
|
|
135
|
+
routes?: readonly StorefrontRouteDefinition[]
|
|
126
136
|
}
|
|
127
137
|
|
|
128
138
|
export interface ResolvedStorefrontConfig extends StorefrontConfig {
|
|
@@ -130,7 +140,11 @@ export interface ResolvedStorefrontConfig extends StorefrontConfig {
|
|
|
130
140
|
locale: string
|
|
131
141
|
shipping: { flatRate: number; freeAbove?: number }
|
|
132
142
|
payments: { mode: 'mock' | 'pix-mercadopago' }
|
|
133
|
-
|
|
143
|
+
commerceMode: StorefrontCommerceMode
|
|
144
|
+
enquiry: Required<Pick<StorefrontEnquiryConfig, 'label' | 'successMessage' | 'subjectPrefix'>> &
|
|
145
|
+
Omit<StorefrontEnquiryConfig, 'label' | 'successMessage' | 'subjectPrefix'>
|
|
146
|
+
features: { discounts: boolean; accounts: boolean; cart: boolean; checkout: boolean }
|
|
147
|
+
imageLoading: Required<StorefrontImageLoadingConfig>
|
|
134
148
|
/** Where the full product list lives ('/' without a home page, '/catalog' with one) */
|
|
135
149
|
catalogPath: string
|
|
136
150
|
nav: NavLink[]
|
|
@@ -138,12 +152,23 @@ export interface ResolvedStorefrontConfig extends StorefrontConfig {
|
|
|
138
152
|
|
|
139
153
|
export function resolveConfig(config: StorefrontConfig): ResolvedStorefrontConfig {
|
|
140
154
|
const catalogPath = config.home ? '/catalog' : '/'
|
|
155
|
+
const commerceMode = config.commerceMode ?? 'checkout'
|
|
156
|
+
const checkoutEnabled = commerceMode === 'checkout'
|
|
141
157
|
return {
|
|
142
158
|
...config,
|
|
143
159
|
currency: config.currency ?? 'BRL',
|
|
144
160
|
locale: config.locale ?? 'pt-BR',
|
|
145
161
|
shipping: { flatRate: config.shipping?.flatRate ?? 0, freeAbove: config.shipping?.freeAbove },
|
|
146
162
|
payments: { mode: config.payments?.mode ?? 'mock' },
|
|
163
|
+
commerceMode,
|
|
164
|
+
enquiry: {
|
|
165
|
+
label: config.enquiry?.label ?? 'Contact me',
|
|
166
|
+
successMessage: config.enquiry?.successMessage ?? 'Thanks. We received your enquiry.',
|
|
167
|
+
subjectPrefix: config.enquiry?.subjectPrefix ?? 'Product enquiry',
|
|
168
|
+
email: config.enquiry?.email,
|
|
169
|
+
whatsappUrl: config.enquiry?.whatsappUrl,
|
|
170
|
+
requirePhone: config.enquiry?.requirePhone,
|
|
171
|
+
},
|
|
147
172
|
features: {
|
|
148
173
|
// Auto-enable the coupon UI when the store actually ships discount codes
|
|
149
174
|
// (via config.discounts or a seeded catalog), unless explicitly overridden —
|
|
@@ -151,7 +176,15 @@ export function resolveConfig(config: StorefrontConfig): ResolvedStorefrontConfi
|
|
|
151
176
|
discounts:
|
|
152
177
|
config.features?.discounts ??
|
|
153
178
|
Boolean(config.discounts?.length || config.catalog?.discounts?.length),
|
|
154
|
-
accounts: config.features?.accounts ??
|
|
179
|
+
accounts: config.features?.accounts ?? checkoutEnabled,
|
|
180
|
+
cart: config.features?.cart ?? checkoutEnabled,
|
|
181
|
+
checkout: config.features?.checkout ?? checkoutEnabled,
|
|
182
|
+
},
|
|
183
|
+
imageLoading: {
|
|
184
|
+
mode: config.imageLoading?.mode ?? 'fade',
|
|
185
|
+
durationMs: config.imageLoading?.durationMs ?? 420,
|
|
186
|
+
easing: config.imageLoading?.easing ?? 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
187
|
+
blur: config.imageLoading?.blur ?? true,
|
|
155
188
|
},
|
|
156
189
|
catalogPath,
|
|
157
190
|
nav:
|
|
@@ -174,3 +207,11 @@ export function useStorefrontConfig(): ResolvedStorefrontConfig {
|
|
|
174
207
|
if (!ctx) throw new Error('useStorefrontConfig must be used inside createStorefrontApp')
|
|
175
208
|
return ctx
|
|
176
209
|
}
|
|
210
|
+
|
|
211
|
+
export function useStorefrontConfigOptional(): ResolvedStorefrontConfig | null {
|
|
212
|
+
return useContext(StorefrontConfigContext)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getStorefrontComponents(config: ResolvedStorefrontConfig): StorefrontComponents {
|
|
216
|
+
return config.components ?? {}
|
|
217
|
+
}
|
|
@@ -3,9 +3,12 @@ import { setShopProvider } from '@fayz-ai/shop/runtime'
|
|
|
3
3
|
import { createMockShopProvider } from '@fayz-ai/shop/mock'
|
|
4
4
|
import type { MockShopSeed } from '@fayz-ai/shop/mock'
|
|
5
5
|
import type { Discount } from '@fayz-ai/shop/types'
|
|
6
|
+
import type { ShopProvider } from '@fayz-ai/shop/provider'
|
|
6
7
|
import { initCustomerAuth, resolveAuthAdapter } from './auth'
|
|
7
|
-
import { StorefrontConfigProvider, resolveConfig, useStorefrontConfig } from './config'
|
|
8
|
+
import { getStorefrontComponents, StorefrontConfigProvider, resolveConfig, useStorefrontConfig } from './config'
|
|
8
9
|
import type { StorefrontConfig, StorefrontDiscountConfig } from './config'
|
|
10
|
+
import type { StorefrontComponents } from './component-contracts'
|
|
11
|
+
import type { StorefrontSection } from './sections'
|
|
9
12
|
import { useHashPath, matchPath } from './router'
|
|
10
13
|
import { StorefrontThemeStyle } from './theme'
|
|
11
14
|
import { StorefrontHeader } from './components/StorefrontHeader'
|
|
@@ -51,8 +54,8 @@ function RouteSwitch({ path }: { path: string }) {
|
|
|
51
54
|
const order = matchPath('/order/:id', path)
|
|
52
55
|
if (order?.id) return <OrderConfirmationPage orderId={order.id} />
|
|
53
56
|
|
|
54
|
-
if (matchPath('/checkout', path)) return <CheckoutPage />
|
|
55
|
-
if (matchPath('/account', path)) return <MyPurchasesPage />
|
|
57
|
+
if (matchPath('/checkout', path) && config.features.checkout) return <CheckoutPage />
|
|
58
|
+
if (matchPath('/account', path) && config.features.accounts) return <MyPurchasesPage />
|
|
56
59
|
if (matchPath('/catalog', path)) return <CatalogPage />
|
|
57
60
|
if (matchPath('/privacy', path)) return <PolicyPage kind="privacy" />
|
|
58
61
|
if (matchPath('/terms', path)) return <PolicyPage kind="terms" />
|
|
@@ -112,7 +115,12 @@ function buildMockSeed(config: StorefrontConfig): MockShopSeed | undefined {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
export function initStorefrontRuntime(config: StorefrontConfig): void {
|
|
115
|
-
|
|
118
|
+
if (resolveConfig(config).features.accounts) {
|
|
119
|
+
initCustomerAuth(resolveAuthAdapter(config.auth, {
|
|
120
|
+
supabaseUrl: config.supabaseUrl,
|
|
121
|
+
supabaseAnonKey: config.supabaseAnonKey,
|
|
122
|
+
}))
|
|
123
|
+
}
|
|
116
124
|
|
|
117
125
|
if (config.provider) {
|
|
118
126
|
setShopProvider(config.provider)
|
|
@@ -133,16 +141,34 @@ export function StorefrontShell() {
|
|
|
133
141
|
const config = useStorefrontConfig()
|
|
134
142
|
const path = useHashPath()
|
|
135
143
|
const focused = isFocusedRoute(config, path)
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
const components = getStorefrontComponents(config)
|
|
145
|
+
const chromeProps = { config, commerceMode: config.commerceMode }
|
|
146
|
+
const body = (
|
|
147
|
+
<>
|
|
138
148
|
{config.theme && <StorefrontThemeStyle theme={config.theme} />}
|
|
139
|
-
{!focused &&
|
|
149
|
+
{!focused &&
|
|
150
|
+
(components.Header ? <components.Header {...chromeProps} /> : <StorefrontHeader />)}
|
|
140
151
|
<StorefrontErrorBoundary key={path}>
|
|
141
152
|
<RouteSwitch path={path} />
|
|
142
153
|
</StorefrontErrorBoundary>
|
|
143
|
-
{!focused && <CartDrawer />}
|
|
144
|
-
{!focused &&
|
|
154
|
+
{!focused && config.features.cart && <CartDrawer />}
|
|
155
|
+
{!focused &&
|
|
156
|
+
(components.Footer ? <components.Footer {...chromeProps} /> : <StorefrontFooter />)}
|
|
145
157
|
<Toaster />
|
|
158
|
+
</>
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if (components.Shell) {
|
|
162
|
+
return (
|
|
163
|
+
<components.Shell {...chromeProps}>
|
|
164
|
+
{body}
|
|
165
|
+
</components.Shell>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="min-h-screen bg-background text-foreground">
|
|
171
|
+
{body}
|
|
146
172
|
</div>
|
|
147
173
|
)
|
|
148
174
|
}
|
|
@@ -169,3 +195,30 @@ export function createStorefrontApp(config: StorefrontConfig): React.ComponentTy
|
|
|
169
195
|
StorefrontApp.displayName = 'StorefrontApp'
|
|
170
196
|
return StorefrontApp
|
|
171
197
|
}
|
|
198
|
+
|
|
199
|
+
export interface CreateStorefrontOptions {
|
|
200
|
+
config: StorefrontConfig
|
|
201
|
+
provider?: ShopProvider
|
|
202
|
+
components?: StorefrontComponents
|
|
203
|
+
routes?: StorefrontConfig['routes']
|
|
204
|
+
pages?: StorefrontConfig['routes']
|
|
205
|
+
sections?: readonly StorefrontSection[]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function createStorefront(options: CreateStorefrontOptions): React.ComponentType {
|
|
209
|
+
const routes = [...(options.pages ?? []), ...(options.routes ?? [])]
|
|
210
|
+
const config: StorefrontConfig = {
|
|
211
|
+
...options.config,
|
|
212
|
+
provider: options.provider ?? options.config.provider,
|
|
213
|
+
components: {
|
|
214
|
+
...options.config.components,
|
|
215
|
+
...options.components,
|
|
216
|
+
},
|
|
217
|
+
routes: routes.length > 0 ? routes : options.config.routes,
|
|
218
|
+
home: options.sections
|
|
219
|
+
? { ...(options.config.home ?? { sections: [] }), sections: options.sections }
|
|
220
|
+
: options.config.home,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return createStorefrontApp(config)
|
|
224
|
+
}
|
package/src/define.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StorefrontConfig,
|
|
3
|
+
StorefrontRouteDefinition,
|
|
4
|
+
} from './config'
|
|
5
|
+
import type { StorefrontComponents } from './component-contracts'
|
|
6
|
+
import type { StorefrontSection } from './sections'
|
|
7
|
+
import type { CreateStorefrontOptions } from './createStorefrontApp'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Identity helpers for generated/custom storefronts.
|
|
11
|
+
*
|
|
12
|
+
* They do not add runtime behavior; they make the intended client-app shape
|
|
13
|
+
* explicit and keep TypeScript inference tight across config, components, routes,
|
|
14
|
+
* sections and app creation options.
|
|
15
|
+
*/
|
|
16
|
+
export function defineStorefrontConfig<const T extends StorefrontConfig>(config: T): T {
|
|
17
|
+
return config
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function defineStorefrontComponents<const T extends StorefrontComponents>(components: T): T {
|
|
21
|
+
return components
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function defineStorefrontRoutes<const T extends readonly StorefrontRouteDefinition[]>(routes: T): T {
|
|
25
|
+
return routes
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function defineStorefrontSections<const T extends readonly StorefrontSection[]>(sections: T): T {
|
|
29
|
+
return sections
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function defineStorefrontApp<const T extends CreateStorefrontOptions>(options: T): T {
|
|
33
|
+
return options
|
|
34
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { useProducts } from './useProducts'
|
|
2
2
|
export { useProduct } from './useProduct'
|
|
3
3
|
export { useCategories } from './useCategories'
|
|
4
|
+
export { useStorefront, useCatalog, useCart, useEnquiry, useStorefrontActions } from './useStorefront'
|
|
4
5
|
export { useMyOrders } from './useMyOrders'
|
|
5
6
|
export { useDiscountValidator } from './useDiscountValidator'
|