@ecomiq/storefront 2.0.12 → 2.0.14
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 +3 -1
- package/package.json +4 -2
- package/src/auth/create-storefront-auth.ts +65 -6
- package/src/auth/ecomiq-oauth-password-constants.ts +1 -3
- package/src/auth/ecomiq-oauth-password-plugin.ts +12 -17
- package/src/auth/ecomiq-storefront-sign-in.ts +0 -3
- package/src/auth/secure-cookies.ts +23 -0
- package/src/auth/storefront-auth-base-url.ts +117 -0
- package/src/codegen.mjs +27 -1
- package/src/components/account/customer-addresses-panel.tsx +678 -0
- package/src/components/account/customer-order-detail-panel.tsx +285 -0
- package/src/components/account/customer-orders-panel.tsx +192 -0
- package/src/components/account/customer-profile-panel.tsx +379 -0
- package/src/components/checkout/checkout-contact-section.tsx +17 -11
- package/src/components/checkout/checkout-session-view.tsx +83 -2
- package/src/components/checkout/checkout-shipping-address-section.tsx +107 -2
- package/src/components/checkout/checkout-steps.ts +15 -0
- package/src/components/checkout/map-customer-to-checkout.ts +188 -0
- package/src/components/checkout/use-checkout-customer-prefill.ts +409 -0
- package/src/components/checkout/use-checkout-session.ts +5 -4
- package/src/config.d.ts +4 -0
- package/src/config.mjs +14 -0
- package/src/index.ts +54 -0
- package/src/pages/account/account-page-skeleton.tsx +69 -0
- package/src/pages/account/account-page-view.tsx +270 -0
- package/src/pages/account/account-shell.tsx +101 -0
- package/src/pages/account/parse-account-route.ts +36 -0
- package/src/pages/account.tsx +16 -0
- package/src/pages/checkout.$checkoutId.tsx +1 -1
- package/src/providers/auth.tsx +11 -2
- package/src/proxy-api.ts +20 -0
- package/src/vite-inline.mjs +54 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { useId, useRef, useState, type FormEvent } from 'react'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import {
|
|
4
|
+
customerProfileToPatch,
|
|
5
|
+
type CustomerProfile,
|
|
6
|
+
type CustomerProfilePatch,
|
|
7
|
+
} from '@ecomiq/core'
|
|
8
|
+
import { Loader2, Pencil } from 'lucide-react'
|
|
9
|
+
import { Button, cn } from '../../ui'
|
|
10
|
+
import {
|
|
11
|
+
CheckoutSelectField,
|
|
12
|
+
CheckoutTextField,
|
|
13
|
+
FieldError,
|
|
14
|
+
} from '../checkout/checkout-form-controls'
|
|
15
|
+
|
|
16
|
+
const IDENTIFICATION_TYPES = ['DNI', 'CE', 'Pasaporte', 'RUC'] as const
|
|
17
|
+
|
|
18
|
+
const nameSchema = z.object({
|
|
19
|
+
firstName: z.string().trim().min(1, 'Nombre requerido').max(50),
|
|
20
|
+
lastName: z.string().trim().min(1, 'Apellido requerido').max(50),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const phoneSchema = z.object({
|
|
24
|
+
phone: z.string().trim().min(6, 'Ingresa un número válido'),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const documentSchema = z.object({
|
|
28
|
+
identificationType: z.string().min(1, 'Selecciona el tipo'),
|
|
29
|
+
identificationNumber: z.string().trim().min(1, 'Número de documento requerido'),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
type EditSection = 'name' | 'phone' | 'document' | null
|
|
33
|
+
|
|
34
|
+
function formatDocument(profile: CustomerProfile): string {
|
|
35
|
+
if (profile.identificationType && profile.identificationNumber) {
|
|
36
|
+
return `${profile.identificationType} · ${profile.identificationNumber}`
|
|
37
|
+
}
|
|
38
|
+
if (profile.identificationNumber) return profile.identificationNumber
|
|
39
|
+
return 'Sin registrar'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type CustomerProfilePanelProps = {
|
|
43
|
+
profile?: CustomerProfile | null
|
|
44
|
+
isLoading: boolean
|
|
45
|
+
errorMessage?: string | null
|
|
46
|
+
onUpdateProfile: (payload: CustomerProfilePatch) => Promise<void>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function CustomerProfilePanel({
|
|
50
|
+
profile,
|
|
51
|
+
isLoading,
|
|
52
|
+
errorMessage,
|
|
53
|
+
onUpdateProfile,
|
|
54
|
+
}: CustomerProfilePanelProps) {
|
|
55
|
+
const formId = useId()
|
|
56
|
+
const dialogRef = useRef<HTMLDialogElement>(null)
|
|
57
|
+
const [editSection, setEditSection] = useState<EditSection>(null)
|
|
58
|
+
const [saving, setSaving] = useState(false)
|
|
59
|
+
const [formError, setFormError] = useState<string | null>(null)
|
|
60
|
+
|
|
61
|
+
const [firstName, setFirstName] = useState('')
|
|
62
|
+
const [lastName, setLastName] = useState('')
|
|
63
|
+
const [phone, setPhone] = useState('')
|
|
64
|
+
const [identificationType, setIdentificationType] = useState('DNI')
|
|
65
|
+
const [identificationNumber, setIdentificationNumber] = useState('')
|
|
66
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
|
67
|
+
|
|
68
|
+
const openEdit = (section: Exclude<EditSection, null>) => {
|
|
69
|
+
if (!profile) return
|
|
70
|
+
setEditSection(section)
|
|
71
|
+
setFormError(null)
|
|
72
|
+
setFieldErrors({})
|
|
73
|
+
if (section === 'name') {
|
|
74
|
+
setFirstName(profile.firstName)
|
|
75
|
+
setLastName(profile.lastName)
|
|
76
|
+
} else if (section === 'phone') {
|
|
77
|
+
setPhone(profile.phone || '')
|
|
78
|
+
} else {
|
|
79
|
+
setIdentificationType(profile.identificationType || 'DNI')
|
|
80
|
+
setIdentificationNumber(profile.identificationNumber || '')
|
|
81
|
+
}
|
|
82
|
+
dialogRef.current?.showModal()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const closeEdit = () => {
|
|
86
|
+
dialogRef.current?.close()
|
|
87
|
+
setEditSection(null)
|
|
88
|
+
setFormError(null)
|
|
89
|
+
setFieldErrors({})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const savePatch = async (partial: Partial<CustomerProfilePatch>) => {
|
|
93
|
+
if (!profile) return
|
|
94
|
+
try {
|
|
95
|
+
setSaving(true)
|
|
96
|
+
setFormError(null)
|
|
97
|
+
await onUpdateProfile({
|
|
98
|
+
...customerProfileToPatch(profile),
|
|
99
|
+
...partial,
|
|
100
|
+
})
|
|
101
|
+
closeEdit()
|
|
102
|
+
} catch (error) {
|
|
103
|
+
setFormError(
|
|
104
|
+
error instanceof Error
|
|
105
|
+
? error.message
|
|
106
|
+
: 'No se pudieron guardar los cambios',
|
|
107
|
+
)
|
|
108
|
+
} finally {
|
|
109
|
+
setSaving(false)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const handleSubmit = (event: FormEvent) => {
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
if (editSection === 'name') {
|
|
116
|
+
const parsed = nameSchema.safeParse({ firstName, lastName })
|
|
117
|
+
if (!parsed.success) {
|
|
118
|
+
const next: Record<string, string> = {}
|
|
119
|
+
for (const issue of parsed.error.issues) {
|
|
120
|
+
const key = issue.path[0]
|
|
121
|
+
if (typeof key === 'string' && !(key in next)) next[key] = issue.message
|
|
122
|
+
}
|
|
123
|
+
setFieldErrors(next)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
void savePatch(parsed.data)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if (editSection === 'phone') {
|
|
130
|
+
const parsed = phoneSchema.safeParse({ phone })
|
|
131
|
+
if (!parsed.success) {
|
|
132
|
+
setFieldErrors({ phone: parsed.error.issues[0]?.message ?? 'Inválido' })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
void savePatch(parsed.data)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (editSection === 'document') {
|
|
139
|
+
const parsed = documentSchema.safeParse({
|
|
140
|
+
identificationType,
|
|
141
|
+
identificationNumber,
|
|
142
|
+
})
|
|
143
|
+
if (!parsed.success) {
|
|
144
|
+
const next: Record<string, string> = {}
|
|
145
|
+
for (const issue of parsed.error.issues) {
|
|
146
|
+
const key = issue.path[0]
|
|
147
|
+
if (typeof key === 'string' && !(key in next)) next[key] = issue.message
|
|
148
|
+
}
|
|
149
|
+
setFieldErrors(next)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
void savePatch(parsed.data)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fullName = profile
|
|
157
|
+
? `${profile.firstName} ${profile.lastName}`.trim() || 'Cliente'
|
|
158
|
+
: '—'
|
|
159
|
+
|
|
160
|
+
const dialogTitle =
|
|
161
|
+
editSection === 'name'
|
|
162
|
+
? 'Editar nombre'
|
|
163
|
+
: editSection === 'phone'
|
|
164
|
+
? 'Editar celular'
|
|
165
|
+
: 'Documento de identidad'
|
|
166
|
+
|
|
167
|
+
if (isLoading) {
|
|
168
|
+
return (
|
|
169
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-8 text-sm text-muted-foreground">
|
|
170
|
+
<Loader2 className="size-4 shrink-0 animate-spin" />
|
|
171
|
+
Cargando datos personales…
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className="space-y-6">
|
|
178
|
+
<div>
|
|
179
|
+
<h2 className="text-lg font-medium text-foreground">Datos personales</h2>
|
|
180
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
181
|
+
Administra tu nombre, documento y contacto para pedidos y facturación.
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{errorMessage ? (
|
|
186
|
+
<p className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
|
187
|
+
{errorMessage}
|
|
188
|
+
</p>
|
|
189
|
+
) : null}
|
|
190
|
+
|
|
191
|
+
<div className="overflow-hidden rounded-lg border border-border bg-background">
|
|
192
|
+
<ProfileRow
|
|
193
|
+
label="Nombre y apellidos"
|
|
194
|
+
value={fullName}
|
|
195
|
+
onEdit={() => openEdit('name')}
|
|
196
|
+
disabled={!profile}
|
|
197
|
+
/>
|
|
198
|
+
<ProfileRow
|
|
199
|
+
label="Tipo de documento"
|
|
200
|
+
value={profile ? formatDocument(profile) : 'Sin registrar'}
|
|
201
|
+
onEdit={() => openEdit('document')}
|
|
202
|
+
disabled={!profile}
|
|
203
|
+
/>
|
|
204
|
+
<ProfileRow
|
|
205
|
+
label="Celular"
|
|
206
|
+
value={profile?.phone?.trim() ? profile.phone : 'Sin registrar'}
|
|
207
|
+
onEdit={() => openEdit('phone')}
|
|
208
|
+
disabled={!profile}
|
|
209
|
+
/>
|
|
210
|
+
<ProfileRow
|
|
211
|
+
label="Correo"
|
|
212
|
+
value={profile?.email ?? '—'}
|
|
213
|
+
editable={false}
|
|
214
|
+
isLast
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<dialog
|
|
219
|
+
ref={dialogRef}
|
|
220
|
+
className="fixed left-1/2 top-1/2 z-50 m-0 w-[min(100%-2rem,28rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-0 shadow-lg backdrop:bg-black/40"
|
|
221
|
+
onClose={() => {
|
|
222
|
+
setEditSection(null)
|
|
223
|
+
setFormError(null)
|
|
224
|
+
setFieldErrors({})
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<form className="space-y-4 p-6" onSubmit={handleSubmit}>
|
|
228
|
+
<h3 className="text-base font-medium text-foreground">{dialogTitle}</h3>
|
|
229
|
+
|
|
230
|
+
{editSection === 'name' ? (
|
|
231
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
232
|
+
<CheckoutTextField
|
|
233
|
+
id={`${formId}-firstName`}
|
|
234
|
+
label="Nombre"
|
|
235
|
+
value={firstName}
|
|
236
|
+
error={fieldErrors.firstName}
|
|
237
|
+
autoComplete="given-name"
|
|
238
|
+
onChange={(v) => {
|
|
239
|
+
setFieldErrors((prev) => {
|
|
240
|
+
const next = { ...prev }
|
|
241
|
+
delete next.firstName
|
|
242
|
+
return next
|
|
243
|
+
})
|
|
244
|
+
setFirstName(v)
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
<CheckoutTextField
|
|
248
|
+
id={`${formId}-lastName`}
|
|
249
|
+
label="Apellidos"
|
|
250
|
+
value={lastName}
|
|
251
|
+
error={fieldErrors.lastName}
|
|
252
|
+
autoComplete="family-name"
|
|
253
|
+
onChange={(v) => {
|
|
254
|
+
setFieldErrors((prev) => {
|
|
255
|
+
const next = { ...prev }
|
|
256
|
+
delete next.lastName
|
|
257
|
+
return next
|
|
258
|
+
})
|
|
259
|
+
setLastName(v)
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
) : null}
|
|
264
|
+
|
|
265
|
+
{editSection === 'phone' ? (
|
|
266
|
+
<CheckoutTextField
|
|
267
|
+
id={`${formId}-phone`}
|
|
268
|
+
label="Celular"
|
|
269
|
+
value={phone}
|
|
270
|
+
error={fieldErrors.phone}
|
|
271
|
+
placeholder="+51999999999"
|
|
272
|
+
autoComplete="tel"
|
|
273
|
+
onChange={(v) => {
|
|
274
|
+
setFieldErrors((prev) => {
|
|
275
|
+
const next = { ...prev }
|
|
276
|
+
delete next.phone
|
|
277
|
+
return next
|
|
278
|
+
})
|
|
279
|
+
setPhone(v)
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
) : null}
|
|
283
|
+
|
|
284
|
+
{editSection === 'document' ? (
|
|
285
|
+
<div className="grid gap-4">
|
|
286
|
+
<CheckoutSelectField
|
|
287
|
+
id={`${formId}-identificationType`}
|
|
288
|
+
label="Tipo"
|
|
289
|
+
value={identificationType}
|
|
290
|
+
error={fieldErrors.identificationType}
|
|
291
|
+
options={IDENTIFICATION_TYPES.map((t) => ({
|
|
292
|
+
value: t,
|
|
293
|
+
label: t,
|
|
294
|
+
}))}
|
|
295
|
+
onChange={(v) => {
|
|
296
|
+
setFieldErrors((prev) => {
|
|
297
|
+
const next = { ...prev }
|
|
298
|
+
delete next.identificationType
|
|
299
|
+
return next
|
|
300
|
+
})
|
|
301
|
+
setIdentificationType(v)
|
|
302
|
+
}}
|
|
303
|
+
/>
|
|
304
|
+
<CheckoutTextField
|
|
305
|
+
id={`${formId}-identificationNumber`}
|
|
306
|
+
label="Número"
|
|
307
|
+
value={identificationNumber}
|
|
308
|
+
error={fieldErrors.identificationNumber}
|
|
309
|
+
placeholder="12345678"
|
|
310
|
+
onChange={(v) => {
|
|
311
|
+
setFieldErrors((prev) => {
|
|
312
|
+
const next = { ...prev }
|
|
313
|
+
delete next.identificationNumber
|
|
314
|
+
return next
|
|
315
|
+
})
|
|
316
|
+
setIdentificationNumber(v)
|
|
317
|
+
}}
|
|
318
|
+
/>
|
|
319
|
+
</div>
|
|
320
|
+
) : null}
|
|
321
|
+
|
|
322
|
+
{formError ? <FieldError message={formError} /> : null}
|
|
323
|
+
|
|
324
|
+
<div className="flex flex-wrap justify-end gap-2 pt-2">
|
|
325
|
+
<Button type="button" variant="outline" onClick={closeEdit}>
|
|
326
|
+
Cancelar
|
|
327
|
+
</Button>
|
|
328
|
+
<Button type="submit" disabled={saving}>
|
|
329
|
+
{saving ? <Loader2 className="size-4 animate-spin" /> : 'Guardar'}
|
|
330
|
+
</Button>
|
|
331
|
+
</div>
|
|
332
|
+
</form>
|
|
333
|
+
</dialog>
|
|
334
|
+
</div>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function ProfileRow({
|
|
339
|
+
label,
|
|
340
|
+
value,
|
|
341
|
+
onEdit,
|
|
342
|
+
editable = true,
|
|
343
|
+
disabled = false,
|
|
344
|
+
isLast = false,
|
|
345
|
+
}: {
|
|
346
|
+
label: string
|
|
347
|
+
value: string
|
|
348
|
+
onEdit?: () => void
|
|
349
|
+
editable?: boolean
|
|
350
|
+
disabled?: boolean
|
|
351
|
+
isLast?: boolean
|
|
352
|
+
}) {
|
|
353
|
+
return (
|
|
354
|
+
<div
|
|
355
|
+
className={cn(
|
|
356
|
+
'flex items-start justify-between gap-4 px-5 py-5',
|
|
357
|
+
!isLast && 'border-b border-border',
|
|
358
|
+
)}
|
|
359
|
+
>
|
|
360
|
+
<div className="min-w-0">
|
|
361
|
+
<p className="text-sm font-medium text-foreground">{label}</p>
|
|
362
|
+
<p className="mt-1 break-words text-sm text-muted-foreground">{value}</p>
|
|
363
|
+
</div>
|
|
364
|
+
{editable && onEdit ? (
|
|
365
|
+
<Button
|
|
366
|
+
type="button"
|
|
367
|
+
variant="ghost"
|
|
368
|
+
size="sm"
|
|
369
|
+
className="shrink-0 gap-1"
|
|
370
|
+
onClick={onEdit}
|
|
371
|
+
disabled={disabled}
|
|
372
|
+
>
|
|
373
|
+
<Pencil className="size-3.5" />
|
|
374
|
+
Editar
|
|
375
|
+
</Button>
|
|
376
|
+
) : null}
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
@@ -3,6 +3,11 @@ import { Button, buttonVariants, cn } from '../../ui'
|
|
|
3
3
|
import type { CheckoutFieldId } from './form-schema'
|
|
4
4
|
import { CheckoutTextField } from './checkout-form-controls'
|
|
5
5
|
|
|
6
|
+
function checkoutAuthRedirect(): string {
|
|
7
|
+
if (typeof window === 'undefined') return '/checkout'
|
|
8
|
+
return `${window.location.pathname}${window.location.search}`
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
/** Paso 1: Mis datos (email). */
|
|
7
12
|
export function CheckoutContactSection({
|
|
8
13
|
email,
|
|
@@ -21,6 +26,8 @@ export function CheckoutContactSection({
|
|
|
21
26
|
onClearError: (id: CheckoutFieldId) => void
|
|
22
27
|
onContinueAsGuest: () => void | Promise<void>
|
|
23
28
|
}) {
|
|
29
|
+
const authRedirect = checkoutAuthRedirect()
|
|
30
|
+
|
|
24
31
|
return (
|
|
25
32
|
<div className="space-y-5">
|
|
26
33
|
{sessionLabel ? (
|
|
@@ -42,18 +49,17 @@ export function CheckoutContactSection({
|
|
|
42
49
|
autoComplete="email"
|
|
43
50
|
/>
|
|
44
51
|
|
|
45
|
-
<div className="flex flex-col gap-3 sm:flex-row">
|
|
52
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
|
46
53
|
{!sessionLabel ? (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
redirect:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
</Link>
|
|
54
|
+
<>
|
|
55
|
+
<Link
|
|
56
|
+
to="/login"
|
|
57
|
+
search={{ redirect: authRedirect }}
|
|
58
|
+
className={cn(buttonVariants({ variant: 'default' }), 'uppercase tracking-wide')}
|
|
59
|
+
>
|
|
60
|
+
¿Ya tienes una cuenta?
|
|
61
|
+
</Link>
|
|
62
|
+
</>
|
|
57
63
|
) : null}
|
|
58
64
|
<Button
|
|
59
65
|
type="button"
|
|
@@ -42,11 +42,13 @@ import {
|
|
|
42
42
|
} from './form-schema'
|
|
43
43
|
import type { CheckoutContactForm } from './types'
|
|
44
44
|
import { useCheckoutGeo } from './use-checkout-geo'
|
|
45
|
+
import { useCheckoutCustomerPrefill } from './use-checkout-customer-prefill'
|
|
45
46
|
import { useCheckoutSession } from './use-checkout-session'
|
|
46
47
|
|
|
47
48
|
export function CheckoutSessionView({ cartId }: { cartId: string }) {
|
|
48
49
|
const { store } = useSettings()
|
|
49
50
|
const { session } = useAuth()
|
|
51
|
+
const isAuthenticated = Boolean(session?.user)
|
|
50
52
|
const checkout = useCheckoutSession(cartId)
|
|
51
53
|
const [stepFlow, setStepFlow] = useState<CheckoutStepFlow>(() => createCheckoutStepFlow())
|
|
52
54
|
const [fieldErrors, setFieldErrors] = useState<
|
|
@@ -58,6 +60,9 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
|
|
|
58
60
|
const [acceptedAge, setAcceptedAge] = useState(false)
|
|
59
61
|
const [acceptedPrivacy, setAcceptedPrivacy] = useState(false)
|
|
60
62
|
const [consentError, setConsentError] = useState<string | null>(null)
|
|
63
|
+
const [addressSaveError, setAddressSaveError] = useState<string | null>(null)
|
|
64
|
+
const [saveToAccount, setSaveToAccount] = useState(false)
|
|
65
|
+
const [saveAddressKey, setSaveAddressKey] = useState('Casa')
|
|
61
66
|
|
|
62
67
|
const geo = useCheckoutGeo(
|
|
63
68
|
checkout.contact.countryCode,
|
|
@@ -65,6 +70,34 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
|
|
|
65
70
|
checkout.contact.provinceCode,
|
|
66
71
|
)
|
|
67
72
|
|
|
73
|
+
const customerPrefill = useCheckoutCustomerPrefill({
|
|
74
|
+
enabled: isAuthenticated && checkout.loadStatus === 'ready',
|
|
75
|
+
cartId,
|
|
76
|
+
currency: store.currency,
|
|
77
|
+
contact: checkout.contact,
|
|
78
|
+
setContact: checkout.setContact,
|
|
79
|
+
departments: geo.departments,
|
|
80
|
+
provinces: geo.provinces,
|
|
81
|
+
districts: geo.districts,
|
|
82
|
+
selectedShippingKey: checkout.selectedShippingKey,
|
|
83
|
+
shippingFlat: checkout.shippingFlat,
|
|
84
|
+
shippingLoading: checkout.shippingLoading,
|
|
85
|
+
stepFlow,
|
|
86
|
+
setStepFlow,
|
|
87
|
+
persistDeliveryStep: checkout.persistDeliveryStep,
|
|
88
|
+
invalidateDeliveryPersistence: checkout.invalidateDeliveryPersistence,
|
|
89
|
+
saveAddressIntent:
|
|
90
|
+
isAuthenticated && saveToAccount
|
|
91
|
+
? { enabled: true, addressKey: saveAddressKey.trim() || 'Casa' }
|
|
92
|
+
: null,
|
|
93
|
+
onAddressSaveError: (message) => setAddressSaveError(message),
|
|
94
|
+
onAddressSaved: () => {
|
|
95
|
+
setSaveToAccount(false)
|
|
96
|
+
setAddressSaveError(null)
|
|
97
|
+
},
|
|
98
|
+
associateBuyer: () => checkout.saveBuyer().then(() => undefined),
|
|
99
|
+
})
|
|
100
|
+
|
|
68
101
|
const { ensureDefaultPaymentMethod } = checkout
|
|
69
102
|
|
|
70
103
|
useEffect(() => {
|
|
@@ -143,10 +176,28 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
|
|
|
143
176
|
return
|
|
144
177
|
}
|
|
145
178
|
setFieldErrors({})
|
|
146
|
-
|
|
179
|
+
|
|
180
|
+
const { email, firstName, lastName, phone } = checkout.contact
|
|
181
|
+
const canAssociateBuyer =
|
|
182
|
+
isAuthenticated &&
|
|
183
|
+
Boolean(email.trim() && firstName.trim() && lastName.trim() && phone.trim())
|
|
184
|
+
|
|
185
|
+
void (async () => {
|
|
186
|
+
if (canAssociateBuyer) {
|
|
187
|
+
try {
|
|
188
|
+
await checkout.saveBuyer()
|
|
189
|
+
} catch {
|
|
190
|
+
// El avance del paso no depende de asociar el buyer; se reintenta en delivery.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
setStepFlow((flow) => completeCheckoutStep(flow, 'contact'))
|
|
194
|
+
})()
|
|
147
195
|
}
|
|
148
196
|
|
|
149
|
-
const handleContinueDelivery = async (
|
|
197
|
+
const handleContinueDelivery = async (options?: {
|
|
198
|
+
saveToAccount?: boolean
|
|
199
|
+
addressKey?: string
|
|
200
|
+
}) => {
|
|
150
201
|
const parsed = buildCheckoutDeliveryStepSchema(store.currency).safeParse(validationContext())
|
|
151
202
|
if (!parsed.success) {
|
|
152
203
|
const errors = mapZodIssuesToFieldErrors(parsed.error)
|
|
@@ -156,8 +207,26 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
|
|
|
156
207
|
return
|
|
157
208
|
}
|
|
158
209
|
setFieldErrors({})
|
|
210
|
+
setAddressSaveError(null)
|
|
211
|
+
const shouldSave = Boolean(options?.saveToAccount ?? saveToAccount) && isAuthenticated
|
|
212
|
+
const key = (options?.addressKey ?? saveAddressKey).trim() || 'Casa'
|
|
159
213
|
try {
|
|
160
214
|
await checkout.persistDeliveryStep()
|
|
215
|
+
if (shouldSave) {
|
|
216
|
+
try {
|
|
217
|
+
await customerPrefill.saveContactAsCustomerAddress(checkout.contact, {
|
|
218
|
+
addressKey: key,
|
|
219
|
+
})
|
|
220
|
+
setSaveToAccount(false)
|
|
221
|
+
} catch (error) {
|
|
222
|
+
setAddressSaveError(
|
|
223
|
+
error instanceof Error
|
|
224
|
+
? error.message
|
|
225
|
+
: 'No se pudo guardar la dirección en tu cuenta.',
|
|
226
|
+
)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
}
|
|
161
230
|
setStepFlow((flow) => completeCheckoutStep(flow, 'delivery'))
|
|
162
231
|
} catch {
|
|
163
232
|
/* formError en sesión */
|
|
@@ -328,6 +397,18 @@ export function CheckoutSessionView({ cartId }: { cartId: string }) {
|
|
|
328
397
|
onSelectShipping={checkout.setSelectedShippingKey}
|
|
329
398
|
onClearError={clearFieldError}
|
|
330
399
|
onContinue={handleContinueDelivery}
|
|
400
|
+
savedAddresses={customerPrefill.savedAddresses}
|
|
401
|
+
selectedSavedAddressId={customerPrefill.selectedSavedAddressId}
|
|
402
|
+
onSelectSavedAddress={customerPrefill.selectSavedAddress}
|
|
403
|
+
canSaveToAccount={isAuthenticated}
|
|
404
|
+
addressSaveError={addressSaveError}
|
|
405
|
+
saveToAccount={saveToAccount}
|
|
406
|
+
saveAddressKey={saveAddressKey}
|
|
407
|
+
onSaveToAccountChange={(checked) => {
|
|
408
|
+
setSaveToAccount(checked)
|
|
409
|
+
setAddressSaveError(null)
|
|
410
|
+
}}
|
|
411
|
+
onSaveAddressKeyChange={setSaveAddressKey}
|
|
331
412
|
/>
|
|
332
413
|
</CheckoutStepCard>
|
|
333
414
|
|