@fayz-ai/storefront 0.2.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/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/auth.d.ts +29 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/blocks.d.ts +6 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/components/CartDrawer.d.ts +3 -0
- package/dist/components/CartDrawer.d.ts.map +1 -0
- package/dist/components/CustomerAccountShell.d.ts +13 -0
- package/dist/components/CustomerAccountShell.d.ts.map +1 -0
- package/dist/components/FiltersPanel.d.ts +3 -0
- package/dist/components/FiltersPanel.d.ts.map +1 -0
- package/dist/components/OrderTrackingTimeline.d.ts +7 -0
- package/dist/components/OrderTrackingTimeline.d.ts.map +1 -0
- package/dist/components/Price.d.ts +11 -0
- package/dist/components/Price.d.ts.map +1 -0
- package/dist/components/ProductCard.d.ts +7 -0
- package/dist/components/ProductCard.d.ts.map +1 -0
- package/dist/components/ProductGrid.d.ts +7 -0
- package/dist/components/ProductGrid.d.ts.map +1 -0
- package/dist/components/ProductOptionSelector.d.ts +9 -0
- package/dist/components/ProductOptionSelector.d.ts.map +1 -0
- package/dist/components/ProductSpecs.d.ts +8 -0
- package/dist/components/ProductSpecs.d.ts.map +1 -0
- package/dist/components/QuantityInput.d.ts +12 -0
- package/dist/components/QuantityInput.d.ts.map +1 -0
- package/dist/components/RelatedProducts.d.ts +9 -0
- package/dist/components/RelatedProducts.d.ts.map +1 -0
- package/dist/components/SignInModal.d.ts +19 -0
- package/dist/components/SignInModal.d.ts.map +1 -0
- package/dist/components/StorefrontErrorBoundary.d.ts +20 -0
- package/dist/components/StorefrontErrorBoundary.d.ts.map +1 -0
- package/dist/components/StorefrontFooter.d.ts +3 -0
- package/dist/components/StorefrontFooter.d.ts.map +1 -0
- package/dist/components/StorefrontHeader.d.ts +3 -0
- package/dist/components/StorefrontHeader.d.ts.map +1 -0
- package/dist/components/Toaster.d.ts +3 -0
- package/dist/components/Toaster.d.ts.map +1 -0
- package/dist/components/sections/CategoryShowcase.d.ts +6 -0
- package/dist/components/sections/CategoryShowcase.d.ts.map +1 -0
- package/dist/components/sections/HeroSection.d.ts +9 -0
- package/dist/components/sections/HeroSection.d.ts.map +1 -0
- package/dist/components/sections/MiscSections.d.ts +33 -0
- package/dist/components/sections/MiscSections.d.ts.map +1 -0
- package/dist/components/sections/ProductRail.d.ts +9 -0
- package/dist/components/sections/ProductRail.d.ts.map +1 -0
- package/dist/config.d.ts +149 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/createStorefrontApp.d.ts +16 -0
- package/dist/createStorefrontApp.d.ts.map +1 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useCategories.d.ts +6 -0
- package/dist/hooks/useCategories.d.ts.map +1 -0
- package/dist/hooks/useDiscountValidator.d.ts +14 -0
- package/dist/hooks/useDiscountValidator.d.ts.map +1 -0
- package/dist/hooks/useMyOrders.d.ts +7 -0
- package/dist/hooks/useMyOrders.d.ts.map +1 -0
- package/dist/hooks/useProduct.d.ts +7 -0
- package/dist/hooks/useProduct.d.ts.map +1 -0
- package/dist/hooks/useProducts.d.ts +8 -0
- package/dist/hooks/useProducts.d.ts.map +1 -0
- package/dist/hooks/useStorefrontHead.d.ts +11 -0
- package/dist/hooks/useStorefrontHead.d.ts.map +1 -0
- package/dist/index.cjs +4186 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4085 -0
- package/dist/index.js.map +1 -0
- package/dist/motion.d.ts +19 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/order-status.d.ts +10 -0
- package/dist/order-status.d.ts.map +1 -0
- package/dist/pages/CatalogPage.d.ts +3 -0
- package/dist/pages/CatalogPage.d.ts.map +1 -0
- package/dist/pages/CheckoutPage.d.ts +3 -0
- package/dist/pages/CheckoutPage.d.ts.map +1 -0
- package/dist/pages/HomePage.d.ts +6 -0
- package/dist/pages/HomePage.d.ts.map +1 -0
- package/dist/pages/MyPurchasesPage.d.ts +3 -0
- package/dist/pages/MyPurchasesPage.d.ts.map +1 -0
- package/dist/pages/NotFoundPage.d.ts +4 -0
- package/dist/pages/NotFoundPage.d.ts.map +1 -0
- package/dist/pages/OrderConfirmationPage.d.ts +5 -0
- package/dist/pages/OrderConfirmationPage.d.ts.map +1 -0
- package/dist/pages/PolicyPage.d.ts +15 -0
- package/dist/pages/PolicyPage.d.ts.map +1 -0
- package/dist/pages/ProductDetailPage.d.ts +5 -0
- package/dist/pages/ProductDetailPage.d.ts.map +1 -0
- package/dist/presets.d.ts +33 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/product-options.d.ts +12 -0
- package/dist/product-options.d.ts.map +1 -0
- package/dist/router.d.ts +285 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/scaffold.d.ts +14 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/sections.d.ts +84 -0
- package/dist/sections.d.ts.map +1 -0
- package/dist/slot-contracts.d.ts +34 -0
- package/dist/slot-contracts.d.ts.map +1 -0
- package/dist/stores/cart.store.d.ts +58 -0
- package/dist/stores/cart.store.d.ts.map +1 -0
- package/dist/stores/catalog.store.d.ts +18 -0
- package/dist/stores/catalog.store.d.ts.map +1 -0
- package/dist/stores/session.store.d.ts +23 -0
- package/dist/stores/session.store.d.ts.map +1 -0
- package/dist/stores/toast.store.d.ts +27 -0
- package/dist/stores/toast.store.d.ts.map +1 -0
- package/dist/testids.d.ts +93 -0
- package/dist/testids.d.ts.map +1 -0
- package/dist/theme.d.ts +59 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/workflows/checkout.d.ts +33 -0
- package/dist/workflows/checkout.d.ts.map +1 -0
- package/package.json +56 -0
- package/src/auth.ts +134 -0
- package/src/blocks.tsx +47 -0
- package/src/components/CartDrawer.tsx +250 -0
- package/src/components/CustomerAccountShell.tsx +102 -0
- package/src/components/FiltersPanel.tsx +109 -0
- package/src/components/OrderTrackingTimeline.tsx +135 -0
- package/src/components/Price.tsx +32 -0
- package/src/components/ProductCard.tsx +104 -0
- package/src/components/ProductGrid.tsx +34 -0
- package/src/components/ProductOptionSelector.tsx +50 -0
- package/src/components/ProductSpecs.tsx +75 -0
- package/src/components/QuantityInput.tsx +52 -0
- package/src/components/RelatedProducts.tsx +31 -0
- package/src/components/SignInModal.tsx +120 -0
- package/src/components/StorefrontErrorBoundary.tsx +51 -0
- package/src/components/StorefrontFooter.tsx +100 -0
- package/src/components/StorefrontHeader.tsx +342 -0
- package/src/components/Toaster.tsx +60 -0
- package/src/components/sections/CategoryShowcase.tsx +79 -0
- package/src/components/sections/HeroSection.tsx +177 -0
- package/src/components/sections/MiscSections.tsx +157 -0
- package/src/components/sections/ProductRail.tsx +73 -0
- package/src/config.ts +176 -0
- package/src/createStorefrontApp.tsx +171 -0
- package/src/format.ts +7 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useCategories.ts +26 -0
- package/src/hooks/useDiscountValidator.ts +43 -0
- package/src/hooks/useMyOrders.ts +41 -0
- package/src/hooks/useProduct.ts +36 -0
- package/src/hooks/useProducts.ts +39 -0
- package/src/hooks/useStorefrontHead.ts +25 -0
- package/src/index.ts +132 -0
- package/src/motion.tsx +94 -0
- package/src/order-status.ts +33 -0
- package/src/pages/CatalogPage.tsx +118 -0
- package/src/pages/CheckoutPage.tsx +578 -0
- package/src/pages/HomePage.tsx +12 -0
- package/src/pages/MyPurchasesPage.tsx +310 -0
- package/src/pages/NotFoundPage.tsx +25 -0
- package/src/pages/OrderConfirmationPage.tsx +189 -0
- package/src/pages/PolicyPage.tsx +47 -0
- package/src/pages/ProductDetailPage.tsx +184 -0
- package/src/presets.ts +258 -0
- package/src/product-options.ts +63 -0
- package/src/router.ts +49 -0
- package/src/scaffold.tsx +109 -0
- package/src/sections.ts +69 -0
- package/src/slot-contracts.ts +20 -0
- package/src/stores/cart.store.ts +149 -0
- package/src/stores/catalog.store.ts +39 -0
- package/src/stores/session.store.ts +28 -0
- package/src/stores/toast.store.ts +43 -0
- package/src/testids.ts +103 -0
- package/src/theme.ts +160 -0
- package/src/workflows/checkout.ts +91 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { CreditCard, LogOut, Mail, Package, Phone, Search, ShoppingBag, User, UserCircle, X } from 'lucide-react'
|
|
3
|
+
import { signOutCustomer } from '../auth'
|
|
4
|
+
import { useCartStore, selectCount } from '../stores/cart.store'
|
|
5
|
+
import { useCatalogStore } from '../stores/catalog.store'
|
|
6
|
+
import { useSessionStore } from '../stores/session.store'
|
|
7
|
+
import { useCategories } from '../hooks/useCategories'
|
|
8
|
+
import { useStorefrontConfig } from '../config'
|
|
9
|
+
import { useScrolled, usePopOnChange } from '../motion'
|
|
10
|
+
import { Link, navigateTo, useHashPath } from '../router'
|
|
11
|
+
import { TID } from '../testids'
|
|
12
|
+
|
|
13
|
+
// Header tokens come from the theme (--sf-header-bg/fg) so dark headers
|
|
14
|
+
// (Brasília pattern) work without component changes. Dividers derive from the
|
|
15
|
+
// header FOREGROUND (not the page --border token, which is light and shows as
|
|
16
|
+
// white lines on dark headers).
|
|
17
|
+
const headerStyle: React.CSSProperties = {
|
|
18
|
+
backgroundColor: 'hsl(var(--sf-header-bg, var(--background)))',
|
|
19
|
+
color: 'hsl(var(--sf-header-fg, var(--foreground)))',
|
|
20
|
+
borderColor: 'hsl(var(--sf-header-fg, var(--foreground)) / 0.14)',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const headerDivider: React.CSSProperties = {
|
|
24
|
+
borderColor: 'hsl(var(--sf-header-fg, var(--foreground)) / 0.14)',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ANNOUNCEMENT_KEY = 'fayz.storefront.announcement.dismissed'
|
|
28
|
+
|
|
29
|
+
function AnnouncementBar() {
|
|
30
|
+
const config = useStorefrontConfig()
|
|
31
|
+
const [dismissed, setDismissed] = useState(() => {
|
|
32
|
+
try {
|
|
33
|
+
return localStorage.getItem(ANNOUNCEMENT_KEY) === config.announcement
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
if (!config.announcement || dismissed) return null
|
|
39
|
+
|
|
40
|
+
const dismiss = () => {
|
|
41
|
+
setDismissed(true)
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(ANNOUNCEMENT_KEY, config.announcement ?? '')
|
|
44
|
+
} catch {
|
|
45
|
+
/* storage unavailable — dismiss for this session only */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
data-testid={TID.announcementBar}
|
|
52
|
+
className="relative px-4 py-2 text-center text-xs font-semibold tracking-wide"
|
|
53
|
+
style={{
|
|
54
|
+
backgroundColor: 'hsl(var(--sf-announcement-bg, var(--primary)))',
|
|
55
|
+
color: 'hsl(var(--sf-announcement-fg, var(--primary-foreground)))',
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{config.announcement}
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
aria-label="Fechar aviso"
|
|
62
|
+
onClick={dismiss}
|
|
63
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 opacity-70 transition-opacity hover:opacity-100"
|
|
64
|
+
>
|
|
65
|
+
<X className="h-3.5 w-3.5" />
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Slim utility strip (Flex pattern): contact on the left, social on the right. */
|
|
72
|
+
function UtilityBar() {
|
|
73
|
+
const config = useStorefrontConfig()
|
|
74
|
+
const contact = config.footer?.contact
|
|
75
|
+
const social = config.footer?.social
|
|
76
|
+
if (!contact?.phone && !contact?.email && !social?.length) return null
|
|
77
|
+
return (
|
|
78
|
+
<div className="hidden border-b text-xs opacity-80 md:block" style={headerDivider}>
|
|
79
|
+
<div className="mx-auto flex h-8 max-w-7xl items-center gap-5 px-4 sm:px-6">
|
|
80
|
+
{contact?.phone && (
|
|
81
|
+
<span className="inline-flex items-center gap-1.5">
|
|
82
|
+
<Phone className="h-3 w-3" /> {contact.phone}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
{contact?.email && (
|
|
86
|
+
<span className="inline-flex items-center gap-1.5">
|
|
87
|
+
<Mail className="h-3 w-3" /> {contact.email}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
<span className="ml-auto flex items-center gap-4">
|
|
91
|
+
{social?.map((s) => (
|
|
92
|
+
<a key={s.href} href={s.href} target="_blank" rel="noreferrer" className="hover:underline">
|
|
93
|
+
{s.label}
|
|
94
|
+
</a>
|
|
95
|
+
))}
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function SearchInput({ className }: { className?: string }) {
|
|
103
|
+
const config = useStorefrontConfig()
|
|
104
|
+
const search = useCatalogStore((s) => s.search)
|
|
105
|
+
const setSearch = useCatalogStore((s) => s.setSearch)
|
|
106
|
+
const path = useHashPath()
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className={`relative ${className ?? ''}`}>
|
|
110
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 opacity-60" />
|
|
111
|
+
<input
|
|
112
|
+
data-testid={TID.headerSearch}
|
|
113
|
+
type="search"
|
|
114
|
+
placeholder="Buscar produtos..."
|
|
115
|
+
value={search}
|
|
116
|
+
onChange={(e) => {
|
|
117
|
+
setSearch(e.target.value)
|
|
118
|
+
if (path !== config.catalogPath) navigateTo(config.catalogPath)
|
|
119
|
+
}}
|
|
120
|
+
className="w-full border bg-white/10 py-2 pl-9 pr-4 text-sm outline-none transition-colors placeholder:opacity-60 focus:border-primary focus:bg-white focus:text-gray-900"
|
|
121
|
+
style={{
|
|
122
|
+
borderRadius: 'var(--sf-radius-input)',
|
|
123
|
+
borderColor: 'hsl(var(--sf-header-fg, var(--foreground)) / 0.25)',
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function HeaderActions() {
|
|
131
|
+
const config = useStorefrontConfig()
|
|
132
|
+
const count = useCartStore(selectCount)
|
|
133
|
+
const openDrawer = useCartStore((s) => s.openDrawer)
|
|
134
|
+
const session = useSessionStore()
|
|
135
|
+
const [accountOpen, setAccountOpen] = useState(false)
|
|
136
|
+
const pop = usePopOnChange(count)
|
|
137
|
+
const firstName = session.name?.split(/\s+/)[0]
|
|
138
|
+
const initial = (firstName ?? session.email ?? 'C').slice(0, 1).toUpperCase()
|
|
139
|
+
return (
|
|
140
|
+
<nav className="flex items-center gap-1">
|
|
141
|
+
{config.features.accounts && (
|
|
142
|
+
<div className="relative">
|
|
143
|
+
{session.email ? (
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
data-testid={TID.accountLink}
|
|
147
|
+
aria-label="Abrir menu da conta"
|
|
148
|
+
aria-expanded={accountOpen}
|
|
149
|
+
onClick={() => setAccountOpen((open) => !open)}
|
|
150
|
+
className="flex items-center gap-1.5 rounded-full p-1.5 transition-opacity hover:opacity-80"
|
|
151
|
+
>
|
|
152
|
+
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
|
153
|
+
{initial}
|
|
154
|
+
</span>
|
|
155
|
+
{firstName && (
|
|
156
|
+
<span data-testid={TID.accountName} className="hidden max-w-24 truncate text-sm font-medium lg:inline">
|
|
157
|
+
{firstName}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
</button>
|
|
161
|
+
) : (
|
|
162
|
+
<Link
|
|
163
|
+
to="/account"
|
|
164
|
+
data-testid={TID.accountLink}
|
|
165
|
+
aria-label="Minhas compras"
|
|
166
|
+
className="flex items-center gap-1.5 rounded-full p-2.5 transition-opacity hover:opacity-70"
|
|
167
|
+
>
|
|
168
|
+
<User className="h-5 w-5" />
|
|
169
|
+
</Link>
|
|
170
|
+
)}
|
|
171
|
+
{accountOpen && session.email && (
|
|
172
|
+
<div
|
|
173
|
+
data-testid={TID.accountDropdown}
|
|
174
|
+
className="absolute right-0 top-full z-50 mt-3 w-72 overflow-hidden rounded-xl border bg-background text-foreground shadow-xl"
|
|
175
|
+
>
|
|
176
|
+
<div className="border-b p-4">
|
|
177
|
+
<p className="font-semibold">{session.name || firstName || 'Cliente'}</p>
|
|
178
|
+
<p className="truncate text-sm text-muted-foreground">{session.email}</p>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="p-2">
|
|
181
|
+
<Link to="/account" className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-semibold hover:bg-muted">
|
|
182
|
+
<UserCircle className="h-4 w-4" />
|
|
183
|
+
Perfil
|
|
184
|
+
</Link>
|
|
185
|
+
<Link to="/account" className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-semibold hover:bg-muted">
|
|
186
|
+
<Package className="h-4 w-4" />
|
|
187
|
+
Meus pedidos
|
|
188
|
+
</Link>
|
|
189
|
+
<Link to="/account" className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-semibold hover:bg-muted">
|
|
190
|
+
<CreditCard className="h-4 w-4" />
|
|
191
|
+
Pagamentos
|
|
192
|
+
</Link>
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={() => {
|
|
196
|
+
setAccountOpen(false)
|
|
197
|
+
void signOutCustomer()
|
|
198
|
+
}}
|
|
199
|
+
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-semibold hover:bg-muted"
|
|
200
|
+
>
|
|
201
|
+
<LogOut className="h-4 w-4" />
|
|
202
|
+
Sair
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
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>
|
|
228
|
+
</nav>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function NavLinks({ className }: { className?: string }) {
|
|
233
|
+
const config = useStorefrontConfig()
|
|
234
|
+
const { categories } = useCategories()
|
|
235
|
+
const setCategoryId = useCatalogStore((s) => s.setCategoryId)
|
|
236
|
+
|
|
237
|
+
const goCategory = (categoryId: string) => {
|
|
238
|
+
useCatalogStore.getState().reset()
|
|
239
|
+
setCategoryId(categoryId)
|
|
240
|
+
navigateTo(config.catalogPath)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<nav className={`flex items-center gap-6 ${className ?? ''}`}>
|
|
245
|
+
{config.nav.map((link) => (
|
|
246
|
+
<Link
|
|
247
|
+
key={link.to}
|
|
248
|
+
to={link.to}
|
|
249
|
+
data-testid={TID.navLink(link.label)}
|
|
250
|
+
className="sf-nav-link text-sm font-medium transition-opacity hover:opacity-70"
|
|
251
|
+
>
|
|
252
|
+
{link.label}
|
|
253
|
+
</Link>
|
|
254
|
+
))}
|
|
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
|
+
))}
|
|
267
|
+
</nav>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function StorefrontHeader() {
|
|
272
|
+
const config = useStorefrontConfig()
|
|
273
|
+
const variant = config.theme?.header?.variant ?? 'classic'
|
|
274
|
+
const scrolled = useScrolled()
|
|
275
|
+
const logo = (
|
|
276
|
+
<Link to="/" className="sf-heading shrink-0 text-xl font-bold tracking-tight">
|
|
277
|
+
{config.logo ?? config.name}
|
|
278
|
+
</Link>
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<header
|
|
283
|
+
className={`sticky top-0 z-40 border-b transition-shadow duration-300 ${scrolled ? 'shadow-md' : ''}`}
|
|
284
|
+
style={headerStyle}
|
|
285
|
+
>
|
|
286
|
+
<AnnouncementBar />
|
|
287
|
+
<UtilityBar />
|
|
288
|
+
|
|
289
|
+
{variant === 'centered' ? (
|
|
290
|
+
// Rio/Flex pattern: centered logo row, nav row below
|
|
291
|
+
<>
|
|
292
|
+
<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" />
|
|
294
|
+
<div className="text-center">{logo}</div>
|
|
295
|
+
<div className="justify-self-end">
|
|
296
|
+
<HeaderActions />
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
<div className="hidden justify-center border-t py-2.5 sm:flex" style={headerDivider}>
|
|
300
|
+
<NavLinks />
|
|
301
|
+
</div>
|
|
302
|
+
</>
|
|
303
|
+
) : variant === 'search' ? (
|
|
304
|
+
// Brasília pattern: prominent search left, logo center, actions right; nav row below
|
|
305
|
+
<>
|
|
306
|
+
<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" />
|
|
308
|
+
<div className="text-center">{logo}</div>
|
|
309
|
+
<div className="justify-self-end">
|
|
310
|
+
<HeaderActions />
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
<div className="hidden border-t sm:block" style={headerDivider}>
|
|
314
|
+
<div className="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
|
|
315
|
+
<NavLinks />
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
</>
|
|
319
|
+
) : variant === 'minimal' ? (
|
|
320
|
+
// Uyuni pattern: nav left, centered logo, actions right — single compact row
|
|
321
|
+
<div className="mx-auto grid h-16 max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
|
|
322
|
+
<div className="flex items-center gap-5">
|
|
323
|
+
<NavLinks className="hidden sm:flex" />
|
|
324
|
+
</div>
|
|
325
|
+
<div className="text-center">{logo}</div>
|
|
326
|
+
<div className="flex items-center justify-end gap-3">
|
|
327
|
+
<SearchInput className="hidden w-44 lg:block" />
|
|
328
|
+
<HeaderActions />
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
) : (
|
|
332
|
+
// classic: logo left, search center-right, actions right (original layout)
|
|
333
|
+
<div className="mx-auto flex h-16 max-w-7xl items-center gap-6 px-4 sm:px-6">
|
|
334
|
+
{logo}
|
|
335
|
+
<NavLinks className="hidden md:flex" />
|
|
336
|
+
<SearchInput className="ml-auto hidden w-full max-w-sm sm:block" />
|
|
337
|
+
<HeaderActions />
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
</header>
|
|
341
|
+
)
|
|
342
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
import { AlertCircle, Check, Info, X } from 'lucide-react'
|
|
3
|
+
import { useToastStore, type Toast } from '../stores/toast.store'
|
|
4
|
+
|
|
5
|
+
const ICON: Record<Toast['variant'], React.ComponentType<{ className?: string }>> = {
|
|
6
|
+
success: Check,
|
|
7
|
+
error: AlertCircle,
|
|
8
|
+
info: Info,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ACCENT: Record<Toast['variant'], string> = {
|
|
12
|
+
success: 'bg-emerald-500/10 text-emerald-600',
|
|
13
|
+
error: 'bg-destructive/10 text-destructive',
|
|
14
|
+
info: 'bg-primary/10 text-primary',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ToastCard({ toast }: { toast: Toast }) {
|
|
18
|
+
const dismiss = useToastStore((s) => s.dismiss)
|
|
19
|
+
const Icon = ICON[toast.variant]
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const timer = window.setTimeout(() => dismiss(toast.id), toast.durationMs)
|
|
23
|
+
return () => window.clearTimeout(timer)
|
|
24
|
+
}, [toast.id, toast.durationMs, dismiss])
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
role="status"
|
|
29
|
+
className="pointer-events-auto flex w-80 max-w-[calc(100vw-2rem)] animate-slide-in-from-right items-start gap-3 rounded-xl border bg-card p-3.5 shadow-lg"
|
|
30
|
+
>
|
|
31
|
+
<span className={`grid h-8 w-8 shrink-0 place-items-center rounded-full ${ACCENT[toast.variant]}`}>
|
|
32
|
+
<Icon className="h-4 w-4" />
|
|
33
|
+
</span>
|
|
34
|
+
<div className="min-w-0 flex-1">
|
|
35
|
+
<p className="text-sm font-semibold leading-tight">{toast.title}</p>
|
|
36
|
+
{toast.description && <p className="mt-0.5 text-xs text-muted-foreground">{toast.description}</p>}
|
|
37
|
+
</div>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
aria-label="Fechar"
|
|
41
|
+
onClick={() => dismiss(toast.id)}
|
|
42
|
+
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
43
|
+
>
|
|
44
|
+
<X className="h-4 w-4" />
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Toaster() {
|
|
51
|
+
const toasts = useToastStore((s) => s.toasts)
|
|
52
|
+
if (toasts.length === 0) return null
|
|
53
|
+
return (
|
|
54
|
+
<div className="pointer-events-none fixed bottom-4 right-4 z-[70] flex flex-col gap-2">
|
|
55
|
+
{toasts.map((t) => (
|
|
56
|
+
<ToastCard key={t.id} toast={t} />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useCategories } from '../../hooks/useCategories'
|
|
3
|
+
import { useCatalogStore } from '../../stores/catalog.store'
|
|
4
|
+
import { useStorefrontConfig } from '../../config'
|
|
5
|
+
import { bannerPlaceholder } from '../../sections'
|
|
6
|
+
import { Reveal } from '../../motion'
|
|
7
|
+
import { navigateTo } from '../../router'
|
|
8
|
+
import { TID } from '../../testids'
|
|
9
|
+
|
|
10
|
+
export function CategoryShowcase({ style, title }: { style: 'bubbles' | 'tiles'; title?: string }) {
|
|
11
|
+
const { categories } = useCategories()
|
|
12
|
+
const config = useStorefrontConfig()
|
|
13
|
+
const setCategoryId = useCatalogStore((s) => s.setCategoryId)
|
|
14
|
+
|
|
15
|
+
if (categories.length === 0) return null
|
|
16
|
+
|
|
17
|
+
const go = (categoryId: string) => {
|
|
18
|
+
useCatalogStore.getState().reset()
|
|
19
|
+
setCategoryId(categoryId)
|
|
20
|
+
navigateTo(config.catalogPath)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (style === 'bubbles') {
|
|
24
|
+
return (
|
|
25
|
+
<section data-testid={TID.categoryShowcase} className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
|
|
26
|
+
{title && (
|
|
27
|
+
<Reveal>
|
|
28
|
+
<h2 className="sf-heading mb-8 text-center text-3xl font-bold">{title}</h2>
|
|
29
|
+
</Reveal>
|
|
30
|
+
)}
|
|
31
|
+
<div className="flex flex-wrap items-start justify-center gap-8">
|
|
32
|
+
{categories.map((c, i) => (
|
|
33
|
+
<Reveal key={c.id} delay={i * 80}>
|
|
34
|
+
<button type="button" onClick={() => go(c.id)} className="group flex w-28 flex-col items-center gap-3">
|
|
35
|
+
<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
|
+
src={c.imageUrl ?? bannerPlaceholder(c.name, 30 + i * 70, 70 + i * 70, 200, 200)}
|
|
38
|
+
alt={c.name}
|
|
39
|
+
className="h-full w-full object-cover"
|
|
40
|
+
/>
|
|
41
|
+
</span>
|
|
42
|
+
<span className="text-center text-xs font-medium leading-tight">{c.name}</span>
|
|
43
|
+
</button>
|
|
44
|
+
</Reveal>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section data-testid={TID.categoryShowcase} className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
|
|
53
|
+
{title && (
|
|
54
|
+
<Reveal>
|
|
55
|
+
<h2 className="sf-heading mb-8 text-center text-3xl font-bold">{title}</h2>
|
|
56
|
+
</Reveal>
|
|
57
|
+
)}
|
|
58
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
59
|
+
{categories.map((c, i) => (
|
|
60
|
+
<Reveal key={c.id} delay={i * 90}>
|
|
61
|
+
<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
|
+
src={c.imageUrl ?? bannerPlaceholder(c.name, 30 + i * 70, 70 + i * 70, 480, 600)}
|
|
64
|
+
alt={c.name}
|
|
65
|
+
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
|
|
66
|
+
/>
|
|
67
|
+
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent p-5 pt-12 text-left">
|
|
68
|
+
<span className="sf-heading block text-lg font-semibold text-white">{c.name}</span>
|
|
69
|
+
<span className="mt-0.5 block text-xs text-white/80 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
|
70
|
+
Explorar →
|
|
71
|
+
</span>
|
|
72
|
+
</span>
|
|
73
|
+
</button>
|
|
74
|
+
</Reveal>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
3
|
+
import type { HeroSlide } from '../../sections'
|
|
4
|
+
import type { HeroVariant } from '../../theme'
|
|
5
|
+
import { bannerPlaceholder } from '../../sections'
|
|
6
|
+
import { prefersReducedMotion } from '../../motion'
|
|
7
|
+
import { Link } from '../../router'
|
|
8
|
+
import { TID } from '../../testids'
|
|
9
|
+
|
|
10
|
+
function slideImage(slide: HeroSlide, w = 1600, h = 700): string {
|
|
11
|
+
return slide.image ?? bannerPlaceholder(slide.title, slide.hue ?? 200, (slide.hue ?? 200) + 40, w, h)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function SlideCopy({ slide, active }: { slide: HeroSlide; active: boolean }) {
|
|
15
|
+
const stagger = (i: number) =>
|
|
16
|
+
active ? { className: 'animate-fade-up', style: { animationDelay: `${i * 120}ms` } } : { className: 'opacity-0' }
|
|
17
|
+
|
|
18
|
+
const inner = (
|
|
19
|
+
<>
|
|
20
|
+
<h2 className={`sf-heading text-3xl font-bold leading-tight tracking-tight sm:text-5xl ${stagger(0).className}`} style={stagger(0).style}>
|
|
21
|
+
{slide.title}
|
|
22
|
+
</h2>
|
|
23
|
+
{slide.subtitle && (
|
|
24
|
+
<p className={`mt-3 max-w-md text-base sm:text-lg ${stagger(1).className}`} style={stagger(1).style}>
|
|
25
|
+
{slide.subtitle}
|
|
26
|
+
</p>
|
|
27
|
+
)}
|
|
28
|
+
{slide.cta && (
|
|
29
|
+
<div className={stagger(2).className} style={stagger(2).style}>
|
|
30
|
+
<Link
|
|
31
|
+
to={slide.href ?? '/catalog'}
|
|
32
|
+
className="sf-cta mt-6 inline-block bg-primary px-8 py-3.5 font-semibold text-primary-foreground shadow-lg transition-all hover:-translate-y-0.5 hover:shadow-xl"
|
|
33
|
+
style={{ borderRadius: 'var(--sf-radius-button)' }}
|
|
34
|
+
>
|
|
35
|
+
{slide.cta}
|
|
36
|
+
</Link>
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</>
|
|
40
|
+
)
|
|
41
|
+
if (slide.boxed) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="border border-foreground/30 bg-background/90 p-8 backdrop-blur-sm sm:p-10">{inner}</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return <div className="text-white">{inner}</div>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function HeroSection({
|
|
50
|
+
variant,
|
|
51
|
+
slides,
|
|
52
|
+
height = 'tall',
|
|
53
|
+
}: {
|
|
54
|
+
variant: HeroVariant
|
|
55
|
+
slides: HeroSlide[]
|
|
56
|
+
height?: 'tall' | 'medium'
|
|
57
|
+
}) {
|
|
58
|
+
const [index, setIndex] = useState(0)
|
|
59
|
+
const interacted = useRef(false)
|
|
60
|
+
const hClass = height === 'tall' ? 'h-[72vh] min-h-[440px]' : 'h-[48vh] min-h-[340px]'
|
|
61
|
+
|
|
62
|
+
// Auto-advance until the visitor takes over; never under reduced motion.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (variant !== 'slider' || slides.length < 2 || prefersReducedMotion()) return
|
|
65
|
+
const timer = setInterval(() => {
|
|
66
|
+
if (!interacted.current) setIndex((i) => (i + 1) % slides.length)
|
|
67
|
+
}, 7000)
|
|
68
|
+
return () => clearInterval(timer)
|
|
69
|
+
}, [variant, slides.length])
|
|
70
|
+
|
|
71
|
+
const goTo = (i: number) => {
|
|
72
|
+
interacted.current = true
|
|
73
|
+
setIndex((i + slides.length) % slides.length)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (slides.length === 0) return null
|
|
77
|
+
|
|
78
|
+
if (variant === 'split') {
|
|
79
|
+
return (
|
|
80
|
+
<section data-testid={TID.hero} className="grid gap-px sm:grid-cols-2">
|
|
81
|
+
{slides.slice(0, 2).map((slide, i) => (
|
|
82
|
+
<Link
|
|
83
|
+
key={i}
|
|
84
|
+
to={slide.href ?? '/catalog'}
|
|
85
|
+
className="group relative block h-[42vh] min-h-[320px] overflow-hidden"
|
|
86
|
+
>
|
|
87
|
+
<img
|
|
88
|
+
src={slideImage(slide, 900, 700)}
|
|
89
|
+
alt={slide.title}
|
|
90
|
+
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
|
91
|
+
/>
|
|
92
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-t from-black/65 via-black/25 to-black/10 p-6 text-center text-white">
|
|
93
|
+
<h2
|
|
94
|
+
className="sf-heading animate-fade-up text-3xl font-bold uppercase tracking-wide drop-shadow-md sm:text-4xl"
|
|
95
|
+
style={{ animationDelay: `${i * 150}ms` }}
|
|
96
|
+
>
|
|
97
|
+
{slide.title}
|
|
98
|
+
</h2>
|
|
99
|
+
{slide.subtitle && (
|
|
100
|
+
<p className="animate-fade-up mt-2 max-w-sm drop-shadow" style={{ animationDelay: `${i * 150 + 120}ms` }}>
|
|
101
|
+
{slide.subtitle}
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
<span className="mt-4 inline-block border-b-2 border-white/0 pb-0.5 text-sm font-semibold uppercase tracking-wider opacity-0 transition-all duration-300 group-hover:border-white group-hover:opacity-100">
|
|
105
|
+
Explorar →
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
</Link>
|
|
109
|
+
))}
|
|
110
|
+
</section>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const active = slides[Math.min(index, slides.length - 1)]!
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<section data-testid={TID.hero} className={`relative w-full overflow-hidden ${hClass}`}>
|
|
118
|
+
{/* stacked slides, crossfade + Ken Burns on the active one */}
|
|
119
|
+
{slides.map((slide, i) => (
|
|
120
|
+
<img
|
|
121
|
+
key={i}
|
|
122
|
+
src={slideImage(slide)}
|
|
123
|
+
alt={slide.title}
|
|
124
|
+
aria-hidden={i !== index}
|
|
125
|
+
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ${
|
|
126
|
+
i === index ? 'animate-kenburns opacity-100' : 'opacity-0'
|
|
127
|
+
}`}
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
<div
|
|
131
|
+
className={`absolute inset-0 flex items-end p-6 sm:p-12 ${
|
|
132
|
+
active.boxed ? '' : 'bg-gradient-to-t from-black/60 via-black/20 to-transparent'
|
|
133
|
+
}`}
|
|
134
|
+
>
|
|
135
|
+
<div className="mx-auto w-full max-w-7xl">
|
|
136
|
+
<div className="max-w-xl">
|
|
137
|
+
<SlideCopy key={index} slide={active} active />
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{variant === 'slider' && slides.length > 1 && (
|
|
143
|
+
<>
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
aria-label="Slide anterior"
|
|
147
|
+
data-testid={TID.heroPrev}
|
|
148
|
+
onClick={() => goTo(index - 1)}
|
|
149
|
+
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/85 p-2.5 shadow transition-transform hover:scale-110 hover:bg-white"
|
|
150
|
+
>
|
|
151
|
+
<ChevronLeft className="h-5 w-5 text-gray-900" />
|
|
152
|
+
</button>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
aria-label="Próximo slide"
|
|
156
|
+
data-testid={TID.heroNext}
|
|
157
|
+
onClick={() => goTo(index + 1)}
|
|
158
|
+
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/85 p-2.5 shadow transition-transform hover:scale-110 hover:bg-white"
|
|
159
|
+
>
|
|
160
|
+
<ChevronRight className="h-5 w-5 text-gray-900" />
|
|
161
|
+
</button>
|
|
162
|
+
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
|
|
163
|
+
{slides.map((_, i) => (
|
|
164
|
+
<button
|
|
165
|
+
key={i}
|
|
166
|
+
type="button"
|
|
167
|
+
aria-label={`Ir para o slide ${i + 1}`}
|
|
168
|
+
onClick={() => goTo(i)}
|
|
169
|
+
className={`h-2 rounded-full transition-all duration-300 ${i === index ? 'w-7 bg-white' : 'w-2 bg-white/60 hover:bg-white/80'}`}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
</>
|
|
174
|
+
)}
|
|
175
|
+
</section>
|
|
176
|
+
)
|
|
177
|
+
}
|