@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.
Files changed (94) hide show
  1. package/dist/auth.d.ts +12 -2
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/blocks.d.ts +3 -3
  4. package/dist/blocks.d.ts.map +1 -1
  5. package/dist/component-contracts.d.ts +124 -0
  6. package/dist/component-contracts.d.ts.map +1 -0
  7. package/dist/component-selectors.d.ts +61 -0
  8. package/dist/component-selectors.d.ts.map +1 -0
  9. package/dist/components/CartDrawer.d.ts.map +1 -1
  10. package/dist/components/ProductCard.d.ts +2 -5
  11. package/dist/components/ProductCard.d.ts.map +1 -1
  12. package/dist/components/ProductEnquiryForm.d.ts +8 -0
  13. package/dist/components/ProductEnquiryForm.d.ts.map +1 -0
  14. package/dist/components/ProductGrid.d.ts.map +1 -1
  15. package/dist/components/RelatedProducts.d.ts +1 -1
  16. package/dist/components/RelatedProducts.d.ts.map +1 -1
  17. package/dist/components/SmoothImage.d.ts +7 -0
  18. package/dist/components/SmoothImage.d.ts.map +1 -0
  19. package/dist/components/StorefrontFooter.d.ts.map +1 -1
  20. package/dist/components/StorefrontHeader.d.ts.map +1 -1
  21. package/dist/components/sections/CategoryShowcase.d.ts.map +1 -1
  22. package/dist/components/sections/HeroSection.d.ts.map +1 -1
  23. package/dist/components/sections/MediaCarousel.d.ts +11 -0
  24. package/dist/components/sections/MediaCarousel.d.ts.map +1 -0
  25. package/dist/components/sections/MiscSections.d.ts.map +1 -1
  26. package/dist/components/sections/ProductRail.d.ts.map +1 -1
  27. package/dist/components/sections/ProductSlider.d.ts +15 -0
  28. package/dist/components/sections/ProductSlider.d.ts.map +1 -0
  29. package/dist/config.d.ts +38 -21
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/createStorefrontApp.d.ts +12 -0
  32. package/dist/createStorefrontApp.d.ts.map +1 -1
  33. package/dist/define.d.ts +17 -0
  34. package/dist/define.d.ts.map +1 -0
  35. package/dist/hooks/index.d.ts +1 -0
  36. package/dist/hooks/index.d.ts.map +1 -1
  37. package/dist/hooks/useStorefront.d.ts +35 -0
  38. package/dist/hooks/useStorefront.d.ts.map +1 -0
  39. package/dist/index.cjs +904 -185
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.ts +20 -10
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +797 -95
  44. package/dist/index.js.map +1 -1
  45. package/dist/pages/CheckoutPage.d.ts.map +1 -1
  46. package/dist/pages/HomePage.d.ts +5 -2
  47. package/dist/pages/HomePage.d.ts.map +1 -1
  48. package/dist/pages/MyPurchasesPage.d.ts.map +1 -1
  49. package/dist/pages/OrderConfirmationPage.d.ts.map +1 -1
  50. package/dist/pages/ProductDetailPage.d.ts.map +1 -1
  51. package/dist/sections.d.ts +45 -2
  52. package/dist/sections.d.ts.map +1 -1
  53. package/dist/testids.d.ts +20 -0
  54. package/dist/testids.d.ts.map +1 -1
  55. package/dist/theme.d.ts +4 -0
  56. package/dist/theme.d.ts.map +1 -1
  57. package/package.json +7 -9
  58. package/src/auth.ts +47 -10
  59. package/src/blocks.tsx +7 -3
  60. package/src/component-contracts.ts +140 -0
  61. package/src/component-selectors.ts +31 -0
  62. package/src/components/CartDrawer.tsx +2 -1
  63. package/src/components/ProductCard.tsx +39 -23
  64. package/src/components/ProductEnquiryForm.tsx +175 -0
  65. package/src/components/ProductGrid.tsx +12 -3
  66. package/src/components/RelatedProducts.tsx +13 -4
  67. package/src/components/SmoothImage.tsx +59 -0
  68. package/src/components/StorefrontFooter.tsx +24 -14
  69. package/src/components/StorefrontHeader.tsx +43 -38
  70. package/src/components/sections/CategoryShowcase.tsx +3 -2
  71. package/src/components/sections/HeroSection.tsx +18 -2
  72. package/src/components/sections/MediaCarousel.tsx +141 -0
  73. package/src/components/sections/MiscSections.tsx +2 -1
  74. package/src/components/sections/ProductRail.tsx +11 -3
  75. package/src/components/sections/ProductSlider.tsx +119 -0
  76. package/src/config.ts +65 -24
  77. package/src/createStorefrontApp.tsx +62 -9
  78. package/src/define.ts +34 -0
  79. package/src/hooks/index.ts +1 -0
  80. package/src/hooks/useStorefront.ts +58 -0
  81. package/src/index.ts +61 -11
  82. package/src/pages/CheckoutPage.tsx +2 -1
  83. package/src/pages/HomePage.tsx +6 -2
  84. package/src/pages/MyPurchasesPage.tsx +2 -1
  85. package/src/pages/OrderConfirmationPage.tsx +2 -1
  86. package/src/pages/ProductDetailPage.tsx +138 -62
  87. package/src/scaffold.tsx +1 -1
  88. package/src/sections.ts +49 -2
  89. package/src/testids.ts +20 -0
  90. package/src/theme.ts +4 -0
  91. package/src/workflows/checkout.ts +1 -1
  92. package/dist/slot-contracts.d.ts +0 -34
  93. package/dist/slot-contracts.d.ts.map +0 -1
  94. 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 ProductCardComponent = config.slots?.ProductCard ?? ProductCard
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 key={p.id} product={p} />
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 slot for visual
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 Card = config.slots?.ProductCard ?? ProductCard
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 key={p.id} product={p} />
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">Departamentos</h3>
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
- Ver todos ({categories.length})
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">Navegação</h3>
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
- <li>
62
- <Link to="/account" className="transition-opacity hover:opacity-70">Minhas compras</Link>
63
- </li>
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">Contato</h3>
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">Privacidade</Link>
87
- <Link to="/terms" className="transition-colors hover:text-foreground">Termos</Link>
88
- <Link to="/refunds" className="transition-colors hover:text-foreground">Trocas e devoluções</Link>
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
- © {config.name} — powered by Fayz · Fotos:{' '}
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
- <button
210
- type="button"
211
- data-testid={TID.cartButton}
212
- aria-label="Carrinho"
213
- onClick={openDrawer}
214
- className="relative rounded-full p-2.5 transition-opacity hover:opacity-70"
215
- >
216
- <ShoppingBag className="h-5 w-5" />
217
- {count > 0 && (
218
- <span
219
- data-testid={TID.cartCount}
220
- className={`absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-xs font-semibold text-primary-foreground ${
221
- pop ? 'animate-badge-pop' : ''
222
- }`}
223
- >
224
- {count}
225
- </span>
226
- )}
227
- </button>
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
- {categories.slice(0, 6).map((c) => (
256
- <button
257
- key={c.id}
258
- type="button"
259
- data-testid={TID.navCategory(c.slug)}
260
- onClick={() => goCategory(c.id)}
261
- className="sf-nav-link group relative hidden text-sm font-medium transition-opacity hover:opacity-80 lg:inline"
262
- >
263
- {c.name}
264
- <span className="absolute -bottom-1 left-0 h-0.5 w-0 bg-primary transition-all duration-300 group-hover:w-full" />
265
- </button>
266
- ))}
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="hidden justify-center border-t py-2.5 sm:flex" style={headerDivider}>
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="hidden border-t sm:block" style={headerDivider}>
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
- <img
37
+ <SmoothImage
37
38
  src={c.imageUrl ?? bannerPlaceholder(c.name, 30 + i * 70, 70 + i * 70, 200, 200)}
38
39
  alt={c.name}
39
40
  className="h-full w-full object-cover"
@@ -59,7 +60,7 @@ export function CategoryShowcase({ style, title }: { style: 'bubbles' | 'tiles';
59
60
  {categories.map((c, i) => (
60
61
  <Reveal key={c.id} delay={i * 90}>
61
62
  <button type="button" onClick={() => go(c.id)} className="group relative block aspect-[4/5] w-full overflow-hidden" style={{ borderRadius: 'var(--sf-radius-card)' }}>
62
- <img
63
+ <SmoothImage
63
64
  src={c.imageUrl ?? bannerPlaceholder(c.name, 30 + i * 70, 70 + i * 70, 480, 600)}
64
65
  alt={c.name}
65
66
  className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
@@ -3,9 +3,11 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'
3
3
  import type { HeroSlide } from '../../sections'
4
4
  import type { HeroVariant } from '../../theme'
5
5
  import { bannerPlaceholder } from '../../sections'
6
+ import { useStorefrontConfig } from '../../config'
6
7
  import { prefersReducedMotion } from '../../motion'
7
8
  import { Link } from '../../router'
8
9
  import { TID } from '../../testids'
10
+ import { SmoothImage } from '../SmoothImage'
9
11
 
10
12
  function slideImage(slide: HeroSlide, w = 1600, h = 700): string {
11
13
  return slide.image ?? bannerPlaceholder(slide.title, slide.hue ?? 200, (slide.hue ?? 200) + 40, w, h)
@@ -56,8 +58,18 @@ export function HeroSection({
56
58
  height?: 'tall' | 'medium'
57
59
  }) {
58
60
  const [index, setIndex] = useState(0)
61
+ const [loadedSlides, setLoadedSlides] = useState<Record<number, boolean>>({})
62
+ const config = useStorefrontConfig()
59
63
  const interacted = useRef(false)
60
64
  const hClass = height === 'tall' ? 'h-[72vh] min-h-[440px]' : 'h-[48vh] min-h-[340px]'
65
+ const revealImages = config.imageLoading.mode !== 'none'
66
+ const revealStyle = (loaded: boolean): React.CSSProperties | undefined =>
67
+ revealImages
68
+ ? {
69
+ filter: config.imageLoading.blur && !loaded ? 'blur(10px)' : 'blur(0px)',
70
+ transition: `opacity 700ms ease, filter ${config.imageLoading.durationMs}ms ${config.imageLoading.easing}`,
71
+ }
72
+ : undefined
61
73
 
62
74
  // Auto-advance until the visitor takes over; never under reduced motion.
63
75
  useEffect(() => {
@@ -84,7 +96,7 @@ export function HeroSection({
84
96
  to={slide.href ?? '/catalog'}
85
97
  className="group relative block h-[42vh] min-h-[320px] overflow-hidden"
86
98
  >
87
- <img
99
+ <SmoothImage
88
100
  src={slideImage(slide, 900, 700)}
89
101
  alt={slide.title}
90
102
  className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
@@ -122,8 +134,12 @@ export function HeroSection({
122
134
  src={slideImage(slide)}
123
135
  alt={slide.title}
124
136
  aria-hidden={i !== index}
137
+ loading={i === 0 ? 'eager' : 'lazy'}
138
+ decoding="async"
139
+ onLoad={() => setLoadedSlides((current) => ({ ...current, [i]: true }))}
140
+ style={revealStyle(Boolean(loadedSlides[i]))}
125
141
  className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ${
126
- i === index ? 'animate-kenburns opacity-100' : 'opacity-0'
142
+ i === index && (!revealImages || loadedSlides[i]) ? 'animate-kenburns opacity-100' : 'opacity-0'
127
143
  }`}
128
144
  />
129
145
  ))}