@fayz-ai/storefront 0.8.0 → 0.8.3
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/dist/auth.d.ts.map +1 -1
- package/dist/components/CartDrawer.d.ts.map +1 -1
- package/dist/components/DeliveryEstimator.d.ts +17 -0
- package/dist/components/DeliveryEstimator.d.ts.map +1 -0
- package/dist/components/ProductGallery.d.ts +22 -0
- package/dist/components/ProductGallery.d.ts.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.cjs +674 -275
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +576 -177
- package/dist/index.js.map +1 -1
- package/dist/pages/CheckoutPage.d.ts.map +1 -1
- package/dist/pages/ProductDetailPage.d.ts.map +1 -1
- package/dist/stores/cart.store.d.ts +16 -0
- package/dist/stores/cart.store.d.ts.map +1 -1
- package/dist/stores/delivery.store.d.ts +64 -0
- package/dist/stores/delivery.store.d.ts.map +1 -0
- package/dist/testids.d.ts +11 -0
- package/dist/testids.d.ts.map +1 -1
- package/dist/workflows/checkout.d.ts +5 -2
- package/dist/workflows/checkout.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/auth.ts +57 -1
- package/src/components/CartDrawer.tsx +23 -1
- package/src/components/DeliveryEstimator.tsx +157 -0
- package/src/components/ProductGallery.tsx +174 -0
- package/src/config.ts +15 -3
- package/src/pages/CheckoutPage.tsx +240 -134
- package/src/pages/ProductDetailPage.tsx +17 -13
- package/src/stores/cart.store.ts +21 -0
- package/src/stores/delivery.store.ts +152 -0
- package/src/testids.ts +12 -0
- package/src/workflows/checkout.ts +30 -5
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import React, { useEffect, useState } from 'react'
|
|
2
|
-
import { CreditCard, Lock, PackageCheck, ShieldCheck
|
|
2
|
+
import { CreditCard, Lock, PackageCheck, ShieldCheck } from 'lucide-react'
|
|
3
|
+
import { getShopProvider } from '@fayz-ai/shop/runtime'
|
|
4
|
+
import { formatPostalCode, normalizePostalCode, lookupPostalCode } from '@fayz-ai/core'
|
|
5
|
+
import type { CustomerAddress, PaymentMethodKind } from '@fayz-ai/shop/types'
|
|
3
6
|
import { prefersReducedMotion } from '../motion'
|
|
4
7
|
import {
|
|
5
8
|
useCartStore,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
selectTotal,
|
|
10
13
|
} from '../stores/cart.store'
|
|
11
14
|
import { useSessionStore } from '../stores/session.store'
|
|
15
|
+
import { useDeliveryStore } from '../stores/delivery.store'
|
|
12
16
|
import { useStorefrontConfig } from '../config'
|
|
13
17
|
import { Link, navigateTo } from '../router'
|
|
14
18
|
import { formatMoney } from '../format'
|
|
@@ -26,30 +30,36 @@ interface CheckoutForm {
|
|
|
26
30
|
street: string
|
|
27
31
|
city: string
|
|
28
32
|
zip: string
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
number: string
|
|
34
|
+
complement: string
|
|
35
|
+
district: string
|
|
36
|
+
state: string
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
type AddressMode = 'saved' | 'new'
|
|
35
|
-
type PaymentMode = 'saved' | 'new'
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* There is no payment service provider connected, so the checkout does not ask
|
|
43
|
+
* for a card number. It used to, prefilled with 4242 4242 4242 4242, and the
|
|
44
|
+
* digits were validated and then thrown away — a PAN typed into React state
|
|
45
|
+
* that reaches no processor is a liability with no upside. The buyer states how
|
|
46
|
+
* they intend to pay; the order stays `pending` until the merchant confirms the
|
|
47
|
+
* money arrived (shop_confirm_payment, which since 0019 only they can call).
|
|
48
|
+
*/
|
|
49
|
+
const PAYMENT_LABELS: Record<PaymentMethodKind, { label: string; hint: string }> = {
|
|
50
|
+
pix: { label: 'Pix', hint: 'Você recebe a chave para pagar após confirmar o pedido' },
|
|
51
|
+
credit_card: { label: 'Cartão de crédito', hint: 'Maquininha na entrega' },
|
|
52
|
+
debit_card: { label: 'Cartão de débito', hint: 'Maquininha na entrega' },
|
|
53
|
+
boleto: { label: 'Boleto', hint: 'Enviado por e-mail após a confirmação' },
|
|
54
|
+
cash: { label: 'Dinheiro', hint: 'Pagamento na entrega' },
|
|
55
|
+
other: { label: 'Combinar com a loja', hint: 'A loja entra em contato para acertar o pagamento' },
|
|
56
|
+
}
|
|
44
57
|
|
|
45
58
|
const PROCESSING_STEPS = [
|
|
46
59
|
{ icon: Lock, label: 'Validando seus dados...' },
|
|
47
|
-
{ icon:
|
|
48
|
-
{ icon: PackageCheck, label: 'Confirmando seu pedido...' },
|
|
60
|
+
{ icon: PackageCheck, label: 'Registrando seu pedido...' },
|
|
49
61
|
]
|
|
50
62
|
|
|
51
|
-
const DEFAULT_ADDRESS = SAVED_ADDRESSES[0]!
|
|
52
|
-
const DEFAULT_PAYMENT = SAVED_PAYMENT_METHODS[0]!
|
|
53
63
|
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
54
64
|
|
|
55
65
|
function ProcessingOverlay({ step }: { step: number }) {
|
|
@@ -98,24 +108,27 @@ export function CheckoutPage() {
|
|
|
98
108
|
const config = useStorefrontConfig()
|
|
99
109
|
const cart = useCartStore()
|
|
100
110
|
const session = useSessionStore()
|
|
111
|
+
const delivery = useDeliveryStore()
|
|
101
112
|
const validateDiscount = useDiscountValidator()
|
|
102
113
|
useStorefrontHead({ title: `Checkout — ${config.name}` })
|
|
103
114
|
|
|
104
|
-
const
|
|
105
|
-
const [
|
|
106
|
-
const [savedAddresses, setSavedAddresses] = useState(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const [
|
|
115
|
+
const paymentMethods = config.payments.methods
|
|
116
|
+
const [selectedAddressId, setSelectedAddressId] = useState('new')
|
|
117
|
+
const [savedAddresses, setSavedAddresses] = useState<CustomerAddress[]>([])
|
|
118
|
+
// 'new' until the address book actually loads: an empty book must show an
|
|
119
|
+
// empty form, never a placeholder address belonging to nobody.
|
|
120
|
+
const [addressMode, setAddressMode] = useState<AddressMode>('new')
|
|
121
|
+
const [paymentMethod, setPaymentMethod] = useState<PaymentMethodKind>(paymentMethods[0] ?? 'pix')
|
|
110
122
|
const [form, setForm] = useState<CheckoutForm>({
|
|
111
123
|
email: session.email ?? '',
|
|
112
124
|
name: session.name ?? '',
|
|
113
|
-
street:
|
|
114
|
-
city:
|
|
115
|
-
zip:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
street: '',
|
|
126
|
+
city: '',
|
|
127
|
+
zip: '',
|
|
128
|
+
number: '',
|
|
129
|
+
complement: '',
|
|
130
|
+
district: '',
|
|
131
|
+
state: '',
|
|
119
132
|
})
|
|
120
133
|
const [error, setError] = useState<string | null>(null)
|
|
121
134
|
const [discountCode, setDiscountCode] = useState('')
|
|
@@ -125,6 +138,7 @@ export function CheckoutPage() {
|
|
|
125
138
|
const [placing, setPlacing] = useState(false)
|
|
126
139
|
const [processingStep, setProcessingStep] = useState(0)
|
|
127
140
|
const [showSignin, setShowSignin] = useState(false)
|
|
141
|
+
const [zipLookup, setZipLookup] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle')
|
|
128
142
|
|
|
129
143
|
const subtotal = selectSubtotal(cart)
|
|
130
144
|
const discountTotal = selectDiscountTotal(cart)
|
|
@@ -136,8 +150,46 @@ export function CheckoutPage() {
|
|
|
136
150
|
if (cart.lines.length === 0 && !placing) navigateTo(config.catalogPath)
|
|
137
151
|
}, [cart.lines.length, config.catalogPath, placing])
|
|
138
152
|
|
|
153
|
+
|
|
139
154
|
const set = (key: keyof CheckoutForm) => (value: string) => setForm((current) => ({ ...current, [key]: value }))
|
|
140
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Editing the CEP re-fetches the address and refills what the postal service
|
|
158
|
+
* knows, leaving number and complement alone — those are the buyer's and must
|
|
159
|
+
* survive a correction to the postcode.
|
|
160
|
+
*
|
|
161
|
+
* Only what the lookup can supply is overwritten. Silently keeping a stale
|
|
162
|
+
* street from a previous CEP is worse than an empty one: the parcel goes to
|
|
163
|
+
* the wrong place and everything on screen looks filled in.
|
|
164
|
+
*/
|
|
165
|
+
async function setZip(value: string) {
|
|
166
|
+
const masked = formatPostalCode(value)
|
|
167
|
+
setForm((current) => ({ ...current, zip: masked }))
|
|
168
|
+
if (normalizePostalCode(masked).length !== 8) {
|
|
169
|
+
setZipLookup('idle')
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
setZipLookup('loading')
|
|
173
|
+
try {
|
|
174
|
+
const found = await lookupPostalCode(masked)
|
|
175
|
+
if (!found) {
|
|
176
|
+
setZipLookup('not-found')
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
setZipLookup('found')
|
|
180
|
+
setForm((current) => ({
|
|
181
|
+
...current,
|
|
182
|
+
street: found.street || current.street,
|
|
183
|
+
district: found.district || current.district,
|
|
184
|
+
city: found.city,
|
|
185
|
+
state: found.state,
|
|
186
|
+
}))
|
|
187
|
+
} catch {
|
|
188
|
+
// Offline or the provider is down — the buyer can still type it out.
|
|
189
|
+
setZipLookup('idle')
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
141
193
|
async function applyDiscountCode() {
|
|
142
194
|
setApplyingDiscount(true)
|
|
143
195
|
setDiscountError(null)
|
|
@@ -166,10 +218,70 @@ export function CheckoutPage() {
|
|
|
166
218
|
setDiscountError(null)
|
|
167
219
|
}
|
|
168
220
|
|
|
169
|
-
|
|
221
|
+
// The signed-in shopper's real address book. RLS scopes it to them, so a
|
|
222
|
+
// guest (or a customer whose account was never linked to an auth identity)
|
|
223
|
+
// simply gets nothing back and types their address.
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
const customerId = session.customerId
|
|
226
|
+
if (!customerId) {
|
|
227
|
+
setSavedAddresses([])
|
|
228
|
+
setAddressMode('new')
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
let cancelled = false
|
|
232
|
+
const provider = getShopProvider()
|
|
233
|
+
void provider
|
|
234
|
+
.listCustomerAddresses?.(customerId)
|
|
235
|
+
.then((addresses) => {
|
|
236
|
+
if (cancelled || addresses.length === 0) return
|
|
237
|
+
setSavedAddresses(addresses)
|
|
238
|
+
applySavedAddress(addresses[0]!)
|
|
239
|
+
})
|
|
240
|
+
.catch(() => {})
|
|
241
|
+
return () => {
|
|
242
|
+
cancelled = true
|
|
243
|
+
}
|
|
244
|
+
}, [session.customerId])
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The CEP given back on the product page lands here.
|
|
248
|
+
*
|
|
249
|
+
* This is the payoff of the whole delivery flow: street, district, city and
|
|
250
|
+
* UF arrive filled and the buyer types a house number. It only fires when no
|
|
251
|
+
* saved address was selected — a signed-in shopper's own address book still
|
|
252
|
+
* wins over a postcode lookup, because it also knows their number and
|
|
253
|
+
* complement, which no postal service can supply.
|
|
254
|
+
*/
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (addressMode !== 'new') return
|
|
257
|
+
const looked = delivery.address
|
|
258
|
+
if (!looked) return
|
|
259
|
+
setForm((current) => {
|
|
260
|
+
if (current.zip.trim() || current.street.trim()) return current
|
|
261
|
+
return {
|
|
262
|
+
...current,
|
|
263
|
+
zip: formatPostalCode(looked.postalCode),
|
|
264
|
+
street: looked.street,
|
|
265
|
+
district: looked.district,
|
|
266
|
+
city: looked.city,
|
|
267
|
+
state: looked.state,
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
}, [delivery.address, addressMode])
|
|
271
|
+
|
|
272
|
+
function applySavedAddress(address: CustomerAddress) {
|
|
170
273
|
setAddressMode('saved')
|
|
171
274
|
setSelectedAddressId(address.id)
|
|
172
|
-
setForm((current) => ({
|
|
275
|
+
setForm((current) => ({
|
|
276
|
+
...current,
|
|
277
|
+
street: address.street,
|
|
278
|
+
city: address.city,
|
|
279
|
+
zip: address.postalCode,
|
|
280
|
+
number: address.number ?? '',
|
|
281
|
+
complement: address.complement ?? '',
|
|
282
|
+
district: address.district ?? '',
|
|
283
|
+
state: address.state,
|
|
284
|
+
}))
|
|
173
285
|
}
|
|
174
286
|
|
|
175
287
|
function selectAddress(addressId: string) {
|
|
@@ -180,42 +292,7 @@ export function CheckoutPage() {
|
|
|
180
292
|
function addNewAddress() {
|
|
181
293
|
setAddressMode('new')
|
|
182
294
|
setSelectedAddressId('new')
|
|
183
|
-
setForm((current) => ({ ...current, street: '', city: '', zip: '' }))
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function removeAddress(addressId: string) {
|
|
187
|
-
const nextAddresses = savedAddresses.filter((item) => item.id !== addressId)
|
|
188
|
-
setSavedAddresses(nextAddresses)
|
|
189
|
-
if (selectedAddressId !== addressId) return
|
|
190
|
-
const fallback = nextAddresses[0]
|
|
191
|
-
if (fallback) applySavedAddress(fallback)
|
|
192
|
-
else addNewAddress()
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function applySavedPayment(method: typeof SAVED_PAYMENT_METHODS[number]) {
|
|
196
|
-
setPaymentMode('saved')
|
|
197
|
-
setSelectedPaymentId(method.id)
|
|
198
|
-
setForm((current) => ({ ...current, card: method.card, expiry: method.expiry, cvc: method.cvc }))
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function selectPayment(paymentId: string) {
|
|
202
|
-
const method = savedPaymentMethods.find((item) => item.id === paymentId)
|
|
203
|
-
if (method) applySavedPayment(method)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function addNewPayment() {
|
|
207
|
-
setPaymentMode('new')
|
|
208
|
-
setSelectedPaymentId('new')
|
|
209
|
-
setForm((current) => ({ ...current, card: '', expiry: '', cvc: '' }))
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function removePayment(paymentId: string) {
|
|
213
|
-
const nextMethods = savedPaymentMethods.filter((item) => item.id !== paymentId)
|
|
214
|
-
setSavedPaymentMethods(nextMethods)
|
|
215
|
-
if (selectedPaymentId !== paymentId) return
|
|
216
|
-
const fallback = nextMethods[0]
|
|
217
|
-
if (fallback) applySavedPayment(fallback)
|
|
218
|
-
else addNewPayment()
|
|
295
|
+
setForm((current) => ({ ...current, street: '', city: '', zip: '', number: '', complement: '', district: '', state: '' }))
|
|
219
296
|
}
|
|
220
297
|
|
|
221
298
|
function validate(): boolean {
|
|
@@ -228,12 +305,30 @@ export function CheckoutPage() {
|
|
|
228
305
|
setError('Informe seu nome completo.')
|
|
229
306
|
return false
|
|
230
307
|
}
|
|
231
|
-
|
|
308
|
+
// Number and district are required because a courier cannot deliver without
|
|
309
|
+
// them; the old form collected only street/city/CEP and logistics had to guess.
|
|
310
|
+
if (!form.street.trim() || !form.city.trim() || !form.zip.trim() || !form.district.trim()) {
|
|
232
311
|
setError('Preencha ou selecione o endereço de entrega.')
|
|
233
312
|
return false
|
|
234
313
|
}
|
|
235
|
-
|
|
236
|
-
|
|
314
|
+
// Called out on its own: with the CEP filling everything else, the number is
|
|
315
|
+
// usually the only thing missing, and "preencha o endereço" sent people
|
|
316
|
+
// hunting through fields that were already correct.
|
|
317
|
+
if (!form.number.trim()) {
|
|
318
|
+
setError('Falta o número do endereço.')
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
// UF is required now: 117 of the 266 addresses already in the pool have no
|
|
322
|
+
// state, and a carrier cannot quote or ship without one.
|
|
323
|
+
if (form.state.trim().length !== 2) {
|
|
324
|
+
setError('Informe a UF com duas letras (ex.: RJ).')
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
// Coverage. shop_place_order refuses an unserved postal code anyway (0021),
|
|
328
|
+
// so this only turns a raw SQL error into a sentence the buyer can act on —
|
|
329
|
+
// the rule itself lives on the server, not here.
|
|
330
|
+
if (delivery.status === 'unserved' && delivery.postalCode === normalizePostalCode(form.zip)) {
|
|
331
|
+
setError('Ainda não entregamos nesse CEP. Tente outro endereço.')
|
|
237
332
|
return false
|
|
238
333
|
}
|
|
239
334
|
return true
|
|
@@ -257,9 +352,22 @@ export function CheckoutPage() {
|
|
|
257
352
|
session,
|
|
258
353
|
customer: { email: form.email, name: form.name },
|
|
259
354
|
address: { street: form.street, city: form.city, zip: form.zip },
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
355
|
+
shippingAddress: {
|
|
356
|
+
postalCode: form.zip.trim(),
|
|
357
|
+
street: form.street.trim(),
|
|
358
|
+
number: form.number.trim() || undefined,
|
|
359
|
+
complement: form.complement.trim() || undefined,
|
|
360
|
+
district: form.district.trim() || undefined,
|
|
361
|
+
city: form.city.trim(),
|
|
362
|
+
state: form.state.trim().toUpperCase(),
|
|
363
|
+
},
|
|
364
|
+
// The method the buyer actually chose, which opens the ledger row in
|
|
365
|
+
// public.transactions with the right kind.
|
|
366
|
+
paymentMethod,
|
|
367
|
+
// Never marked paid from the browser. It used to be, through an RPC that
|
|
368
|
+
// was granted to anon — so any buyer holding their own order id could
|
|
369
|
+
// declare it settled. The order is now born `pending` and only the
|
|
370
|
+
// merchant (or a PSP webhook) can confirm the money arrived.
|
|
263
371
|
})
|
|
264
372
|
|
|
265
373
|
await minDuration
|
|
@@ -359,17 +467,13 @@ export function CheckoutPage() {
|
|
|
359
467
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-bold text-primary">Selecionado</span>
|
|
360
468
|
)}
|
|
361
469
|
</span>
|
|
362
|
-
<span className="mt-1 block text-muted-foreground">
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
aria-label={`Remover endereço ${address.label}`}
|
|
370
|
-
title="Remover endereço"
|
|
371
|
-
>
|
|
372
|
-
<Trash2 className="h-4 w-4" />
|
|
470
|
+
<span className="mt-1 block text-muted-foreground">
|
|
471
|
+
{[address.street, address.number].filter(Boolean).join(', ')}
|
|
472
|
+
{address.complement ? ` — ${address.complement}` : ''}
|
|
473
|
+
</span>
|
|
474
|
+
<span className="block text-muted-foreground">
|
|
475
|
+
{[address.district, address.city, address.state].filter(Boolean).join(' · ')} — {address.postalCode}
|
|
476
|
+
</span>
|
|
373
477
|
</button>
|
|
374
478
|
</div>
|
|
375
479
|
))}
|
|
@@ -380,75 +484,77 @@ export function CheckoutPage() {
|
|
|
380
484
|
addressMode === 'new' ? 'border-primary bg-primary/5' : 'border-border bg-muted/20'
|
|
381
485
|
}`}
|
|
382
486
|
>
|
|
383
|
-
<span className="block font-semibold text-primary"
|
|
487
|
+
<span className="block font-semibold text-primary">
|
|
488
|
+
{savedAddresses.length > 0 ? '+ Adicionar novo endereço' : 'Informe o endereço de entrega'}
|
|
489
|
+
</span>
|
|
384
490
|
<span className="mt-1 block text-muted-foreground">Preencher outro endereço para esta compra</span>
|
|
385
491
|
</button>
|
|
386
492
|
</div>
|
|
387
493
|
{addressMode === 'new' && (
|
|
388
494
|
<div className="grid gap-3">
|
|
389
|
-
{
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
495
|
+
{/* CEP first, because it FILLS the fields below it. With it at
|
|
496
|
+
the bottom the buyer typed street, district, city and UF by
|
|
497
|
+
hand and only then reached the one field that would have
|
|
498
|
+
supplied all four — and editing it later has to refill
|
|
499
|
+
them, which reads as the form rewriting itself. */}
|
|
500
|
+
<div className="grid gap-1 sm:grid-cols-[200px_1fr] sm:items-center">
|
|
501
|
+
{field('CEP', TID.checkoutZip, form.zip, setZip, {
|
|
502
|
+
placeholder: 'CEP',
|
|
503
|
+
inputMode: 'numeric',
|
|
504
|
+
autoComplete: 'postal-code',
|
|
505
|
+
})}
|
|
506
|
+
<p className="px-1 text-xs text-muted-foreground">
|
|
507
|
+
{zipLookup === 'loading' ? 'Buscando endereço…'
|
|
508
|
+
: zipLookup === 'not-found' ? 'CEP não encontrado — preencha o endereço à mão.'
|
|
509
|
+
: 'Preenchemos o endereço para você.'}
|
|
510
|
+
</p>
|
|
393
511
|
</div>
|
|
512
|
+
{field('Endereço', TID.checkoutStreet, form.street, set('street'), { placeholder: 'Rua, avenida…' })}
|
|
513
|
+
<div className="grid grid-cols-2 gap-3">
|
|
514
|
+
{field('Número', 'checkout-number', form.number, set('number'), { placeholder: 'Número' })}
|
|
515
|
+
{field('Complemento', 'checkout-complement', form.complement, set('complement'), { placeholder: 'Apto, bloco (opcional)' })}
|
|
516
|
+
</div>
|
|
517
|
+
<div className="grid grid-cols-2 gap-3">
|
|
518
|
+
{field('Bairro', 'checkout-district', form.district, set('district'), { placeholder: 'Bairro' })}
|
|
519
|
+
{field('UF', 'checkout-state', form.state, set('state'), { placeholder: 'UF' })}
|
|
520
|
+
</div>
|
|
521
|
+
{field('Cidade', TID.checkoutCity, form.city, set('city'), { placeholder: 'Cidade' })}
|
|
394
522
|
</div>
|
|
395
523
|
)}
|
|
396
524
|
</section>
|
|
397
525
|
|
|
398
526
|
<section>
|
|
399
527
|
<h2 className="mb-3 text-xl font-semibold tracking-tight">Pagamento</h2>
|
|
400
|
-
<div className="mb-3 grid gap-3">
|
|
401
|
-
{
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
528
|
+
<div className="mb-3 grid gap-3" role="radiogroup" aria-label="Forma de pagamento">
|
|
529
|
+
{paymentMethods.map((method) => {
|
|
530
|
+
const copy = PAYMENT_LABELS[method]
|
|
531
|
+
const selected = paymentMethod === method
|
|
532
|
+
return (
|
|
533
|
+
<button
|
|
534
|
+
key={method}
|
|
535
|
+
type="button"
|
|
536
|
+
role="radio"
|
|
537
|
+
aria-checked={selected}
|
|
538
|
+
data-testid={`checkout-payment-${method}`}
|
|
539
|
+
onClick={() => setPaymentMethod(method)}
|
|
540
|
+
className={`rounded-lg border p-3 text-left text-sm transition hover:bg-muted/40 ${
|
|
541
|
+
selected ? 'border-primary bg-primary/5' : 'border-border bg-background'
|
|
542
|
+
}`}
|
|
543
|
+
>
|
|
409
544
|
<span className="flex items-center justify-between gap-3 font-semibold">
|
|
410
|
-
<span className="flex items-center gap-2"><CreditCard className="h-4 w-4" />{
|
|
411
|
-
{
|
|
545
|
+
<span className="flex items-center gap-2"><CreditCard className="h-4 w-4" />{copy.label}</span>
|
|
546
|
+
{selected && (
|
|
412
547
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-bold text-primary">Selecionado</span>
|
|
413
548
|
)}
|
|
414
549
|
</span>
|
|
415
|
-
<span className="mt-1 block text-muted-foreground">
|
|
550
|
+
<span className="mt-1 block text-muted-foreground">{copy.hint}</span>
|
|
416
551
|
</button>
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
onClick={() => removePayment(method.id)}
|
|
420
|
-
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"
|
|
421
|
-
aria-label={`Remover cartão ${method.label}`}
|
|
422
|
-
title="Remover cartão"
|
|
423
|
-
>
|
|
424
|
-
<Trash2 className="h-4 w-4" />
|
|
425
|
-
</button>
|
|
426
|
-
</div>
|
|
427
|
-
))}
|
|
428
|
-
<button
|
|
429
|
-
type="button"
|
|
430
|
-
onClick={addNewPayment}
|
|
431
|
-
className={`rounded-lg border border-dashed p-3 text-left text-sm transition hover:bg-muted/40 ${
|
|
432
|
-
paymentMode === 'new' ? 'border-primary bg-primary/5' : 'border-border bg-muted/20'
|
|
433
|
-
}`}
|
|
434
|
-
>
|
|
435
|
-
<span className="flex items-center gap-2 font-semibold text-primary"><CreditCard className="h-4 w-4" />+ Adicionar novo cartão</span>
|
|
436
|
-
<span className="mt-1 block text-muted-foreground">Usar outro cartão nesta compra</span>
|
|
437
|
-
</button>
|
|
552
|
+
)
|
|
553
|
+
})}
|
|
438
554
|
</div>
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
placeholder: 'Número do cartão',
|
|
443
|
-
inputMode: 'numeric',
|
|
444
|
-
})}
|
|
445
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
446
|
-
{field('Validade', TID.checkoutExpiry, form.expiry, set('expiry'), { placeholder: 'MM/AA' })}
|
|
447
|
-
{field('CVC', TID.checkoutCvc, form.cvc, set('cvc'), { placeholder: 'CVC', inputMode: 'numeric' })}
|
|
448
|
-
</div>
|
|
449
|
-
</div>
|
|
450
|
-
)}
|
|
451
|
-
<p className="mt-2 text-xs text-muted-foreground">Pagamento de demonstração - nenhuma cobrança é feita.</p>
|
|
555
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
556
|
+
Nenhuma cobrança é feita agora. Seu pedido é registrado e a loja confirma o pagamento.
|
|
557
|
+
</p>
|
|
452
558
|
</section>
|
|
453
559
|
|
|
454
560
|
{error && (
|
|
@@ -13,6 +13,8 @@ import { ProductSpecs } from '../components/ProductSpecs'
|
|
|
13
13
|
import { RelatedProducts } from '../components/RelatedProducts'
|
|
14
14
|
import { ProductEnquiryForm } from '../components/ProductEnquiryForm'
|
|
15
15
|
import { SmoothImage } from '../components/SmoothImage'
|
|
16
|
+
import { ProductGallery } from '../components/ProductGallery'
|
|
17
|
+
import { DeliveryEstimator } from '../components/DeliveryEstimator'
|
|
16
18
|
import { getProductOptionGroups, type ProductOptionSelection } from '../product-options'
|
|
17
19
|
import { storefrontComponentContracts } from '../component-selectors'
|
|
18
20
|
import { TID } from '../testids'
|
|
@@ -68,6 +70,9 @@ export function ProductDetailPage({ slug }: { slug: string }) {
|
|
|
68
70
|
const image = product.images.find((i) => i.isPrimary) ?? product.images[0]
|
|
69
71
|
const components = getStorefrontComponents(config)
|
|
70
72
|
const ProductDetailComponent = components.ProductDetail
|
|
73
|
+
// Honours the ProductGallery override contract, which has existed in
|
|
74
|
+
// component-contracts.ts since before any component implemented it.
|
|
75
|
+
const GalleryComponent = components.ProductGallery ?? ProductGallery
|
|
71
76
|
const addToCart = () => addItem(product, qty, selectedOptions)
|
|
72
77
|
const openEnquiry = () => {
|
|
73
78
|
document.getElementById('storefront-product-enquiry')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
@@ -105,19 +110,13 @@ export function ProductDetailPage({ slug }: { slug: string }) {
|
|
|
105
110
|
</Link>
|
|
106
111
|
|
|
107
112
|
<div className="grid animate-fade-up gap-10 md:grid-cols-2">
|
|
108
|
-
<
|
|
109
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
{
|
|
114
|
-
|
|
115
|
-
src={image.url}
|
|
116
|
-
alt={image.altText ?? product.name}
|
|
117
|
-
className="aspect-square w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
|
118
|
-
/>
|
|
119
|
-
)}
|
|
120
|
-
</div>
|
|
113
|
+
<GalleryComponent
|
|
114
|
+
product={product}
|
|
115
|
+
images={product.images}
|
|
116
|
+
primaryImage={image}
|
|
117
|
+
config={config}
|
|
118
|
+
commerceMode={config.commerceMode}
|
|
119
|
+
/>
|
|
121
120
|
|
|
122
121
|
<div className="flex flex-col py-2 lg:sticky lg:top-24 lg:self-start">
|
|
123
122
|
<nav className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
|
@@ -225,6 +224,11 @@ export function ProductDetailPage({ slug }: { slug: string }) {
|
|
|
225
224
|
)}
|
|
226
225
|
</div>
|
|
227
226
|
|
|
227
|
+
{/* Asked here, before the cart exists: "do you deliver to me and for
|
|
228
|
+
how much" is the question that decides whether this page converts,
|
|
229
|
+
and it used to be answerable only at the end of checkout. */}
|
|
230
|
+
{config.commerceMode === 'checkout' && <DeliveryEstimator />}
|
|
231
|
+
|
|
228
232
|
{config.commerceMode === 'checkout' && (
|
|
229
233
|
<div className="mt-6 grid grid-cols-3 gap-3 border-t pt-6 text-center">
|
|
230
234
|
<div className="flex flex-col items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
|
package/src/stores/cart.store.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'
|
|
|
3
3
|
import type { Product } from '@fayz-ai/shop/types'
|
|
4
4
|
import { roundCents } from '../format'
|
|
5
5
|
import type { ResolvedStorefrontConfig } from '../config'
|
|
6
|
+
import { useDeliveryStore, selectQuotedShipping } from './delivery.store'
|
|
6
7
|
import {
|
|
7
8
|
formatProductOptionSelection,
|
|
8
9
|
normalizeProductOptionSelection,
|
|
@@ -147,12 +148,32 @@ export const selectSubtotal = (s: Pick<CartState, 'lines'>): number =>
|
|
|
147
148
|
export const selectDiscountTotal = (s: Pick<CartState, 'lines' | 'discountPercent'>): number =>
|
|
148
149
|
roundCents(selectSubtotal(s) * (s.discountPercent / 100))
|
|
149
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Freight to display.
|
|
153
|
+
*
|
|
154
|
+
* When the shopper has given a CEP and the store quoted it, that quote wins —
|
|
155
|
+
* it came from the same shipping_zones rows shop_place_order will charge from.
|
|
156
|
+
* Otherwise this falls back to the store-wide rate in config, which is what
|
|
157
|
+
* every storefront did before zones existed.
|
|
158
|
+
*
|
|
159
|
+
* The subtotal is PRE-discount on both sides of this. That is 0017's rule, and
|
|
160
|
+
* the quote is requested against the same number, so a coupon can never make
|
|
161
|
+
* the cart and the order disagree.
|
|
162
|
+
*
|
|
163
|
+
* Read through getState() rather than a hook because this is a plain selector
|
|
164
|
+
* called from several screens; components that render the value subscribe to
|
|
165
|
+
* useDeliveryStore themselves so a fresh quote re-renders them.
|
|
166
|
+
*/
|
|
150
167
|
export const selectShipping = (
|
|
151
168
|
s: Pick<CartState, 'lines'>,
|
|
152
169
|
cfg: ResolvedStorefrontConfig,
|
|
153
170
|
): number => {
|
|
154
171
|
if (s.lines.length === 0) return 0
|
|
155
172
|
const subtotal = selectSubtotal(s)
|
|
173
|
+
|
|
174
|
+
const quoted = selectQuotedShipping(useDeliveryStore.getState(), subtotal)
|
|
175
|
+
if (quoted != null) return quoted
|
|
176
|
+
|
|
156
177
|
if (cfg.shipping.freeAbove != null && subtotal >= cfg.shipping.freeAbove) return 0
|
|
157
178
|
return cfg.shipping.flatRate
|
|
158
179
|
}
|