@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.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/dist/auth.d.ts +29 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/blocks.d.ts +6 -0
  6. package/dist/blocks.d.ts.map +1 -0
  7. package/dist/components/CartDrawer.d.ts +3 -0
  8. package/dist/components/CartDrawer.d.ts.map +1 -0
  9. package/dist/components/CustomerAccountShell.d.ts +13 -0
  10. package/dist/components/CustomerAccountShell.d.ts.map +1 -0
  11. package/dist/components/FiltersPanel.d.ts +3 -0
  12. package/dist/components/FiltersPanel.d.ts.map +1 -0
  13. package/dist/components/OrderTrackingTimeline.d.ts +7 -0
  14. package/dist/components/OrderTrackingTimeline.d.ts.map +1 -0
  15. package/dist/components/Price.d.ts +11 -0
  16. package/dist/components/Price.d.ts.map +1 -0
  17. package/dist/components/ProductCard.d.ts +7 -0
  18. package/dist/components/ProductCard.d.ts.map +1 -0
  19. package/dist/components/ProductGrid.d.ts +7 -0
  20. package/dist/components/ProductGrid.d.ts.map +1 -0
  21. package/dist/components/ProductOptionSelector.d.ts +9 -0
  22. package/dist/components/ProductOptionSelector.d.ts.map +1 -0
  23. package/dist/components/ProductSpecs.d.ts +8 -0
  24. package/dist/components/ProductSpecs.d.ts.map +1 -0
  25. package/dist/components/QuantityInput.d.ts +12 -0
  26. package/dist/components/QuantityInput.d.ts.map +1 -0
  27. package/dist/components/RelatedProducts.d.ts +9 -0
  28. package/dist/components/RelatedProducts.d.ts.map +1 -0
  29. package/dist/components/SignInModal.d.ts +19 -0
  30. package/dist/components/SignInModal.d.ts.map +1 -0
  31. package/dist/components/StorefrontErrorBoundary.d.ts +20 -0
  32. package/dist/components/StorefrontErrorBoundary.d.ts.map +1 -0
  33. package/dist/components/StorefrontFooter.d.ts +3 -0
  34. package/dist/components/StorefrontFooter.d.ts.map +1 -0
  35. package/dist/components/StorefrontHeader.d.ts +3 -0
  36. package/dist/components/StorefrontHeader.d.ts.map +1 -0
  37. package/dist/components/Toaster.d.ts +3 -0
  38. package/dist/components/Toaster.d.ts.map +1 -0
  39. package/dist/components/sections/CategoryShowcase.d.ts +6 -0
  40. package/dist/components/sections/CategoryShowcase.d.ts.map +1 -0
  41. package/dist/components/sections/HeroSection.d.ts +9 -0
  42. package/dist/components/sections/HeroSection.d.ts.map +1 -0
  43. package/dist/components/sections/MiscSections.d.ts +33 -0
  44. package/dist/components/sections/MiscSections.d.ts.map +1 -0
  45. package/dist/components/sections/ProductRail.d.ts +9 -0
  46. package/dist/components/sections/ProductRail.d.ts.map +1 -0
  47. package/dist/config.d.ts +149 -0
  48. package/dist/config.d.ts.map +1 -0
  49. package/dist/createStorefrontApp.d.ts +16 -0
  50. package/dist/createStorefrontApp.d.ts.map +1 -0
  51. package/dist/format.d.ts +3 -0
  52. package/dist/format.d.ts.map +1 -0
  53. package/dist/hooks/index.d.ts +6 -0
  54. package/dist/hooks/index.d.ts.map +1 -0
  55. package/dist/hooks/useCategories.d.ts +6 -0
  56. package/dist/hooks/useCategories.d.ts.map +1 -0
  57. package/dist/hooks/useDiscountValidator.d.ts +14 -0
  58. package/dist/hooks/useDiscountValidator.d.ts.map +1 -0
  59. package/dist/hooks/useMyOrders.d.ts +7 -0
  60. package/dist/hooks/useMyOrders.d.ts.map +1 -0
  61. package/dist/hooks/useProduct.d.ts +7 -0
  62. package/dist/hooks/useProduct.d.ts.map +1 -0
  63. package/dist/hooks/useProducts.d.ts +8 -0
  64. package/dist/hooks/useProducts.d.ts.map +1 -0
  65. package/dist/hooks/useStorefrontHead.d.ts +11 -0
  66. package/dist/hooks/useStorefrontHead.d.ts.map +1 -0
  67. package/dist/index.cjs +4186 -0
  68. package/dist/index.cjs.map +1 -0
  69. package/dist/index.d.ts +55 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +4085 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/motion.d.ts +19 -0
  74. package/dist/motion.d.ts.map +1 -0
  75. package/dist/order-status.d.ts +10 -0
  76. package/dist/order-status.d.ts.map +1 -0
  77. package/dist/pages/CatalogPage.d.ts +3 -0
  78. package/dist/pages/CatalogPage.d.ts.map +1 -0
  79. package/dist/pages/CheckoutPage.d.ts +3 -0
  80. package/dist/pages/CheckoutPage.d.ts.map +1 -0
  81. package/dist/pages/HomePage.d.ts +6 -0
  82. package/dist/pages/HomePage.d.ts.map +1 -0
  83. package/dist/pages/MyPurchasesPage.d.ts +3 -0
  84. package/dist/pages/MyPurchasesPage.d.ts.map +1 -0
  85. package/dist/pages/NotFoundPage.d.ts +4 -0
  86. package/dist/pages/NotFoundPage.d.ts.map +1 -0
  87. package/dist/pages/OrderConfirmationPage.d.ts +5 -0
  88. package/dist/pages/OrderConfirmationPage.d.ts.map +1 -0
  89. package/dist/pages/PolicyPage.d.ts +15 -0
  90. package/dist/pages/PolicyPage.d.ts.map +1 -0
  91. package/dist/pages/ProductDetailPage.d.ts +5 -0
  92. package/dist/pages/ProductDetailPage.d.ts.map +1 -0
  93. package/dist/presets.d.ts +33 -0
  94. package/dist/presets.d.ts.map +1 -0
  95. package/dist/product-options.d.ts +12 -0
  96. package/dist/product-options.d.ts.map +1 -0
  97. package/dist/router.d.ts +285 -0
  98. package/dist/router.d.ts.map +1 -0
  99. package/dist/scaffold.d.ts +14 -0
  100. package/dist/scaffold.d.ts.map +1 -0
  101. package/dist/sections.d.ts +84 -0
  102. package/dist/sections.d.ts.map +1 -0
  103. package/dist/slot-contracts.d.ts +34 -0
  104. package/dist/slot-contracts.d.ts.map +1 -0
  105. package/dist/stores/cart.store.d.ts +58 -0
  106. package/dist/stores/cart.store.d.ts.map +1 -0
  107. package/dist/stores/catalog.store.d.ts +18 -0
  108. package/dist/stores/catalog.store.d.ts.map +1 -0
  109. package/dist/stores/session.store.d.ts +23 -0
  110. package/dist/stores/session.store.d.ts.map +1 -0
  111. package/dist/stores/toast.store.d.ts +27 -0
  112. package/dist/stores/toast.store.d.ts.map +1 -0
  113. package/dist/testids.d.ts +93 -0
  114. package/dist/testids.d.ts.map +1 -0
  115. package/dist/theme.d.ts +59 -0
  116. package/dist/theme.d.ts.map +1 -0
  117. package/dist/workflows/checkout.d.ts +33 -0
  118. package/dist/workflows/checkout.d.ts.map +1 -0
  119. package/package.json +56 -0
  120. package/src/auth.ts +134 -0
  121. package/src/blocks.tsx +47 -0
  122. package/src/components/CartDrawer.tsx +250 -0
  123. package/src/components/CustomerAccountShell.tsx +102 -0
  124. package/src/components/FiltersPanel.tsx +109 -0
  125. package/src/components/OrderTrackingTimeline.tsx +135 -0
  126. package/src/components/Price.tsx +32 -0
  127. package/src/components/ProductCard.tsx +104 -0
  128. package/src/components/ProductGrid.tsx +34 -0
  129. package/src/components/ProductOptionSelector.tsx +50 -0
  130. package/src/components/ProductSpecs.tsx +75 -0
  131. package/src/components/QuantityInput.tsx +52 -0
  132. package/src/components/RelatedProducts.tsx +31 -0
  133. package/src/components/SignInModal.tsx +120 -0
  134. package/src/components/StorefrontErrorBoundary.tsx +51 -0
  135. package/src/components/StorefrontFooter.tsx +100 -0
  136. package/src/components/StorefrontHeader.tsx +342 -0
  137. package/src/components/Toaster.tsx +60 -0
  138. package/src/components/sections/CategoryShowcase.tsx +79 -0
  139. package/src/components/sections/HeroSection.tsx +177 -0
  140. package/src/components/sections/MiscSections.tsx +157 -0
  141. package/src/components/sections/ProductRail.tsx +73 -0
  142. package/src/config.ts +176 -0
  143. package/src/createStorefrontApp.tsx +171 -0
  144. package/src/format.ts +7 -0
  145. package/src/hooks/index.ts +5 -0
  146. package/src/hooks/useCategories.ts +26 -0
  147. package/src/hooks/useDiscountValidator.ts +43 -0
  148. package/src/hooks/useMyOrders.ts +41 -0
  149. package/src/hooks/useProduct.ts +36 -0
  150. package/src/hooks/useProducts.ts +39 -0
  151. package/src/hooks/useStorefrontHead.ts +25 -0
  152. package/src/index.ts +132 -0
  153. package/src/motion.tsx +94 -0
  154. package/src/order-status.ts +33 -0
  155. package/src/pages/CatalogPage.tsx +118 -0
  156. package/src/pages/CheckoutPage.tsx +578 -0
  157. package/src/pages/HomePage.tsx +12 -0
  158. package/src/pages/MyPurchasesPage.tsx +310 -0
  159. package/src/pages/NotFoundPage.tsx +25 -0
  160. package/src/pages/OrderConfirmationPage.tsx +189 -0
  161. package/src/pages/PolicyPage.tsx +47 -0
  162. package/src/pages/ProductDetailPage.tsx +184 -0
  163. package/src/presets.ts +258 -0
  164. package/src/product-options.ts +63 -0
  165. package/src/router.ts +49 -0
  166. package/src/scaffold.tsx +109 -0
  167. package/src/sections.ts +69 -0
  168. package/src/slot-contracts.ts +20 -0
  169. package/src/stores/cart.store.ts +149 -0
  170. package/src/stores/catalog.store.ts +39 -0
  171. package/src/stores/session.store.ts +28 -0
  172. package/src/stores/toast.store.ts +43 -0
  173. package/src/testids.ts +103 -0
  174. package/src/theme.ts +160 -0
  175. 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
+ }