@fayz-ai/storefront 0.8.0 → 0.8.2

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.
@@ -1,5 +1,8 @@
1
1
  import React, { useEffect, useState } from 'react'
2
- import { CreditCard, Lock, PackageCheck, ShieldCheck, Trash2 } from 'lucide-react'
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
- card: string
30
- expiry: string
31
- cvc: string
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
- const SAVED_ADDRESSES = [
38
- { id: 'home', label: 'Casa', street: 'Rua das Flores, 123', city: 'São Paulo', zip: '01310-100' },
39
- ]
40
-
41
- const SAVED_PAYMENT_METHODS = [
42
- { id: 'visa', label: 'Visa final 4242', card: '4242 4242 4242 4242', expiry: '12/29', cvc: '123' },
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: CreditCard, label: 'Processando pagamento...' },
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 [selectedAddressId, setSelectedAddressId] = useState(DEFAULT_ADDRESS.id)
105
- const [selectedPaymentId, setSelectedPaymentId] = useState(DEFAULT_PAYMENT.id)
106
- const [savedAddresses, setSavedAddresses] = useState(SAVED_ADDRESSES)
107
- const [savedPaymentMethods, setSavedPaymentMethods] = useState(SAVED_PAYMENT_METHODS)
108
- const [addressMode, setAddressMode] = useState<AddressMode>('saved')
109
- const [paymentMode, setPaymentMode] = useState<PaymentMode>('saved')
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: DEFAULT_ADDRESS.street,
114
- city: DEFAULT_ADDRESS.city,
115
- zip: DEFAULT_ADDRESS.zip,
116
- card: DEFAULT_PAYMENT.card,
117
- expiry: DEFAULT_PAYMENT.expiry,
118
- cvc: DEFAULT_PAYMENT.cvc,
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
- function applySavedAddress(address: typeof SAVED_ADDRESSES[number]) {
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) => ({ ...current, street: address.street, city: address.city, zip: address.zip }))
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
- if (!form.street.trim() || !form.city.trim() || !form.zip.trim()) {
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
- if (!/^\d{16}$/.test(form.card.replace(/\s+/g, '')) || !form.expiry.trim() || !form.cvc.trim()) {
236
- setError('Selecione ou preencha um método de pagamento válido.')
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
- // Demo only: mock mode instantly approves. Real Pix/Mercado Pago (M4)
261
- // leaves the order pending until the payment webhook confirms.
262
- markPaid: config.payments.mode === 'mock',
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">{address.street}</span>
363
- <span className="block text-muted-foreground">{address.city} - {address.zip}</span>
364
- </button>
365
- <button
366
- type="button"
367
- onClick={() => removeAddress(address.id)}
368
- 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"
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">+ Adicionar novo endereço</span>
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
- {field('Endereço', TID.checkoutStreet, form.street, set('street'), { placeholder: 'Endereço' })}
390
- <div className="grid gap-3 sm:grid-cols-[1fr_160px]">
391
- {field('Cidade', TID.checkoutCity, form.city, set('city'), { placeholder: 'Cidade' })}
392
- {field('CEP', TID.checkoutZip, form.zip, set('zip'), { placeholder: 'CEP' })}
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
- {savedPaymentMethods.map((method) => (
402
- <div
403
- key={method.id}
404
- className={`group relative rounded-lg border p-3 pr-11 text-left text-sm transition hover:bg-muted/40 ${
405
- selectedPaymentId === method.id ? 'border-primary bg-primary/5' : 'border-border bg-background'
406
- }`}
407
- >
408
- <button type="button" onClick={() => selectPayment(method.id)} className="block w-full text-left">
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" />{method.label}</span>
411
- {paymentMode === 'saved' && selectedPaymentId === method.id && (
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">Expira em {method.expiry}</span>
550
+ <span className="mt-1 block text-muted-foreground">{copy.hint}</span>
416
551
  </button>
417
- <button
418
- type="button"
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
- {paymentMode === 'new' && (
440
- <div className="grid gap-3">
441
- {field('Número do cartão', TID.checkoutCard, form.card, set('card'), {
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
- <div
109
- {...storefrontComponentContracts.productDetail.gallery}
110
- className="group overflow-hidden border bg-muted"
111
- style={{ borderRadius: 'var(--sf-radius-card)' }}
112
- >
113
- {image && (
114
- <SmoothImage
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">
@@ -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
  }