@ecomiq/storefront 2.0.11 → 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.
Files changed (34) 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/hooks/index.ts +9 -0
  24. package/src/hooks/recently-viewed.ts +125 -0
  25. package/src/index.ts +63 -0
  26. package/src/pages/account/account-page-skeleton.tsx +69 -0
  27. package/src/pages/account/account-page-view.tsx +270 -0
  28. package/src/pages/account/account-shell.tsx +101 -0
  29. package/src/pages/account/parse-account-route.ts +36 -0
  30. package/src/pages/account.tsx +16 -0
  31. package/src/pages/checkout.$checkoutId.tsx +1 -1
  32. package/src/providers/auth.tsx +11 -2
  33. package/src/proxy-api.ts +20 -0
  34. package/src/vite-inline.mjs +54 -1
@@ -1,4 +1,6 @@
1
+ import { useState } from 'react'
1
2
  import type { Country, Department, District, Province } from '@ecomiq/core'
3
+ import type { CustomerSavedAddress } from '@ecomiq/core'
2
4
  import { Button } from '../../ui'
3
5
  import type { CheckoutContactForm } from './types'
4
6
  import type { CheckoutFieldId } from './form-schema'
@@ -46,6 +48,15 @@ export function CheckoutShippingAddressSection({
46
48
  onSelectShipping,
47
49
  onClearError,
48
50
  onContinue,
51
+ savedAddresses,
52
+ selectedSavedAddressId,
53
+ onSelectSavedAddress,
54
+ canSaveToAccount = false,
55
+ addressSaveError,
56
+ saveToAccount = false,
57
+ saveAddressKey = 'Casa',
58
+ onSaveToAccountChange,
59
+ onSaveAddressKeyChange,
49
60
  }: {
50
61
  contact: CheckoutContactForm
51
62
  fieldErrors: Partial<Record<CheckoutFieldId, string>>
@@ -62,12 +73,65 @@ export function CheckoutShippingAddressSection({
62
73
  onPatch: (patch: Partial<CheckoutContactForm>) => void
63
74
  onSelectShipping: (key: string) => void
64
75
  onClearError: (id: CheckoutFieldId) => void
65
- onContinue: () => void | Promise<void>
76
+ onContinue: (options?: {
77
+ saveToAccount?: boolean
78
+ addressKey?: string
79
+ }) => void | Promise<void>
80
+ savedAddresses?: CustomerSavedAddress[]
81
+ selectedSavedAddressId?: string | null
82
+ onSelectSavedAddress?: (addressId: string) => void
83
+ /** Muestra checkbox para guardar en “Mis direcciones” (usuario logueado). */
84
+ canSaveToAccount?: boolean
85
+ addressSaveError?: string | null
86
+ saveToAccount?: boolean
87
+ saveAddressKey?: string
88
+ onSaveToAccountChange?: (checked: boolean) => void
89
+ onSaveAddressKeyChange?: (value: string) => void
66
90
  }) {
67
91
  const isPe = contact.countryCode.toUpperCase() === 'PE'
92
+ const hasSavedAddresses = Boolean(savedAddresses?.length && onSelectSavedAddress)
93
+ const [localSaveError, setLocalSaveError] = useState<string | null>(null)
94
+ const saveError = addressSaveError ?? localSaveError
68
95
 
69
96
  return (
70
97
  <div className="space-y-6">
98
+ {hasSavedAddresses ? (
99
+ <div className="min-w-0">
100
+ <label
101
+ htmlFor="saved-address"
102
+ className="text-sm font-medium text-foreground"
103
+ >
104
+ Dirección guardada
105
+ </label>
106
+ <select
107
+ id="saved-address"
108
+ className={fieldClass()}
109
+ value={selectedSavedAddressId ?? ''}
110
+ onChange={(e) => {
111
+ const id = e.target.value
112
+ if (id) onSelectSavedAddress?.(id)
113
+ }}
114
+ >
115
+ <option value="" disabled>
116
+ Seleccionar dirección…
117
+ </option>
118
+ {savedAddresses!.map((addr) => (
119
+ <option key={addr.id} value={addr.id}>
120
+ {addr.addressKey}
121
+ {addr.isDefault ? ' (predeterminada)' : ''}
122
+ {' — '}
123
+ {[addr.addressLine, addr.addressNumber, addr.district]
124
+ .filter(Boolean)
125
+ .join(' ')}
126
+ </option>
127
+ ))}
128
+ </select>
129
+ <p className="mt-1.5 text-xs text-muted-foreground">
130
+ Usa una dirección de tu cuenta o edita los campos abajo.
131
+ </p>
132
+ </div>
133
+ ) : null}
134
+
71
135
  <div className="grid gap-4 sm:grid-cols-2">
72
136
  <CheckoutTextField
73
137
  id="firstName"
@@ -281,12 +345,53 @@ export function CheckoutShippingAddressSection({
281
345
  </label>
282
346
  </div>
283
347
 
348
+ {canSaveToAccount ? (
349
+ <div className="space-y-3 rounded-lg border border-border bg-muted/20 p-4">
350
+ <label className="flex items-start gap-2.5 text-sm text-foreground">
351
+ <input
352
+ type="checkbox"
353
+ className="mt-0.5 size-4 rounded border-border accent-foreground"
354
+ checked={saveToAccount}
355
+ onChange={(e) => {
356
+ onSaveToAccountChange?.(e.target.checked)
357
+ setLocalSaveError(null)
358
+ }}
359
+ />
360
+ <span>
361
+ Guardar esta dirección en{' '}
362
+ <span className="font-medium">Mis direcciones</span>
363
+ </span>
364
+ </label>
365
+ {saveToAccount ? (
366
+ <CheckoutTextField
367
+ id="addressKey"
368
+ label="Nombre de la dirección"
369
+ value={saveAddressKey}
370
+ placeholder="Casa, Oficina…"
371
+ onChange={(v) => onSaveAddressKeyChange?.(v)}
372
+ />
373
+ ) : null}
374
+ {saveError ? <FieldError message={saveError} /> : null}
375
+ </div>
376
+ ) : null}
377
+
284
378
  <Button
285
379
  type="button"
286
380
  className="w-full uppercase tracking-wide"
287
381
  size="lg"
288
382
  disabled={isSaving}
289
- onClick={() => void onContinue()}
383
+ onClick={() => {
384
+ setLocalSaveError(null)
385
+ if (saveToAccount && !saveAddressKey.trim()) {
386
+ setLocalSaveError('Indica un nombre para la dirección')
387
+ return
388
+ }
389
+ void onContinue(
390
+ canSaveToAccount && saveToAccount
391
+ ? { saveToAccount: true, addressKey: saveAddressKey.trim() }
392
+ : undefined,
393
+ )
394
+ }}
290
395
  >
291
396
  {isSaving ? 'Guardando…' : 'Enviar y continuar'}
292
397
  </Button>
@@ -86,6 +86,21 @@ export function editCheckoutStep(
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * Marca contact + delivery como completos y deja el pago activo.
91
+ * Usado al prefill automático de un usuario logueado con dirección válida.
92
+ */
93
+ export function jumpCheckoutToPayment(): CheckoutStepFlow {
94
+ return {
95
+ active: 'payment',
96
+ completed: {
97
+ contact: true,
98
+ delivery: true,
99
+ payment: false,
100
+ },
101
+ }
102
+ }
103
+
89
104
  export function checkoutStepStatus(
90
105
  flow: CheckoutStepFlow,
91
106
  step: CheckoutStepId,
@@ -0,0 +1,188 @@
1
+ import type {
2
+ CustomerAddressUpsertInput,
3
+ CustomerProfile,
4
+ CustomerSavedAddress,
5
+ } from '@ecomiq/core'
6
+ import type { CheckoutContactForm } from './types'
7
+
8
+ export function findGeoCodeByName(
9
+ items: { code: string; name: string }[],
10
+ name: string,
11
+ ): string {
12
+ const target = name.trim().toLowerCase()
13
+ if (!target) return ''
14
+ return (
15
+ items.find((item) => item.name.trim().toLowerCase() === target)?.code ?? ''
16
+ )
17
+ }
18
+
19
+ /** Normaliza teléfono a dígitos nacionales (PE: 9). */
20
+ export function normalizeCheckoutPhone(
21
+ phone: string,
22
+ countryCode: string,
23
+ ): string {
24
+ let digits = phone.replace(/\D/g, '')
25
+ const country = countryCode.toUpperCase()
26
+ if (country === 'PE' && digits.startsWith('51') && digits.length > 9) {
27
+ digits = digits.slice(-9)
28
+ }
29
+ if (country === 'PE' && digits.length > 9) {
30
+ digits = digits.slice(-9)
31
+ }
32
+ return digits
33
+ }
34
+
35
+ export function customerAddressToCheckoutPatch(
36
+ address: CustomerSavedAddress,
37
+ profile?: CustomerProfile | null,
38
+ ): Partial<CheckoutContactForm> {
39
+ const countryCode = (
40
+ address.countryIsoCode ||
41
+ profile?.countryIsoCode ||
42
+ 'PE'
43
+ ).toUpperCase()
44
+ const phoneRaw =
45
+ address.cellPhone || address.phone || profile?.phone || ''
46
+ const addressLine = [address.addressLine, address.addressNumber]
47
+ .map((part) => part.trim())
48
+ .filter(Boolean)
49
+ .join(' ')
50
+
51
+ return {
52
+ firstName: profile?.firstName?.trim() || undefined,
53
+ lastName: profile?.lastName?.trim() || undefined,
54
+ email: profile?.email?.trim() || undefined,
55
+ phone: phoneRaw
56
+ ? normalizeCheckoutPhone(phoneRaw, countryCode)
57
+ : undefined,
58
+ countryCode,
59
+ address: addressLine || undefined,
60
+ address2: address.reference?.trim() || undefined,
61
+ postalCode: address.postalCode?.trim() || undefined,
62
+ departmentName: address.department?.trim() || undefined,
63
+ provinceName: address.province?.trim() || undefined,
64
+ province: address.province?.trim() || undefined,
65
+ districtName: address.district?.trim() || undefined,
66
+ city: address.district?.trim() || undefined,
67
+ // Los códigos se resuelven luego vía geo INEI.
68
+ departmentCode: '',
69
+ provinceCode: '',
70
+ districtCode: '',
71
+ }
72
+ }
73
+
74
+ export function customerProfileToCheckoutPatch(
75
+ profile: CustomerProfile,
76
+ ): Partial<CheckoutContactForm> {
77
+ const countryCode = (profile.countryIsoCode || 'PE').toUpperCase()
78
+ return {
79
+ email: profile.email?.trim() || undefined,
80
+ firstName: profile.firstName?.trim() || undefined,
81
+ lastName: profile.lastName?.trim() || undefined,
82
+ phone: profile.phone
83
+ ? normalizeCheckoutPhone(profile.phone, countryCode)
84
+ : undefined,
85
+ countryCode,
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Rellena solo campos vacíos del formulario (el carrito/hidratación tienen prioridad).
91
+ */
92
+ export function mergeCheckoutContactPrefill(
93
+ current: CheckoutContactForm,
94
+ prefill: Partial<CheckoutContactForm>,
95
+ ): CheckoutContactForm {
96
+ const next = { ...current }
97
+ for (const key of Object.keys(prefill) as (keyof CheckoutContactForm)[]) {
98
+ const value = prefill[key]
99
+ if (value == null || String(value).trim() === '') continue
100
+ if (String(current[key] ?? '').trim()) continue
101
+ next[key] = value
102
+ }
103
+ return next
104
+ }
105
+
106
+ /** Aplica una dirección guardada pisando campos de entrega (elige el usuario). */
107
+ export function applySavedAddressToContact(
108
+ current: CheckoutContactForm,
109
+ address: CustomerSavedAddress,
110
+ profile?: CustomerProfile | null,
111
+ ): CheckoutContactForm {
112
+ const patch = customerAddressToCheckoutPatch(address, profile)
113
+ return {
114
+ ...current,
115
+ countryCode: patch.countryCode || current.countryCode,
116
+ address: patch.address || '',
117
+ address2: patch.address2 ?? '',
118
+ postalCode: patch.postalCode || '',
119
+ departmentName: patch.departmentName || '',
120
+ provinceName: patch.provinceName || '',
121
+ province: patch.province || '',
122
+ districtName: patch.districtName || '',
123
+ city: patch.city || '',
124
+ phone: patch.phone || current.phone,
125
+ firstName: current.firstName.trim() || patch.firstName || '',
126
+ lastName: current.lastName.trim() || patch.lastName || '',
127
+ email: current.email.trim() || patch.email || '',
128
+ departmentCode: '',
129
+ provinceCode: '',
130
+ districtCode: '',
131
+ }
132
+ }
133
+
134
+ export function pickDefaultCustomerAddress(
135
+ addresses: CustomerSavedAddress[],
136
+ ): CustomerSavedAddress | null {
137
+ if (!addresses.length) return null
138
+ return addresses.find((a) => a.isDefault) ?? addresses[0] ?? null
139
+ }
140
+
141
+ /** Separa calle y número; si no hay número claro usa `S/N`. */
142
+ export function splitStreetAddress(address: string): {
143
+ addressLine: string
144
+ addressNumber: string
145
+ } {
146
+ const trimmed = address.trim()
147
+ if (!trimmed) return { addressLine: 'Dirección', addressNumber: 'S/N' }
148
+ const match = trimmed.match(/^(.*)\s+(\S+)$/)
149
+ if (match?.[1]?.trim() && match[2]) {
150
+ return { addressLine: match[1].trim(), addressNumber: match[2] }
151
+ }
152
+ return { addressLine: trimmed, addressNumber: 'S/N' }
153
+ }
154
+
155
+ /** Checkout → payload de `POST customer/addresses`. */
156
+ export function checkoutContactToCustomerAddressUpsert(
157
+ contact: CheckoutContactForm,
158
+ options: {
159
+ addressKey?: string
160
+ isDefault?: boolean
161
+ } = {},
162
+ ): CustomerAddressUpsertInput {
163
+ const countryIsoCode = (contact.countryCode || 'PE').toUpperCase()
164
+ const { addressLine, addressNumber } = splitStreetAddress(contact.address)
165
+ const department =
166
+ contact.departmentName.trim() || contact.province.trim() || '—'
167
+ const province =
168
+ contact.provinceName.trim() || contact.province.trim() || '—'
169
+ const district = contact.districtName.trim() || contact.city.trim() || '—'
170
+ const phone = contact.phone.trim()
171
+
172
+ return {
173
+ addressKey: (options.addressKey?.trim() || 'Casa').slice(0, 48),
174
+ countryIsoCode,
175
+ addressLine,
176
+ addressNumber,
177
+ department,
178
+ province,
179
+ district,
180
+ phone,
181
+ cellPhone: phone,
182
+ postalCode: contact.postalCode.trim() || '00000',
183
+ reference: contact.address2.trim(),
184
+ latitude: null,
185
+ longitude: null,
186
+ isDefault: Boolean(options.isDefault),
187
+ }
188
+ }