@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.
- package/bin/ecomiq.mjs +1 -1
- package/package.json +2 -2
- package/src/api-proxy.ts +2 -2
- package/src/client.ts +2 -2
- package/src/codegen.mjs +3 -8
- package/src/components/checkout/checkout-contact-section.tsx +70 -0
- package/src/components/checkout/checkout-order-summary.tsx +11 -2
- package/src/components/checkout/checkout-payment-section.tsx +4 -7
- package/src/components/checkout/checkout-session-view.tsx +311 -68
- package/src/components/checkout/checkout-shipping-address-section.tsx +119 -90
- package/src/components/checkout/checkout-shipping-section.tsx +22 -14
- package/src/components/checkout/checkout-step-card.tsx +101 -0
- package/src/components/checkout/checkout-steps.ts +96 -0
- package/src/components/checkout/form-schema.ts +152 -100
- package/src/components/checkout/format.ts +0 -5
- package/src/components/checkout/index.ts +4 -50
- package/src/components/checkout/types.ts +0 -2
- package/src/components/checkout/use-checkout-session.ts +72 -15
- package/src/config.d.ts +6 -12
- package/src/config.mjs +5 -64
- package/src/hooks/index.ts +9 -0
- package/src/hooks/recently-viewed.ts +125 -0
- package/src/index.ts +17 -4
- package/src/public-config.ts +10 -30
- package/src/runtime/default-layout.tsx +5 -21
- package/src/runtime/head.ts +70 -47
- package/src/runtime/page.tsx +45 -13
- package/src/server-config-store.ts +1 -1
- package/templates/__root.tsx +1 -1
|
@@ -59,105 +59,146 @@ export type CheckoutFormValidationContext = {
|
|
|
59
59
|
paymentMethodsCount: number
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
62
|
+
function phoneDigitsForCurrency(currency: string) {
|
|
63
|
+
return currency.toUpperCase() === 'PEN' ? 9 : 10
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const contactStepObject = z.object({
|
|
67
|
+
email: z
|
|
68
|
+
.string()
|
|
69
|
+
.trim()
|
|
70
|
+
.min(1, 'El email es requerido')
|
|
71
|
+
.email('Ingresa un email válido'),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
function deliveryStepObject(currency: string) {
|
|
75
|
+
const phoneDigits = phoneDigitsForCurrency(currency)
|
|
76
|
+
return z.object({
|
|
77
|
+
firstName: z
|
|
78
|
+
.string()
|
|
79
|
+
.trim()
|
|
80
|
+
.min(1, 'El nombre es requerido')
|
|
81
|
+
.min(2, 'El nombre debe tener al menos 2 caracteres'),
|
|
82
|
+
lastName: z
|
|
83
|
+
.string()
|
|
84
|
+
.trim()
|
|
85
|
+
.min(1, 'El apellido es requerido')
|
|
86
|
+
.min(2, 'El apellido debe tener al menos 2 caracteres'),
|
|
87
|
+
countryCode: z.string().trim().min(2, 'Selecciona un país').max(2),
|
|
88
|
+
address: z
|
|
89
|
+
.string()
|
|
90
|
+
.trim()
|
|
91
|
+
.min(1, 'La dirección es requerida')
|
|
92
|
+
.min(5, 'La dirección debe tener al menos 5 caracteres'),
|
|
93
|
+
phone: z
|
|
94
|
+
.string()
|
|
95
|
+
.trim()
|
|
96
|
+
.min(1, 'El teléfono es requerido')
|
|
97
|
+
.regex(/^\d+$/, 'El teléfono solo debe contener números')
|
|
98
|
+
.length(phoneDigits, `El teléfono debe tener ${phoneDigits} dígitos`),
|
|
99
|
+
departmentCode: z.string(),
|
|
100
|
+
provinceCode: z.string(),
|
|
101
|
+
districtCode: z.string(),
|
|
102
|
+
city: z.string(),
|
|
103
|
+
province: z.string(),
|
|
104
|
+
selectedShippingKey: z.string().nullable(),
|
|
105
|
+
shippingOptionsCount: z.number().int().nonnegative(),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const paymentStepObject = z.object({
|
|
110
|
+
selectedPaymentCode: z.string().nullable(),
|
|
111
|
+
paymentMethodsCount: z.number().int().nonnegative(),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
function refineDelivery(
|
|
115
|
+
data: z.infer<ReturnType<typeof deliveryStepObject>>,
|
|
116
|
+
ctx: z.RefinementCtx,
|
|
117
|
+
) {
|
|
118
|
+
const country = data.countryCode.toUpperCase()
|
|
119
|
+
|
|
120
|
+
if (country === 'PE') {
|
|
121
|
+
if (!data.departmentCode.trim()) {
|
|
122
|
+
ctx.addIssue({
|
|
123
|
+
code: z.ZodIssueCode.custom,
|
|
124
|
+
path: ['departmentCode'],
|
|
125
|
+
message: 'Selecciona un departamento',
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
if (!data.provinceCode.trim()) {
|
|
129
|
+
ctx.addIssue({
|
|
130
|
+
code: z.ZodIssueCode.custom,
|
|
131
|
+
path: ['provinceCode'],
|
|
132
|
+
message: 'Selecciona una provincia',
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
if (!data.districtCode.trim()) {
|
|
136
|
+
ctx.addIssue({
|
|
137
|
+
code: z.ZodIssueCode.custom,
|
|
138
|
+
path: ['districtCode'],
|
|
139
|
+
message: 'Selecciona un distrito',
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
if (!data.city.trim()) {
|
|
144
|
+
ctx.addIssue({
|
|
145
|
+
code: z.ZodIssueCode.custom,
|
|
146
|
+
path: ['city'],
|
|
147
|
+
message: 'La ciudad es requerida',
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
if (!data.province.trim()) {
|
|
151
|
+
ctx.addIssue({
|
|
152
|
+
code: z.ZodIssueCode.custom,
|
|
153
|
+
path: ['province'],
|
|
154
|
+
message: 'La región es requerida',
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (data.shippingOptionsCount > 0 && !data.selectedShippingKey?.trim()) {
|
|
160
|
+
ctx.addIssue({
|
|
161
|
+
code: z.ZodIssueCode.custom,
|
|
162
|
+
path: ['selectedShippingKey'],
|
|
163
|
+
message: 'Selecciona un método de envío',
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function refinePayment(
|
|
169
|
+
data: z.infer<typeof paymentStepObject>,
|
|
170
|
+
ctx: z.RefinementCtx,
|
|
171
|
+
) {
|
|
172
|
+
if (data.paymentMethodsCount > 0 && !data.selectedPaymentCode?.trim()) {
|
|
173
|
+
ctx.addIssue({
|
|
174
|
+
code: z.ZodIssueCode.custom,
|
|
175
|
+
path: ['selectedPaymentCode'],
|
|
176
|
+
message: 'Selecciona un método de pago',
|
|
103
177
|
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function buildCheckoutContactStepSchema() {
|
|
182
|
+
return contactStepObject
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function buildCheckoutDeliveryStepSchema(currency: string) {
|
|
186
|
+
return deliveryStepObject(currency).superRefine(refineDelivery)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function buildCheckoutPaymentStepSchema() {
|
|
190
|
+
return paymentStepObject.superRefine(refinePayment)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function buildCheckoutFormValidationSchema(currency: string) {
|
|
194
|
+
const delivery = deliveryStepObject(currency)
|
|
195
|
+
|
|
196
|
+
return contactStepObject
|
|
197
|
+
.merge(delivery)
|
|
198
|
+
.merge(paymentStepObject)
|
|
104
199
|
.superRefine((data, ctx) => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (country === 'PE') {
|
|
108
|
-
if (!data.departmentCode.trim()) {
|
|
109
|
-
ctx.addIssue({
|
|
110
|
-
code: z.ZodIssueCode.custom,
|
|
111
|
-
path: ['departmentCode'],
|
|
112
|
-
message: 'Selecciona un departamento',
|
|
113
|
-
})
|
|
114
|
-
}
|
|
115
|
-
if (!data.provinceCode.trim()) {
|
|
116
|
-
ctx.addIssue({
|
|
117
|
-
code: z.ZodIssueCode.custom,
|
|
118
|
-
path: ['provinceCode'],
|
|
119
|
-
message: 'Selecciona una provincia',
|
|
120
|
-
})
|
|
121
|
-
}
|
|
122
|
-
if (!data.districtCode.trim()) {
|
|
123
|
-
ctx.addIssue({
|
|
124
|
-
code: z.ZodIssueCode.custom,
|
|
125
|
-
path: ['districtCode'],
|
|
126
|
-
message: 'Selecciona un distrito',
|
|
127
|
-
})
|
|
128
|
-
}
|
|
129
|
-
} else {
|
|
130
|
-
if (!data.city.trim()) {
|
|
131
|
-
ctx.addIssue({
|
|
132
|
-
code: z.ZodIssueCode.custom,
|
|
133
|
-
path: ['city'],
|
|
134
|
-
message: 'La ciudad es requerida',
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
if (!data.province.trim()) {
|
|
138
|
-
ctx.addIssue({
|
|
139
|
-
code: z.ZodIssueCode.custom,
|
|
140
|
-
path: ['province'],
|
|
141
|
-
message: 'La región es requerida',
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (data.shippingOptionsCount > 0 && !data.selectedShippingKey?.trim()) {
|
|
147
|
-
ctx.addIssue({
|
|
148
|
-
code: z.ZodIssueCode.custom,
|
|
149
|
-
path: ['selectedShippingKey'],
|
|
150
|
-
message: 'Selecciona un método de envío',
|
|
151
|
-
})
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (data.paymentMethodsCount > 0 && !data.selectedPaymentCode?.trim()) {
|
|
155
|
-
ctx.addIssue({
|
|
156
|
-
code: z.ZodIssueCode.custom,
|
|
157
|
-
path: ['selectedPaymentCode'],
|
|
158
|
-
message: 'Selecciona un método de pago',
|
|
159
|
-
})
|
|
160
|
-
}
|
|
200
|
+
refineDelivery(data, ctx)
|
|
201
|
+
refinePayment(data, ctx)
|
|
161
202
|
})
|
|
162
203
|
}
|
|
163
204
|
|
|
@@ -216,9 +257,11 @@ export function focusCheckoutField(fieldId: CheckoutFieldId): void {
|
|
|
216
257
|
return
|
|
217
258
|
}
|
|
218
259
|
if (fieldId === 'checkout-shipping-method') {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
260
|
+
const selected =
|
|
261
|
+
document.querySelector<HTMLButtonElement>('button[data-checkout-shipping][aria-pressed="true"]') ??
|
|
262
|
+
document.querySelector<HTMLButtonElement>('#checkout-shipping-method button')
|
|
263
|
+
selected?.focus()
|
|
264
|
+
selected?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
222
265
|
return
|
|
223
266
|
}
|
|
224
267
|
if (fieldId === 'checkout-payment-method') {
|
|
@@ -227,3 +270,12 @@ export function focusCheckoutField(fieldId: CheckoutFieldId): void {
|
|
|
227
270
|
radio?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
228
271
|
}
|
|
229
272
|
}
|
|
273
|
+
|
|
274
|
+
/** Mapea el primer campo inválido al paso del flujo secuencial. */
|
|
275
|
+
export function checkoutStepForField(
|
|
276
|
+
fieldId: CheckoutFieldId,
|
|
277
|
+
): 'contact' | 'delivery' | 'payment' {
|
|
278
|
+
if (fieldId === 'email') return 'contact'
|
|
279
|
+
if (fieldId === 'checkout-payment-method') return 'payment'
|
|
280
|
+
return 'delivery'
|
|
281
|
+
}
|
|
@@ -15,11 +15,6 @@ export function thankYouUrl(cartId: string, transactionId?: string) {
|
|
|
15
15
|
return `/thank-you?${q.toString()}`
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** @deprecated Usa `thankYouUrl`. */
|
|
19
|
-
export function processingUrl(cartId: string, transactionId?: string) {
|
|
20
|
-
return thankYouUrl(cartId, transactionId)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
export function paymentRedirectUrl(result: CartCheckoutResponse): string | null {
|
|
24
19
|
const actionUrl = result.payment?.action?.url
|
|
25
20
|
if (typeof actionUrl === 'string' && actionUrl.trim()) return actionUrl
|
|
@@ -1,51 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
buildCheckoutFormValidationSchema,
|
|
6
|
-
checkoutValidationInputFromContext,
|
|
7
|
-
firstInvalidFieldId,
|
|
8
|
-
focusCheckoutField,
|
|
9
|
-
mapZodIssuesToFieldErrors,
|
|
10
|
-
CHECKOUT_FIELD_IDS,
|
|
11
|
-
CHECKOUT_FIELD_FOCUS_ORDER,
|
|
12
|
-
type CheckoutFieldId,
|
|
13
|
-
type CheckoutFormValidationContext,
|
|
14
|
-
} from './form-schema'
|
|
15
|
-
|
|
16
|
-
export {
|
|
17
|
-
areCartSignaturesEqual,
|
|
18
|
-
cartItemsSignature,
|
|
19
|
-
cartSessionSignature,
|
|
20
|
-
findCartReconciliationActions,
|
|
21
|
-
isCheckoutCartAlreadyPurchased,
|
|
22
|
-
methodUsesProviderCheckoutUi,
|
|
23
|
-
toSignatureItems,
|
|
24
|
-
type CartLineSignatureItem,
|
|
25
|
-
type CartReconcileAction,
|
|
26
|
-
} from './cart-sync'
|
|
27
|
-
|
|
28
|
-
export { formatMoney, paymentRedirectUrl, thankYouUrl, processingUrl } from './format'
|
|
29
|
-
export { useCheckoutSession } from './use-checkout-session'
|
|
30
|
-
export { useCheckoutGeo } from './use-checkout-geo'
|
|
31
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Superficie interna del checkout. La página solo necesita `CheckoutSessionView`;
|
|
3
|
+
* el resto se importa por path directo en tests o módulos hermanos.
|
|
4
|
+
*/
|
|
32
5
|
export { CheckoutSessionView } from './checkout-session-view'
|
|
33
|
-
export { CheckoutShippingAddressSection } from './checkout-shipping-address-section'
|
|
34
|
-
export { CheckoutShippingSection } from './checkout-shipping-section'
|
|
35
|
-
export { CheckoutPaymentSection } from './checkout-payment-section'
|
|
36
|
-
export { CheckoutOrderSummary } from './checkout-order-summary'
|
|
37
|
-
export { CheckoutOrderBumps } from './checkout-line-items'
|
|
38
|
-
export { CheckoutExternalUpdateBanner } from './checkout-external-update-banner'
|
|
39
|
-
export {
|
|
40
|
-
CheckoutAlreadyPurchasedState,
|
|
41
|
-
CheckoutEmptyState,
|
|
42
|
-
CheckoutErrorState,
|
|
43
|
-
CheckoutLoadingState,
|
|
44
|
-
} from './checkout-states'
|
|
45
|
-
export {
|
|
46
|
-
CheckoutTextField,
|
|
47
|
-
CheckoutSelectField,
|
|
48
|
-
CheckoutPhoneField,
|
|
49
|
-
FieldError,
|
|
50
|
-
fieldClass,
|
|
51
|
-
} from './checkout-form-controls'
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
methodUsesProviderCheckoutUi,
|
|
26
26
|
toSignatureItems,
|
|
27
27
|
} from './cart-sync'
|
|
28
|
-
import { emptyCheckoutContact, type
|
|
28
|
+
import { emptyCheckoutContact, type CheckoutContactForm } from './types'
|
|
29
29
|
|
|
30
30
|
type ShippingFlat = {
|
|
31
31
|
key: string
|
|
@@ -46,7 +46,6 @@ export function useCheckoutSession(cartId: string) {
|
|
|
46
46
|
const [cartChangedExternally, setCartChangedExternally] = useState(false)
|
|
47
47
|
|
|
48
48
|
const [contact, setContact] = useState<CheckoutContactForm>(() => emptyCheckoutContact())
|
|
49
|
-
const [billingChoice, setBillingChoice] = useState<BillingChoice>('same_as_shipping')
|
|
50
49
|
const [selectedShippingKey, setSelectedShippingKey] = useState<string | null>(null)
|
|
51
50
|
const [selectedPaymentCode, setSelectedPaymentCode] = useState<string | null>(null)
|
|
52
51
|
const [providerPaymentCompleted, setProviderPaymentCompleted] = useState(false)
|
|
@@ -66,13 +65,13 @@ export function useCheckoutSession(cartId: string) {
|
|
|
66
65
|
)
|
|
67
66
|
const [checkoutPaymentLoading, setCheckoutPaymentLoading] = useState(false)
|
|
68
67
|
const [checkoutPaymentError, setCheckoutPaymentError] = useState<string | null>(null)
|
|
68
|
+
const [deliveryPersisted, setDeliveryPersisted] = useState(false)
|
|
69
69
|
|
|
70
70
|
const hydratedRef = useRef(false)
|
|
71
71
|
const reconcileAttemptRef = useRef<string | null>(null)
|
|
72
72
|
const isReconcilingRef = useRef(false)
|
|
73
73
|
const checkoutPaymentInitKeyRef = useRef<string | null>(null)
|
|
74
74
|
const baselineSigRef = useRef<string | null>(null)
|
|
75
|
-
const skipClearOnPurchasedRef = useRef(false)
|
|
76
75
|
|
|
77
76
|
const localItems = useMemo(
|
|
78
77
|
() => toSignatureItems(providerCart?.items),
|
|
@@ -93,7 +92,6 @@ export function useCheckoutSession(cartId: string) {
|
|
|
93
92
|
try {
|
|
94
93
|
const data = await serverApi.getCartCheckout({ cartId })
|
|
95
94
|
if (isCheckoutCartAlreadyPurchased(data.cart)) {
|
|
96
|
-
skipClearOnPurchasedRef.current = true
|
|
97
95
|
setCheckoutAlreadyPurchased(true)
|
|
98
96
|
clearCart()
|
|
99
97
|
setCheckoutCart(data.cart)
|
|
@@ -124,7 +122,7 @@ export function useCheckoutSession(cartId: string) {
|
|
|
124
122
|
setProviderPaymentCompleted(false)
|
|
125
123
|
setCheckoutPaymentResponse(null)
|
|
126
124
|
setCheckoutPaymentMethodCode(null)
|
|
127
|
-
|
|
125
|
+
setDeliveryPersisted(false)
|
|
128
126
|
void loadCheckout()
|
|
129
127
|
}, [cartId, loadCheckout])
|
|
130
128
|
|
|
@@ -337,13 +335,23 @@ export function useCheckoutSession(cartId: string) {
|
|
|
337
335
|
[initProviderCheckout],
|
|
338
336
|
)
|
|
339
337
|
|
|
340
|
-
|
|
341
|
-
|
|
338
|
+
const clearPaymentSession = useCallback(() => {
|
|
339
|
+
checkoutPaymentInitKeyRef.current = null
|
|
340
|
+
setSelectedPaymentCode(null)
|
|
341
|
+
setProviderPaymentCompleted(false)
|
|
342
|
+
setCheckoutPaymentResponse(null)
|
|
343
|
+
setCheckoutPaymentMethodCode(null)
|
|
344
|
+
setCheckoutPaymentError(null)
|
|
345
|
+
setCheckoutPaymentLoading(false)
|
|
346
|
+
}, [])
|
|
347
|
+
|
|
348
|
+
const ensureDefaultPaymentMethod = useCallback(() => {
|
|
349
|
+
if (selectedPaymentCode || !paymentMethods.length) return
|
|
342
350
|
const first = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0]
|
|
343
351
|
if (first) selectPaymentMethod(first.code)
|
|
344
|
-
}, [paymentMethods,
|
|
352
|
+
}, [paymentMethods, selectPaymentMethod, selectedPaymentCode])
|
|
345
353
|
|
|
346
|
-
const
|
|
354
|
+
const saveBuyer = useCallback(async () => {
|
|
347
355
|
const buyerCart = await serverApi.patchCartBuyer({
|
|
348
356
|
cartId,
|
|
349
357
|
email: contact.email,
|
|
@@ -352,7 +360,10 @@ export function useCheckoutSession(cartId: string) {
|
|
|
352
360
|
phone: contact.phone,
|
|
353
361
|
})
|
|
354
362
|
setCheckoutCart(buyerCart)
|
|
363
|
+
return buyerCart
|
|
364
|
+
}, [cartId, contact.email, contact.firstName, contact.lastName, contact.phone])
|
|
355
365
|
|
|
366
|
+
const saveAddresses = useCallback(async () => {
|
|
356
367
|
const countryCode = (contact.countryCode || 'PE').toUpperCase()
|
|
357
368
|
const districtName = contact.districtName || contact.city
|
|
358
369
|
const provinceName = contact.provinceName || contact.province
|
|
@@ -382,8 +393,14 @@ export function useCheckoutSession(cartId: string) {
|
|
|
382
393
|
billingAddress: baseAddress,
|
|
383
394
|
})
|
|
384
395
|
setCheckoutCart(updated)
|
|
396
|
+
return updated
|
|
385
397
|
}, [cartId, contact])
|
|
386
398
|
|
|
399
|
+
const saveContactAndDelivery = useCallback(async () => {
|
|
400
|
+
await saveBuyer()
|
|
401
|
+
await saveAddresses()
|
|
402
|
+
}, [saveAddresses, saveBuyer])
|
|
403
|
+
|
|
387
404
|
const confirmShipping = useCallback(
|
|
388
405
|
async (picked: ShippingFlat) => {
|
|
389
406
|
const updated = await serverApi.putCartShippingMethod({
|
|
@@ -399,10 +416,44 @@ export function useCheckoutSession(cartId: string) {
|
|
|
399
416
|
scheduledTime: null,
|
|
400
417
|
})
|
|
401
418
|
setCheckoutCart(updated)
|
|
419
|
+
return updated
|
|
402
420
|
},
|
|
403
421
|
[cartId],
|
|
404
422
|
)
|
|
405
423
|
|
|
424
|
+
/** Persiste buyer + dirección + envío al completar el paso Dirección. */
|
|
425
|
+
const persistDeliveryStep = useCallback(async () => {
|
|
426
|
+
setFormError(null)
|
|
427
|
+
setIsSaving(true)
|
|
428
|
+
try {
|
|
429
|
+
await saveContactAndDelivery()
|
|
430
|
+
if (selectedShippingKey) {
|
|
431
|
+
const picked = shippingFlat.find((o) => o.key === selectedShippingKey)
|
|
432
|
+
if (picked) await confirmShipping(picked)
|
|
433
|
+
}
|
|
434
|
+
setDeliveryPersisted(true)
|
|
435
|
+
clearPaymentSession()
|
|
436
|
+
} catch (error) {
|
|
437
|
+
setFormError(
|
|
438
|
+
error instanceof Error ? error.message : 'No se pudo guardar la dirección.',
|
|
439
|
+
)
|
|
440
|
+
throw error
|
|
441
|
+
} finally {
|
|
442
|
+
setIsSaving(false)
|
|
443
|
+
}
|
|
444
|
+
}, [
|
|
445
|
+
clearPaymentSession,
|
|
446
|
+
confirmShipping,
|
|
447
|
+
saveContactAndDelivery,
|
|
448
|
+
selectedShippingKey,
|
|
449
|
+
shippingFlat,
|
|
450
|
+
])
|
|
451
|
+
|
|
452
|
+
const invalidateDeliveryPersistence = useCallback(() => {
|
|
453
|
+
setDeliveryPersisted(false)
|
|
454
|
+
clearPaymentSession()
|
|
455
|
+
}, [clearPaymentSession])
|
|
456
|
+
|
|
406
457
|
const handleApplyCoupon = useCallback(async () => {
|
|
407
458
|
const code = couponCode.trim()
|
|
408
459
|
if (!code) return
|
|
@@ -455,10 +506,13 @@ export function useCheckoutSession(cartId: string) {
|
|
|
455
506
|
setFormError(null)
|
|
456
507
|
setIsSaving(true)
|
|
457
508
|
try {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
509
|
+
if (!deliveryPersisted) {
|
|
510
|
+
await saveContactAndDelivery()
|
|
511
|
+
if (selectedShippingKey) {
|
|
512
|
+
const picked = shippingFlat.find((o) => o.key === selectedShippingKey)
|
|
513
|
+
if (picked) await confirmShipping(picked)
|
|
514
|
+
}
|
|
515
|
+
setDeliveryPersisted(true)
|
|
462
516
|
}
|
|
463
517
|
const code =
|
|
464
518
|
selectedPaymentCode ?? paymentMethods[0]?.code
|
|
@@ -507,6 +561,7 @@ export function useCheckoutSession(cartId: string) {
|
|
|
507
561
|
checkoutPaymentMethodCode,
|
|
508
562
|
checkoutPaymentResponse,
|
|
509
563
|
confirmShipping,
|
|
564
|
+
deliveryPersisted,
|
|
510
565
|
externalRef,
|
|
511
566
|
paymentMethods,
|
|
512
567
|
providerPaymentCompleted,
|
|
@@ -548,12 +603,14 @@ export function useCheckoutSession(cartId: string) {
|
|
|
548
603
|
clearCart,
|
|
549
604
|
contact,
|
|
550
605
|
setContact,
|
|
551
|
-
billingChoice,
|
|
552
|
-
setBillingChoice,
|
|
553
606
|
selectedShippingKey,
|
|
554
607
|
setSelectedShippingKey,
|
|
555
608
|
selectedPaymentCode,
|
|
556
609
|
selectPaymentMethod,
|
|
610
|
+
ensureDefaultPaymentMethod,
|
|
611
|
+
clearPaymentSession,
|
|
612
|
+
persistDeliveryStep,
|
|
613
|
+
invalidateDeliveryPersistence,
|
|
557
614
|
providerPaymentCompleted,
|
|
558
615
|
setProviderPaymentCompleted,
|
|
559
616
|
payNow,
|
package/src/config.d.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
EcomiqAnalyticsConfig,
|
|
3
|
-
EcomiqLayoutConfig,
|
|
4
|
-
EcomiqSeoConfig,
|
|
5
|
-
EcomiqThemeConfig,
|
|
6
|
-
} from './index'
|
|
1
|
+
import type { EcomiqThemeConfig } from './index'
|
|
7
2
|
|
|
8
3
|
export interface EcomiqApiConfig {
|
|
9
4
|
baseUrl?: string
|
|
@@ -32,14 +27,13 @@ export type EcomiqCloudflareRoute =
|
|
|
32
27
|
}
|
|
33
28
|
|
|
34
29
|
export interface EcomiqConfigInput {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Identidad de tienda para `x-Store-Domain`.
|
|
32
|
+
* Acepta `marketplace`, `marketplace.ecomiq.app` o `marketplace.pe`.
|
|
33
|
+
*/
|
|
34
|
+
domain: string
|
|
38
35
|
api?: EcomiqApiConfig
|
|
39
36
|
theme?: EcomiqThemeConfig
|
|
40
|
-
seo?: EcomiqSeoConfig
|
|
41
|
-
analytics?: EcomiqAnalyticsConfig
|
|
42
|
-
layout?: EcomiqLayoutConfig
|
|
43
37
|
dev?: {
|
|
44
38
|
port?: number
|
|
45
39
|
apiBaseUrl?: string
|
package/src/config.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Configuración base de un proyecto Ecomiq (ecomiq.config.js).
|
|
3
3
|
* Se valida con Zod al arrancar la CLI para dar errores claros.
|
|
4
|
+
*
|
|
5
|
+
* Identidad de tienda, SEO, analytics y menús salen de `/v1/settings`.
|
|
6
|
+
* Aquí solo queda lo operativo: domain, api, theme, dev, deploy, env.
|
|
4
7
|
*/
|
|
5
8
|
import { z } from 'zod'
|
|
6
9
|
|
|
@@ -31,15 +34,11 @@ const cloudflareRouteSchema = z.union([
|
|
|
31
34
|
])
|
|
32
35
|
|
|
33
36
|
const configSchema = z.object({
|
|
34
|
-
/** Nombre visible de la tienda (header, <title>, worker por defecto). */
|
|
35
|
-
name: z.string().min(1),
|
|
36
37
|
/**
|
|
37
38
|
* Identidad de la tienda en el API storefront (header x-Store-Domain).
|
|
38
|
-
*
|
|
39
|
+
* Acepta slug corto o dominio: `marketplace`, `marketplace.ecomiq.app`, `marketplace.pe`.
|
|
39
40
|
*/
|
|
40
|
-
|
|
41
|
-
/** Descripción para SEO por defecto. */
|
|
42
|
-
description: z.string().optional(),
|
|
41
|
+
domain: z.string().min(1),
|
|
43
42
|
/** API de storefront. baseUrl default: https://storefront.ecomiq.pe */
|
|
44
43
|
api: z
|
|
45
44
|
.object({
|
|
@@ -62,64 +61,6 @@ const configSchema = z.object({
|
|
|
62
61
|
radius: z.string().optional(),
|
|
63
62
|
})
|
|
64
63
|
.optional(),
|
|
65
|
-
/** SEO global, se mezcla con el `meta` de cada página (la página gana). */
|
|
66
|
-
seo: z
|
|
67
|
-
.object({
|
|
68
|
-
/** Plantilla del <title>. `%s` se reemplaza por el título de la página. */
|
|
69
|
-
titleTemplate: z.string().optional(),
|
|
70
|
-
keywords: z.array(z.string()).optional(),
|
|
71
|
-
/** Ruta dentro de public/, ej. '/favicon.svg'. */
|
|
72
|
-
favicon: z.string().optional(),
|
|
73
|
-
/** Imagen OG por defecto (URL absoluta o ruta en public/). */
|
|
74
|
-
ogImage: z.string().optional(),
|
|
75
|
-
twitterCard: z.enum(['summary', 'summary_large_image']).optional(),
|
|
76
|
-
/** Metas extra: [{ name | property, content }]. */
|
|
77
|
-
meta: z
|
|
78
|
-
.array(
|
|
79
|
-
z.object({
|
|
80
|
-
name: z.string().optional(),
|
|
81
|
-
property: z.string().optional(),
|
|
82
|
-
content: z.string(),
|
|
83
|
-
}),
|
|
84
|
-
)
|
|
85
|
-
.optional(),
|
|
86
|
-
/** <link> extra: [{ rel, href, ... }]. */
|
|
87
|
-
links: z.array(z.record(z.string())).optional(),
|
|
88
|
-
})
|
|
89
|
-
.optional(),
|
|
90
|
-
/** Analytics sin escribir código. */
|
|
91
|
-
analytics: z
|
|
92
|
-
.object({
|
|
93
|
-
/** ID de Google Analytics 4, ej. 'G-XXXXXXX'. */
|
|
94
|
-
googleAnalytics: z.string().optional(),
|
|
95
|
-
/** ID de Meta Pixel. */
|
|
96
|
-
metaPixel: z.string().optional(),
|
|
97
|
-
/** Dominio para Plausible, ej. 'tienda.ecomiq.app'. */
|
|
98
|
-
plausible: z.string().optional(),
|
|
99
|
-
/** Scripts arbitrarios: [{ src }] o [{ inline: '...' }]. */
|
|
100
|
-
scripts: z
|
|
101
|
-
.array(
|
|
102
|
-
z.object({
|
|
103
|
-
src: z.string().optional(),
|
|
104
|
-
inline: z.string().optional(),
|
|
105
|
-
defer: z.boolean().optional(),
|
|
106
|
-
async: z.boolean().optional(),
|
|
107
|
-
}),
|
|
108
|
-
)
|
|
109
|
-
.optional(),
|
|
110
|
-
})
|
|
111
|
-
.optional(),
|
|
112
|
-
/** Layout declarativo (usado por el layout por defecto; un layout.tsx en la raíz lo reemplaza). */
|
|
113
|
-
layout: z
|
|
114
|
-
.object({
|
|
115
|
-
/** Enlaces del header: [{ label, to }]. */
|
|
116
|
-
nav: z.array(z.object({ label: z.string(), to: z.string() })).optional(),
|
|
117
|
-
/** Texto del footer. Default: '{name} — creado con Ecomiq'. */
|
|
118
|
-
footerText: z.string().optional(),
|
|
119
|
-
/** Header pegado arriba con blur. Default: true. */
|
|
120
|
-
stickyHeader: z.boolean().default(true).optional(),
|
|
121
|
-
})
|
|
122
|
-
.optional(),
|
|
123
64
|
dev: z
|
|
124
65
|
.object({
|
|
125
66
|
port: z.number().int().default(3000),
|