@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,578 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { CreditCard, Lock, PackageCheck, ShieldCheck, Trash2 } from 'lucide-react'
|
|
3
|
+
import { prefersReducedMotion } from '../motion'
|
|
4
|
+
import {
|
|
5
|
+
useCartStore,
|
|
6
|
+
selectSubtotal,
|
|
7
|
+
selectDiscountTotal,
|
|
8
|
+
selectShipping,
|
|
9
|
+
selectTotal,
|
|
10
|
+
} from '../stores/cart.store'
|
|
11
|
+
import { useSessionStore } from '../stores/session.store'
|
|
12
|
+
import { useStorefrontConfig } from '../config'
|
|
13
|
+
import { Link, navigateTo } from '../router'
|
|
14
|
+
import { formatMoney } from '../format'
|
|
15
|
+
import { TID } from '../testids'
|
|
16
|
+
import { placeStorefrontOrder } from '../workflows/checkout'
|
|
17
|
+
import { useDiscountValidator } from '../hooks/useDiscountValidator'
|
|
18
|
+
import { toast } from '../stores/toast.store'
|
|
19
|
+
import { useStorefrontHead } from '../hooks/useStorefrontHead'
|
|
20
|
+
import { SignInModal } from '../components/SignInModal'
|
|
21
|
+
|
|
22
|
+
interface CheckoutForm {
|
|
23
|
+
email: string
|
|
24
|
+
name: string
|
|
25
|
+
street: string
|
|
26
|
+
city: string
|
|
27
|
+
zip: string
|
|
28
|
+
card: string
|
|
29
|
+
expiry: string
|
|
30
|
+
cvc: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type AddressMode = 'saved' | 'new'
|
|
34
|
+
type PaymentMode = 'saved' | 'new'
|
|
35
|
+
|
|
36
|
+
const SAVED_ADDRESSES = [
|
|
37
|
+
{ id: 'home', label: 'Casa', street: 'Rua das Flores, 123', city: 'São Paulo', zip: '01310-100' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const SAVED_PAYMENT_METHODS = [
|
|
41
|
+
{ id: 'visa', label: 'Visa final 4242', card: '4242 4242 4242 4242', expiry: '12/29', cvc: '123' },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const PROCESSING_STEPS = [
|
|
45
|
+
{ icon: Lock, label: 'Validando seus dados...' },
|
|
46
|
+
{ icon: CreditCard, label: 'Processando pagamento...' },
|
|
47
|
+
{ icon: PackageCheck, label: 'Confirmando seu pedido...' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const DEFAULT_ADDRESS = SAVED_ADDRESSES[0]!
|
|
51
|
+
const DEFAULT_PAYMENT = SAVED_PAYMENT_METHODS[0]!
|
|
52
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
53
|
+
|
|
54
|
+
function ProcessingOverlay({ step }: { step: number }) {
|
|
55
|
+
const current = PROCESSING_STEPS[Math.min(step, PROCESSING_STEPS.length - 1)]!
|
|
56
|
+
const Icon = current.icon
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
data-testid={TID.checkoutProcessing}
|
|
60
|
+
className="fixed inset-0 z-50 flex animate-fade-in flex-col items-center justify-center bg-background/95 backdrop-blur-sm"
|
|
61
|
+
role="status"
|
|
62
|
+
aria-live="polite"
|
|
63
|
+
>
|
|
64
|
+
<div className="relative flex h-24 w-24 items-center justify-center">
|
|
65
|
+
<span className="absolute inset-0 animate-spin rounded-full border-[3px] border-muted border-t-primary" style={{ animationDuration: '1.1s' }} />
|
|
66
|
+
<Icon key={step} className="h-9 w-9 animate-pop-in text-primary" />
|
|
67
|
+
</div>
|
|
68
|
+
<p key={`label-${step}`} className="mt-6 animate-fade-up font-medium">{current.label}</p>
|
|
69
|
+
<p className="mt-8 text-xs text-muted-foreground">Pagamento seguro - não feche esta janela</p>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function field(
|
|
75
|
+
label: string,
|
|
76
|
+
testId: string,
|
|
77
|
+
value: string,
|
|
78
|
+
onChange: (value: string) => void,
|
|
79
|
+
props?: React.InputHTMLAttributes<HTMLInputElement>,
|
|
80
|
+
) {
|
|
81
|
+
return (
|
|
82
|
+
<label className="block">
|
|
83
|
+
<span className="sr-only">{label}</span>
|
|
84
|
+
<input
|
|
85
|
+
data-testid={testId}
|
|
86
|
+
aria-label={label}
|
|
87
|
+
value={value}
|
|
88
|
+
onChange={(event) => onChange(event.target.value)}
|
|
89
|
+
className="w-full rounded-lg border bg-background px-3.5 py-3 text-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
</label>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function CheckoutPage() {
|
|
97
|
+
const config = useStorefrontConfig()
|
|
98
|
+
const cart = useCartStore()
|
|
99
|
+
const session = useSessionStore()
|
|
100
|
+
const validateDiscount = useDiscountValidator()
|
|
101
|
+
useStorefrontHead({ title: `Checkout — ${config.name}` })
|
|
102
|
+
|
|
103
|
+
const [selectedAddressId, setSelectedAddressId] = useState(DEFAULT_ADDRESS.id)
|
|
104
|
+
const [selectedPaymentId, setSelectedPaymentId] = useState(DEFAULT_PAYMENT.id)
|
|
105
|
+
const [savedAddresses, setSavedAddresses] = useState(SAVED_ADDRESSES)
|
|
106
|
+
const [savedPaymentMethods, setSavedPaymentMethods] = useState(SAVED_PAYMENT_METHODS)
|
|
107
|
+
const [addressMode, setAddressMode] = useState<AddressMode>('saved')
|
|
108
|
+
const [paymentMode, setPaymentMode] = useState<PaymentMode>('saved')
|
|
109
|
+
const [form, setForm] = useState<CheckoutForm>({
|
|
110
|
+
email: session.email ?? '',
|
|
111
|
+
name: session.name ?? '',
|
|
112
|
+
street: DEFAULT_ADDRESS.street,
|
|
113
|
+
city: DEFAULT_ADDRESS.city,
|
|
114
|
+
zip: DEFAULT_ADDRESS.zip,
|
|
115
|
+
card: DEFAULT_PAYMENT.card,
|
|
116
|
+
expiry: DEFAULT_PAYMENT.expiry,
|
|
117
|
+
cvc: DEFAULT_PAYMENT.cvc,
|
|
118
|
+
})
|
|
119
|
+
const [error, setError] = useState<string | null>(null)
|
|
120
|
+
const [discountCode, setDiscountCode] = useState('')
|
|
121
|
+
const [discountError, setDiscountError] = useState<string | null>(null)
|
|
122
|
+
const [discountSuccess, setDiscountSuccess] = useState<string | null>(null)
|
|
123
|
+
const [applyingDiscount, setApplyingDiscount] = useState(false)
|
|
124
|
+
const [placing, setPlacing] = useState(false)
|
|
125
|
+
const [processingStep, setProcessingStep] = useState(0)
|
|
126
|
+
const [showSignin, setShowSignin] = useState(false)
|
|
127
|
+
|
|
128
|
+
const subtotal = selectSubtotal(cart)
|
|
129
|
+
const discountTotal = selectDiscountTotal(cart)
|
|
130
|
+
const shipping = selectShipping(cart, config)
|
|
131
|
+
const total = selectTotal(cart, config)
|
|
132
|
+
const money = (value: number) => formatMoney(value, config.currency, config.locale)
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (cart.lines.length === 0 && !placing) navigateTo(config.catalogPath)
|
|
136
|
+
}, [cart.lines.length, config.catalogPath, placing])
|
|
137
|
+
|
|
138
|
+
const set = (key: keyof CheckoutForm) => (value: string) => setForm((current) => ({ ...current, [key]: value }))
|
|
139
|
+
|
|
140
|
+
async function applyDiscountCode() {
|
|
141
|
+
setApplyingDiscount(true)
|
|
142
|
+
setDiscountError(null)
|
|
143
|
+
setDiscountSuccess(null)
|
|
144
|
+
try {
|
|
145
|
+
const appliedCode = discountCode.trim().toUpperCase()
|
|
146
|
+
const result = await validateDiscount(discountCode)
|
|
147
|
+
if (result.valid) {
|
|
148
|
+
cart.applyDiscount(appliedCode, result.percent)
|
|
149
|
+
setDiscountCode(appliedCode)
|
|
150
|
+
setDiscountSuccess(`${appliedCode} aplicado: ${result.percent}% de desconto.`)
|
|
151
|
+
toast.success('Cupom aplicado!', `${appliedCode} • ${result.percent}% de desconto`)
|
|
152
|
+
} else {
|
|
153
|
+
setDiscountError(result.message ?? 'Cupom inválido.')
|
|
154
|
+
toast.error('Cupom inválido', result.message ?? 'Verifique o código e tente novamente.')
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
setApplyingDiscount(false)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function clearDiscountCode() {
|
|
162
|
+
cart.clearDiscount()
|
|
163
|
+
setDiscountCode('')
|
|
164
|
+
setDiscountSuccess(null)
|
|
165
|
+
setDiscountError(null)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function applySavedAddress(address: typeof SAVED_ADDRESSES[number]) {
|
|
169
|
+
setAddressMode('saved')
|
|
170
|
+
setSelectedAddressId(address.id)
|
|
171
|
+
setForm((current) => ({ ...current, street: address.street, city: address.city, zip: address.zip }))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function selectAddress(addressId: string) {
|
|
175
|
+
const address = savedAddresses.find((item) => item.id === addressId)
|
|
176
|
+
if (address) applySavedAddress(address)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function addNewAddress() {
|
|
180
|
+
setAddressMode('new')
|
|
181
|
+
setSelectedAddressId('new')
|
|
182
|
+
setForm((current) => ({ ...current, street: '', city: '', zip: '' }))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function removeAddress(addressId: string) {
|
|
186
|
+
const nextAddresses = savedAddresses.filter((item) => item.id !== addressId)
|
|
187
|
+
setSavedAddresses(nextAddresses)
|
|
188
|
+
if (selectedAddressId !== addressId) return
|
|
189
|
+
const fallback = nextAddresses[0]
|
|
190
|
+
if (fallback) applySavedAddress(fallback)
|
|
191
|
+
else addNewAddress()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function applySavedPayment(method: typeof SAVED_PAYMENT_METHODS[number]) {
|
|
195
|
+
setPaymentMode('saved')
|
|
196
|
+
setSelectedPaymentId(method.id)
|
|
197
|
+
setForm((current) => ({ ...current, card: method.card, expiry: method.expiry, cvc: method.cvc }))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function selectPayment(paymentId: string) {
|
|
201
|
+
const method = savedPaymentMethods.find((item) => item.id === paymentId)
|
|
202
|
+
if (method) applySavedPayment(method)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function addNewPayment() {
|
|
206
|
+
setPaymentMode('new')
|
|
207
|
+
setSelectedPaymentId('new')
|
|
208
|
+
setForm((current) => ({ ...current, card: '', expiry: '', cvc: '' }))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function removePayment(paymentId: string) {
|
|
212
|
+
const nextMethods = savedPaymentMethods.filter((item) => item.id !== paymentId)
|
|
213
|
+
setSavedPaymentMethods(nextMethods)
|
|
214
|
+
if (selectedPaymentId !== paymentId) return
|
|
215
|
+
const fallback = nextMethods[0]
|
|
216
|
+
if (fallback) applySavedPayment(fallback)
|
|
217
|
+
else addNewPayment()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validate(): boolean {
|
|
221
|
+
setError(null)
|
|
222
|
+
if (!form.email.trim() || !/.+@.+\..+/.test(form.email)) {
|
|
223
|
+
setError('Informe um e-mail válido.')
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
if (!form.name.trim()) {
|
|
227
|
+
setError('Informe seu nome completo.')
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
if (!form.street.trim() || !form.city.trim() || !form.zip.trim()) {
|
|
231
|
+
setError('Preencha ou selecione o endereço de entrega.')
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
if (!/^\d{16}$/.test(form.card.replace(/\s+/g, '')) || !form.expiry.trim() || !form.cvc.trim()) {
|
|
235
|
+
setError('Selecione ou preencha um método de pagamento válido.')
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function placeOrder() {
|
|
242
|
+
if (!validate()) return
|
|
243
|
+
|
|
244
|
+
setPlacing(true)
|
|
245
|
+
setProcessingStep(0)
|
|
246
|
+
const stepMs = prefersReducedMotion() ? 120 : 600
|
|
247
|
+
const stepTimers = [
|
|
248
|
+
setTimeout(() => setProcessingStep(1), stepMs),
|
|
249
|
+
setTimeout(() => setProcessingStep(2), stepMs * 2),
|
|
250
|
+
]
|
|
251
|
+
const minDuration = sleep(stepMs * 2)
|
|
252
|
+
try {
|
|
253
|
+
const { order } = await placeStorefrontOrder({
|
|
254
|
+
config,
|
|
255
|
+
cart,
|
|
256
|
+
session,
|
|
257
|
+
customer: { email: form.email, name: form.name },
|
|
258
|
+
address: { street: form.street, city: form.city, zip: form.zip },
|
|
259
|
+
// Demo only: mock mode instantly approves. Real Pix/Mercado Pago (M4)
|
|
260
|
+
// leaves the order pending until the payment webhook confirms.
|
|
261
|
+
markPaid: config.payments.mode === 'mock',
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
await minDuration
|
|
265
|
+
cart.clear()
|
|
266
|
+
navigateTo(`/order/${order.id}`)
|
|
267
|
+
} catch (error) {
|
|
268
|
+
setError(error instanceof Error ? error.message : 'Não foi possível concluir o pedido.')
|
|
269
|
+
setPlacing(false)
|
|
270
|
+
} finally {
|
|
271
|
+
stepTimers.forEach(clearTimeout)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const primaryCta = placing ? 'Processando...' : `Comprar agora - ${money(total)}`
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<main className="min-h-screen bg-background">
|
|
279
|
+
{placing && <ProcessingOverlay step={processingStep} />}
|
|
280
|
+
{showSignin && !session.email && (
|
|
281
|
+
<SignInModal
|
|
282
|
+
defaultEmail={form.email}
|
|
283
|
+
onClose={() => setShowSignin(false)}
|
|
284
|
+
onSignedIn={(signed) =>
|
|
285
|
+
setForm((current) => ({ ...current, email: signed.email ?? current.email, name: signed.name || current.name }))
|
|
286
|
+
}
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
<header className="border-b bg-background">
|
|
290
|
+
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-6 sm:px-6">
|
|
291
|
+
<Link to={config.catalogPath} className="text-sm text-muted-foreground hover:text-foreground">
|
|
292
|
+
Voltar à loja
|
|
293
|
+
</Link>
|
|
294
|
+
<div className="text-center">
|
|
295
|
+
<div className="sf-heading text-2xl font-bold tracking-tight">{config.logo ?? config.name}</div>
|
|
296
|
+
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
297
|
+
<Lock className="h-3.5 w-3.5" />
|
|
298
|
+
Checkout seguro
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
<a href="mailto:suporte@example.com" className="text-sm text-muted-foreground hover:text-foreground">
|
|
302
|
+
Ajuda
|
|
303
|
+
</a>
|
|
304
|
+
</div>
|
|
305
|
+
</header>
|
|
306
|
+
|
|
307
|
+
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-8 sm:px-6 lg:grid-cols-5 lg:px-6">
|
|
308
|
+
<section className="lg:col-span-3 lg:pr-10">
|
|
309
|
+
<nav className="mb-8 flex flex-wrap items-center gap-2 text-sm text-muted-foreground" aria-label="Checkout steps">
|
|
310
|
+
<span className="font-medium text-foreground">Informações</span>
|
|
311
|
+
<span>/</span>
|
|
312
|
+
<span>Entrega</span>
|
|
313
|
+
<span>/</span>
|
|
314
|
+
<span>Pagamento</span>
|
|
315
|
+
</nav>
|
|
316
|
+
|
|
317
|
+
<div className="space-y-8">
|
|
318
|
+
<section>
|
|
319
|
+
<div className="mb-3 flex items-center justify-between gap-4">
|
|
320
|
+
<h1 className="text-xl font-semibold tracking-tight">Informações de contato</h1>
|
|
321
|
+
{session.email ? (
|
|
322
|
+
<span className="text-sm text-muted-foreground">Conectado como {session.email}</span>
|
|
323
|
+
) : (
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
onClick={() => setShowSignin((value) => !value)}
|
|
327
|
+
className="text-sm font-medium text-primary hover:underline"
|
|
328
|
+
>
|
|
329
|
+
Já tem conta? Entrar
|
|
330
|
+
</button>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
334
|
+
{field('E-mail', TID.checkoutEmail, form.email, set('email'), {
|
|
335
|
+
type: 'email',
|
|
336
|
+
placeholder: 'E-mail ou telefone',
|
|
337
|
+
})}
|
|
338
|
+
{field('Nome completo', TID.checkoutName, form.name, set('name'), {
|
|
339
|
+
placeholder: 'Nome completo',
|
|
340
|
+
})}
|
|
341
|
+
</div>
|
|
342
|
+
</section>
|
|
343
|
+
|
|
344
|
+
<section>
|
|
345
|
+
<h2 className="mb-3 text-xl font-semibold tracking-tight">Endereço de entrega</h2>
|
|
346
|
+
<div className="mb-3 grid gap-3">
|
|
347
|
+
{savedAddresses.map((address) => (
|
|
348
|
+
<div
|
|
349
|
+
key={address.id}
|
|
350
|
+
className={`group relative rounded-lg border p-3 pr-11 text-left text-sm transition hover:bg-muted/40 ${
|
|
351
|
+
selectedAddressId === address.id ? 'border-primary bg-primary/5' : 'border-border bg-background'
|
|
352
|
+
}`}
|
|
353
|
+
>
|
|
354
|
+
<button type="button" onClick={() => selectAddress(address.id)} className="block w-full text-left">
|
|
355
|
+
<span className="flex items-center justify-between gap-3 font-semibold">
|
|
356
|
+
{address.label}
|
|
357
|
+
{addressMode === 'saved' && selectedAddressId === address.id && (
|
|
358
|
+
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-bold text-primary">Selecionado</span>
|
|
359
|
+
)}
|
|
360
|
+
</span>
|
|
361
|
+
<span className="mt-1 block text-muted-foreground">{address.street}</span>
|
|
362
|
+
<span className="block text-muted-foreground">{address.city} - {address.zip}</span>
|
|
363
|
+
</button>
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={() => removeAddress(address.id)}
|
|
367
|
+
className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
|
|
368
|
+
aria-label={`Remover endereço ${address.label}`}
|
|
369
|
+
title="Remover endereço"
|
|
370
|
+
>
|
|
371
|
+
<Trash2 className="h-4 w-4" />
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
))}
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={addNewAddress}
|
|
378
|
+
className={`rounded-lg border border-dashed p-3 text-left text-sm transition hover:bg-muted/40 ${
|
|
379
|
+
addressMode === 'new' ? 'border-primary bg-primary/5' : 'border-border bg-muted/20'
|
|
380
|
+
}`}
|
|
381
|
+
>
|
|
382
|
+
<span className="block font-semibold text-primary">+ Adicionar novo endereço</span>
|
|
383
|
+
<span className="mt-1 block text-muted-foreground">Preencher outro endereço para esta compra</span>
|
|
384
|
+
</button>
|
|
385
|
+
</div>
|
|
386
|
+
{addressMode === 'new' && (
|
|
387
|
+
<div className="grid gap-3">
|
|
388
|
+
{field('Endereço', TID.checkoutStreet, form.street, set('street'), { placeholder: 'Endereço' })}
|
|
389
|
+
<div className="grid gap-3 sm:grid-cols-[1fr_160px]">
|
|
390
|
+
{field('Cidade', TID.checkoutCity, form.city, set('city'), { placeholder: 'Cidade' })}
|
|
391
|
+
{field('CEP', TID.checkoutZip, form.zip, set('zip'), { placeholder: 'CEP' })}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
</section>
|
|
396
|
+
|
|
397
|
+
<section>
|
|
398
|
+
<h2 className="mb-3 text-xl font-semibold tracking-tight">Pagamento</h2>
|
|
399
|
+
<div className="mb-3 grid gap-3">
|
|
400
|
+
{savedPaymentMethods.map((method) => (
|
|
401
|
+
<div
|
|
402
|
+
key={method.id}
|
|
403
|
+
className={`group relative rounded-lg border p-3 pr-11 text-left text-sm transition hover:bg-muted/40 ${
|
|
404
|
+
selectedPaymentId === method.id ? 'border-primary bg-primary/5' : 'border-border bg-background'
|
|
405
|
+
}`}
|
|
406
|
+
>
|
|
407
|
+
<button type="button" onClick={() => selectPayment(method.id)} className="block w-full text-left">
|
|
408
|
+
<span className="flex items-center justify-between gap-3 font-semibold">
|
|
409
|
+
<span className="flex items-center gap-2"><CreditCard className="h-4 w-4" />{method.label}</span>
|
|
410
|
+
{paymentMode === 'saved' && selectedPaymentId === method.id && (
|
|
411
|
+
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-bold text-primary">Selecionado</span>
|
|
412
|
+
)}
|
|
413
|
+
</span>
|
|
414
|
+
<span className="mt-1 block text-muted-foreground">Expira em {method.expiry}</span>
|
|
415
|
+
</button>
|
|
416
|
+
<button
|
|
417
|
+
type="button"
|
|
418
|
+
onClick={() => removePayment(method.id)}
|
|
419
|
+
className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
|
|
420
|
+
aria-label={`Remover cartão ${method.label}`}
|
|
421
|
+
title="Remover cartão"
|
|
422
|
+
>
|
|
423
|
+
<Trash2 className="h-4 w-4" />
|
|
424
|
+
</button>
|
|
425
|
+
</div>
|
|
426
|
+
))}
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
onClick={addNewPayment}
|
|
430
|
+
className={`rounded-lg border border-dashed p-3 text-left text-sm transition hover:bg-muted/40 ${
|
|
431
|
+
paymentMode === 'new' ? 'border-primary bg-primary/5' : 'border-border bg-muted/20'
|
|
432
|
+
}`}
|
|
433
|
+
>
|
|
434
|
+
<span className="flex items-center gap-2 font-semibold text-primary"><CreditCard className="h-4 w-4" />+ Adicionar novo cartão</span>
|
|
435
|
+
<span className="mt-1 block text-muted-foreground">Usar outro cartão nesta compra</span>
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
{paymentMode === 'new' && (
|
|
439
|
+
<div className="grid gap-3">
|
|
440
|
+
{field('Número do cartão', TID.checkoutCard, form.card, set('card'), {
|
|
441
|
+
placeholder: 'Número do cartão',
|
|
442
|
+
inputMode: 'numeric',
|
|
443
|
+
})}
|
|
444
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
445
|
+
{field('Validade', TID.checkoutExpiry, form.expiry, set('expiry'), { placeholder: 'MM/AA' })}
|
|
446
|
+
{field('CVC', TID.checkoutCvc, form.cvc, set('cvc'), { placeholder: 'CVC', inputMode: 'numeric' })}
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
<p className="mt-2 text-xs text-muted-foreground">Pagamento de demonstração - nenhuma cobrança é feita.</p>
|
|
451
|
+
</section>
|
|
452
|
+
|
|
453
|
+
{error && (
|
|
454
|
+
<p data-testid={TID.checkoutError} className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
455
|
+
{error}
|
|
456
|
+
</p>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<footer className="mt-10 flex flex-wrap gap-4 border-t pt-5 text-xs text-muted-foreground">
|
|
462
|
+
<a href="#/privacy" className="hover:text-foreground">Privacidade</a>
|
|
463
|
+
<a href="#/terms" className="hover:text-foreground">Termos</a>
|
|
464
|
+
<a href="#/refunds" className="hover:text-foreground">Trocas e devoluções</a>
|
|
465
|
+
</footer>
|
|
466
|
+
</section>
|
|
467
|
+
|
|
468
|
+
<aside className="rounded-xl border bg-muted/20 p-5 lg:sticky lg:top-6 lg:col-span-2 lg:self-start">
|
|
469
|
+
<h2 className="sr-only">Resumo do pedido</h2>
|
|
470
|
+
<ul className="space-y-4">
|
|
471
|
+
{cart.lines.map((line) => (
|
|
472
|
+
<li key={line.lineId ?? line.productId} className="flex gap-3 text-sm">
|
|
473
|
+
<div className="relative h-16 w-16 flex-none overflow-hidden rounded-lg border bg-background">
|
|
474
|
+
{line.imageUrl && <img src={line.imageUrl} alt={line.name} className="h-full w-full object-cover" />}
|
|
475
|
+
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-muted-foreground px-1.5 text-[10px] font-bold text-background">
|
|
476
|
+
{line.quantity}
|
|
477
|
+
</span>
|
|
478
|
+
</div>
|
|
479
|
+
<div className="min-w-0 flex-1">
|
|
480
|
+
<p className="truncate font-medium">{line.name}</p>
|
|
481
|
+
{line.sku && <p className="text-xs text-muted-foreground">{line.sku}</p>}
|
|
482
|
+
{line.optionsLabel && <p className="text-xs text-muted-foreground">{line.optionsLabel}</p>}
|
|
483
|
+
</div>
|
|
484
|
+
<span data-price={(line.unitPrice * line.quantity).toFixed(2)}>
|
|
485
|
+
{money(line.unitPrice * line.quantity)}
|
|
486
|
+
</span>
|
|
487
|
+
</li>
|
|
488
|
+
))}
|
|
489
|
+
</ul>
|
|
490
|
+
{config.features.discounts && (
|
|
491
|
+
<div className="mt-5 space-y-2 border-t pt-5">
|
|
492
|
+
<div className="flex gap-2">
|
|
493
|
+
<input
|
|
494
|
+
data-testid={TID.discountInput}
|
|
495
|
+
type="text"
|
|
496
|
+
placeholder="Cupom de desconto"
|
|
497
|
+
value={discountCode}
|
|
498
|
+
onChange={(event) => {
|
|
499
|
+
setDiscountCode(event.target.value)
|
|
500
|
+
setDiscountSuccess(null)
|
|
501
|
+
setDiscountError(null)
|
|
502
|
+
}}
|
|
503
|
+
className="min-w-0 flex-1 rounded-lg border bg-background px-3 py-2 text-sm uppercase"
|
|
504
|
+
/>
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
data-testid={TID.discountApply}
|
|
508
|
+
onClick={applyDiscountCode}
|
|
509
|
+
disabled={applyingDiscount || !discountCode.trim()}
|
|
510
|
+
className="rounded-lg border px-3 py-2 text-sm font-semibold hover:bg-muted disabled:opacity-50"
|
|
511
|
+
>
|
|
512
|
+
Aplicar
|
|
513
|
+
</button>
|
|
514
|
+
</div>
|
|
515
|
+
{discountError && (
|
|
516
|
+
<p data-testid={TID.discountError} className="text-xs text-destructive">
|
|
517
|
+
{discountError}
|
|
518
|
+
</p>
|
|
519
|
+
)}
|
|
520
|
+
{discountSuccess && (
|
|
521
|
+
<p className="text-xs font-semibold text-emerald-700">
|
|
522
|
+
{discountSuccess}
|
|
523
|
+
</p>
|
|
524
|
+
)}
|
|
525
|
+
</div>
|
|
526
|
+
)}
|
|
527
|
+
<dl className="mt-8 space-y-3 border-t pt-6 text-sm">
|
|
528
|
+
<div className="flex justify-between">
|
|
529
|
+
<dt className="text-muted-foreground">Subtotal</dt>
|
|
530
|
+
<dd data-price={subtotal.toFixed(2)}>{money(subtotal)}</dd>
|
|
531
|
+
</div>
|
|
532
|
+
{discountTotal > 0 && (
|
|
533
|
+
<div className="flex justify-between text-emerald-700" data-testid={TID.discountRow}>
|
|
534
|
+
<dt>
|
|
535
|
+
Desconto{' '}
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={clearDiscountCode}
|
|
539
|
+
className="text-xs text-muted-foreground underline"
|
|
540
|
+
>
|
|
541
|
+
({cart.discountCode} ×)
|
|
542
|
+
</button>
|
|
543
|
+
</dt>
|
|
544
|
+
<dd data-price={discountTotal.toFixed(2)}>-{money(discountTotal)}</dd>
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
<div className="flex justify-between">
|
|
548
|
+
<dt className="text-muted-foreground">Entrega</dt>
|
|
549
|
+
<dd data-price={shipping.toFixed(2)}>{shipping === 0 ? 'Grátis' : money(shipping)}</dd>
|
|
550
|
+
</div>
|
|
551
|
+
<div className="flex justify-between border-t pt-5 text-lg font-bold">
|
|
552
|
+
<dt>Total</dt>
|
|
553
|
+
<dd data-price={total.toFixed(2)}>{money(total)}</dd>
|
|
554
|
+
</div>
|
|
555
|
+
</dl>
|
|
556
|
+
<div className="mt-6 flex items-center gap-2 text-xs text-muted-foreground">
|
|
557
|
+
<ShieldCheck className="h-4 w-4 text-primary" />
|
|
558
|
+
Compra protegida e dados criptografados.
|
|
559
|
+
</div>
|
|
560
|
+
<div className="mt-6 grid gap-3 border-t pt-5">
|
|
561
|
+
<button
|
|
562
|
+
type="button"
|
|
563
|
+
data-testid={TID.placeOrder}
|
|
564
|
+
onClick={placeOrder}
|
|
565
|
+
disabled={placing}
|
|
566
|
+
className="rounded-lg bg-primary px-6 py-4 text-sm font-bold text-primary-foreground transition hover:opacity-90 disabled:opacity-60"
|
|
567
|
+
>
|
|
568
|
+
{primaryCta}
|
|
569
|
+
</button>
|
|
570
|
+
<Link to={config.catalogPath} className="text-center text-sm text-muted-foreground hover:text-foreground">
|
|
571
|
+
Voltar ao carrinho
|
|
572
|
+
</Link>
|
|
573
|
+
</div>
|
|
574
|
+
</aside>
|
|
575
|
+
</div>
|
|
576
|
+
</main>
|
|
577
|
+
)
|
|
578
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { renderBlocks } from '@fayz-ai/core'
|
|
3
|
+
import type { HomeSection } from '../sections'
|
|
4
|
+
import { sectionsToBlocks } from '../blocks'
|
|
5
|
+
|
|
6
|
+
// The home page is now a block tree resolved through the universal block
|
|
7
|
+
// registry (see ../blocks). Section components and their props are unchanged;
|
|
8
|
+
// only the dispatch mechanism moved from a switch to the registry, so the same
|
|
9
|
+
// composition works from a pure-data AppManifest.
|
|
10
|
+
export function HomePage({ sections }: { sections: HomeSection[] }) {
|
|
11
|
+
return <main>{renderBlocks(sectionsToBlocks(sections))}</main>
|
|
12
|
+
}
|