@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,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
|
+
}
|