@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,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
- <Link
48
- to="/account/login"
49
- search={{
50
- redirect:
51
- typeof window !== 'undefined' ? window.location.pathname : '/checkout',
52
- }}
53
- className={cn(buttonVariants({ variant: 'default' }), 'uppercase tracking-wide')}
54
- >
55
- ¿Ya tienes una cuenta?
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
- setStepFlow((flow) => completeCheckoutStep(flow, 'contact'))
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