@ecomiq/storefront 2.0.8 → 2.0.11

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.
@@ -1,5 +1,5 @@
1
- import { useState } from 'react'
2
1
  import type { Country, Department, District, Province } from '@ecomiq/core'
2
+ import { Button } from '../../ui'
3
3
  import type { CheckoutContactForm } from './types'
4
4
  import type { CheckoutFieldId } from './form-schema'
5
5
  import {
@@ -9,6 +9,10 @@ import {
9
9
  FieldError,
10
10
  fieldClass,
11
11
  } from './checkout-form-controls'
12
+ import {
13
+ CheckoutShippingSection,
14
+ type CheckoutShippingOption,
15
+ } from './checkout-shipping-section'
12
16
 
13
17
  const DIAL_BY_COUNTRY: Record<string, string> = {
14
18
  PE: '+51',
@@ -24,7 +28,7 @@ function dialCodeFor(countryCode: string) {
24
28
  return DIAL_BY_COUNTRY[countryCode.toUpperCase()] ?? '+51'
25
29
  }
26
30
 
27
- /** Dirección de envío: contacto + geo en el layout del mockup. */
31
+ /** Paso 2: dirección de entrega + método de envío + facturación. */
28
32
  export function CheckoutShippingAddressSection({
29
33
  contact,
30
34
  fieldErrors,
@@ -32,9 +36,16 @@ export function CheckoutShippingAddressSection({
32
36
  departments,
33
37
  provinces,
34
38
  districts,
35
- sessionLabel,
39
+ shippingOptions,
40
+ selectedShippingKey,
41
+ shippingLoading,
42
+ shippingError,
43
+ isSaving,
44
+ money,
36
45
  onPatch,
46
+ onSelectShipping,
37
47
  onClearError,
48
+ onContinue,
38
49
  }: {
39
50
  contact: CheckoutContactForm
40
51
  fieldErrors: Partial<Record<CheckoutFieldId, string>>
@@ -42,22 +53,21 @@ export function CheckoutShippingAddressSection({
42
53
  departments: Department[]
43
54
  provinces: Province[]
44
55
  districts: District[]
45
- sessionLabel?: string | null
56
+ shippingOptions: CheckoutShippingOption[]
57
+ selectedShippingKey: string | null
58
+ shippingLoading: boolean
59
+ shippingError: string | null
60
+ isSaving?: boolean
61
+ money: (value: number) => string
46
62
  onPatch: (patch: Partial<CheckoutContactForm>) => void
63
+ onSelectShipping: (key: string) => void
47
64
  onClearError: (id: CheckoutFieldId) => void
65
+ onContinue: () => void | Promise<void>
48
66
  }) {
49
67
  const isPe = contact.countryCode.toUpperCase() === 'PE'
50
- const [saveNextTime, setSaveNextTime] = useState(false)
51
68
 
52
69
  return (
53
- <section className="space-y-5">
54
- <h2 className="text-xl font-semibold tracking-tight">Dirección de envío</h2>
55
- {sessionLabel ? (
56
- <p className="text-sm text-muted-foreground">
57
- Sesión como <span className="font-medium text-foreground">{sessionLabel}</span>
58
- </p>
59
- ) : null}
60
-
70
+ <div className="space-y-6">
61
71
  <div className="grid gap-4 sm:grid-cols-2">
62
72
  <CheckoutTextField
63
73
  id="firstName"
@@ -83,75 +93,68 @@ export function CheckoutShippingAddressSection({
83
93
  />
84
94
  </div>
85
95
 
86
- <div className="grid gap-4 sm:grid-cols-2">
87
- <CheckoutTextField
88
- id="email"
89
- label="E-mail"
90
- error={fieldErrors.email}
91
- value={contact.email}
92
- onChange={(v) => {
93
- onClearError('email')
94
- onPatch({ email: v })
95
- }}
96
- type="email"
97
- autoComplete="email"
98
- />
99
- <CheckoutPhoneField
100
- id="phone"
101
- label="Teléfono"
102
- error={fieldErrors.phone}
103
- value={contact.phone}
104
- dialCode={dialCodeFor(contact.countryCode)}
105
- onChange={(v) => {
106
- onClearError('phone')
107
- onPatch({ phone: v })
108
- }}
109
- />
110
- </div>
96
+ <CheckoutPhoneField
97
+ id="phone"
98
+ label="Teléfono"
99
+ error={fieldErrors.phone}
100
+ value={contact.phone}
101
+ dialCode={dialCodeFor(contact.countryCode)}
102
+ onChange={(v) => {
103
+ onClearError('phone')
104
+ onPatch({ phone: v })
105
+ }}
106
+ />
111
107
 
112
- <div className="grid gap-4 sm:grid-cols-2">
113
- <div className="min-w-0">
114
- <label htmlFor="countryCode" className="text-sm font-medium">
115
- País
116
- </label>
117
- <select
118
- id="countryCode"
119
- className={fieldClass(fieldErrors.countryCode)}
120
- value={contact.countryCode}
121
- onChange={(e) => {
122
- onClearError('countryCode')
123
- onPatch({
124
- countryCode: e.target.value,
125
- departmentCode: '',
126
- provinceCode: '',
127
- districtCode: '',
128
- departmentName: '',
129
- provinceName: '',
130
- districtName: '',
131
- city: '',
132
- province: '',
133
- })
134
- }}
135
- >
136
- {(countries.length ? countries : [{ code: 'PE', name: 'Perú' }]).map((c) => (
137
- <option key={c.code} value={c.code}>
138
- {c.name}
139
- </option>
140
- ))}
141
- </select>
142
- <FieldError message={fieldErrors.countryCode} />
143
- </div>
144
- <CheckoutTextField
145
- id="address"
146
- label="Dirección"
147
- error={fieldErrors.address}
148
- value={contact.address}
149
- onChange={(v) => {
150
- onClearError('address')
151
- onPatch({ address: v })
108
+ <CheckoutTextField
109
+ id="address"
110
+ label="Dirección"
111
+ error={fieldErrors.address}
112
+ value={contact.address}
113
+ onChange={(v) => {
114
+ onClearError('address')
115
+ onPatch({ address: v })
116
+ }}
117
+ autoComplete="street-address"
118
+ />
119
+
120
+ <CheckoutTextField
121
+ id="address2"
122
+ label="Información adicional de referencia"
123
+ value={contact.address2}
124
+ onChange={(v) => onPatch({ address2: v })}
125
+ autoComplete="address-line2"
126
+ />
127
+
128
+ <div className="min-w-0">
129
+ <label htmlFor="countryCode" className="text-sm font-medium">
130
+ País
131
+ </label>
132
+ <select
133
+ id="countryCode"
134
+ className={fieldClass(fieldErrors.countryCode)}
135
+ value={contact.countryCode}
136
+ onChange={(e) => {
137
+ onClearError('countryCode')
138
+ onPatch({
139
+ countryCode: e.target.value,
140
+ departmentCode: '',
141
+ provinceCode: '',
142
+ districtCode: '',
143
+ departmentName: '',
144
+ provinceName: '',
145
+ districtName: '',
146
+ city: '',
147
+ province: '',
148
+ })
152
149
  }}
153
- autoComplete="street-address"
154
- />
150
+ >
151
+ {(countries.length ? countries : [{ code: 'PE', name: 'Perú' }]).map((c) => (
152
+ <option key={c.code} value={c.code}>
153
+ {c.name}
154
+ </option>
155
+ ))}
156
+ </select>
157
+ <FieldError message={fieldErrors.countryCode} />
155
158
  </div>
156
159
 
157
160
  {isPe ? (
@@ -252,15 +255,41 @@ export function CheckoutShippingAddressSection({
252
255
  />
253
256
  ) : null}
254
257
 
255
- <label className="flex cursor-pointer items-center gap-2.5 text-sm text-muted-foreground">
256
- <input
257
- type="checkbox"
258
- className="size-4 rounded border-border accent-foreground"
259
- checked={saveNextTime}
260
- onChange={(e) => setSaveNextTime(e.target.checked)}
261
- />
262
- Guardar esta información para la próxima vez
263
- </label>
264
- </section>
258
+ <CheckoutShippingSection
259
+ options={shippingOptions}
260
+ selectedKey={selectedShippingKey}
261
+ loading={shippingLoading}
262
+ error={shippingError}
263
+ needsPostalCode={!contact.postalCode.trim()}
264
+ fieldError={fieldErrors['checkout-shipping-method']}
265
+ money={money}
266
+ onSelect={onSelectShipping}
267
+ onClearError={onClearError}
268
+ />
269
+
270
+ <div className="space-y-3">
271
+ <h3 className="text-base font-semibold tracking-tight">Dirección de facturación</h3>
272
+ <label className="flex items-center gap-2.5 text-sm text-foreground">
273
+ <input
274
+ type="checkbox"
275
+ className="size-4 rounded border-border accent-foreground"
276
+ checked
277
+ disabled
278
+ readOnly
279
+ />
280
+ Misma dirección que la de entrega
281
+ </label>
282
+ </div>
283
+
284
+ <Button
285
+ type="button"
286
+ className="w-full uppercase tracking-wide"
287
+ size="lg"
288
+ disabled={isSaving}
289
+ onClick={() => void onContinue()}
290
+ >
291
+ {isSaving ? 'Guardando…' : 'Enviar y continuar'}
292
+ </Button>
293
+ </div>
265
294
  )
266
295
  }
@@ -40,8 +40,8 @@ export function CheckoutShippingSection({
40
40
  onClearError: (id: CheckoutFieldId) => void
41
41
  }) {
42
42
  return (
43
- <section className="space-y-4">
44
- <h2 className="text-xl font-semibold tracking-tight">Método de envío</h2>
43
+ <div className="space-y-4">
44
+ <h3 className="text-base font-semibold tracking-tight">Envío</h3>
45
45
  {loading ? <p className="text-sm text-muted-foreground">Cargando opciones…</p> : null}
46
46
  {error ? <p className="text-sm text-destructive">{error}</p> : null}
47
47
  {needsPostalCode ? (
@@ -50,17 +50,19 @@ export function CheckoutShippingSection({
50
50
  </p>
51
51
  ) : null}
52
52
 
53
- <div className="grid gap-3 sm:grid-cols-2">
53
+ <div id="checkout-shipping-method" className="grid gap-3">
54
54
  {options.map((opt) => {
55
55
  const selected = selectedKey === opt.key
56
56
  return (
57
57
  <button
58
58
  key={opt.key}
59
59
  type="button"
60
+ data-checkout-shipping={opt.key}
61
+ aria-pressed={selected}
60
62
  className={cn(
61
- 'relative flex min-h-[5.5rem] flex-col items-start rounded-lg border bg-background px-4 py-3.5 text-left transition-colors',
63
+ 'relative flex min-h-[4.5rem] items-center justify-between gap-4 rounded-lg border bg-background px-4 py-3.5 text-left transition-colors',
62
64
  selected
63
- ? 'border-emerald-500 ring-1 ring-emerald-500'
65
+ ? 'border-foreground ring-1 ring-foreground'
64
66
  : 'border-border [@media(hover:hover)]:hover:border-foreground/30',
65
67
  )}
66
68
  onClick={() => {
@@ -68,23 +70,29 @@ export function CheckoutShippingSection({
68
70
  onSelect(opt.key)
69
71
  }}
70
72
  >
73
+ <span className="min-w-0 pr-8">
74
+ <span className="block text-sm font-semibold">{opt.rate.name}</span>
75
+ <span className="mt-1 block text-xs text-muted-foreground">
76
+ {shippingSubtitle(opt.rate)}
77
+ </span>
78
+ </span>
79
+ <span className="shrink-0 text-right text-sm font-semibold tabular-nums">
80
+ {opt.rate.price === 0 ? (
81
+ 'GRATIS'
82
+ ) : (
83
+ money(opt.rate.price)
84
+ )}
85
+ </span>
71
86
  {selected ? (
72
- <span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white">
87
+ <span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full bg-foreground text-background">
73
88
  <Check className="size-3" strokeWidth={3} aria-hidden="true" />
74
89
  </span>
75
90
  ) : null}
76
- <span className="pr-8 text-sm font-semibold">{opt.rate.name}</span>
77
- <span className="mt-1 text-xs text-muted-foreground">
78
- {shippingSubtitle(opt.rate)}
79
- </span>
80
- <span className="mt-auto pt-3 text-sm font-semibold tabular-nums">
81
- {opt.rate.price === 0 ? 'Gratis' : money(opt.rate.price)}
82
- </span>
83
91
  </button>
84
92
  )
85
93
  })}
86
94
  </div>
87
95
  <FieldError message={fieldError} />
88
- </section>
96
+ </div>
89
97
  )
90
98
  }
@@ -0,0 +1,101 @@
1
+ import type { ComponentType, ReactNode, SVGProps } from 'react'
2
+ import { cn } from '../../ui'
3
+ import type { CheckoutStepId } from './checkout-steps'
4
+ import { checkoutStepStatus, type CheckoutStepFlow } from './checkout-steps'
5
+
6
+ type StepIcon = ComponentType<SVGProps<SVGSVGElement>>
7
+
8
+ export function CheckoutStepCard({
9
+ step,
10
+ flow,
11
+ title,
12
+ icon: Icon,
13
+ summary,
14
+ onEdit,
15
+ children,
16
+ }: {
17
+ step: CheckoutStepId
18
+ flow: CheckoutStepFlow
19
+ title: string
20
+ icon: StepIcon
21
+ summary?: ReactNode
22
+ onEdit?: () => void
23
+ children?: ReactNode
24
+ }) {
25
+ const status = checkoutStepStatus(flow, step)
26
+ const locked = status === 'locked'
27
+ const complete = status === 'complete'
28
+ const active = status === 'active'
29
+
30
+ return (
31
+ <section
32
+ aria-labelledby={`checkout-step-${step}-title`}
33
+ aria-disabled={locked || undefined}
34
+ className={cn(
35
+ 'overflow-hidden rounded-xl border bg-card shadow-sm transition-opacity',
36
+ locked && 'opacity-55',
37
+ )}
38
+ >
39
+ <header
40
+ className={cn(
41
+ 'flex items-center gap-3 border-b px-4 py-3.5 sm:px-5',
42
+ locked && 'border-transparent',
43
+ complete && 'border-border',
44
+ active && 'border-border',
45
+ )}
46
+ >
47
+ <Icon
48
+ className={cn(
49
+ 'size-5 shrink-0',
50
+ locked ? 'text-muted-foreground/70' : 'text-foreground',
51
+ )}
52
+ aria-hidden="true"
53
+ />
54
+ <h2
55
+ id={`checkout-step-${step}-title`}
56
+ className={cn(
57
+ 'min-w-0 flex-1 text-base font-semibold tracking-tight',
58
+ locked && 'text-muted-foreground',
59
+ )}
60
+ >
61
+ {title}
62
+ </h2>
63
+ {complete && onEdit ? (
64
+ <button
65
+ type="button"
66
+ className="text-xs font-semibold uppercase tracking-[0.08em] text-foreground underline-offset-4 hover:underline"
67
+ onClick={onEdit}
68
+ >
69
+ Editar
70
+ </button>
71
+ ) : null}
72
+ </header>
73
+
74
+ {complete && summary ? (
75
+ <div className="space-y-3 px-4 py-4 sm:px-5">{summary}</div>
76
+ ) : null}
77
+
78
+ {active ? <div className="space-y-5 px-4 py-5 sm:px-5">{children}</div> : null}
79
+ </section>
80
+ )
81
+ }
82
+
83
+ export function CheckoutStepSummaryBlock({
84
+ children,
85
+ className,
86
+ }: {
87
+ children: ReactNode
88
+ className?: string
89
+ }) {
90
+ return (
91
+ <div
92
+ className={cn(
93
+ 'relative rounded-lg border border-border bg-background py-3 pl-4 pr-3',
94
+ 'before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:rounded-full before:bg-foreground',
95
+ className,
96
+ )}
97
+ >
98
+ {children}
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,96 @@
1
+ export const CHECKOUT_STEPS = ['contact', 'delivery', 'payment'] as const
2
+
3
+ export type CheckoutStepId = (typeof CHECKOUT_STEPS)[number]
4
+
5
+ export type CheckoutStepFlow = {
6
+ active: CheckoutStepId
7
+ completed: Record<CheckoutStepId, boolean>
8
+ }
9
+
10
+ export function createCheckoutStepFlow(
11
+ active: CheckoutStepId = 'contact',
12
+ ): CheckoutStepFlow {
13
+ return {
14
+ active,
15
+ completed: {
16
+ contact: false,
17
+ delivery: false,
18
+ payment: false,
19
+ },
20
+ }
21
+ }
22
+
23
+ export function checkoutStepIndex(step: CheckoutStepId): number {
24
+ return CHECKOUT_STEPS.indexOf(step)
25
+ }
26
+
27
+ export function isCheckoutStepLocked(
28
+ flow: CheckoutStepFlow,
29
+ step: CheckoutStepId,
30
+ ): boolean {
31
+ return checkoutStepIndex(step) > checkoutStepIndex(flow.active)
32
+ }
33
+
34
+ export function isCheckoutStepActive(
35
+ flow: CheckoutStepFlow,
36
+ step: CheckoutStepId,
37
+ ): boolean {
38
+ return flow.active === step
39
+ }
40
+
41
+ export function isCheckoutStepComplete(
42
+ flow: CheckoutStepFlow,
43
+ step: CheckoutStepId,
44
+ ): boolean {
45
+ return flow.completed[step] && flow.active !== step
46
+ }
47
+
48
+ /** Completa el paso activo y avanza al siguiente (si existe). */
49
+ export function completeCheckoutStep(
50
+ flow: CheckoutStepFlow,
51
+ step: CheckoutStepId,
52
+ ): CheckoutStepFlow {
53
+ if (flow.active !== step) return flow
54
+ const idx = checkoutStepIndex(step)
55
+ const next = CHECKOUT_STEPS[idx + 1]
56
+ return {
57
+ active: next ?? step,
58
+ completed: {
59
+ ...flow.completed,
60
+ [step]: true,
61
+ },
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Reabre un paso ya completado para editarlo e invalida todos los posteriores.
67
+ */
68
+ export function editCheckoutStep(
69
+ flow: CheckoutStepFlow,
70
+ step: CheckoutStepId,
71
+ ): CheckoutStepFlow {
72
+ const idx = checkoutStepIndex(step)
73
+ if (idx < 0) return flow
74
+ if (checkoutStepIndex(flow.active) < idx && !flow.completed[step]) {
75
+ return flow
76
+ }
77
+
78
+ const completed = { ...flow.completed }
79
+ for (const id of CHECKOUT_STEPS) {
80
+ if (checkoutStepIndex(id) >= idx) completed[id] = false
81
+ }
82
+
83
+ return {
84
+ active: step,
85
+ completed,
86
+ }
87
+ }
88
+
89
+ export function checkoutStepStatus(
90
+ flow: CheckoutStepFlow,
91
+ step: CheckoutStepId,
92
+ ): 'active' | 'complete' | 'locked' {
93
+ if (isCheckoutStepActive(flow, step)) return 'active'
94
+ if (isCheckoutStepComplete(flow, step)) return 'complete'
95
+ return 'locked'
96
+ }