@ecomiq/storefront 2.0.9 → 2.0.12

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,15 +1,26 @@
1
- import { useState, type FormEvent } from 'react'
2
- import { ArrowLeft, Lock } from 'lucide-react'
1
+ import { useEffect, useState, type FormEvent } from 'react'
2
+ import { ArrowLeft, CreditCard, Lock, Truck, User } from 'lucide-react'
3
3
  import type { CartCheckoutResponse } from '@ecomiq/core'
4
4
  import { Link } from '../../link'
5
5
  import { useAuth, useSettings } from '../../providers'
6
6
  import { buttonVariants } from '../../ui'
7
+ import { CheckoutContactSection } from './checkout-contact-section'
7
8
  import { CheckoutExternalUpdateBanner } from './checkout-external-update-banner'
8
9
  import { CheckoutOrderBumps } from './checkout-line-items'
9
10
  import { CheckoutOrderSummary } from './checkout-order-summary'
10
11
  import { CheckoutPaymentSection } from './checkout-payment-section'
11
12
  import { CheckoutShippingAddressSection } from './checkout-shipping-address-section'
12
- import { CheckoutShippingSection } from './checkout-shipping-section'
13
+ import {
14
+ CheckoutStepCard,
15
+ CheckoutStepSummaryBlock,
16
+ } from './checkout-step-card'
17
+ import {
18
+ completeCheckoutStep,
19
+ createCheckoutStepFlow,
20
+ editCheckoutStep,
21
+ type CheckoutStepFlow,
22
+ type CheckoutStepId,
23
+ } from './checkout-steps'
13
24
  import {
14
25
  CheckoutAlreadyPurchasedState,
15
26
  CheckoutEmptyState,
@@ -18,7 +29,11 @@ import {
18
29
  } from './checkout-states'
19
30
  import { formatMoney, paymentRedirectUrl, thankYouUrl } from './format'
20
31
  import {
32
+ buildCheckoutContactStepSchema,
33
+ buildCheckoutDeliveryStepSchema,
21
34
  buildCheckoutFormValidationSchema,
35
+ buildCheckoutPaymentStepSchema,
36
+ checkoutStepForField,
22
37
  checkoutValidationInputFromContext,
23
38
  firstInvalidFieldId,
24
39
  focusCheckoutField,
@@ -33,12 +48,16 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
33
48
  const { store } = useSettings()
34
49
  const { session } = useAuth()
35
50
  const checkout = useCheckoutSession(cartId)
51
+ const [stepFlow, setStepFlow] = useState<CheckoutStepFlow>(() => createCheckoutStepFlow())
36
52
  const [fieldErrors, setFieldErrors] = useState<
37
53
  Partial<Record<CheckoutFieldId, string>>
38
54
  >({})
39
55
  const [receiptFile, setReceiptFile] = useState<File | null>(null)
40
56
  const [receiptUrl, setReceiptUrl] = useState<string | null>(null)
41
57
  const [receiptError, setReceiptError] = useState<string | null>(null)
58
+ const [acceptedAge, setAcceptedAge] = useState(false)
59
+ const [acceptedPrivacy, setAcceptedPrivacy] = useState(false)
60
+ const [consentError, setConsentError] = useState<string | null>(null)
42
61
 
43
62
  const geo = useCheckoutGeo(
44
63
  checkout.contact.countryCode,
@@ -46,6 +65,13 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
46
65
  checkout.contact.provinceCode,
47
66
  )
48
67
 
68
+ const { ensureDefaultPaymentMethod } = checkout
69
+
70
+ useEffect(() => {
71
+ if (stepFlow.active !== 'payment') return
72
+ ensureDefaultPaymentMethod()
73
+ }, [ensureDefaultPaymentMethod, stepFlow.active])
74
+
49
75
  if (checkout.loadStatus === 'loading' || checkout.isReconciling) {
50
76
  return <CheckoutLoadingState />
51
77
  }
@@ -72,6 +98,9 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
72
98
  const selectedMethod =
73
99
  checkout.paymentMethods.find((m) => m.code === checkout.selectedPaymentCode) ?? null
74
100
  const isManual = Boolean(selectedMethod?.isManual)
101
+ const selectedShipping = checkout.shippingFlat.find(
102
+ (o) => o.key === checkout.selectedShippingKey,
103
+ )
75
104
 
76
105
  const clearFieldError = (id: CheckoutFieldId) => {
77
106
  setFieldErrors((prev) => {
@@ -86,23 +115,86 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
86
115
  checkout.setContact((c) => ({ ...c, ...patch }))
87
116
  }
88
117
 
118
+ const openStep = (step: CheckoutStepId) => {
119
+ setStepFlow((flow) => editCheckoutStep(flow, step))
120
+ checkout.invalidateDeliveryPersistence()
121
+ setFieldErrors({})
122
+ setConsentError(null)
123
+ }
124
+
125
+ const validationContext = () =>
126
+ checkoutValidationInputFromContext({
127
+ contact: checkout.contact,
128
+ selectedShippingKey: checkout.selectedShippingKey,
129
+ shippingOptionsCount: checkout.shippingFlat.length,
130
+ selectedPaymentCode: checkout.selectedPaymentCode,
131
+ paymentMethodsCount: checkout.paymentMethods.length,
132
+ })
133
+
134
+ const handleContinueContact = () => {
135
+ const parsed = buildCheckoutContactStepSchema().safeParse({
136
+ email: checkout.contact.email,
137
+ })
138
+ if (!parsed.success) {
139
+ const errors = mapZodIssuesToFieldErrors(parsed.error)
140
+ setFieldErrors(errors)
141
+ const first = firstInvalidFieldId(errors)
142
+ if (first) focusCheckoutField(first)
143
+ return
144
+ }
145
+ setFieldErrors({})
146
+ setStepFlow((flow) => completeCheckoutStep(flow, 'contact'))
147
+ }
148
+
149
+ const handleContinueDelivery = async () => {
150
+ const parsed = buildCheckoutDeliveryStepSchema(store.currency).safeParse(validationContext())
151
+ if (!parsed.success) {
152
+ const errors = mapZodIssuesToFieldErrors(parsed.error)
153
+ setFieldErrors(errors)
154
+ const first = firstInvalidFieldId(errors)
155
+ if (first) focusCheckoutField(first)
156
+ return
157
+ }
158
+ setFieldErrors({})
159
+ try {
160
+ await checkout.persistDeliveryStep()
161
+ setStepFlow((flow) => completeCheckoutStep(flow, 'delivery'))
162
+ } catch {
163
+ /* formError en sesión */
164
+ }
165
+ }
166
+
89
167
  const handlePay = async (event: FormEvent) => {
90
168
  event.preventDefault()
169
+ if (stepFlow.active !== 'payment') return
170
+
171
+ if (!acceptedAge || !acceptedPrivacy) {
172
+ setConsentError('Debes confirmar edad y política de privacidad.')
173
+ return
174
+ }
175
+ setConsentError(null)
176
+
177
+ const paymentParsed = buildCheckoutPaymentStepSchema().safeParse(validationContext())
178
+ if (!paymentParsed.success) {
179
+ const errors = mapZodIssuesToFieldErrors(paymentParsed.error)
180
+ setFieldErrors(errors)
181
+ const first = firstInvalidFieldId(errors)
182
+ if (first) focusCheckoutField(first)
183
+ return
184
+ }
185
+
91
186
  const schema = buildCheckoutFormValidationSchema(store.currency)
92
- const parsed = schema.safeParse(
93
- checkoutValidationInputFromContext({
94
- contact: checkout.contact,
95
- selectedShippingKey: checkout.selectedShippingKey,
96
- shippingOptionsCount: checkout.shippingFlat.length,
97
- selectedPaymentCode: checkout.selectedPaymentCode,
98
- paymentMethodsCount: checkout.paymentMethods.length,
99
- }),
100
- )
187
+ const parsed = schema.safeParse(validationContext())
101
188
  if (!parsed.success) {
102
189
  const errors = mapZodIssuesToFieldErrors(parsed.error)
103
190
  setFieldErrors(errors)
104
191
  const first = firstInvalidFieldId(errors)
105
- if (first) focusCheckoutField(first)
192
+ if (first) {
193
+ const step = checkoutStepForField(first)
194
+ setStepFlow((flow) => editCheckoutStep(flow, step))
195
+ if (step !== 'payment') checkout.invalidateDeliveryPersistence()
196
+ focusCheckoutField(first)
197
+ }
106
198
  return
107
199
  }
108
200
  setFieldErrors({})
@@ -110,9 +202,19 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
110
202
  try {
111
203
  let uploadedReceipt = receiptUrl ?? undefined
112
204
  if (isManual && receiptFile && !uploadedReceipt) {
113
- const uploaded = await checkout.uploadPaymentReceipt(receiptFile)
114
- uploadedReceipt = uploaded.url
115
- setReceiptUrl(uploaded.url)
205
+ try {
206
+ const uploaded = await checkout.uploadPaymentReceipt(receiptFile)
207
+ uploadedReceipt = uploaded.url
208
+ setReceiptUrl(uploaded.url)
209
+ setReceiptError(null)
210
+ } catch (error) {
211
+ setReceiptError(
212
+ error instanceof Error
213
+ ? error.message
214
+ : 'No se pudo subir el comprobante de pago.',
215
+ )
216
+ return
217
+ }
116
218
  }
117
219
 
118
220
  const result: CartCheckoutResponse | undefined = await checkout.payNow(
@@ -157,58 +259,147 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
157
259
  onSubmit={(e) => void handlePay(e)}
158
260
  className="grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-10"
159
261
  >
160
- <div className="space-y-10">
161
- <CheckoutShippingAddressSection
162
- contact={checkout.contact}
163
- fieldErrors={fieldErrors}
164
- countries={geo.countries}
165
- departments={geo.departments}
166
- provinces={geo.provinces}
167
- districts={geo.districts}
168
- sessionLabel={session?.user?.email ?? session?.user?.name}
169
- onPatch={patchContact}
170
- onClearError={clearFieldError}
171
- />
172
-
173
- <CheckoutShippingSection
174
- options={checkout.shippingFlat}
175
- selectedKey={checkout.selectedShippingKey}
176
- loading={checkout.shippingLoading}
177
- error={checkout.shippingError}
178
- needsPostalCode={!checkout.contact.postalCode.trim()}
179
- fieldError={fieldErrors['checkout-shipping-method']}
180
- money={money}
181
- onSelect={checkout.setSelectedShippingKey}
182
- onClearError={clearFieldError}
183
- />
184
-
185
- <CheckoutPaymentSection
186
- methods={checkout.paymentMethods}
187
- selectedCode={checkout.selectedPaymentCode}
188
- fieldError={fieldErrors['checkout-payment-method']}
189
- checkoutPaymentLoading={checkout.checkoutPaymentLoading}
190
- checkoutPaymentError={checkout.checkoutPaymentError}
191
- checkoutPaymentResponse={checkout.checkoutPaymentResponse}
192
- checkoutPaymentMethodCode={checkout.checkoutPaymentMethodCode}
193
- providerPaymentCompleted={checkout.providerPaymentCompleted}
194
- receiptUrl={receiptUrl}
195
- receiptError={receiptError}
196
- onSelect={checkout.selectPaymentMethod}
197
- onClearError={clearFieldError}
198
- onMarkProviderCompleted={() => checkout.setProviderPaymentCompleted(true)}
199
- onReceiptFileChange={(file) => {
200
- setReceiptFile(file)
201
- setReceiptUrl(null)
202
- setReceiptError(null)
203
- }}
204
- />
205
-
206
- <CheckoutOrderBumps
207
- bumps={checkout.orderBumps}
208
- money={money}
209
- busy={checkout.isSaving}
210
- onAdd={(bump) => void checkout.handleAddOrderBump(bump)}
211
- />
262
+ <div className="space-y-4">
263
+ <CheckoutStepCard
264
+ step="contact"
265
+ flow={stepFlow}
266
+ title="Mis datos"
267
+ icon={User}
268
+ onEdit={() => openStep('contact')}
269
+ summary={
270
+ <CheckoutStepSummaryBlock>
271
+ <p className="text-sm">{checkout.contact.email}</p>
272
+ </CheckoutStepSummaryBlock>
273
+ }
274
+ >
275
+ <CheckoutContactSection
276
+ email={checkout.contact.email}
277
+ fieldError={fieldErrors.email}
278
+ sessionLabel={session?.user?.email ?? session?.user?.name}
279
+ isSaving={checkout.isSaving}
280
+ onEmailChange={(email) => patchContact({ email })}
281
+ onClearError={clearFieldError}
282
+ onContinueAsGuest={handleContinueContact}
283
+ />
284
+ </CheckoutStepCard>
285
+
286
+ <CheckoutStepCard
287
+ step="delivery"
288
+ flow={stepFlow}
289
+ title="Dirección de entrega"
290
+ icon={Truck}
291
+ onEdit={() => openStep('delivery')}
292
+ summary={
293
+ <DeliveryStepSummary
294
+ contact={checkout.contact}
295
+ shippingLabel={
296
+ selectedShipping
297
+ ? `${selectedShipping.rate.name}${
298
+ selectedShipping.rate.description
299
+ ? ` (${selectedShipping.rate.description})`
300
+ : ''
301
+ }`
302
+ : null
303
+ }
304
+ shippingPrice={
305
+ selectedShipping
306
+ ? selectedShipping.rate.price === 0
307
+ ? 'GRATIS'
308
+ : money(selectedShipping.rate.price)
309
+ : null
310
+ }
311
+ />
312
+ }
313
+ >
314
+ <CheckoutShippingAddressSection
315
+ contact={checkout.contact}
316
+ fieldErrors={fieldErrors}
317
+ countries={geo.countries}
318
+ departments={geo.departments}
319
+ provinces={geo.provinces}
320
+ districts={geo.districts}
321
+ shippingOptions={checkout.shippingFlat}
322
+ selectedShippingKey={checkout.selectedShippingKey}
323
+ shippingLoading={checkout.shippingLoading}
324
+ shippingError={checkout.shippingError}
325
+ isSaving={checkout.isSaving}
326
+ money={money}
327
+ onPatch={patchContact}
328
+ onSelectShipping={checkout.setSelectedShippingKey}
329
+ onClearError={clearFieldError}
330
+ onContinue={handleContinueDelivery}
331
+ />
332
+ </CheckoutStepCard>
333
+
334
+ <CheckoutStepCard
335
+ step="payment"
336
+ flow={stepFlow}
337
+ title="Pago"
338
+ icon={CreditCard}
339
+ >
340
+ <div className="space-y-5">
341
+ <div className="space-y-3">
342
+ <label className="flex cursor-pointer items-start gap-2.5 text-sm leading-5">
343
+ <input
344
+ type="checkbox"
345
+ className="mt-0.5 size-4 shrink-0 rounded border-border accent-foreground"
346
+ checked={acceptedAge}
347
+ onChange={(e) => {
348
+ setAcceptedAge(e.target.checked)
349
+ setConsentError(null)
350
+ }}
351
+ />
352
+ <span>Tengo 18 años o más</span>
353
+ </label>
354
+ <label className="flex cursor-pointer items-start gap-2.5 text-sm leading-5">
355
+ <input
356
+ type="checkbox"
357
+ className="mt-0.5 size-4 shrink-0 rounded border-border accent-foreground"
358
+ checked={acceptedPrivacy}
359
+ onChange={(e) => {
360
+ setAcceptedPrivacy(e.target.checked)
361
+ setConsentError(null)
362
+ }}
363
+ />
364
+ <span>
365
+ Doy mi consentimiento para que mis datos personales sean tratados conforme a
366
+ la política de privacidad
367
+ </span>
368
+ </label>
369
+ {consentError ? (
370
+ <p className="text-xs text-destructive">{consentError}</p>
371
+ ) : null}
372
+ </div>
373
+
374
+ <CheckoutPaymentSection
375
+ methods={checkout.paymentMethods}
376
+ selectedCode={checkout.selectedPaymentCode}
377
+ fieldError={fieldErrors['checkout-payment-method']}
378
+ checkoutPaymentLoading={checkout.checkoutPaymentLoading}
379
+ checkoutPaymentError={checkout.checkoutPaymentError}
380
+ checkoutPaymentResponse={checkout.checkoutPaymentResponse}
381
+ checkoutPaymentMethodCode={checkout.checkoutPaymentMethodCode}
382
+ providerPaymentCompleted={checkout.providerPaymentCompleted}
383
+ receiptUrl={receiptUrl}
384
+ receiptError={receiptError}
385
+ onSelect={checkout.selectPaymentMethod}
386
+ onClearError={clearFieldError}
387
+ onMarkProviderCompleted={() => checkout.setProviderPaymentCompleted(true)}
388
+ onReceiptFileChange={(file) => {
389
+ setReceiptFile(file)
390
+ setReceiptUrl(null)
391
+ setReceiptError(null)
392
+ }}
393
+ />
394
+
395
+ <CheckoutOrderBumps
396
+ bumps={checkout.orderBumps}
397
+ money={money}
398
+ busy={checkout.isSaving}
399
+ onAdd={(bump) => void checkout.handleAddOrderBump(bump)}
400
+ />
401
+ </div>
402
+ </CheckoutStepCard>
212
403
 
213
404
  <p className="flex items-start gap-2 text-xs leading-5 text-muted-foreground">
214
405
  <Lock className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
@@ -230,6 +421,12 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
230
421
  promotionCodes={cart.promotionCodes}
231
422
  formError={checkout.formError}
232
423
  isSaving={checkout.isSaving}
424
+ canSubmit={stepFlow.active === 'payment'}
425
+ submitLabel={
426
+ selectedMethod
427
+ ? `Pagar con ${selectedMethod.name}`
428
+ : 'Pagar'
429
+ }
233
430
  onCouponCodeChange={checkout.setCouponCode}
234
431
  onApplyCoupon={() => void checkout.handleApplyCoupon()}
235
432
  onRemoveCoupon={(code) => void checkout.handleRemoveCoupon(code)}
@@ -238,3 +435,49 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
238
435
  </div>
239
436
  )
240
437
  }
438
+
439
+ function DeliveryStepSummary({
440
+ contact,
441
+ shippingLabel,
442
+ shippingPrice,
443
+ }: {
444
+ contact: CheckoutContactForm
445
+ shippingLabel: string | null
446
+ shippingPrice: string | null
447
+ }) {
448
+ const fullName = `${contact.firstName} ${contact.lastName}`.trim()
449
+ const locality = [contact.districtName || contact.city, contact.provinceName || contact.province]
450
+ .filter(Boolean)
451
+ .join(', ')
452
+ const addressLine = [contact.address, contact.address2, locality, contact.postalCode]
453
+ .filter(Boolean)
454
+ .join(', ')
455
+
456
+ return (
457
+ <div className="space-y-3">
458
+ <CheckoutStepSummaryBlock>
459
+ <p className="text-sm font-medium">{fullName || 'Destinatario'}</p>
460
+ <p className="mt-1 text-sm text-muted-foreground">{addressLine}</p>
461
+ </CheckoutStepSummaryBlock>
462
+ {shippingLabel ? (
463
+ <CheckoutStepSummaryBlock>
464
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
465
+ Envío
466
+ </p>
467
+ <div className="mt-1 flex items-start justify-between gap-3">
468
+ <p className="text-sm">{shippingLabel}</p>
469
+ {shippingPrice ? (
470
+ <p className="shrink-0 text-sm font-semibold tabular-nums">{shippingPrice}</p>
471
+ ) : null}
472
+ </div>
473
+ </CheckoutStepSummaryBlock>
474
+ ) : null}
475
+ <CheckoutStepSummaryBlock>
476
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
477
+ Dirección de facturación
478
+ </p>
479
+ <p className="mt-1 text-sm">Misma dirección que la de entrega</p>
480
+ </CheckoutStepSummaryBlock>
481
+ </div>
482
+ )
483
+ }