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