@ecomiq/storefront 2.0.12 → 2.0.15

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 (32) hide show
  1. package/bin/ecomiq.mjs +3 -1
  2. package/package.json +4 -2
  3. package/src/auth/create-storefront-auth.ts +65 -6
  4. package/src/auth/ecomiq-oauth-password-constants.ts +1 -3
  5. package/src/auth/ecomiq-oauth-password-plugin.ts +12 -17
  6. package/src/auth/ecomiq-storefront-sign-in.ts +0 -3
  7. package/src/auth/secure-cookies.ts +23 -0
  8. package/src/auth/storefront-auth-base-url.ts +117 -0
  9. package/src/codegen.mjs +27 -1
  10. package/src/components/account/customer-addresses-panel.tsx +678 -0
  11. package/src/components/account/customer-order-detail-panel.tsx +285 -0
  12. package/src/components/account/customer-orders-panel.tsx +192 -0
  13. package/src/components/account/customer-profile-panel.tsx +379 -0
  14. package/src/components/checkout/checkout-contact-section.tsx +17 -11
  15. package/src/components/checkout/checkout-session-view.tsx +83 -2
  16. package/src/components/checkout/checkout-shipping-address-section.tsx +107 -2
  17. package/src/components/checkout/checkout-steps.ts +15 -0
  18. package/src/components/checkout/map-customer-to-checkout.ts +188 -0
  19. package/src/components/checkout/use-checkout-customer-prefill.ts +409 -0
  20. package/src/components/checkout/use-checkout-session.ts +5 -4
  21. package/src/config.d.ts +4 -0
  22. package/src/config.mjs +14 -0
  23. package/src/index.ts +54 -0
  24. package/src/pages/account/account-page-skeleton.tsx +69 -0
  25. package/src/pages/account/account-page-view.tsx +270 -0
  26. package/src/pages/account/account-shell.tsx +101 -0
  27. package/src/pages/account/parse-account-route.ts +36 -0
  28. package/src/pages/account.tsx +16 -0
  29. package/src/pages/checkout.$checkoutId.tsx +1 -1
  30. package/src/providers/auth.tsx +11 -2
  31. package/src/proxy-api.ts +20 -0
  32. package/src/vite-inline.mjs +54 -1
@@ -0,0 +1,678 @@
1
+ import { useEffect, useId, useRef, useState, type FormEvent } from 'react'
2
+ import { z } from 'zod'
3
+ import type {
4
+ CustomerAddressUpsertInput,
5
+ CustomerSavedAddress,
6
+ } from '@ecomiq/core/models/customer-address'
7
+ import { Loader2, MapPin, Pencil, Plus, Star, Trash2 } from 'lucide-react'
8
+ import { Button, cn } from '../../ui'
9
+ import {
10
+ CheckoutSelectField,
11
+ CheckoutTextField,
12
+ FieldError,
13
+ fieldClass,
14
+ } from '../checkout/checkout-form-controls'
15
+ import { useCheckoutGeo } from '../checkout/use-checkout-geo'
16
+
17
+ const DEFAULT_COUNTRY_CODE = 'PE'
18
+
19
+ const addressFormSchema = z.object({
20
+ addressKey: z.string().trim().min(1, 'Nombre de la dirección requerido').max(48),
21
+ addressLine: z.string().trim().min(1, 'Calle o avenida requerida'),
22
+ addressNumber: z.string().trim().min(1, 'Número requerido'),
23
+ postalCode: z.string().trim().min(1, 'Código postal requerido'),
24
+ reference: z.string().trim(),
25
+ phone: z.string().trim(),
26
+ cellPhone: z.string().trim(),
27
+ departmentCode: z.string().min(1, 'Selecciona departamento'),
28
+ departmentName: z.string().min(1, 'Selecciona departamento'),
29
+ provinceCode: z.string().min(1, 'Selecciona provincia'),
30
+ provinceName: z.string().min(1, 'Selecciona provincia'),
31
+ districtCode: z.string().min(1, 'Selecciona distrito'),
32
+ districtName: z.string().min(1, 'Selecciona distrito'),
33
+ isDefault: z.boolean(),
34
+ })
35
+
36
+ type AddressFormValues = z.infer<typeof addressFormSchema>
37
+ type AddressFormErrors = Partial<Record<keyof AddressFormValues, string>>
38
+
39
+ const emptyDefaults: AddressFormValues = {
40
+ addressKey: 'Casa',
41
+ addressLine: '',
42
+ addressNumber: '',
43
+ postalCode: '',
44
+ reference: '',
45
+ phone: '',
46
+ cellPhone: '',
47
+ departmentCode: '',
48
+ departmentName: '',
49
+ provinceCode: '',
50
+ provinceName: '',
51
+ districtCode: '',
52
+ districtName: '',
53
+ isDefault: false,
54
+ }
55
+
56
+ function toUpsertPayload(values: AddressFormValues): CustomerAddressUpsertInput {
57
+ return {
58
+ addressKey: values.addressKey.trim(),
59
+ countryIsoCode: DEFAULT_COUNTRY_CODE,
60
+ addressLine: values.addressLine.trim(),
61
+ addressNumber: values.addressNumber.trim(),
62
+ department: values.departmentName.trim(),
63
+ province: values.provinceName.trim(),
64
+ district: values.districtName.trim(),
65
+ phone: values.phone.trim(),
66
+ cellPhone: values.cellPhone.trim() || values.phone.trim(),
67
+ postalCode: values.postalCode.trim(),
68
+ reference: values.reference.trim(),
69
+ latitude: null,
70
+ longitude: null,
71
+ isDefault: values.isDefault,
72
+ }
73
+ }
74
+
75
+ function findCodeByName(
76
+ items: { code: string; name: string }[],
77
+ name: string,
78
+ ): string {
79
+ const target = name.trim().toLowerCase()
80
+ if (!target) return ''
81
+ return (
82
+ items.find((item) => item.name.trim().toLowerCase() === target)?.code ?? ''
83
+ )
84
+ }
85
+
86
+ export type CustomerAddressesPanelProps = {
87
+ addresses?: CustomerSavedAddress[]
88
+ isLoading: boolean
89
+ errorMessage?: string | null
90
+ onCreateAddress: (payload: CustomerAddressUpsertInput) => Promise<void>
91
+ onUpdateAddress: (
92
+ addressId: string,
93
+ payload: CustomerAddressUpsertInput,
94
+ ) => Promise<void>
95
+ onDeleteAddress: (addressId: string) => Promise<void>
96
+ }
97
+
98
+ export function CustomerAddressesPanel({
99
+ addresses,
100
+ isLoading,
101
+ errorMessage,
102
+ onCreateAddress,
103
+ onUpdateAddress,
104
+ onDeleteAddress,
105
+ }: CustomerAddressesPanelProps) {
106
+ const formId = useId()
107
+ const dialogRef = useRef<HTMLDialogElement>(null)
108
+ const deleteDialogRef = useRef<HTMLDialogElement>(null)
109
+
110
+ const [editing, setEditing] = useState<CustomerSavedAddress | null>(null)
111
+ const [form, setForm] = useState<AddressFormValues>(emptyDefaults)
112
+ const [fieldErrors, setFieldErrors] = useState<AddressFormErrors>({})
113
+ const [formError, setFormError] = useState<string | null>(null)
114
+ const [saving, setSaving] = useState(false)
115
+ const [deleteTarget, setDeleteTarget] = useState<CustomerSavedAddress | null>(
116
+ null,
117
+ )
118
+ const [deleting, setDeleting] = useState(false)
119
+ const [actionError, setActionError] = useState<string | null>(null)
120
+
121
+ const geo = useCheckoutGeo(
122
+ DEFAULT_COUNTRY_CODE,
123
+ form.departmentCode,
124
+ form.provinceCode,
125
+ )
126
+
127
+ const patchForm = (patch: Partial<AddressFormValues>) => {
128
+ setForm((prev) => ({ ...prev, ...patch }))
129
+ }
130
+
131
+ const clearFieldError = (key: keyof AddressFormValues) => {
132
+ setFieldErrors((prev) => {
133
+ if (!prev[key]) return prev
134
+ const next = { ...prev }
135
+ delete next[key]
136
+ return next
137
+ })
138
+ }
139
+
140
+ const openCreate = () => {
141
+ setEditing(null)
142
+ setForm(emptyDefaults)
143
+ setFieldErrors({})
144
+ setFormError(null)
145
+ dialogRef.current?.showModal()
146
+ }
147
+
148
+ const openEdit = (addr: CustomerSavedAddress) => {
149
+ setEditing(addr)
150
+ setForm({
151
+ addressKey: addr.addressKey,
152
+ addressLine: addr.addressLine,
153
+ addressNumber: addr.addressNumber,
154
+ postalCode: addr.postalCode,
155
+ reference: addr.reference ?? '',
156
+ phone: addr.phone ?? '',
157
+ cellPhone: addr.cellPhone ?? '',
158
+ departmentCode: '',
159
+ departmentName: addr.department,
160
+ provinceCode: '',
161
+ provinceName: addr.province,
162
+ districtCode: '',
163
+ districtName: addr.district,
164
+ isDefault: addr.isDefault,
165
+ })
166
+ setFieldErrors({})
167
+ setFormError(null)
168
+ dialogRef.current?.showModal()
169
+ }
170
+
171
+ const closeDialog = () => {
172
+ dialogRef.current?.close()
173
+ setEditing(null)
174
+ setForm(emptyDefaults)
175
+ setFieldErrors({})
176
+ setFormError(null)
177
+ }
178
+
179
+ useEffect(() => {
180
+ if (!editing || !form.departmentName || form.departmentCode) return
181
+ const code = findCodeByName(geo.departments, form.departmentName)
182
+ if (code) patchForm({ departmentCode: code })
183
+ }, [editing, form.departmentName, form.departmentCode, geo.departments])
184
+
185
+ useEffect(() => {
186
+ if (!editing || !form.provinceName || !form.departmentCode || form.provinceCode)
187
+ return
188
+ const code = findCodeByName(geo.provinces, form.provinceName)
189
+ if (code) patchForm({ provinceCode: code })
190
+ }, [
191
+ editing,
192
+ form.provinceName,
193
+ form.departmentCode,
194
+ form.provinceCode,
195
+ geo.provinces,
196
+ ])
197
+
198
+ useEffect(() => {
199
+ if (!editing || !form.districtName || !form.provinceCode || form.districtCode)
200
+ return
201
+ const code = findCodeByName(geo.districts, form.districtName)
202
+ if (code) patchForm({ districtCode: code })
203
+ }, [
204
+ editing,
205
+ form.districtName,
206
+ form.provinceCode,
207
+ form.districtCode,
208
+ geo.districts,
209
+ ])
210
+
211
+ const handleSubmit = async (event: FormEvent) => {
212
+ event.preventDefault()
213
+ const parsed = addressFormSchema.safeParse(form)
214
+ if (!parsed.success) {
215
+ const next: AddressFormErrors = {}
216
+ for (const issue of parsed.error.issues) {
217
+ const key = issue.path[0]
218
+ if (typeof key === 'string' && !(key in next)) {
219
+ next[key as keyof AddressFormValues] = issue.message
220
+ }
221
+ }
222
+ setFieldErrors(next)
223
+ return
224
+ }
225
+
226
+ try {
227
+ setSaving(true)
228
+ setFormError(null)
229
+ const payload = toUpsertPayload(parsed.data)
230
+ if (editing?.id) {
231
+ await onUpdateAddress(editing.id, payload)
232
+ } else {
233
+ await onCreateAddress(payload)
234
+ }
235
+ closeDialog()
236
+ } catch (error) {
237
+ setFormError(
238
+ error instanceof Error ? error.message : 'No se pudo guardar la dirección',
239
+ )
240
+ } finally {
241
+ setSaving(false)
242
+ }
243
+ }
244
+
245
+ const handleSetDefault = async (addr: CustomerSavedAddress) => {
246
+ if (!addr.id || addr.isDefault) return
247
+ const payload: CustomerAddressUpsertInput = {
248
+ addressKey: addr.addressKey,
249
+ countryIsoCode: addr.countryIsoCode,
250
+ addressLine: addr.addressLine,
251
+ addressNumber: addr.addressNumber,
252
+ department: addr.department,
253
+ province: addr.province,
254
+ district: addr.district,
255
+ phone: addr.phone,
256
+ cellPhone: addr.cellPhone || addr.phone,
257
+ postalCode: addr.postalCode,
258
+ reference: addr.reference,
259
+ latitude: addr.latitude,
260
+ longitude: addr.longitude,
261
+ isDefault: true,
262
+ }
263
+ try {
264
+ setActionError(null)
265
+ await onUpdateAddress(addr.id, payload)
266
+ } catch (error) {
267
+ setActionError(
268
+ error instanceof Error ? error.message : 'No se pudo actualizar',
269
+ )
270
+ }
271
+ }
272
+
273
+ const openDelete = (addr: CustomerSavedAddress) => {
274
+ setDeleteTarget(addr)
275
+ setActionError(null)
276
+ deleteDialogRef.current?.showModal()
277
+ }
278
+
279
+ const closeDelete = () => {
280
+ deleteDialogRef.current?.close()
281
+ setDeleteTarget(null)
282
+ }
283
+
284
+ const confirmDelete = async () => {
285
+ if (!deleteTarget?.id) return
286
+ try {
287
+ setDeleting(true)
288
+ setActionError(null)
289
+ await onDeleteAddress(deleteTarget.id)
290
+ closeDelete()
291
+ } catch (error) {
292
+ setActionError(
293
+ error instanceof Error ? error.message : 'No se pudo eliminar',
294
+ )
295
+ } finally {
296
+ setDeleting(false)
297
+ }
298
+ }
299
+
300
+ const list = addresses ?? []
301
+
302
+ return (
303
+ <div className="space-y-6">
304
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
305
+ <div>
306
+ <h2 className="text-lg font-medium text-foreground">Tus direcciones</h2>
307
+ <p className="mt-1 text-sm text-muted-foreground">
308
+ Envío a domicilio y datos de contacto para tus pedidos.
309
+ </p>
310
+ </div>
311
+ <Button type="button" onClick={openCreate} className="shrink-0 gap-2">
312
+ <Plus className="size-4" />
313
+ Añadir dirección
314
+ </Button>
315
+ </div>
316
+
317
+ {actionError ? (
318
+ <p className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
319
+ {actionError}
320
+ </p>
321
+ ) : null}
322
+
323
+ {isLoading ? (
324
+ <div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-8 text-sm text-muted-foreground">
325
+ <Loader2 className="size-4 animate-spin" />
326
+ Cargando direcciones…
327
+ </div>
328
+ ) : null}
329
+
330
+ {errorMessage && !isLoading ? (
331
+ <p className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
332
+ {errorMessage}
333
+ </p>
334
+ ) : null}
335
+
336
+ {!isLoading && !errorMessage && list.length === 0 ? (
337
+ <div className="rounded-lg border border-dashed border-border bg-muted/30 px-6 py-10 text-center text-sm text-muted-foreground">
338
+ <MapPin className="mx-auto mb-3 size-10 text-muted-foreground/70" />
339
+ Todavía no tienes direcciones guardadas.
340
+ </div>
341
+ ) : null}
342
+
343
+ {!isLoading && list.length > 0 ? (
344
+ <ul className="grid gap-4 lg:grid-cols-2">
345
+ {list.map((addr) => (
346
+ <li
347
+ key={addr.id || `${addr.addressKey}-${addr.postalCode}`}
348
+ className={cn(
349
+ 'relative overflow-hidden rounded-lg border border-border bg-background p-5',
350
+ addr.isDefault && 'ring-1 ring-primary/40',
351
+ )}
352
+ >
353
+ {addr.isDefault ? (
354
+ <span className="absolute right-0 top-0 rounded-bl-md bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
355
+ Predeterminada
356
+ </span>
357
+ ) : null}
358
+ <div className="flex items-start gap-3 pr-24">
359
+ <span className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
360
+ <MapPin className="size-5" />
361
+ </span>
362
+ <div className="min-w-0 flex-1">
363
+ <p className="font-medium text-foreground">{addr.addressKey}</p>
364
+ <p className="mt-1 text-sm leading-relaxed text-muted-foreground">
365
+ {[addr.addressLine, addr.addressNumber].filter(Boolean).join(' ')}
366
+ <br />
367
+ {addr.district}, {addr.province}, {addr.department}
368
+ </p>
369
+ <p className="mt-1 text-xs text-muted-foreground">
370
+ {addr.countryIsoCode} · CP {addr.postalCode}
371
+ </p>
372
+ {addr.reference ? (
373
+ <p className="mt-1 text-xs text-muted-foreground">
374
+ Ref: {addr.reference}
375
+ </p>
376
+ ) : null}
377
+ {(addr.phone || addr.cellPhone) && (
378
+ <p className="mt-2 text-xs text-muted-foreground">
379
+ {addr.phone}
380
+ {addr.cellPhone && addr.cellPhone !== addr.phone
381
+ ? ` · ${addr.cellPhone}`
382
+ : ''}
383
+ </p>
384
+ )}
385
+ </div>
386
+ </div>
387
+ <div className="mt-4 flex flex-wrap gap-2 border-t border-border pt-4">
388
+ <Button
389
+ type="button"
390
+ variant="outline"
391
+ size="sm"
392
+ className="gap-1"
393
+ onClick={() => openEdit(addr)}
394
+ disabled={!addr.id}
395
+ >
396
+ <Pencil className="size-3.5" />
397
+ Editar
398
+ </Button>
399
+ <Button
400
+ type="button"
401
+ variant="outline"
402
+ size="sm"
403
+ className="gap-1"
404
+ disabled={!addr.id || addr.isDefault}
405
+ onClick={() => void handleSetDefault(addr)}
406
+ >
407
+ <Star className="size-3.5" />
408
+ Predeterminada
409
+ </Button>
410
+ <Button
411
+ type="button"
412
+ variant="ghost"
413
+ size="sm"
414
+ className="ml-auto gap-1 text-destructive hover:bg-destructive/10 hover:text-destructive"
415
+ disabled={!addr.id}
416
+ onClick={() => openDelete(addr)}
417
+ >
418
+ <Trash2 className="size-3.5" />
419
+ Eliminar
420
+ </Button>
421
+ </div>
422
+ </li>
423
+ ))}
424
+ </ul>
425
+ ) : null}
426
+
427
+ <dialog
428
+ ref={dialogRef}
429
+ className="fixed left-1/2 top-1/2 z-50 m-0 max-h-[90vh] w-[min(100%-2rem,36rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border border-border bg-background p-0 shadow-lg open:flex open:flex-col backdrop:bg-black/40"
430
+ onClose={() => {
431
+ setEditing(null)
432
+ setForm(emptyDefaults)
433
+ setFieldErrors({})
434
+ setFormError(null)
435
+ }}
436
+ >
437
+ <form className="space-y-4 p-6" onSubmit={(e) => void handleSubmit(e)}>
438
+ <h3 className="text-base font-medium text-foreground">
439
+ {editing ? 'Editar dirección' : 'Nueva dirección'}
440
+ </h3>
441
+
442
+ <div className="grid gap-4 sm:grid-cols-2">
443
+ <CheckoutTextField
444
+ id={`${formId}-addressKey`}
445
+ label="Identificador"
446
+ value={form.addressKey}
447
+ error={fieldErrors.addressKey}
448
+ placeholder="Casa, Oficina…"
449
+ onChange={(v) => {
450
+ clearFieldError('addressKey')
451
+ patchForm({ addressKey: v })
452
+ }}
453
+ />
454
+ <CheckoutTextField
455
+ id={`${formId}-postalCode`}
456
+ label="Código postal"
457
+ value={form.postalCode}
458
+ error={fieldErrors.postalCode}
459
+ placeholder="15074"
460
+ onChange={(v) => {
461
+ clearFieldError('postalCode')
462
+ patchForm({ postalCode: v })
463
+ }}
464
+ />
465
+ </div>
466
+
467
+ <CheckoutTextField
468
+ id={`${formId}-addressLine`}
469
+ label="Dirección (calle / av.)"
470
+ value={form.addressLine}
471
+ error={fieldErrors.addressLine}
472
+ placeholder="Av. Principal"
473
+ onChange={(v) => {
474
+ clearFieldError('addressLine')
475
+ patchForm({ addressLine: v })
476
+ }}
477
+ />
478
+
479
+ <CheckoutTextField
480
+ id={`${formId}-addressNumber`}
481
+ label="Número / interior"
482
+ value={form.addressNumber}
483
+ error={fieldErrors.addressNumber}
484
+ placeholder="123, Dpto. 4"
485
+ onChange={(v) => {
486
+ clearFieldError('addressNumber')
487
+ patchForm({ addressNumber: v })
488
+ }}
489
+ />
490
+
491
+ <div>
492
+ <label
493
+ htmlFor={`${formId}-country`}
494
+ className="text-sm font-medium text-foreground"
495
+ >
496
+ País
497
+ </label>
498
+ <select
499
+ id={`${formId}-country`}
500
+ className={fieldClass()}
501
+ value={DEFAULT_COUNTRY_CODE}
502
+ disabled
503
+ >
504
+ <option value={DEFAULT_COUNTRY_CODE}>Perú</option>
505
+ </select>
506
+ </div>
507
+
508
+ <div className="grid gap-4 sm:grid-cols-3">
509
+ <CheckoutSelectField
510
+ id={`${formId}-department`}
511
+ label="Departamento"
512
+ value={form.departmentCode}
513
+ error={fieldErrors.departmentCode || fieldErrors.departmentName}
514
+ options={geo.departments.map((d) => ({
515
+ value: d.code,
516
+ label: d.name,
517
+ }))}
518
+ onChange={(code) => {
519
+ clearFieldError('departmentCode')
520
+ clearFieldError('departmentName')
521
+ const name =
522
+ geo.departments.find((d) => d.code === code)?.name ?? ''
523
+ patchForm({
524
+ departmentCode: code,
525
+ departmentName: name,
526
+ provinceCode: '',
527
+ provinceName: '',
528
+ districtCode: '',
529
+ districtName: '',
530
+ })
531
+ }}
532
+ />
533
+ <CheckoutSelectField
534
+ id={`${formId}-province`}
535
+ label="Provincia"
536
+ value={form.provinceCode}
537
+ error={fieldErrors.provinceCode || fieldErrors.provinceName}
538
+ options={geo.provinces.map((p) => ({
539
+ value: p.code,
540
+ label: p.name,
541
+ }))}
542
+ onChange={(code) => {
543
+ clearFieldError('provinceCode')
544
+ clearFieldError('provinceName')
545
+ const name =
546
+ geo.provinces.find((p) => p.code === code)?.name ?? ''
547
+ patchForm({
548
+ provinceCode: code,
549
+ provinceName: name,
550
+ districtCode: '',
551
+ districtName: '',
552
+ })
553
+ }}
554
+ />
555
+ <CheckoutSelectField
556
+ id={`${formId}-district`}
557
+ label="Distrito"
558
+ value={form.districtCode}
559
+ error={fieldErrors.districtCode || fieldErrors.districtName}
560
+ options={geo.districts.map((d) => ({
561
+ value: d.code,
562
+ label: d.name,
563
+ }))}
564
+ onChange={(code) => {
565
+ clearFieldError('districtCode')
566
+ clearFieldError('districtName')
567
+ const name =
568
+ geo.districts.find((d) => d.code === code)?.name ?? ''
569
+ patchForm({
570
+ districtCode: code,
571
+ districtName: name,
572
+ })
573
+ }}
574
+ />
575
+ </div>
576
+
577
+ <div>
578
+ <label
579
+ htmlFor={`${formId}-reference`}
580
+ className="text-sm font-medium text-foreground"
581
+ >
582
+ Referencia (opcional)
583
+ </label>
584
+ <textarea
585
+ id={`${formId}-reference`}
586
+ rows={2}
587
+ className={cn(fieldClass(), 'h-auto py-2')}
588
+ value={form.reference}
589
+ placeholder="Puerta azul, portón negro…"
590
+ onChange={(e) => patchForm({ reference: e.target.value })}
591
+ />
592
+ </div>
593
+
594
+ <div className="grid gap-4 sm:grid-cols-2">
595
+ <CheckoutTextField
596
+ id={`${formId}-phone`}
597
+ label="Teléfono"
598
+ value={form.phone}
599
+ error={fieldErrors.phone}
600
+ placeholder="+51999999999"
601
+ onChange={(v) => {
602
+ clearFieldError('phone')
603
+ patchForm({ phone: v })
604
+ }}
605
+ />
606
+ <CheckoutTextField
607
+ id={`${formId}-cellPhone`}
608
+ label="Celular (opcional)"
609
+ value={form.cellPhone}
610
+ error={fieldErrors.cellPhone}
611
+ placeholder="+51999999999"
612
+ onChange={(v) => {
613
+ clearFieldError('cellPhone')
614
+ patchForm({ cellPhone: v })
615
+ }}
616
+ />
617
+ </div>
618
+
619
+ <label className="flex items-center gap-2 text-sm text-foreground">
620
+ <input
621
+ type="checkbox"
622
+ className="size-4 rounded border-border"
623
+ checked={form.isDefault}
624
+ onChange={(e) => patchForm({ isDefault: e.target.checked })}
625
+ />
626
+ Usar como dirección predeterminada
627
+ </label>
628
+
629
+ {formError ? <FieldError message={formError} /> : null}
630
+
631
+ <div className="flex flex-wrap justify-end gap-2 pt-2">
632
+ <Button type="button" variant="outline" onClick={closeDialog}>
633
+ Cancelar
634
+ </Button>
635
+ <Button type="submit" disabled={saving}>
636
+ {saving ? <Loader2 className="size-4 animate-spin" /> : 'Guardar'}
637
+ </Button>
638
+ </div>
639
+ </form>
640
+ </dialog>
641
+
642
+ <dialog
643
+ ref={deleteDialogRef}
644
+ className="fixed left-1/2 top-1/2 z-50 m-0 w-[min(100%-2rem,24rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg backdrop:bg-black/40"
645
+ onClose={() => setDeleteTarget(null)}
646
+ >
647
+ <h3 className="text-base font-medium text-foreground">
648
+ ¿Eliminar esta dirección?
649
+ </h3>
650
+ <p className="mt-2 text-sm text-muted-foreground">
651
+ Esta acción no se puede deshacer. La dirección dejará de aparecer en tu
652
+ cuenta.
653
+ </p>
654
+ {actionError ? (
655
+ <p className="mt-3 text-sm text-destructive">{actionError}</p>
656
+ ) : null}
657
+ <div className="mt-6 flex flex-wrap justify-end gap-2">
658
+ <Button
659
+ type="button"
660
+ variant="outline"
661
+ disabled={deleting}
662
+ onClick={closeDelete}
663
+ >
664
+ Cancelar
665
+ </Button>
666
+ <Button
667
+ type="button"
668
+ variant="destructive"
669
+ disabled={deleting}
670
+ onClick={() => void confirmDelete()}
671
+ >
672
+ {deleting ? <Loader2 className="size-4 animate-spin" /> : 'Eliminar'}
673
+ </Button>
674
+ </div>
675
+ </dialog>
676
+ </div>
677
+ )
678
+ }