@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,250 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { X, Trash2 } from 'lucide-react'
3
+ import {
4
+ useCartStore,
5
+ selectSubtotal,
6
+ selectDiscountTotal,
7
+ selectShipping,
8
+ selectTotal,
9
+ } from '../stores/cart.store'
10
+ import { useDiscountValidator } from '../hooks/useDiscountValidator'
11
+ import { toast } from '../stores/toast.store'
12
+ import { useStorefrontConfig } from '../config'
13
+ import { navigateTo } from '../router'
14
+ import { formatMoney } from '../format'
15
+ import { QuantityInput } from './QuantityInput'
16
+ import { TID } from '../testids'
17
+
18
+ export function CartDrawer() {
19
+ const config = useStorefrontConfig()
20
+ const cart = useCartStore()
21
+ const validate = useDiscountValidator()
22
+ const [code, setCode] = useState('')
23
+ const [discountError, setDiscountError] = useState<string | null>(null)
24
+ const [applying, setApplying] = useState(false)
25
+
26
+ // a11y: close on Escape while the drawer is open.
27
+ useEffect(() => {
28
+ if (!cart.isOpen) return
29
+ const onKey = (e: KeyboardEvent) => {
30
+ if (e.key === 'Escape') cart.closeDrawer()
31
+ }
32
+ document.addEventListener('keydown', onKey)
33
+ return () => document.removeEventListener('keydown', onKey)
34
+ }, [cart.isOpen, cart.closeDrawer])
35
+
36
+ const subtotal = selectSubtotal(cart)
37
+ const discountTotal = selectDiscountTotal(cart)
38
+ const shipping = selectShipping(cart, config)
39
+ const total = selectTotal(cart, config)
40
+ const money = (v: number) => formatMoney(v, config.currency, config.locale)
41
+
42
+ async function applyCode() {
43
+ setApplying(true)
44
+ setDiscountError(null)
45
+ try {
46
+ const result = await validate(code)
47
+ if (result.valid) {
48
+ const applied = code.trim().toUpperCase()
49
+ cart.applyDiscount(applied, result.percent)
50
+ setCode('')
51
+ toast.success('Cupom aplicado!', `${applied} • ${result.percent}% de desconto`)
52
+ } else {
53
+ setDiscountError(result.message ?? 'Cupom inválido.')
54
+ toast.error('Cupom inválido', result.message ?? 'Verifique o código e tente novamente.')
55
+ }
56
+ } finally {
57
+ setApplying(false)
58
+ }
59
+ }
60
+
61
+ if (!cart.isOpen) return null
62
+
63
+ // Free-shipping nudge: how far from the threshold this cart is
64
+ const freeAbove = config.shipping.freeAbove
65
+ const missingForFree = freeAbove != null && subtotal < freeAbove ? freeAbove - subtotal : 0
66
+ const progress = freeAbove != null ? Math.min(100, (subtotal / freeAbove) * 100) : 0
67
+
68
+ return (
69
+ <div className="fixed inset-0 z-50">
70
+ <div
71
+ className="absolute inset-0 animate-fade-in bg-black/40"
72
+ aria-hidden
73
+ onClick={cart.closeDrawer}
74
+ />
75
+ <div
76
+ data-testid={TID.cartDrawer}
77
+ role="dialog"
78
+ aria-label="Carrinho"
79
+ className="absolute right-0 top-0 flex h-full w-full max-w-md animate-slide-in-from-right flex-col bg-background shadow-2xl"
80
+ >
81
+ <div className="flex items-center justify-between border-b px-5 py-4">
82
+ <h2 className="text-lg font-semibold">Carrinho</h2>
83
+ <button
84
+ type="button"
85
+ aria-label="Fechar carrinho"
86
+ onClick={cart.closeDrawer}
87
+ className="rounded-full p-1.5 hover:bg-muted"
88
+ >
89
+ <X className="h-5 w-5" />
90
+ </button>
91
+ </div>
92
+
93
+ {cart.lines.length === 0 ? (
94
+ <div data-testid={TID.cartEmpty} className="flex flex-1 items-center justify-center p-8 text-center text-muted-foreground">
95
+ Seu carrinho está vazio.
96
+ </div>
97
+ ) : (
98
+ <>
99
+ {freeAbove != null && (
100
+ <div data-testid={TID.shippingProgress} className="border-b bg-muted/40 px-5 py-3">
101
+ <p className="text-xs font-medium">
102
+ {missingForFree > 0 ? (
103
+ <>Faltam <strong>{money(missingForFree)}</strong> para frete grátis 🚚</>
104
+ ) : (
105
+ <>🎉 Você ganhou <strong>frete grátis</strong>!</>
106
+ )}
107
+ </p>
108
+ <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-border">
109
+ <div
110
+ className="h-full rounded-full bg-primary transition-all duration-500 ease-out"
111
+ style={{ width: `${progress}%` }}
112
+ />
113
+ </div>
114
+ </div>
115
+ )}
116
+ <div className="flex-1 space-y-4 overflow-y-auto px-5 py-4">
117
+ {cart.lines.map((line) => (
118
+ <div
119
+ key={line.lineId ?? line.productId}
120
+ data-testid={TID.cartLine}
121
+ data-product-id={line.productId}
122
+ data-line-id={line.lineId ?? line.productId}
123
+ className={`flex animate-fade-up gap-3 rounded-lg p-1.5 transition-all hover:bg-muted/40 ${
124
+ (line.lineId ?? line.productId) === cart.justAddedLineId
125
+ ? 'bg-primary/5 ring-1 ring-primary/30'
126
+ : ''
127
+ }`}
128
+ >
129
+ {line.imageUrl && (
130
+ <img src={line.imageUrl} alt={line.name} className="h-20 w-20 shrink-0 rounded-lg border object-cover" />
131
+ )}
132
+ <div className="flex flex-1 flex-col">
133
+ <div className="flex items-start justify-between gap-2">
134
+ <span className="text-sm font-medium leading-snug">{line.name}</span>
135
+ <button
136
+ type="button"
137
+ data-testid={TID.lineRemove}
138
+ aria-label={`Remover ${line.name}`}
139
+ onClick={() => cart.removeItem(line.lineId ?? line.productId)}
140
+ className="text-muted-foreground hover:text-destructive"
141
+ >
142
+ <Trash2 className="h-4 w-4" />
143
+ </button>
144
+ </div>
145
+ <span className="text-xs text-muted-foreground">{line.sku}</span>
146
+ {line.optionsLabel && (
147
+ <span className="text-xs text-muted-foreground">{line.optionsLabel}</span>
148
+ )}
149
+ <div className="mt-auto flex items-center justify-between pt-2">
150
+ <QuantityInput
151
+ value={line.quantity}
152
+ max={line.maxQuantity || undefined}
153
+ onChange={(n) => cart.setQuantity(line.lineId ?? line.productId, n)}
154
+ testId={TID.lineQty}
155
+ incTestId={TID.lineInc}
156
+ decTestId={TID.lineDec}
157
+ />
158
+ <span
159
+ className="text-sm font-semibold"
160
+ data-price={(line.unitPrice * line.quantity).toFixed(2)}
161
+ >
162
+ {money(line.unitPrice * line.quantity)}
163
+ </span>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ ))}
168
+ </div>
169
+
170
+ <div className="space-y-3 border-t px-5 py-4">
171
+ {config.features.discounts && (
172
+ <div className="space-y-1">
173
+ <div className="flex gap-2">
174
+ <input
175
+ data-testid={TID.discountInput}
176
+ type="text"
177
+ placeholder="Cupom de desconto"
178
+ value={code}
179
+ onChange={(e) => setCode(e.target.value)}
180
+ className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm uppercase"
181
+ />
182
+ <button
183
+ type="button"
184
+ data-testid={TID.discountApply}
185
+ onClick={applyCode}
186
+ disabled={applying || !code.trim()}
187
+ className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
188
+ >
189
+ Aplicar
190
+ </button>
191
+ </div>
192
+ {discountError && (
193
+ <p data-testid={TID.discountError} className="text-xs text-destructive">
194
+ {discountError}
195
+ </p>
196
+ )}
197
+ </div>
198
+ )}
199
+
200
+ <dl className="space-y-1.5 text-sm">
201
+ <div className="flex justify-between">
202
+ <dt className="text-muted-foreground">Subtotal</dt>
203
+ <dd data-testid={TID.cartSubtotal} data-price={subtotal.toFixed(2)}>{money(subtotal)}</dd>
204
+ </div>
205
+ {discountTotal > 0 && (
206
+ <div className="flex justify-between text-emerald-700" data-testid={TID.discountRow}>
207
+ <dt>
208
+ Desconto{' '}
209
+ <button
210
+ type="button"
211
+ onClick={cart.clearDiscount}
212
+ className="text-xs text-muted-foreground underline"
213
+ >
214
+ ({cart.discountCode} ✕)
215
+ </button>
216
+ </dt>
217
+ <dd data-price={discountTotal.toFixed(2)}>−{money(discountTotal)}</dd>
218
+ </div>
219
+ )}
220
+ <div className="flex justify-between">
221
+ <dt className="text-muted-foreground">Frete</dt>
222
+ <dd data-testid={TID.cartShipping} data-price={shipping.toFixed(2)}>
223
+ {shipping === 0 ? 'Grátis' : money(shipping)}
224
+ </dd>
225
+ </div>
226
+ <div className="flex justify-between border-t pt-2 text-base font-semibold">
227
+ <dt>Total</dt>
228
+ <dd data-testid={TID.cartTotal} data-price={total.toFixed(2)}>{money(total)}</dd>
229
+ </div>
230
+ </dl>
231
+
232
+ <button
233
+ type="button"
234
+ data-testid={TID.goCheckout}
235
+ onClick={() => {
236
+ cart.closeDrawer()
237
+ navigateTo('/checkout')
238
+ }}
239
+ className="sf-cta w-full bg-primary py-3.5 font-semibold text-primary-foreground shadow-md transition-all hover:-translate-y-0.5 hover:shadow-lg"
240
+ style={{ borderRadius: 'var(--sf-radius-button)' }}
241
+ >
242
+ Finalizar compra
243
+ </button>
244
+ </div>
245
+ </>
246
+ )}
247
+ </div>
248
+ </div>
249
+ )
250
+ }
@@ -0,0 +1,102 @@
1
+ import React from 'react'
2
+ import { CreditCard, LogOut, MapPin, Package, ShieldCheck, User } from 'lucide-react'
3
+ import { signOutCustomer } from '../auth'
4
+ import { useStorefrontConfig } from '../config'
5
+ import { Link } from '../router'
6
+ import { useSessionStore } from '../stores/session.store'
7
+ import { TID } from '../testids'
8
+
9
+ export type AccountSection = 'profile' | 'orders' | 'addresses' | 'payments'
10
+
11
+ interface CustomerAccountShellProps {
12
+ title: string
13
+ subtitle?: string
14
+ active: AccountSection
15
+ /** Switch the active section in-place. */
16
+ onSelect?: (section: AccountSection) => void
17
+ children: React.ReactNode
18
+ }
19
+
20
+ const menuItems = [
21
+ { id: 'profile', label: 'Perfil', icon: User },
22
+ { id: 'orders', label: 'Meus pedidos', icon: Package },
23
+ { id: 'addresses', label: 'Endereços', icon: MapPin },
24
+ { id: 'payments', label: 'Pagamentos', icon: CreditCard },
25
+ ] as const
26
+
27
+ export function CustomerAccountShell({ title, subtitle, active, onSelect, children }: CustomerAccountShellProps) {
28
+ const config = useStorefrontConfig()
29
+ const session = useSessionStore()
30
+ const displayName = session.name || session.email?.split('@')[0] || 'Cliente'
31
+ const displayEmail = session.email || 'Conta criada no checkout'
32
+
33
+ return (
34
+ <main data-testid={TID.customerAccountShell} className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
35
+ <div className="mb-6 flex flex-wrap items-center justify-between gap-4">
36
+ <div>
37
+ <p className="text-sm font-medium text-muted-foreground">{config.name}</p>
38
+ <h1 className="sf-heading text-3xl font-bold tracking-tight">{title}</h1>
39
+ {subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
40
+ </div>
41
+ <Link to={config.catalogPath} className="rounded-lg border px-4 py-2 text-sm font-semibold hover:bg-muted">
42
+ Voltar à loja
43
+ </Link>
44
+ </div>
45
+
46
+ <div className="grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
47
+ <aside className="h-fit rounded-xl border bg-card p-4">
48
+ <div className="flex items-center gap-3 border-b pb-4">
49
+ <div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 font-bold text-primary">
50
+ {displayName.slice(0, 1).toUpperCase()}
51
+ </div>
52
+ <div className="min-w-0">
53
+ <p className="truncate font-semibold">{displayName}</p>
54
+ <p className="truncate text-xs text-muted-foreground">{displayEmail}</p>
55
+ </div>
56
+ </div>
57
+
58
+ <nav data-testid={TID.customerAccountMenu} className="mt-4 space-y-1" aria-label="Área do cliente">
59
+ {menuItems.map((item) => {
60
+ const Icon = item.icon
61
+ const selected = item.id === active
62
+ return (
63
+ <button
64
+ key={item.id}
65
+ type="button"
66
+ aria-current={selected ? 'page' : undefined}
67
+ onClick={() => onSelect?.(item.id)}
68
+ className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-semibold transition ${
69
+ selected ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
70
+ }`}
71
+ >
72
+ <Icon className="h-4 w-4" />
73
+ {item.label}
74
+ </button>
75
+ )
76
+ })}
77
+ </nav>
78
+
79
+ <div className="mt-4 rounded-lg bg-muted/40 p-3 text-xs text-muted-foreground">
80
+ <div className="flex items-center gap-2 font-semibold text-foreground">
81
+ <ShieldCheck className="h-4 w-4 text-primary" />
82
+ Conta protegida
83
+ </div>
84
+ <p className="mt-1">Pedidos, pagamentos e endereços ficam organizados para próximas compras.</p>
85
+ </div>
86
+
87
+ <button
88
+ type="button"
89
+ data-testid={TID.signout}
90
+ onClick={() => void signOutCustomer()}
91
+ className="mt-4 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-semibold text-muted-foreground transition hover:bg-muted hover:text-foreground"
92
+ >
93
+ <LogOut className="h-4 w-4" />
94
+ Sair
95
+ </button>
96
+ </aside>
97
+
98
+ <section className="min-w-0">{children}</section>
99
+ </div>
100
+ </main>
101
+ )
102
+ }
@@ -0,0 +1,109 @@
1
+ import React from 'react'
2
+ import { useCatalogStore } from '../stores/catalog.store'
3
+ import type { CatalogSort } from '../stores/catalog.store'
4
+ import { useCategories } from '../hooks/useCategories'
5
+ import { TID } from '../testids'
6
+
7
+ export function FiltersPanel() {
8
+ const { categories } = useCategories()
9
+ const catalog = useCatalogStore()
10
+
11
+ return (
12
+ <aside className="w-full shrink-0 space-y-6 lg:w-56">
13
+ {/* Sort */}
14
+ <div className="space-y-2">
15
+ <h3 className="text-sm font-semibold">Ordenar por</h3>
16
+ <select
17
+ data-testid={TID.sortSelect}
18
+ value={catalog.sort}
19
+ onChange={(e) => catalog.setSort(e.target.value as CatalogSort)}
20
+ className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
21
+ >
22
+ <option value="newest">Mais recentes</option>
23
+ <option value="price-asc">Menor preço</option>
24
+ <option value="price-desc">Maior preço</option>
25
+ <option value="name">Nome (A–Z)</option>
26
+ </select>
27
+ </div>
28
+
29
+ {/* Categories */}
30
+ <div className="space-y-2">
31
+ <h3 className="text-sm font-semibold">Categorias</h3>
32
+ <ul className="space-y-1">
33
+ <li>
34
+ <button
35
+ type="button"
36
+ onClick={() => catalog.setCategoryId(null)}
37
+ className={`w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
38
+ catalog.categoryId === null ? 'bg-primary/10 font-medium text-primary' : 'hover:bg-muted'
39
+ }`}
40
+ >
41
+ Todas
42
+ </button>
43
+ </li>
44
+ {categories.map((c) => (
45
+ <li key={c.id}>
46
+ <button
47
+ type="button"
48
+ data-testid={TID.filterCategory(c.slug)}
49
+ onClick={() => catalog.setCategoryId(catalog.categoryId === c.id ? null : c.id)}
50
+ className={`w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
51
+ catalog.categoryId === c.id ? 'bg-primary/10 font-medium text-primary' : 'hover:bg-muted'
52
+ }`}
53
+ >
54
+ {c.name}
55
+ </button>
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ </div>
60
+
61
+ {/* Price range */}
62
+ <div className="space-y-2">
63
+ <h3 className="text-sm font-semibold">Preço</h3>
64
+ <div className="flex items-center gap-2">
65
+ <input
66
+ data-testid={TID.filterPriceMin}
67
+ type="number"
68
+ placeholder="Mín"
69
+ min={0}
70
+ value={catalog.priceMin ?? ''}
71
+ onChange={(e) => catalog.setPriceMin(e.target.value === '' ? null : Number(e.target.value))}
72
+ className="w-full rounded-lg border bg-background px-2.5 py-1.5 text-sm"
73
+ />
74
+ <span className="text-muted-foreground">–</span>
75
+ <input
76
+ data-testid={TID.filterPriceMax}
77
+ type="number"
78
+ placeholder="Máx"
79
+ min={0}
80
+ value={catalog.priceMax ?? ''}
81
+ onChange={(e) => catalog.setPriceMax(e.target.value === '' ? null : Number(e.target.value))}
82
+ className="w-full rounded-lg border bg-background px-2.5 py-1.5 text-sm"
83
+ />
84
+ </div>
85
+ </div>
86
+
87
+ {/* Availability */}
88
+ <label className="flex cursor-pointer items-center gap-2 text-sm">
89
+ <input
90
+ data-testid={TID.filterInstock}
91
+ type="checkbox"
92
+ checked={catalog.inStockOnly}
93
+ onChange={(e) => catalog.setInStockOnly(e.target.checked)}
94
+ className="h-4 w-4 rounded border"
95
+ />
96
+ Somente em estoque
97
+ </label>
98
+
99
+ <button
100
+ type="button"
101
+ data-testid={TID.filtersClear}
102
+ onClick={() => catalog.reset()}
103
+ className="text-sm text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
104
+ >
105
+ Limpar filtros
106
+ </button>
107
+ </aside>
108
+ )
109
+ }
@@ -0,0 +1,135 @@
1
+ import React from 'react'
2
+ import { Check, Clock, PackageCheck, RotateCcw, Truck, XCircle } from 'lucide-react'
3
+ import type { Order } from '@fayz-ai/shop/types'
4
+ import { isPaid, isRefunded } from '../order-status'
5
+ import { TID } from '../testids'
6
+
7
+ type TrackingState = 'complete' | 'current' | 'pending' | 'cancelled'
8
+
9
+ interface TrackingStep {
10
+ id: string
11
+ label: string
12
+ description: string
13
+ state: TrackingState
14
+ icon: React.ComponentType<{ className?: string }>
15
+ }
16
+
17
+ function orderTrackingSteps(order: Order): TrackingStep[] {
18
+ const received: TrackingStep = {
19
+ id: 'received',
20
+ label: 'Pedido recebido',
21
+ description: `#${order.orderNumber}`,
22
+ state: 'complete',
23
+ icon: Check,
24
+ }
25
+
26
+ // Terminal states short-circuit the happy path.
27
+ if (order.status === 'cancelled' || order.financialStatus === 'voided') {
28
+ return [
29
+ received,
30
+ { id: 'payment', label: 'Pedido cancelado', description: 'Este pedido foi cancelado', state: 'cancelled', icon: XCircle },
31
+ ]
32
+ }
33
+ if (isRefunded(order.financialStatus)) {
34
+ return [
35
+ received,
36
+ { id: 'payment', label: 'Pagamento aprovado', description: 'Compra confirmada', state: 'complete', icon: Check },
37
+ {
38
+ id: 'refund',
39
+ label: order.financialStatus === 'refunded' ? 'Reembolsado' : 'Reembolso parcial',
40
+ description: 'Valor devolvido ao cliente',
41
+ state: 'cancelled',
42
+ icon: RotateCcw,
43
+ },
44
+ ]
45
+ }
46
+
47
+ const paid = isPaid(order.financialStatus)
48
+ const partiallyFulfilled = order.fulfillmentStatus === 'partially_fulfilled'
49
+ const fulfilled = order.fulfillmentStatus === 'fulfilled'
50
+
51
+ return [
52
+ received,
53
+ {
54
+ id: 'payment',
55
+ label: paid ? 'Pagamento aprovado' : 'Aguardando pagamento',
56
+ description: paid ? 'Compra confirmada' : 'Processando',
57
+ state: paid ? 'complete' : 'current',
58
+ icon: Check,
59
+ },
60
+ {
61
+ id: 'preparing',
62
+ label: 'Preparando envio',
63
+ description: paid ? 'Separando produtos' : 'Começa após o pagamento',
64
+ state: fulfilled || partiallyFulfilled ? 'complete' : paid ? 'current' : 'pending',
65
+ icon: PackageCheck,
66
+ },
67
+ {
68
+ id: 'delivery',
69
+ label: fulfilled ? 'Pedido enviado' : partiallyFulfilled ? 'Envio parcial' : 'Entrega',
70
+ description: fulfilled ? 'A caminho do cliente' : partiallyFulfilled ? 'Parte do pedido saiu' : 'Próxima etapa',
71
+ state: fulfilled ? 'complete' : partiallyFulfilled ? 'current' : 'pending',
72
+ icon: fulfilled || partiallyFulfilled ? Truck : Clock,
73
+ },
74
+ ]
75
+ }
76
+
77
+ const GRID_COLS: Record<number, string> = {
78
+ 2: 'sm:grid-cols-2',
79
+ 3: 'sm:grid-cols-3',
80
+ 4: 'sm:grid-cols-4',
81
+ }
82
+
83
+ export function OrderTrackingTimeline({ order, compact = false }: { order: Order; compact?: boolean }) {
84
+ const steps = orderTrackingSteps(order)
85
+
86
+ return (
87
+ <ol
88
+ data-testid={TID.orderTracking}
89
+ className={`grid ${compact ? 'gap-2' : 'gap-3'} ${GRID_COLS[steps.length] ?? 'sm:grid-cols-4'}`}
90
+ aria-label="Acompanhamento do pedido"
91
+ >
92
+ {steps.map((step) => {
93
+ const Icon = step.icon
94
+ const cancelled = step.state === 'cancelled'
95
+ const active = step.state !== 'pending'
96
+ return (
97
+ <li
98
+ key={step.id}
99
+ data-testid={TID.orderTrackingStep(step.id)}
100
+ data-state={step.state}
101
+ className={[
102
+ 'relative min-w-0 rounded-xl border p-3',
103
+ cancelled
104
+ ? 'border-rose-200 bg-rose-50 text-rose-700'
105
+ : active
106
+ ? 'border-primary/35 bg-primary/5'
107
+ : 'bg-muted/20 text-muted-foreground',
108
+ compact ? 'text-xs' : 'text-sm',
109
+ ].join(' ')}
110
+ >
111
+ <div className="flex items-start gap-2">
112
+ <span
113
+ className={[
114
+ 'grid h-7 w-7 shrink-0 place-items-center rounded-full border',
115
+ cancelled
116
+ ? 'border-rose-300 bg-rose-500 text-white'
117
+ : active
118
+ ? 'border-primary bg-primary text-primary-foreground'
119
+ : 'border-muted-foreground/30 bg-background',
120
+ ].join(' ')}
121
+ aria-hidden
122
+ >
123
+ <Icon className="h-3.5 w-3.5" />
124
+ </span>
125
+ <span className="min-w-0">
126
+ <span className="block truncate font-semibold text-foreground">{step.label}</span>
127
+ <span className="mt-0.5 block truncate text-muted-foreground">{step.description}</span>
128
+ </span>
129
+ </div>
130
+ </li>
131
+ )
132
+ })}
133
+ </ol>
134
+ )
135
+ }
@@ -0,0 +1,32 @@
1
+ import React from 'react'
2
+ import { formatMoney } from '../format'
3
+ import { useStorefrontConfig } from '../config'
4
+
5
+ export interface PriceProps {
6
+ value: number
7
+ compareAt?: number | null
8
+ testId?: string
9
+ compareTestId?: string
10
+ className?: string
11
+ }
12
+
13
+ /** Formatted money. Always carries data-price so tests assert raw numbers, never locale strings. */
14
+ export function Price({ value, compareAt, testId, compareTestId, className }: PriceProps) {
15
+ const { currency, locale } = useStorefrontConfig()
16
+ return (
17
+ <span className={`inline-flex items-baseline gap-2 ${className ?? ''}`}>
18
+ <span data-testid={testId} data-price={value.toFixed(2)} className="font-semibold">
19
+ {formatMoney(value, currency, locale)}
20
+ </span>
21
+ {compareAt != null && compareAt > value && (
22
+ <span
23
+ data-testid={compareTestId}
24
+ data-price={compareAt.toFixed(2)}
25
+ className="text-sm text-muted-foreground line-through"
26
+ >
27
+ {formatMoney(compareAt, currency, locale)}
28
+ </span>
29
+ )}
30
+ </span>
31
+ )
32
+ }