@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,175 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { Mail, MessageCircle, Send } from 'lucide-react'
|
|
3
|
+
import { getShopProvider } from '@fayz-ai/shop/runtime'
|
|
4
|
+
import type { Product } from '@fayz-ai/shop/types'
|
|
5
|
+
import { useStorefrontConfig } from '../config'
|
|
6
|
+
import { storefrontComponentContracts } from '../component-selectors'
|
|
7
|
+
import { TID } from '../testids'
|
|
8
|
+
|
|
9
|
+
export interface ProductEnquiryFormProps {
|
|
10
|
+
product: Product
|
|
11
|
+
onSuccess?: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function defaultSubject(prefix: string, product: Product): string {
|
|
15
|
+
return `${prefix}: ${product.name}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildWhatsAppHref(url: string, product: Product): string {
|
|
19
|
+
const text = encodeURIComponent(`Hello, I would like more information about ${product.name}.`)
|
|
20
|
+
const sep = url.includes('?') ? '&' : '?'
|
|
21
|
+
return `${url}${sep}text=${text}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ProductEnquiryForm({ product, onSuccess }: ProductEnquiryFormProps) {
|
|
25
|
+
const config = useStorefrontConfig()
|
|
26
|
+
const [customerName, setCustomerName] = useState('')
|
|
27
|
+
const [customerEmail, setCustomerEmail] = useState('')
|
|
28
|
+
const [customerPhone, setCustomerPhone] = useState('')
|
|
29
|
+
const [subject, setSubject] = useState(defaultSubject(config.enquiry.subjectPrefix, product))
|
|
30
|
+
const [message, setMessage] = useState(`I would like more information about ${product.name}.`)
|
|
31
|
+
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
|
|
32
|
+
const [error, setError] = useState<string | null>(null)
|
|
33
|
+
|
|
34
|
+
const submit = async (event: React.FormEvent) => {
|
|
35
|
+
event.preventDefault()
|
|
36
|
+
setStatus('submitting')
|
|
37
|
+
setError(null)
|
|
38
|
+
try {
|
|
39
|
+
const provider = getShopProvider()
|
|
40
|
+
if (provider.createProductEnquiry) {
|
|
41
|
+
await provider.createProductEnquiry({
|
|
42
|
+
productId: product.id,
|
|
43
|
+
productName: product.name,
|
|
44
|
+
productSlug: product.slug,
|
|
45
|
+
customerName,
|
|
46
|
+
customerEmail,
|
|
47
|
+
customerPhone: customerPhone || undefined,
|
|
48
|
+
subject,
|
|
49
|
+
message,
|
|
50
|
+
sourceUrl: typeof window === 'undefined' ? undefined : window.location.href,
|
|
51
|
+
metadata: { sku: product.sku, categoryName: product.categoryName },
|
|
52
|
+
})
|
|
53
|
+
} else if (config.enquiry.email) {
|
|
54
|
+
window.location.href = `mailto:${config.enquiry.email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(message)}`
|
|
55
|
+
}
|
|
56
|
+
setStatus('success')
|
|
57
|
+
onSuccess?.()
|
|
58
|
+
} catch (e) {
|
|
59
|
+
setStatus('error')
|
|
60
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<form
|
|
66
|
+
{...storefrontComponentContracts.productDetail.enquiryForm}
|
|
67
|
+
onSubmit={submit}
|
|
68
|
+
className="space-y-4"
|
|
69
|
+
>
|
|
70
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
71
|
+
<label className="space-y-1.5 text-sm font-medium">
|
|
72
|
+
<span>Your name</span>
|
|
73
|
+
<input
|
|
74
|
+
data-testid={TID.enquiryName}
|
|
75
|
+
required
|
|
76
|
+
value={customerName}
|
|
77
|
+
onChange={(e) => setCustomerName(e.target.value)}
|
|
78
|
+
className="w-full border bg-background px-3 py-2 outline-none focus:border-primary"
|
|
79
|
+
style={{ borderRadius: 'var(--sf-radius-input)' }}
|
|
80
|
+
/>
|
|
81
|
+
</label>
|
|
82
|
+
<label className="space-y-1.5 text-sm font-medium">
|
|
83
|
+
<span>Your email</span>
|
|
84
|
+
<input
|
|
85
|
+
data-testid={TID.enquiryEmail}
|
|
86
|
+
required
|
|
87
|
+
type="email"
|
|
88
|
+
value={customerEmail}
|
|
89
|
+
onChange={(e) => setCustomerEmail(e.target.value)}
|
|
90
|
+
className="w-full border bg-background px-3 py-2 outline-none focus:border-primary"
|
|
91
|
+
style={{ borderRadius: 'var(--sf-radius-input)' }}
|
|
92
|
+
/>
|
|
93
|
+
</label>
|
|
94
|
+
</div>
|
|
95
|
+
<label className="block space-y-1.5 text-sm font-medium">
|
|
96
|
+
<span>Phone{config.enquiry.requirePhone ? '' : ' (optional)'}</span>
|
|
97
|
+
<input
|
|
98
|
+
data-testid={TID.enquiryPhone}
|
|
99
|
+
required={config.enquiry.requirePhone}
|
|
100
|
+
value={customerPhone}
|
|
101
|
+
onChange={(e) => setCustomerPhone(e.target.value)}
|
|
102
|
+
className="w-full border bg-background px-3 py-2 outline-none focus:border-primary"
|
|
103
|
+
style={{ borderRadius: 'var(--sf-radius-input)' }}
|
|
104
|
+
/>
|
|
105
|
+
</label>
|
|
106
|
+
<label className="block space-y-1.5 text-sm font-medium">
|
|
107
|
+
<span>Subject</span>
|
|
108
|
+
<input
|
|
109
|
+
data-testid={TID.enquirySubject}
|
|
110
|
+
required
|
|
111
|
+
value={subject}
|
|
112
|
+
onChange={(e) => setSubject(e.target.value)}
|
|
113
|
+
className="w-full border bg-background px-3 py-2 outline-none focus:border-primary"
|
|
114
|
+
style={{ borderRadius: 'var(--sf-radius-input)' }}
|
|
115
|
+
/>
|
|
116
|
+
</label>
|
|
117
|
+
<label className="block space-y-1.5 text-sm font-medium">
|
|
118
|
+
<span>Message</span>
|
|
119
|
+
<textarea
|
|
120
|
+
data-testid={TID.enquiryMessage}
|
|
121
|
+
required
|
|
122
|
+
rows={5}
|
|
123
|
+
value={message}
|
|
124
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
125
|
+
className="w-full resize-none border bg-background px-3 py-2 outline-none focus:border-primary"
|
|
126
|
+
style={{ borderRadius: 'var(--sf-radius-input)' }}
|
|
127
|
+
/>
|
|
128
|
+
</label>
|
|
129
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
130
|
+
<button
|
|
131
|
+
data-testid={TID.enquirySubmit}
|
|
132
|
+
type="submit"
|
|
133
|
+
disabled={status === 'submitting'}
|
|
134
|
+
className="sf-cta inline-flex items-center gap-2 bg-primary px-5 py-3 font-semibold text-primary-foreground disabled:cursor-wait disabled:opacity-60"
|
|
135
|
+
style={{ borderRadius: 'var(--sf-radius-button)' }}
|
|
136
|
+
>
|
|
137
|
+
<Send className="h-4 w-4" />
|
|
138
|
+
{status === 'submitting' ? 'Sending...' : 'Send enquiry'}
|
|
139
|
+
</button>
|
|
140
|
+
{config.enquiry.whatsappUrl && (
|
|
141
|
+
<a
|
|
142
|
+
href={buildWhatsAppHref(config.enquiry.whatsappUrl, product)}
|
|
143
|
+
target="_blank"
|
|
144
|
+
rel="noreferrer"
|
|
145
|
+
className="inline-flex items-center gap-2 border px-5 py-3 text-sm font-semibold transition-colors hover:bg-muted"
|
|
146
|
+
style={{ borderRadius: 'var(--sf-radius-button)' }}
|
|
147
|
+
>
|
|
148
|
+
<MessageCircle className="h-4 w-4" />
|
|
149
|
+
WhatsApp
|
|
150
|
+
</a>
|
|
151
|
+
)}
|
|
152
|
+
{config.enquiry.email && (
|
|
153
|
+
<a
|
|
154
|
+
href={`mailto:${config.enquiry.email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(message)}`}
|
|
155
|
+
className="inline-flex items-center gap-2 border px-5 py-3 text-sm font-semibold transition-colors hover:bg-muted"
|
|
156
|
+
style={{ borderRadius: 'var(--sf-radius-button)' }}
|
|
157
|
+
>
|
|
158
|
+
<Mail className="h-4 w-4" />
|
|
159
|
+
Email
|
|
160
|
+
</a>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
{status === 'success' && (
|
|
164
|
+
<p data-testid={TID.enquirySuccess} className="text-sm font-medium text-emerald-600">
|
|
165
|
+
{config.enquiry.successMessage}
|
|
166
|
+
</p>
|
|
167
|
+
)}
|
|
168
|
+
{error && (
|
|
169
|
+
<p data-testid={TID.enquiryError} className="text-sm font-medium text-red-600">
|
|
170
|
+
{error}
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
</form>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import type { Product } from '@fayz-ai/shop/types'
|
|
3
3
|
import { ProductCard } from './ProductCard'
|
|
4
|
-
import { useStorefrontConfig } from '../config'
|
|
4
|
+
import { getStorefrontComponents, useStorefrontConfig } from '../config'
|
|
5
|
+
import { useStorefrontActions } from '../hooks/useStorefront'
|
|
5
6
|
import { TID } from '../testids'
|
|
6
7
|
|
|
7
8
|
export function ProductGrid({ products, loading }: { products: Product[]; loading: boolean }) {
|
|
8
9
|
const config = useStorefrontConfig()
|
|
9
|
-
const
|
|
10
|
+
const actions = useStorefrontActions()
|
|
11
|
+
const components = getStorefrontComponents(config)
|
|
12
|
+
const ProductCardComponent = components.ProductCard ?? ProductCard
|
|
10
13
|
|
|
11
14
|
if (loading) {
|
|
12
15
|
return (
|
|
@@ -27,7 +30,13 @@ export function ProductGrid({ products, loading }: { products: Product[]; loadin
|
|
|
27
30
|
return (
|
|
28
31
|
<div data-testid={TID.productGrid} className="grid grid-cols-2 gap-5 md:grid-cols-3 xl:grid-cols-4">
|
|
29
32
|
{products.map((p) => (
|
|
30
|
-
<ProductCardComponent
|
|
33
|
+
<ProductCardComponent
|
|
34
|
+
key={p.id}
|
|
35
|
+
product={p}
|
|
36
|
+
config={config}
|
|
37
|
+
commerceMode={config.commerceMode}
|
|
38
|
+
actions={actions}
|
|
39
|
+
/>
|
|
31
40
|
))}
|
|
32
41
|
</div>
|
|
33
42
|
)
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import type { ListProductsOptions, Product } from '@fayz-ai/shop/types'
|
|
3
3
|
import { useProducts } from '../hooks/useProducts'
|
|
4
|
-
import { useStorefrontConfig } from '../config'
|
|
4
|
+
import { getStorefrontComponents, useStorefrontConfig } from '../config'
|
|
5
|
+
import { useStorefrontActions } from '../hooks/useStorefront'
|
|
5
6
|
import { ProductCard } from './ProductCard'
|
|
6
7
|
|
|
7
8
|
/** "Você também pode gostar" — products from the same category, excluding the
|
|
8
|
-
* current one. Renders through the store's configured card
|
|
9
|
+
* current one. Renders through the store's configured card component for visual
|
|
9
10
|
* consistency with the catalog. Hidden when there's nothing relevant. */
|
|
10
11
|
export function RelatedProducts({ product }: { product: Product }) {
|
|
11
12
|
const config = useStorefrontConfig()
|
|
12
|
-
const
|
|
13
|
+
const actions = useStorefrontActions()
|
|
14
|
+
const components = getStorefrontComponents(config)
|
|
15
|
+
const Card = components.ProductCard ?? ProductCard
|
|
13
16
|
const opts: ListProductsOptions = product.categoryId
|
|
14
17
|
? { categoryId: product.categoryId, status: 'active', limit: 5 }
|
|
15
18
|
: { status: 'active', limit: 5 }
|
|
@@ -23,7 +26,13 @@ export function RelatedProducts({ product }: { product: Product }) {
|
|
|
23
26
|
<h2 className="sf-heading text-xl font-semibold tracking-tight">Você também pode gostar</h2>
|
|
24
27
|
<div className="mt-6 grid grid-cols-2 gap-5 md:grid-cols-4">
|
|
25
28
|
{related.map((p) => (
|
|
26
|
-
<Card
|
|
29
|
+
<Card
|
|
30
|
+
key={p.id}
|
|
31
|
+
product={p}
|
|
32
|
+
config={config}
|
|
33
|
+
commerceMode={config.commerceMode}
|
|
34
|
+
actions={actions}
|
|
35
|
+
/>
|
|
27
36
|
))}
|
|
28
37
|
</div>
|
|
29
38
|
</section>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useStorefrontConfigOptional, type StorefrontImageLoadingConfig } from '../config'
|
|
3
|
+
|
|
4
|
+
export interface SmoothImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|
5
|
+
imageLoading?: StorefrontImageLoadingConfig
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function SmoothImage({
|
|
9
|
+
imageLoading,
|
|
10
|
+
loading = 'lazy',
|
|
11
|
+
decoding = 'async',
|
|
12
|
+
onLoad,
|
|
13
|
+
style,
|
|
14
|
+
...props
|
|
15
|
+
}: SmoothImageProps) {
|
|
16
|
+
const config = useStorefrontConfigOptional()
|
|
17
|
+
const resolved = {
|
|
18
|
+
...config?.imageLoading,
|
|
19
|
+
...imageLoading,
|
|
20
|
+
}
|
|
21
|
+
const mode = resolved.mode ?? 'fade'
|
|
22
|
+
const durationMs = resolved.durationMs ?? 420
|
|
23
|
+
const easing = resolved.easing ?? 'cubic-bezier(0.22, 1, 0.36, 1)'
|
|
24
|
+
const blur = resolved.blur ?? true
|
|
25
|
+
const ref = React.useRef<HTMLImageElement | null>(null)
|
|
26
|
+
const [loaded, setLoaded] = React.useState(mode === 'none')
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (mode === 'none') {
|
|
30
|
+
setLoaded(true)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
if (ref.current?.complete && ref.current.naturalWidth > 0) setLoaded(true)
|
|
34
|
+
}, [mode, props.src])
|
|
35
|
+
|
|
36
|
+
const revealStyle: React.CSSProperties =
|
|
37
|
+
mode === 'none'
|
|
38
|
+
? {}
|
|
39
|
+
: {
|
|
40
|
+
opacity: loaded ? 1 : 0,
|
|
41
|
+
filter: blur && !loaded ? 'blur(10px)' : 'blur(0px)',
|
|
42
|
+
transition: `opacity ${durationMs}ms ${easing}, filter ${durationMs}ms ${easing}, transform ${durationMs}ms ${easing}`,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<img
|
|
47
|
+
{...props}
|
|
48
|
+
ref={ref}
|
|
49
|
+
loading={loading}
|
|
50
|
+
decoding={decoding}
|
|
51
|
+
data-sf-image-loaded={loaded ? 'true' : 'false'}
|
|
52
|
+
style={{ ...style, ...revealStyle }}
|
|
53
|
+
onLoad={(event) => {
|
|
54
|
+
setLoaded(true)
|
|
55
|
+
onLoad?.(event)
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -9,6 +9,17 @@ export function StorefrontFooter() {
|
|
|
9
9
|
const { categories } = useCategories()
|
|
10
10
|
const visibleCategories = categories.slice(0, 8)
|
|
11
11
|
const hiddenCategoryCount = Math.max(categories.length - visibleCategories.length, 0)
|
|
12
|
+
const labels = {
|
|
13
|
+
categories: 'Departamentos',
|
|
14
|
+
navigation: 'Navegação',
|
|
15
|
+
contact: 'Contato',
|
|
16
|
+
viewAll: 'Ver todos',
|
|
17
|
+
purchases: 'Minhas compras',
|
|
18
|
+
privacy: 'Privacidade',
|
|
19
|
+
terms: 'Termos',
|
|
20
|
+
returns: 'Trocas e devoluções',
|
|
21
|
+
...config.footer?.labels,
|
|
22
|
+
}
|
|
12
23
|
|
|
13
24
|
const goCategory = (categoryId: string) => {
|
|
14
25
|
useCatalogStore.getState().reset()
|
|
@@ -27,7 +38,7 @@ export function StorefrontFooter() {
|
|
|
27
38
|
</div>
|
|
28
39
|
|
|
29
40
|
<div>
|
|
30
|
-
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
41
|
+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{labels.categories}</h3>
|
|
31
42
|
<ul className="space-y-2 text-sm">
|
|
32
43
|
{visibleCategories.map((c) => (
|
|
33
44
|
<li key={c.id}>
|
|
@@ -43,7 +54,7 @@ export function StorefrontFooter() {
|
|
|
43
54
|
{hiddenCategoryCount > 0 && (
|
|
44
55
|
<li>
|
|
45
56
|
<Link to={config.catalogPath} className="font-medium underline-offset-2 transition-opacity hover:underline">
|
|
46
|
-
|
|
57
|
+
{labels.viewAll} ({categories.length})
|
|
47
58
|
</Link>
|
|
48
59
|
</li>
|
|
49
60
|
)}
|
|
@@ -51,21 +62,23 @@ export function StorefrontFooter() {
|
|
|
51
62
|
</div>
|
|
52
63
|
|
|
53
64
|
<div>
|
|
54
|
-
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
65
|
+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{labels.navigation}</h3>
|
|
55
66
|
<ul className="space-y-2 text-sm">
|
|
56
67
|
{config.nav.map((link) => (
|
|
57
68
|
<li key={link.to}>
|
|
58
69
|
<Link to={link.to} className="transition-opacity hover:opacity-70">{link.label}</Link>
|
|
59
70
|
</li>
|
|
60
71
|
))}
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
72
|
+
{config.features.accounts && (
|
|
73
|
+
<li>
|
|
74
|
+
<Link to="/account" className="transition-opacity hover:opacity-70">{labels.purchases}</Link>
|
|
75
|
+
</li>
|
|
76
|
+
)}
|
|
64
77
|
</ul>
|
|
65
78
|
</div>
|
|
66
79
|
|
|
67
80
|
<div>
|
|
68
|
-
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
81
|
+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{labels.contact}</h3>
|
|
69
82
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
70
83
|
{config.footer?.contact?.phone && <li>{config.footer.contact.phone}</li>}
|
|
71
84
|
{config.footer?.contact?.email && <li>{config.footer.contact.email}</li>}
|
|
@@ -83,15 +96,12 @@ export function StorefrontFooter() {
|
|
|
83
96
|
<div className="border-t">
|
|
84
97
|
<div className="mx-auto flex max-w-7xl flex-col items-center gap-3 px-4 py-5 text-xs text-muted-foreground sm:flex-row sm:justify-between sm:px-6">
|
|
85
98
|
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
|
|
86
|
-
<Link to="/privacy" className="transition-colors hover:text-foreground">
|
|
87
|
-
<Link to="/terms" className="transition-colors hover:text-foreground">
|
|
88
|
-
<Link to="/refunds" className="transition-colors hover:text-foreground">
|
|
99
|
+
<Link to="/privacy" className="transition-colors hover:text-foreground">{labels.privacy}</Link>
|
|
100
|
+
<Link to="/terms" className="transition-colors hover:text-foreground">{labels.terms}</Link>
|
|
101
|
+
<Link to="/refunds" className="transition-colors hover:text-foreground">{labels.returns}</Link>
|
|
89
102
|
</nav>
|
|
90
103
|
<p className="text-center">
|
|
91
|
-
|
|
92
|
-
<a href="https://unsplash.com" target="_blank" rel="noreferrer" className="underline-offset-2 hover:underline">
|
|
93
|
-
Unsplash
|
|
94
|
-
</a>
|
|
104
|
+
{config.footer?.credit ?? <>© {config.name} — powered by Fayz</>}
|
|
95
105
|
</p>
|
|
96
106
|
</div>
|
|
97
107
|
</div>
|
|
@@ -206,25 +206,27 @@ function HeaderActions() {
|
|
|
206
206
|
)}
|
|
207
207
|
</div>
|
|
208
208
|
)}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
209
|
+
{config.features.cart && (
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
data-testid={TID.cartButton}
|
|
213
|
+
aria-label="Carrinho"
|
|
214
|
+
onClick={openDrawer}
|
|
215
|
+
className="relative rounded-full p-2.5 transition-opacity hover:opacity-70"
|
|
216
|
+
>
|
|
217
|
+
<ShoppingBag className="h-5 w-5" />
|
|
218
|
+
{count > 0 && (
|
|
219
|
+
<span
|
|
220
|
+
data-testid={TID.cartCount}
|
|
221
|
+
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 ${
|
|
222
|
+
pop ? 'animate-badge-pop' : ''
|
|
223
|
+
}`}
|
|
224
|
+
>
|
|
225
|
+
{count}
|
|
226
|
+
</span>
|
|
227
|
+
)}
|
|
228
|
+
</button>
|
|
229
|
+
)}
|
|
228
230
|
</nav>
|
|
229
231
|
)
|
|
230
232
|
}
|
|
@@ -233,6 +235,7 @@ function NavLinks({ className }: { className?: string }) {
|
|
|
233
235
|
const config = useStorefrontConfig()
|
|
234
236
|
const { categories } = useCategories()
|
|
235
237
|
const setCategoryId = useCatalogStore((s) => s.setCategoryId)
|
|
238
|
+
const showCategories = config.theme?.header?.showCategories !== false
|
|
236
239
|
|
|
237
240
|
const goCategory = (categoryId: string) => {
|
|
238
241
|
useCatalogStore.getState().reset()
|
|
@@ -241,7 +244,7 @@ function NavLinks({ className }: { className?: string }) {
|
|
|
241
244
|
}
|
|
242
245
|
|
|
243
246
|
return (
|
|
244
|
-
<nav className={`flex items-center gap-6 ${className ?? ''}`}>
|
|
247
|
+
<nav className={`flex min-w-max items-center gap-6 ${className ?? ''}`}>
|
|
245
248
|
{config.nav.map((link) => (
|
|
246
249
|
<Link
|
|
247
250
|
key={link.to}
|
|
@@ -252,18 +255,19 @@ function NavLinks({ className }: { className?: string }) {
|
|
|
252
255
|
{link.label}
|
|
253
256
|
</Link>
|
|
254
257
|
))}
|
|
255
|
-
{
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
258
|
+
{showCategories &&
|
|
259
|
+
categories.slice(0, 6).map((c) => (
|
|
260
|
+
<button
|
|
261
|
+
key={c.id}
|
|
262
|
+
type="button"
|
|
263
|
+
data-testid={TID.navCategory(c.slug)}
|
|
264
|
+
onClick={() => goCategory(c.id)}
|
|
265
|
+
className="sf-nav-link group relative hidden text-sm font-medium transition-opacity hover:opacity-80 lg:inline"
|
|
266
|
+
>
|
|
267
|
+
{c.name}
|
|
268
|
+
<span className="absolute -bottom-1 left-0 h-0.5 w-0 bg-primary transition-all duration-300 group-hover:w-full" />
|
|
269
|
+
</button>
|
|
270
|
+
))}
|
|
267
271
|
</nav>
|
|
268
272
|
)
|
|
269
273
|
}
|
|
@@ -272,6 +276,7 @@ export function StorefrontHeader() {
|
|
|
272
276
|
const config = useStorefrontConfig()
|
|
273
277
|
const variant = config.theme?.header?.variant ?? 'classic'
|
|
274
278
|
const scrolled = useScrolled()
|
|
279
|
+
const showSearch = config.theme?.header?.showSearch !== false
|
|
275
280
|
const logo = (
|
|
276
281
|
<Link to="/" className="sf-heading shrink-0 text-xl font-bold tracking-tight">
|
|
277
282
|
{config.logo ?? config.name}
|
|
@@ -290,13 +295,13 @@ export function StorefrontHeader() {
|
|
|
290
295
|
// Rio/Flex pattern: centered logo row, nav row below
|
|
291
296
|
<>
|
|
292
297
|
<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" />
|
|
298
|
+
{showSearch ? <SearchInput className="hidden w-full max-w-xs sm:block" /> : <div />}
|
|
294
299
|
<div className="text-center">{logo}</div>
|
|
295
300
|
<div className="justify-self-end">
|
|
296
301
|
<HeaderActions />
|
|
297
302
|
</div>
|
|
298
303
|
</div>
|
|
299
|
-
<div className="
|
|
304
|
+
<div className="flex overflow-x-auto border-t py-2.5 sm:justify-center" style={headerDivider}>
|
|
300
305
|
<NavLinks />
|
|
301
306
|
</div>
|
|
302
307
|
</>
|
|
@@ -304,13 +309,13 @@ export function StorefrontHeader() {
|
|
|
304
309
|
// Brasília pattern: prominent search left, logo center, actions right; nav row below
|
|
305
310
|
<>
|
|
306
311
|
<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" />
|
|
312
|
+
{showSearch ? <SearchInput className="hidden w-full max-w-sm sm:block" /> : <div />}
|
|
308
313
|
<div className="text-center">{logo}</div>
|
|
309
314
|
<div className="justify-self-end">
|
|
310
315
|
<HeaderActions />
|
|
311
316
|
</div>
|
|
312
317
|
</div>
|
|
313
|
-
<div className="
|
|
318
|
+
<div className="overflow-x-auto border-t" style={headerDivider}>
|
|
314
319
|
<div className="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
|
|
315
320
|
<NavLinks />
|
|
316
321
|
</div>
|
|
@@ -324,7 +329,7 @@ export function StorefrontHeader() {
|
|
|
324
329
|
</div>
|
|
325
330
|
<div className="text-center">{logo}</div>
|
|
326
331
|
<div className="flex items-center justify-end gap-3">
|
|
327
|
-
<SearchInput className="hidden w-44 lg:block" />
|
|
332
|
+
{showSearch && <SearchInput className="hidden w-44 lg:block" />}
|
|
328
333
|
<HeaderActions />
|
|
329
334
|
</div>
|
|
330
335
|
</div>
|
|
@@ -333,7 +338,7 @@ export function StorefrontHeader() {
|
|
|
333
338
|
<div className="mx-auto flex h-16 max-w-7xl items-center gap-6 px-4 sm:px-6">
|
|
334
339
|
{logo}
|
|
335
340
|
<NavLinks className="hidden md:flex" />
|
|
336
|
-
<SearchInput className="ml-auto hidden w-full max-w-sm sm:block" />
|
|
341
|
+
{showSearch && <SearchInput className="ml-auto hidden w-full max-w-sm sm:block" />}
|
|
337
342
|
<HeaderActions />
|
|
338
343
|
</div>
|
|
339
344
|
)}
|
|
@@ -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
|
))}
|