@ecomiq/storefront 1.1.5 → 2.0.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 (134) hide show
  1. package/README.md +26 -417
  2. package/bin/ecomiq.mjs +154 -0
  3. package/package.json +72 -84
  4. package/src/api-proxy.ts +194 -0
  5. package/src/auth/create-storefront-auth.ts +35 -0
  6. package/src/auth/ecomiq-oauth-password-client.ts +26 -0
  7. package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
  8. package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
  9. package/src/auth/ecomiq-oauth-password-types.ts +25 -0
  10. package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
  11. package/src/auth/env.ts +7 -0
  12. package/src/cart-session.ts +241 -0
  13. package/src/client.ts +48 -0
  14. package/src/codegen.mjs +603 -0
  15. package/src/components/checkout/cart-sync.ts +143 -0
  16. package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
  17. package/src/components/checkout/checkout-form-controls.tsx +139 -0
  18. package/src/components/checkout/checkout-line-items.tsx +43 -0
  19. package/src/components/checkout/checkout-order-summary.tsx +156 -0
  20. package/src/components/checkout/checkout-payment-section.tsx +215 -0
  21. package/src/components/checkout/checkout-session-view.tsx +240 -0
  22. package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
  23. package/src/components/checkout/checkout-shipping-section.tsx +90 -0
  24. package/src/components/checkout/checkout-states.tsx +73 -0
  25. package/src/components/checkout/form-schema.ts +229 -0
  26. package/src/components/checkout/format.ts +27 -0
  27. package/src/components/checkout/index.ts +51 -0
  28. package/src/components/checkout/types.ts +43 -0
  29. package/src/components/checkout/use-checkout-geo.ts +50 -0
  30. package/src/components/checkout/use-checkout-session.ts +583 -0
  31. package/src/components/hero.tsx +291 -0
  32. package/src/config.d.ts +69 -0
  33. package/src/config.mjs +165 -0
  34. package/src/index.ts +274 -0
  35. package/src/link.tsx +2 -0
  36. package/src/pages/checkout.$checkoutId.tsx +60 -0
  37. package/src/pages/checkout.tsx +31 -0
  38. package/src/pages/thank-you.tsx +53 -0
  39. package/src/providers/auth.tsx +69 -0
  40. package/src/providers/cart.tsx +275 -0
  41. package/src/providers/ecomiq.ts +137 -0
  42. package/src/providers/index.ts +26 -0
  43. package/src/public-config.ts +44 -0
  44. package/src/review-submission.ts +89 -0
  45. package/src/runtime/context.ts +1 -0
  46. package/src/runtime/default-layout.tsx +80 -0
  47. package/src/runtime/head.ts +126 -0
  48. package/src/runtime/page.tsx +82 -0
  49. package/src/runtime/postcss-browser-stub.mjs +5 -0
  50. package/src/server-config-store.ts +26 -0
  51. package/src/server.ts +279 -0
  52. package/src/styles.css +76 -0
  53. package/src/ui/badge.tsx +32 -0
  54. package/src/ui/button.tsx +31 -0
  55. package/src/ui/card.tsx +31 -0
  56. package/src/ui/carousel.tsx +218 -0
  57. package/src/ui/cn.ts +6 -0
  58. package/src/ui/index.ts +8 -0
  59. package/src/ui/separator.tsx +6 -0
  60. package/src/ui/skeleton.tsx +6 -0
  61. package/src/ui/storefront-error.tsx +49 -0
  62. package/src/utils.ts +36 -0
  63. package/src/vite-inline.mjs +95 -0
  64. package/templates/__root.tsx +77 -0
  65. package/templates/router.tsx +19 -0
  66. package/dist/chunk-3QGB55R6.js +0 -157
  67. package/dist/chunk-3QGB55R6.js.map +0 -1
  68. package/dist/chunk-64GLOWMJ.js +0 -216
  69. package/dist/chunk-64GLOWMJ.js.map +0 -1
  70. package/dist/chunk-ASFU32ZM.js +0 -236
  71. package/dist/chunk-ASFU32ZM.js.map +0 -1
  72. package/dist/chunk-Q4U5OEUT.js +0 -21
  73. package/dist/chunk-Q4U5OEUT.js.map +0 -1
  74. package/dist/chunk-QRR7EPCH.js +0 -176
  75. package/dist/chunk-QRR7EPCH.js.map +0 -1
  76. package/dist/chunk-S7CPZ4UE.js +0 -6
  77. package/dist/chunk-S7CPZ4UE.js.map +0 -1
  78. package/dist/chunk-ZG2XUYGZ.js +0 -1281
  79. package/dist/chunk-ZG2XUYGZ.js.map +0 -1
  80. package/dist/chunks/client.css +0 -984
  81. package/dist/chunks/client.css.map +0 -1
  82. package/dist/chunks/client.d.ts +0 -10041
  83. package/dist/chunks/client.js +0 -5363
  84. package/dist/chunks/client.js.map +0 -1
  85. package/dist/chunks/config.d.ts +0 -31
  86. package/dist/chunks/config.js +0 -5
  87. package/dist/chunks/config.js.map +0 -1
  88. package/dist/chunks/core-public.d.ts +0 -130
  89. package/dist/chunks/core-public.js +0 -92
  90. package/dist/chunks/core-public.js.map +0 -1
  91. package/dist/chunks/server-nextjs.d.ts +0 -33
  92. package/dist/chunks/server-nextjs.js +0 -47
  93. package/dist/chunks/server-nextjs.js.map +0 -1
  94. package/dist/chunks/server-tanstack.d.ts +0 -22
  95. package/dist/chunks/server-tanstack.js +0 -34
  96. package/dist/chunks/server-tanstack.js.map +0 -1
  97. package/dist/collection-C6bWDLiw.d.ts +0 -3926
  98. package/dist/ecomiq-oauth-password-plugin-J5wDFqzL.d.ts +0 -3629
  99. package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
  100. package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
  101. package/dist/index.d.ts +0 -9
  102. package/dist/index.js +0 -1
  103. package/dist/index.js.map +0 -1
  104. package/dist/index.server.d.ts +0 -9
  105. package/dist/index.server.js +0 -8
  106. package/dist/index.server.js.map +0 -1
  107. package/dist/nextjs/api.d.ts +0 -45
  108. package/dist/nextjs/api.js +0 -32
  109. package/dist/nextjs/api.js.map +0 -1
  110. package/dist/nextjs/globals.css +0 -13
  111. package/dist/nextjs.d.ts +0 -2
  112. package/dist/nextjs.js +0 -2
  113. package/dist/nextjs.js.map +0 -1
  114. package/dist/nextjs.server.d.ts +0 -1
  115. package/dist/nextjs.server.js +0 -1
  116. package/dist/nextjs.server.js.map +0 -1
  117. package/dist/postcss.config.mjs +0 -6
  118. package/dist/react/checkout-page.css +0 -855
  119. package/dist/react/client.css +0 -984
  120. package/dist/react/payment-provider.css +0 -91
  121. package/dist/react/thank-you-page.css +0 -105
  122. package/dist/react.d.ts +0 -1
  123. package/dist/react.js +0 -2
  124. package/dist/react.js.map +0 -1
  125. package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
  126. package/dist/tanstack/api.d.ts +0 -41
  127. package/dist/tanstack/api.js +0 -30
  128. package/dist/tanstack/api.js.map +0 -1
  129. package/dist/tanstack.d.ts +0 -2
  130. package/dist/tanstack.js +0 -2
  131. package/dist/tanstack.js.map +0 -1
  132. package/dist/tanstack.server.d.ts +0 -1
  133. package/dist/tanstack.server.js +0 -1
  134. package/dist/tanstack.server.js.map +0 -1
@@ -0,0 +1,583 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react'
8
+ import type {
9
+ CartCheckoutResponse,
10
+ CartPaymentMethod,
11
+ ShippingOptionGroup,
12
+ ShippingRate,
13
+ StorefrontCart,
14
+ StorefrontOrderBump,
15
+ } from '@ecomiq/core'
16
+ import { cartVariantIdFromProduct } from '@ecomiq/core'
17
+ import { useCart } from '../../providers'
18
+ import { serverApi } from '../../server'
19
+ import {
20
+ areCartSignaturesEqual,
21
+ cartItemsSignature,
22
+ cartSessionSignature,
23
+ findCartReconciliationActions,
24
+ isCheckoutCartAlreadyPurchased,
25
+ methodUsesProviderCheckoutUi,
26
+ toSignatureItems,
27
+ } from './cart-sync'
28
+ import { emptyCheckoutContact, type BillingChoice, type CheckoutContactForm } from './types'
29
+
30
+ type ShippingFlat = {
31
+ key: string
32
+ group: ShippingOptionGroup
33
+ rate: ShippingRate
34
+ }
35
+
36
+ export function useCheckoutSession(cartId: string) {
37
+ const { cart: providerCart, clearCart, reload: reloadProviderCart } = useCart()
38
+
39
+ const [checkoutCart, setCheckoutCart] = useState<StorefrontCart | null>(null)
40
+ const [paymentMethods, setPaymentMethods] = useState<CartPaymentMethod[]>([])
41
+ const [orderBumps, setOrderBumps] = useState<StorefrontOrderBump[]>([])
42
+ const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading')
43
+ const [loadError, setLoadError] = useState<string>()
44
+ const [checkoutAlreadyPurchased, setCheckoutAlreadyPurchased] = useState(false)
45
+ const [isReconciling, setIsReconciling] = useState(false)
46
+ const [cartChangedExternally, setCartChangedExternally] = useState(false)
47
+
48
+ const [contact, setContact] = useState<CheckoutContactForm>(() => emptyCheckoutContact())
49
+ const [billingChoice, setBillingChoice] = useState<BillingChoice>('same_as_shipping')
50
+ const [selectedShippingKey, setSelectedShippingKey] = useState<string | null>(null)
51
+ const [selectedPaymentCode, setSelectedPaymentCode] = useState<string | null>(null)
52
+ const [providerPaymentCompleted, setProviderPaymentCompleted] = useState(false)
53
+ const [couponCode, setCouponCode] = useState('')
54
+ const [couponError, setCouponError] = useState<string | null>(null)
55
+ const [formError, setFormError] = useState<string | null>(null)
56
+ const [isSaving, setIsSaving] = useState(false)
57
+
58
+ const [shippingGroups, setShippingGroups] = useState<ShippingOptionGroup[]>([])
59
+ const [shippingLoading, setShippingLoading] = useState(false)
60
+ const [shippingError, setShippingError] = useState<string | null>(null)
61
+
62
+ const [checkoutPaymentResponse, setCheckoutPaymentResponse] =
63
+ useState<CartCheckoutResponse | null>(null)
64
+ const [checkoutPaymentMethodCode, setCheckoutPaymentMethodCode] = useState<string | null>(
65
+ null,
66
+ )
67
+ const [checkoutPaymentLoading, setCheckoutPaymentLoading] = useState(false)
68
+ const [checkoutPaymentError, setCheckoutPaymentError] = useState<string | null>(null)
69
+
70
+ const hydratedRef = useRef(false)
71
+ const reconcileAttemptRef = useRef<string | null>(null)
72
+ const isReconcilingRef = useRef(false)
73
+ const checkoutPaymentInitKeyRef = useRef<string | null>(null)
74
+ const baselineSigRef = useRef<string | null>(null)
75
+ const skipClearOnPurchasedRef = useRef(false)
76
+
77
+ const localItems = useMemo(
78
+ () => toSignatureItems(providerCart?.items),
79
+ [providerCart?.items],
80
+ )
81
+ const apiItems = useMemo(
82
+ () => toSignatureItems(checkoutCart?.items),
83
+ [checkoutCart?.items],
84
+ )
85
+ const cartsReconciled = useMemo(
86
+ () => areCartSignaturesEqual(localItems, apiItems),
87
+ [localItems, apiItems],
88
+ )
89
+
90
+ const loadCheckout = useCallback(async () => {
91
+ setLoadStatus('loading')
92
+ setLoadError(undefined)
93
+ try {
94
+ const data = await serverApi.getCartCheckout({ cartId })
95
+ if (isCheckoutCartAlreadyPurchased(data.cart)) {
96
+ skipClearOnPurchasedRef.current = true
97
+ setCheckoutAlreadyPurchased(true)
98
+ clearCart()
99
+ setCheckoutCart(data.cart)
100
+ setLoadStatus('ready')
101
+ return
102
+ }
103
+ setCheckoutCart(data.cart)
104
+ setPaymentMethods(data.paymentMethods.methods.filter((m) => m.isActive))
105
+ setOrderBumps(data.orderBumps ?? [])
106
+ setCheckoutAlreadyPurchased(false)
107
+ setLoadStatus('ready')
108
+ } catch (error) {
109
+ setLoadError(
110
+ error instanceof Error ? error.message : 'No pudimos cargar el checkout.',
111
+ )
112
+ setLoadStatus('error')
113
+ }
114
+ }, [cartId, clearCart])
115
+
116
+ useEffect(() => {
117
+ hydratedRef.current = false
118
+ reconcileAttemptRef.current = null
119
+ checkoutPaymentInitKeyRef.current = null
120
+ baselineSigRef.current = null
121
+ setCartChangedExternally(false)
122
+ setSelectedShippingKey(null)
123
+ setSelectedPaymentCode(null)
124
+ setProviderPaymentCompleted(false)
125
+ setCheckoutPaymentResponse(null)
126
+ setCheckoutPaymentMethodCode(null)
127
+ skipClearOnPurchasedRef.current = false
128
+ void loadCheckout()
129
+ }, [cartId, loadCheckout])
130
+
131
+ useEffect(() => {
132
+ if (!checkoutCart || hydratedRef.current) return
133
+ hydratedRef.current = true
134
+ const ship = checkoutCart.shippingAddress
135
+ setContact((c) => ({
136
+ ...c,
137
+ email: checkoutCart.email ?? c.email,
138
+ firstName: checkoutCart.firstName ?? c.firstName,
139
+ lastName: checkoutCart.lastName ?? c.lastName,
140
+ phone: checkoutCart.phone ?? c.phone,
141
+ address: ship?.address1 ?? c.address,
142
+ address2: ship?.address2 ?? c.address2,
143
+ postalCode: ship?.postalCode ?? c.postalCode,
144
+ province: ship?.province ?? c.province,
145
+ city: ship?.city ?? c.city,
146
+ countryCode: ship?.countryCode ?? c.countryCode,
147
+ company: ship?.company ?? c.company,
148
+ districtName: ship?.district ?? c.districtName,
149
+ }))
150
+ }, [checkoutCart])
151
+
152
+ // Cross-tab: sesión de carrito o ítems del provider cambian tras baseline.
153
+ useEffect(() => {
154
+ if (loadStatus !== 'ready' || isReconciling || checkoutAlreadyPurchased) return
155
+ const sig = cartSessionSignature(cartId, localItems)
156
+ if (baselineSigRef.current === null) {
157
+ baselineSigRef.current = sig
158
+ return
159
+ }
160
+ if (sig !== baselineSigRef.current) {
161
+ setCartChangedExternally(true)
162
+ }
163
+ }, [cartId, checkoutAlreadyPurchased, isReconciling, loadStatus, localItems])
164
+
165
+ useEffect(() => {
166
+ const key = `ecomiq:cart:v1:`
167
+ const onStorage = (event: StorageEvent) => {
168
+ if (!event.key?.startsWith(key)) return
169
+ setCartChangedExternally(true)
170
+ }
171
+ window.addEventListener('storage', onStorage)
172
+ return () => window.removeEventListener('storage', onStorage)
173
+ }, [])
174
+
175
+ // Reconciliación local → API
176
+ useEffect(() => {
177
+ if (loadStatus !== 'ready' || !checkoutCart || checkoutAlreadyPurchased) return
178
+ if (isReconcilingRef.current) return
179
+ if (providerCart && providerCart.id !== cartId) return
180
+ if (localItems.length === 0 && apiItems.length === 0) return
181
+ if (areCartSignaturesEqual(localItems, apiItems)) return
182
+
183
+ const actions = findCartReconciliationActions(localItems, apiItems)
184
+ if (actions.length === 0) return
185
+
186
+ const attemptKey = `${cartId}::${cartItemsSignature(localItems)}::${cartItemsSignature(apiItems)}`
187
+ if (reconcileAttemptRef.current === attemptKey) return
188
+
189
+ reconcileAttemptRef.current = attemptKey
190
+ isReconcilingRef.current = true
191
+ setIsReconciling(true)
192
+
193
+ void (async () => {
194
+ try {
195
+ for (const action of actions) {
196
+ if (action.action === 'remove') {
197
+ try {
198
+ const next = await serverApi.removeCartItem({
199
+ cartId,
200
+ cartItemId: action.itemId,
201
+ })
202
+ if (next.ok) setCheckoutCart(next.value)
203
+ } catch {
204
+ /* stock / línea inválida */
205
+ }
206
+ } else if (action.action === 'update') {
207
+ try {
208
+ const next = await serverApi.setCartItemQuantity({
209
+ cartId,
210
+ cartItemId: action.itemId,
211
+ quantity: action.quantity,
212
+ })
213
+ if (next.ok) setCheckoutCart(next.value)
214
+ } catch {
215
+ /* */
216
+ }
217
+ }
218
+ }
219
+ for (const action of actions) {
220
+ if (action.action !== 'add') continue
221
+ try {
222
+ const next = await serverApi.addCartItem({
223
+ cartId,
224
+ variantId: cartVariantIdFromProduct(action.variantId),
225
+ quantity: action.quantity,
226
+ })
227
+ setCheckoutCart(next)
228
+ } catch {
229
+ /* */
230
+ }
231
+ }
232
+ await loadCheckout()
233
+ await reloadProviderCart()
234
+ baselineSigRef.current = null
235
+ } finally {
236
+ isReconcilingRef.current = false
237
+ setIsReconciling(false)
238
+ }
239
+ })()
240
+ }, [
241
+ apiItems,
242
+ cartId,
243
+ checkoutAlreadyPurchased,
244
+ checkoutCart,
245
+ loadCheckout,
246
+ loadStatus,
247
+ localItems,
248
+ providerCart,
249
+ reloadProviderCart,
250
+ ])
251
+
252
+ const postalCode = contact.postalCode.trim()
253
+ useEffect(() => {
254
+ if (!postalCode || checkoutAlreadyPurchased) {
255
+ setShippingGroups([])
256
+ return
257
+ }
258
+ let cancelled = false
259
+ setShippingLoading(true)
260
+ setShippingError(null)
261
+ void serverApi
262
+ .getShippingOptions({ cartId, postalCode })
263
+ .then((res) => {
264
+ if (!cancelled) setShippingGroups(res.groups)
265
+ })
266
+ .catch((error) => {
267
+ if (!cancelled) {
268
+ setShippingError(
269
+ error instanceof Error ? error.message : 'No pudimos cargar envíos.',
270
+ )
271
+ setShippingGroups([])
272
+ }
273
+ })
274
+ .finally(() => {
275
+ if (!cancelled) setShippingLoading(false)
276
+ })
277
+ return () => {
278
+ cancelled = true
279
+ }
280
+ }, [cartId, checkoutAlreadyPurchased, postalCode])
281
+
282
+ const shippingFlat = useMemo(() => {
283
+ const out: ShippingFlat[] = []
284
+ for (const group of shippingGroups) {
285
+ for (const rate of group.rates ?? []) {
286
+ out.push({
287
+ key: `${group.sellerId ?? ''}::${rate.rateId}`,
288
+ group,
289
+ rate,
290
+ })
291
+ }
292
+ }
293
+ return out
294
+ }, [shippingGroups])
295
+
296
+ useEffect(() => {
297
+ if (!shippingFlat.length || selectedShippingKey) return
298
+ setSelectedShippingKey(shippingFlat[0]!.key)
299
+ }, [shippingFlat, selectedShippingKey])
300
+
301
+ const externalRef = `checkout-${cartId}`
302
+
303
+ const initProviderCheckout = useCallback(
304
+ (paymentMethodCode: string) => {
305
+ const method = paymentMethods.find((m) => m.code === paymentMethodCode)
306
+ if (!method || !methodUsesProviderCheckoutUi(method)) return
307
+
308
+ const dedupeKey = `${cartId}:${paymentMethodCode}`
309
+ if (checkoutPaymentInitKeyRef.current === dedupeKey) return
310
+ checkoutPaymentInitKeyRef.current = dedupeKey
311
+
312
+ setCheckoutPaymentLoading(true)
313
+ setCheckoutPaymentError(null)
314
+ setCheckoutPaymentMethodCode(paymentMethodCode)
315
+ void serverApi
316
+ .checkoutCart({ cartId, paymentMethodCode, externalRef })
317
+ .then((result) => {
318
+ setCheckoutPaymentResponse(result)
319
+ })
320
+ .catch((error) => {
321
+ setCheckoutPaymentError(
322
+ error instanceof Error ? error.message : 'No pudimos iniciar el pago.',
323
+ )
324
+ setCheckoutPaymentResponse(null)
325
+ })
326
+ .finally(() => setCheckoutPaymentLoading(false))
327
+ },
328
+ [cartId, externalRef, paymentMethods],
329
+ )
330
+
331
+ const selectPaymentMethod = useCallback(
332
+ (paymentMethodCode: string) => {
333
+ setSelectedPaymentCode(paymentMethodCode)
334
+ setProviderPaymentCompleted(false)
335
+ initProviderCheckout(paymentMethodCode)
336
+ },
337
+ [initProviderCheckout],
338
+ )
339
+
340
+ useEffect(() => {
341
+ if (!paymentMethods.length || selectedPaymentCode) return
342
+ const first = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0]
343
+ if (first) selectPaymentMethod(first.code)
344
+ }, [paymentMethods, selectedPaymentCode, selectPaymentMethod])
345
+
346
+ const saveContactAndDelivery = useCallback(async () => {
347
+ const buyerCart = await serverApi.patchCartBuyer({
348
+ cartId,
349
+ email: contact.email,
350
+ firstName: contact.firstName,
351
+ lastName: contact.lastName,
352
+ phone: contact.phone,
353
+ })
354
+ setCheckoutCart(buyerCart)
355
+
356
+ const countryCode = (contact.countryCode || 'PE').toUpperCase()
357
+ const districtName = contact.districtName || contact.city
358
+ const provinceName = contact.provinceName || contact.province
359
+ const baseAddress = {
360
+ name: `${contact.firstName} ${contact.lastName}`.trim() || contact.address,
361
+ address1: contact.address,
362
+ city: districtName,
363
+ countryCode,
364
+ company: contact.company || null,
365
+ address2: contact.address2 || null,
366
+ province: provinceName,
367
+ postalCode: contact.postalCode,
368
+ district: districtName,
369
+ phone: contact.phone || null,
370
+ latitude: null,
371
+ longitude: null,
372
+ geo: {
373
+ departmentCode: contact.departmentCode || '',
374
+ provinceCode: contact.provinceCode || '',
375
+ districtCode: contact.districtCode || '',
376
+ },
377
+ }
378
+
379
+ const updated = await serverApi.patchCartAddresses({
380
+ cartId,
381
+ shippingAddress: baseAddress,
382
+ billingAddress: baseAddress,
383
+ })
384
+ setCheckoutCart(updated)
385
+ }, [cartId, contact])
386
+
387
+ const confirmShipping = useCallback(
388
+ async (picked: ShippingFlat) => {
389
+ const updated = await serverApi.putCartShippingMethod({
390
+ cartId,
391
+ rateId: picked.rate.rateId,
392
+ shippingMethodId: picked.rate.shippingMethodId,
393
+ serviceCode: picked.rate.serviceCode ?? null,
394
+ sellerId: picked.group.sellerId ?? null,
395
+ shippingProfileId: picked.group.shippingProfileId ?? null,
396
+ locationId: null,
397
+ pickupPointId: null,
398
+ scheduledDate: null,
399
+ scheduledTime: null,
400
+ })
401
+ setCheckoutCart(updated)
402
+ },
403
+ [cartId],
404
+ )
405
+
406
+ const handleApplyCoupon = useCallback(async () => {
407
+ const code = couponCode.trim()
408
+ if (!code) return
409
+ setCouponError(null)
410
+ try {
411
+ const next = await serverApi.addCartPromotionCode({ cartId, code })
412
+ setCheckoutCart(next)
413
+ setCouponCode('')
414
+ } catch (error) {
415
+ setCouponError(error instanceof Error ? error.message : 'No se pudo aplicar el cupón.')
416
+ }
417
+ }, [cartId, couponCode])
418
+
419
+ const handleRemoveCoupon = useCallback(
420
+ async (code: string) => {
421
+ setCouponError(null)
422
+ try {
423
+ const next = await serverApi.removeCartPromotionCode({ cartId, code })
424
+ setCheckoutCart(next)
425
+ } catch (error) {
426
+ setCouponError(error instanceof Error ? error.message : 'No se pudo remover el cupón.')
427
+ }
428
+ },
429
+ [cartId],
430
+ )
431
+
432
+ const handleAddOrderBump = useCallback(
433
+ async (orderBump: StorefrontOrderBump) => {
434
+ const next = await serverApi.addCartItem({
435
+ cartId,
436
+ variantId: cartVariantIdFromProduct(orderBump.variantPublicId),
437
+ quantity: 1,
438
+ })
439
+ setCheckoutCart(next)
440
+ await loadCheckout()
441
+ await reloadProviderCart()
442
+ },
443
+ [cartId, loadCheckout, reloadProviderCart],
444
+ )
445
+
446
+ const uploadPaymentReceipt = useCallback(
447
+ async (file: File) => {
448
+ return serverApi.uploadPaymentReceipt({ cartId, file, fileName: file.name })
449
+ },
450
+ [cartId],
451
+ )
452
+
453
+ const payNow = useCallback(
454
+ async (options?: { receiptUrl?: string }): Promise<CartCheckoutResponse | undefined> => {
455
+ setFormError(null)
456
+ setIsSaving(true)
457
+ try {
458
+ await saveContactAndDelivery()
459
+ if (selectedShippingKey) {
460
+ const picked = shippingFlat.find((o) => o.key === selectedShippingKey)
461
+ if (picked) await confirmShipping(picked)
462
+ }
463
+ const code =
464
+ selectedPaymentCode ?? paymentMethods[0]?.code
465
+ if (!code) {
466
+ setFormError('Selecciona un método de pago.')
467
+ return
468
+ }
469
+ const method = paymentMethods.find((m) => m.code === code)
470
+ const usesProviderUi = method ? methodUsesProviderCheckoutUi(method) : false
471
+
472
+ if (usesProviderUi) {
473
+ if (!providerPaymentCompleted) {
474
+ setFormError(
475
+ 'Completa el pago con Stripe o Mercado Pago antes de continuar.',
476
+ )
477
+ return
478
+ }
479
+ if (
480
+ checkoutPaymentResponse &&
481
+ checkoutPaymentMethodCode === code
482
+ ) {
483
+ return checkoutPaymentResponse
484
+ }
485
+ }
486
+
487
+ const result = await serverApi.checkoutCart({
488
+ cartId,
489
+ paymentMethodCode: code,
490
+ externalRef,
491
+ ...(options?.receiptUrl ? { receiptUrl: options.receiptUrl } : {}),
492
+ })
493
+ setCheckoutPaymentResponse(result)
494
+ setCheckoutPaymentMethodCode(code)
495
+ return result
496
+ } catch (error) {
497
+ setFormError(
498
+ error instanceof Error ? error.message : 'No se pudo completar el pago.',
499
+ )
500
+ throw error
501
+ } finally {
502
+ setIsSaving(false)
503
+ }
504
+ },
505
+ [
506
+ cartId,
507
+ checkoutPaymentMethodCode,
508
+ checkoutPaymentResponse,
509
+ confirmShipping,
510
+ externalRef,
511
+ paymentMethods,
512
+ providerPaymentCompleted,
513
+ saveContactAndDelivery,
514
+ selectedPaymentCode,
515
+ selectedShippingKey,
516
+ shippingFlat,
517
+ ],
518
+ )
519
+
520
+ const reloadCheckout = useCallback(() => {
521
+ if (typeof window !== 'undefined') window.location.reload()
522
+ }, [])
523
+
524
+ const uiCart = cartsReconciled || !providerCart ? checkoutCart : providerCart
525
+ const totals = uiCart?.totals
526
+ const subtotal = cartsReconciled
527
+ ? (totals?.subtotal ?? 0)
528
+ : (providerCart?.totals.subtotal ?? 0)
529
+ const shipping = cartsReconciled ? (totals?.shippingTotal ?? 0) : 0
530
+ const discount = cartsReconciled ? (totals?.discountTotal ?? 0) : 0
531
+ const tax = cartsReconciled ? (totals?.taxTotal ?? 0) : 0
532
+ const total = cartsReconciled
533
+ ? (totals?.total ?? subtotal + shipping + tax - discount)
534
+ : subtotal
535
+
536
+ return {
537
+ cartId,
538
+ loadStatus,
539
+ loadError,
540
+ cartData: checkoutCart,
541
+ uiCart,
542
+ paymentMethods,
543
+ orderBumps,
544
+ isReconciling,
545
+ cartChangedExternally,
546
+ checkoutAlreadyPurchased,
547
+ reloadCheckout,
548
+ clearCart,
549
+ contact,
550
+ setContact,
551
+ billingChoice,
552
+ setBillingChoice,
553
+ selectedShippingKey,
554
+ setSelectedShippingKey,
555
+ selectedPaymentCode,
556
+ selectPaymentMethod,
557
+ providerPaymentCompleted,
558
+ setProviderPaymentCompleted,
559
+ payNow,
560
+ couponCode,
561
+ setCouponCode,
562
+ couponError,
563
+ formError,
564
+ handleApplyCoupon,
565
+ handleRemoveCoupon,
566
+ handleAddOrderBump,
567
+ shippingFlat,
568
+ shippingLoading,
569
+ shippingError,
570
+ uploadPaymentReceipt,
571
+ checkoutPaymentResponse,
572
+ checkoutPaymentMethodCode,
573
+ checkoutPaymentLoading,
574
+ checkoutPaymentError,
575
+ isSaving: isSaving || cartChangedExternally,
576
+ cartsReconciled,
577
+ subtotal,
578
+ shipping,
579
+ discount,
580
+ tax,
581
+ total,
582
+ }
583
+ }