@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
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { ShoppingBag } from 'lucide-react'
|
|
3
|
-
import type { Product } from '@fayz-ai/shop/types'
|
|
4
|
-
import { useCartStore } from '../stores/cart.store'
|
|
5
3
|
import { useStorefrontConfig } from '../config'
|
|
6
4
|
import { Link, navigateTo } from '../router'
|
|
7
5
|
import { getProductOptionGroups } from '../product-options'
|
|
8
6
|
import { Price } from './Price'
|
|
9
7
|
import { TID } from '../testids'
|
|
10
|
-
import {
|
|
8
|
+
import { productCardComponentContract } from '../component-selectors'
|
|
9
|
+
import { useStorefrontActions } from '../hooks/useStorefront'
|
|
10
|
+
import type { ProductCardProps } from '../component-contracts'
|
|
11
|
+
import { SmoothImage } from './SmoothImage'
|
|
11
12
|
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const config = useStorefrontConfig()
|
|
18
|
-
const addItem = useCartStore((s) => s.addItem)
|
|
13
|
+
export function ProductCard({ product, config: providedConfig, actions: providedActions }: ProductCardProps) {
|
|
14
|
+
const runtimeConfig = useStorefrontConfig()
|
|
15
|
+
const runtimeActions = useStorefrontActions()
|
|
16
|
+
const config = providedConfig ?? runtimeConfig
|
|
17
|
+
const actions = providedActions ?? runtimeActions
|
|
19
18
|
const hasOptions = getProductOptionGroups(product).length > 0
|
|
20
19
|
const soldOut = product.inventoryCount <= 0
|
|
21
20
|
const onSale = product.compareAtPrice != null && product.compareAtPrice > product.price
|
|
22
21
|
const image = product.images.find((i) => i.isPrimary) ?? product.images[0]
|
|
23
22
|
const cardStyle = config.theme?.productCard?.style ?? 'card'
|
|
24
23
|
const aspect = config.theme?.productCard?.imageAspect === 'portrait' ? 'aspect-[3/4]' : 'aspect-square'
|
|
24
|
+
const purchaseMode = config.commerceMode === 'checkout'
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
27
|
<div
|
|
28
|
-
{...
|
|
28
|
+
{...productCardComponentContract.root}
|
|
29
29
|
data-slug={product.slug}
|
|
30
30
|
className={`group flex flex-col overflow-hidden transition-all duration-300 ${
|
|
31
31
|
cardStyle === 'editorial'
|
|
@@ -36,7 +36,7 @@ export function ProductCard({ product }: ProductCardProps) {
|
|
|
36
36
|
>
|
|
37
37
|
<Link to={`/product/${product.slug}`} className={`relative block ${aspect} overflow-hidden bg-muted`} style={{ borderRadius: cardStyle === 'editorial' ? 'var(--sf-radius-card)' : undefined }}>
|
|
38
38
|
{image && (
|
|
39
|
-
<
|
|
39
|
+
<SmoothImage
|
|
40
40
|
src={image.url}
|
|
41
41
|
alt={image.altText ?? product.name}
|
|
42
42
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
@@ -67,23 +67,39 @@ export function ProductCard({ product }: ProductCardProps) {
|
|
|
67
67
|
<span className="text-xs text-muted-foreground">{product.categoryName}</span>
|
|
68
68
|
<Link
|
|
69
69
|
to={`/product/${product.slug}`}
|
|
70
|
-
{...
|
|
70
|
+
{...productCardComponentContract.name}
|
|
71
71
|
className="font-medium leading-snug hover:underline"
|
|
72
72
|
>
|
|
73
73
|
{product.name}
|
|
74
74
|
</Link>
|
|
75
|
-
<div className="mt-auto flex items-center justify-between pt-2">
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
<div className="mt-auto flex items-center justify-between gap-3 pt-2">
|
|
76
|
+
{purchaseMode ? (
|
|
77
|
+
<Price
|
|
78
|
+
value={product.price}
|
|
79
|
+
compareAt={product.compareAtPrice}
|
|
80
|
+
testId={productCardComponentContract.priceTestId}
|
|
81
|
+
/>
|
|
82
|
+
) : (
|
|
83
|
+
<span data-testid={productCardComponentContract.priceTestId} className="text-sm text-muted-foreground">
|
|
84
|
+
{config.commerceMode === 'enquiry' ? config.enquiry.label : 'Ver detalhes'}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
81
87
|
<button
|
|
82
88
|
type="button"
|
|
83
|
-
{...
|
|
84
|
-
disabled={soldOut}
|
|
85
|
-
aria-label={
|
|
89
|
+
{...productCardComponentContract.addButton}
|
|
90
|
+
disabled={purchaseMode && soldOut}
|
|
91
|
+
aria-label={
|
|
92
|
+
purchaseMode
|
|
93
|
+
? hasOptions
|
|
94
|
+
? `Escolher opções de ${product.name}`
|
|
95
|
+
: `Adicionar ${product.name} ao carrinho`
|
|
96
|
+
: `${config.enquiry.label} ${product.name}`
|
|
97
|
+
}
|
|
86
98
|
onClick={() => {
|
|
99
|
+
if (!purchaseMode) {
|
|
100
|
+
navigateTo(`/product/${product.slug}`)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
87
103
|
// Products with variants (size/color) must pick an option on the PDP
|
|
88
104
|
// — quick-adding would create an option-less line. Others add inline
|
|
89
105
|
// (addItem auto-opens the cart drawer).
|
|
@@ -91,7 +107,7 @@ export function ProductCard({ product }: ProductCardProps) {
|
|
|
91
107
|
navigateTo(`/product/${product.slug}`)
|
|
92
108
|
return
|
|
93
109
|
}
|
|
94
|
-
|
|
110
|
+
actions.addToCart(product)
|
|
95
111
|
}}
|
|
96
112
|
className="rounded-full border bg-background/90 p-2.5 text-foreground shadow-sm transition-all duration-200 hover:scale-110 hover:bg-primary hover:text-primary-foreground hover:shadow disabled:cursor-not-allowed disabled:opacity-40 lg:translate-y-1 lg:opacity-0 lg:group-hover:translate-y-0 lg:group-hover:opacity-100 lg:disabled:opacity-0 lg:group-hover:disabled:opacity-40"
|
|
97
113
|
>
|
|
@@ -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>
|