@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.
- 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/hooks/index.ts +9 -0
- package/src/hooks/recently-viewed.ts +125 -0
- package/src/index.ts +63 -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
|
@@ -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: (
|
|
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={() =>
|
|
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
|
+
}
|