@ecomiq/storefront 1.1.9 → 2.0.0

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 (134) hide show
  1. package/README.md +26 -418
  2. package/bin/ecomiq.mjs +154 -0
  3. package/package.json +72 -94
  4. package/src/api-proxy.ts +194 -0
  5. package/src/auth/create-storefront-auth.ts +35 -0
  6. package/src/auth/ecomiq-oauth-password-client.ts +26 -0
  7. package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
  8. package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
  9. package/src/auth/ecomiq-oauth-password-types.ts +25 -0
  10. package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
  11. package/src/auth/env.ts +7 -0
  12. package/src/cart-session.ts +241 -0
  13. package/src/client.ts +48 -0
  14. package/src/codegen.mjs +603 -0
  15. package/src/components/checkout/cart-sync.ts +143 -0
  16. package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
  17. package/src/components/checkout/checkout-form-controls.tsx +139 -0
  18. package/src/components/checkout/checkout-line-items.tsx +43 -0
  19. package/src/components/checkout/checkout-order-summary.tsx +156 -0
  20. package/src/components/checkout/checkout-payment-section.tsx +215 -0
  21. package/src/components/checkout/checkout-session-view.tsx +240 -0
  22. package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
  23. package/src/components/checkout/checkout-shipping-section.tsx +90 -0
  24. package/src/components/checkout/checkout-states.tsx +73 -0
  25. package/src/components/checkout/form-schema.ts +229 -0
  26. package/src/components/checkout/format.ts +27 -0
  27. package/src/components/checkout/index.ts +51 -0
  28. package/src/components/checkout/types.ts +43 -0
  29. package/src/components/checkout/use-checkout-geo.ts +50 -0
  30. package/src/components/checkout/use-checkout-session.ts +583 -0
  31. package/src/components/hero.tsx +291 -0
  32. package/src/config.d.ts +69 -0
  33. package/src/config.mjs +165 -0
  34. package/src/index.ts +274 -0
  35. package/src/link.tsx +2 -0
  36. package/src/pages/checkout.$checkoutId.tsx +60 -0
  37. package/src/pages/checkout.tsx +31 -0
  38. package/src/pages/thank-you.tsx +53 -0
  39. package/src/providers/auth.tsx +69 -0
  40. package/src/providers/cart.tsx +275 -0
  41. package/src/providers/ecomiq.ts +137 -0
  42. package/src/providers/index.ts +26 -0
  43. package/src/public-config.ts +44 -0
  44. package/src/review-submission.ts +89 -0
  45. package/src/runtime/context.ts +1 -0
  46. package/src/runtime/default-layout.tsx +80 -0
  47. package/src/runtime/head.ts +126 -0
  48. package/src/runtime/page.tsx +82 -0
  49. package/src/runtime/postcss-browser-stub.mjs +5 -0
  50. package/src/server-config-store.ts +26 -0
  51. package/src/server.ts +279 -0
  52. package/src/styles.css +76 -0
  53. package/src/ui/badge.tsx +32 -0
  54. package/src/ui/button.tsx +31 -0
  55. package/src/ui/card.tsx +31 -0
  56. package/src/ui/carousel.tsx +218 -0
  57. package/src/ui/cn.ts +6 -0
  58. package/src/ui/index.ts +8 -0
  59. package/src/ui/separator.tsx +6 -0
  60. package/src/ui/skeleton.tsx +6 -0
  61. package/src/ui/storefront-error.tsx +49 -0
  62. package/src/utils.ts +36 -0
  63. package/src/vite-inline.mjs +95 -0
  64. package/templates/__root.tsx +77 -0
  65. package/templates/router.tsx +19 -0
  66. package/dist/chunk-64GLOWMJ.js +0 -216
  67. package/dist/chunk-64GLOWMJ.js.map +0 -1
  68. package/dist/chunk-AKD5IDEB.js +0 -177
  69. package/dist/chunk-AKD5IDEB.js.map +0 -1
  70. package/dist/chunk-EFEVJJWF.js +0 -1345
  71. package/dist/chunk-EFEVJJWF.js.map +0 -1
  72. package/dist/chunk-NAJQB5DN.js +0 -155
  73. package/dist/chunk-NAJQB5DN.js.map +0 -1
  74. package/dist/chunk-OABDGTBI.js +0 -253
  75. package/dist/chunk-OABDGTBI.js.map +0 -1
  76. package/dist/chunk-Q4U5OEUT.js +0 -21
  77. package/dist/chunk-Q4U5OEUT.js.map +0 -1
  78. package/dist/chunk-S7CPZ4UE.js +0 -6
  79. package/dist/chunk-S7CPZ4UE.js.map +0 -1
  80. package/dist/chunks/client.css +0 -984
  81. package/dist/chunks/client.css.map +0 -1
  82. package/dist/chunks/client.d.ts +0 -10759
  83. package/dist/chunks/client.js +0 -5398
  84. package/dist/chunks/client.js.map +0 -1
  85. package/dist/chunks/config.d.ts +0 -31
  86. package/dist/chunks/config.js +0 -5
  87. package/dist/chunks/config.js.map +0 -1
  88. package/dist/chunks/core-public.d.ts +0 -130
  89. package/dist/chunks/core-public.js +0 -92
  90. package/dist/chunks/core-public.js.map +0 -1
  91. package/dist/chunks/server-nextjs.d.ts +0 -33
  92. package/dist/chunks/server-nextjs.js +0 -59
  93. package/dist/chunks/server-nextjs.js.map +0 -1
  94. package/dist/chunks/server-tanstack.d.ts +0 -22
  95. package/dist/chunks/server-tanstack.js +0 -40
  96. package/dist/chunks/server-tanstack.js.map +0 -1
  97. package/dist/collection-DU7gREXS.d.ts +0 -4062
  98. package/dist/ecomiq-oauth-password-plugin-B_gMdGuc.d.ts +0 -3631
  99. package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
  100. package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
  101. package/dist/index.d.ts +0 -9
  102. package/dist/index.js +0 -1
  103. package/dist/index.js.map +0 -1
  104. package/dist/index.server.d.ts +0 -9
  105. package/dist/index.server.js +0 -8
  106. package/dist/index.server.js.map +0 -1
  107. package/dist/nextjs/api.d.ts +0 -45
  108. package/dist/nextjs/api.js +0 -32
  109. package/dist/nextjs/api.js.map +0 -1
  110. package/dist/nextjs/globals.css +0 -13
  111. package/dist/nextjs.d.ts +0 -2
  112. package/dist/nextjs.js +0 -2
  113. package/dist/nextjs.js.map +0 -1
  114. package/dist/nextjs.server.d.ts +0 -18
  115. package/dist/nextjs.server.js +0 -1
  116. package/dist/nextjs.server.js.map +0 -1
  117. package/dist/postcss.config.mjs +0 -6
  118. package/dist/react/checkout-page.css +0 -855
  119. package/dist/react/client.css +0 -984
  120. package/dist/react/payment-provider.css +0 -91
  121. package/dist/react/thank-you-page.css +0 -105
  122. package/dist/react.d.ts +0 -1
  123. package/dist/react.js +0 -2
  124. package/dist/react.js.map +0 -1
  125. package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
  126. package/dist/tanstack/api.d.ts +0 -41
  127. package/dist/tanstack/api.js +0 -30
  128. package/dist/tanstack/api.js.map +0 -1
  129. package/dist/tanstack.d.ts +0 -1
  130. package/dist/tanstack.js +0 -2
  131. package/dist/tanstack.js.map +0 -1
  132. package/dist/tanstack.server.d.ts +0 -18
  133. package/dist/tanstack.server.js +0 -1
  134. package/dist/tanstack.server.js.map +0 -1
@@ -0,0 +1,266 @@
1
+ import { useState } from 'react'
2
+ import type { Country, Department, District, Province } from '@ecomiq/core'
3
+ import type { CheckoutContactForm } from './types'
4
+ import type { CheckoutFieldId } from './form-schema'
5
+ import {
6
+ CheckoutPhoneField,
7
+ CheckoutSelectField,
8
+ CheckoutTextField,
9
+ FieldError,
10
+ fieldClass,
11
+ } from './checkout-form-controls'
12
+
13
+ const DIAL_BY_COUNTRY: Record<string, string> = {
14
+ PE: '+51',
15
+ US: '+1',
16
+ MX: '+52',
17
+ CL: '+56',
18
+ CO: '+57',
19
+ AR: '+54',
20
+ ES: '+34',
21
+ }
22
+
23
+ function dialCodeFor(countryCode: string) {
24
+ return DIAL_BY_COUNTRY[countryCode.toUpperCase()] ?? '+51'
25
+ }
26
+
27
+ /** Dirección de envío: contacto + geo en el layout del mockup. */
28
+ export function CheckoutShippingAddressSection({
29
+ contact,
30
+ fieldErrors,
31
+ countries,
32
+ departments,
33
+ provinces,
34
+ districts,
35
+ sessionLabel,
36
+ onPatch,
37
+ onClearError,
38
+ }: {
39
+ contact: CheckoutContactForm
40
+ fieldErrors: Partial<Record<CheckoutFieldId, string>>
41
+ countries: Country[]
42
+ departments: Department[]
43
+ provinces: Province[]
44
+ districts: District[]
45
+ sessionLabel?: string | null
46
+ onPatch: (patch: Partial<CheckoutContactForm>) => void
47
+ onClearError: (id: CheckoutFieldId) => void
48
+ }) {
49
+ const isPe = contact.countryCode.toUpperCase() === 'PE'
50
+ const [saveNextTime, setSaveNextTime] = useState(false)
51
+
52
+ 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
+
61
+ <div className="grid gap-4 sm:grid-cols-2">
62
+ <CheckoutTextField
63
+ id="firstName"
64
+ label="Nombre"
65
+ error={fieldErrors.firstName}
66
+ value={contact.firstName}
67
+ onChange={(v) => {
68
+ onClearError('firstName')
69
+ onPatch({ firstName: v })
70
+ }}
71
+ autoComplete="given-name"
72
+ />
73
+ <CheckoutTextField
74
+ id="lastName"
75
+ label="Apellido"
76
+ error={fieldErrors.lastName}
77
+ value={contact.lastName}
78
+ onChange={(v) => {
79
+ onClearError('lastName')
80
+ onPatch({ lastName: v })
81
+ }}
82
+ autoComplete="family-name"
83
+ />
84
+ </div>
85
+
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>
111
+
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 })
152
+ }}
153
+ autoComplete="street-address"
154
+ />
155
+ </div>
156
+
157
+ {isPe ? (
158
+ <div className="grid gap-4 sm:grid-cols-3">
159
+ <CheckoutSelectField
160
+ id="department"
161
+ label="Departamento"
162
+ error={fieldErrors.department}
163
+ value={contact.departmentCode}
164
+ options={departments.map((d) => ({ value: d.code, label: d.name }))}
165
+ onChange={(code) => {
166
+ onClearError('department')
167
+ const dep = departments.find((d) => d.code === code)
168
+ onPatch({
169
+ departmentCode: code,
170
+ departmentName: dep?.name ?? '',
171
+ provinceCode: '',
172
+ provinceName: '',
173
+ districtCode: '',
174
+ districtName: '',
175
+ })
176
+ }}
177
+ />
178
+ <CheckoutSelectField
179
+ id="province-geo"
180
+ label="Provincia"
181
+ error={fieldErrors['province-geo']}
182
+ value={contact.provinceCode}
183
+ options={provinces.map((d) => ({ value: d.code, label: d.name }))}
184
+ onChange={(code) => {
185
+ onClearError('province-geo')
186
+ const prov = provinces.find((d) => d.code === code)
187
+ onPatch({
188
+ provinceCode: code,
189
+ provinceName: prov?.name ?? '',
190
+ province: prov?.name ?? '',
191
+ districtCode: '',
192
+ districtName: '',
193
+ })
194
+ }}
195
+ />
196
+ <CheckoutSelectField
197
+ id="district"
198
+ label="Distrito"
199
+ error={fieldErrors.district}
200
+ value={contact.districtCode}
201
+ options={districts.map((d) => ({ value: d.code, label: d.name }))}
202
+ onChange={(code) => {
203
+ onClearError('district')
204
+ const dist = districts.find((d) => d.code === code)
205
+ onPatch({
206
+ districtCode: code,
207
+ districtName: dist?.name ?? '',
208
+ city: dist?.name ?? '',
209
+ })
210
+ }}
211
+ />
212
+ </div>
213
+ ) : (
214
+ <div className="grid gap-4 sm:grid-cols-3">
215
+ <CheckoutTextField
216
+ id="city"
217
+ label="Ciudad"
218
+ error={fieldErrors.city}
219
+ value={contact.city}
220
+ onChange={(v) => {
221
+ onClearError('city')
222
+ onPatch({ city: v })
223
+ }}
224
+ />
225
+ <CheckoutTextField
226
+ id="province-region"
227
+ label="Región / Estado"
228
+ error={fieldErrors['province-region']}
229
+ value={contact.province}
230
+ onChange={(v) => {
231
+ onClearError('province-region')
232
+ onPatch({ province: v })
233
+ }}
234
+ />
235
+ <CheckoutTextField
236
+ id="postalCode"
237
+ label="Código postal"
238
+ value={contact.postalCode}
239
+ onChange={(v) => onPatch({ postalCode: v })}
240
+ autoComplete="postal-code"
241
+ />
242
+ </div>
243
+ )}
244
+
245
+ {isPe ? (
246
+ <CheckoutTextField
247
+ id="postalCode"
248
+ label="Código postal"
249
+ value={contact.postalCode}
250
+ onChange={(v) => onPatch({ postalCode: v })}
251
+ autoComplete="postal-code"
252
+ />
253
+ ) : null}
254
+
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>
265
+ )
266
+ }
@@ -0,0 +1,90 @@
1
+ import { Check } from 'lucide-react'
2
+ import type { ShippingOptionGroup, ShippingRate } from '@ecomiq/core'
3
+ import { cn } from '../../ui'
4
+ import type { CheckoutFieldId } from './form-schema'
5
+ import { FieldError } from './checkout-form-controls'
6
+
7
+ export type CheckoutShippingOption = {
8
+ key: string
9
+ group: ShippingOptionGroup
10
+ rate: ShippingRate
11
+ }
12
+
13
+ function shippingSubtitle(rate: ShippingRate) {
14
+ if (rate.description?.trim()) return rate.description
15
+ if (rate.estimatedDeliveryDays != null) {
16
+ return `${rate.estimatedDeliveryDays} días hábiles`
17
+ }
18
+ return 'Entrega estándar'
19
+ }
20
+
21
+ export function CheckoutShippingSection({
22
+ options,
23
+ selectedKey,
24
+ loading,
25
+ error,
26
+ needsPostalCode,
27
+ fieldError,
28
+ money,
29
+ onSelect,
30
+ onClearError,
31
+ }: {
32
+ options: CheckoutShippingOption[]
33
+ selectedKey: string | null
34
+ loading: boolean
35
+ error: string | null
36
+ needsPostalCode: boolean
37
+ fieldError?: string
38
+ money: (value: number) => string
39
+ onSelect: (key: string) => void
40
+ onClearError: (id: CheckoutFieldId) => void
41
+ }) {
42
+ return (
43
+ <section className="space-y-4">
44
+ <h2 className="text-xl font-semibold tracking-tight">Método de envío</h2>
45
+ {loading ? <p className="text-sm text-muted-foreground">Cargando opciones…</p> : null}
46
+ {error ? <p className="text-sm text-destructive">{error}</p> : null}
47
+ {needsPostalCode ? (
48
+ <p className="text-sm text-muted-foreground">
49
+ Ingresa un código postal para ver métodos de envío.
50
+ </p>
51
+ ) : null}
52
+
53
+ <div className="grid gap-3 sm:grid-cols-2">
54
+ {options.map((opt) => {
55
+ const selected = selectedKey === opt.key
56
+ return (
57
+ <button
58
+ key={opt.key}
59
+ type="button"
60
+ 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',
62
+ selected
63
+ ? 'border-emerald-500 ring-1 ring-emerald-500'
64
+ : 'border-border [@media(hover:hover)]:hover:border-foreground/30',
65
+ )}
66
+ onClick={() => {
67
+ onClearError('checkout-shipping-method')
68
+ onSelect(opt.key)
69
+ }}
70
+ >
71
+ {selected ? (
72
+ <span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white">
73
+ <Check className="size-3" strokeWidth={3} aria-hidden="true" />
74
+ </span>
75
+ ) : 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
+ </button>
84
+ )
85
+ })}
86
+ </div>
87
+ <FieldError message={fieldError} />
88
+ </section>
89
+ )
90
+ }
@@ -0,0 +1,73 @@
1
+ import { ShoppingBag } from 'lucide-react'
2
+ import { Link } from '../../link'
3
+ import { Skeleton, StorefrontError, buttonVariants, cn } from '../../ui'
4
+
5
+ export function CheckoutLoadingState() {
6
+ return (
7
+ <div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:py-10" role="status">
8
+ <Skeleton className="h-10 w-56" />
9
+ <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]">
10
+ <Skeleton className="h-72 w-full rounded-xl" />
11
+ <Skeleton className="h-80 w-full rounded-xl" />
12
+ </div>
13
+ <span className="sr-only">Cargando checkout</span>
14
+ </div>
15
+ )
16
+ }
17
+
18
+ export function CheckoutAlreadyPurchasedState() {
19
+ return (
20
+ <section className="mx-auto flex min-h-[50vh] w-full max-w-lg flex-col items-center justify-center px-4 py-16 text-center">
21
+ <h1 className="text-2xl font-semibold tracking-tight">Pedido ya realizado</h1>
22
+ <p className="mt-2 text-sm text-muted-foreground">
23
+ Este checkout ya fue completado. Tu bolsa se ha reiniciado para que puedas seguir
24
+ comprando.
25
+ </p>
26
+ <Link to="/" className={cn(buttonVariants(), 'mt-6')}>
27
+ Volver a la tienda
28
+ </Link>
29
+ </section>
30
+ )
31
+ }
32
+
33
+ export function CheckoutErrorState({
34
+ description,
35
+ onRetry,
36
+ }: {
37
+ description?: string
38
+ onRetry: () => void
39
+ }) {
40
+ return (
41
+ <StorefrontError
42
+ title="No pudimos cargar el checkout"
43
+ description={description ?? 'El carrito puede haber expirado.'}
44
+ onRetry={onRetry}
45
+ returnTo="/cart"
46
+ returnLabel="Volver al carrito"
47
+ />
48
+ )
49
+ }
50
+
51
+ export function CheckoutEmptyState() {
52
+ return (
53
+ <section className="mx-auto flex min-h-[55vh] w-full max-w-6xl items-center px-4 py-16">
54
+ <div className="max-w-lg">
55
+ <div className="mb-5 flex size-12 items-center justify-center rounded-lg bg-muted text-muted-foreground">
56
+ <ShoppingBag className="size-5" aria-hidden="true" />
57
+ </div>
58
+ <h1 className="text-3xl font-semibold tracking-tight">No hay productos para pagar</h1>
59
+ <p className="mt-3 text-sm text-muted-foreground">
60
+ Agrega productos al carrito antes de continuar con el checkout.
61
+ </p>
62
+ <div className="mt-6 flex flex-wrap gap-3">
63
+ <Link to="/cart" className={buttonVariants({ size: 'lg', variant: 'outline' })}>
64
+ Ver carrito
65
+ </Link>
66
+ <Link to="/products" className={buttonVariants({ size: 'lg' })}>
67
+ Ir al catálogo
68
+ </Link>
69
+ </div>
70
+ </div>
71
+ </section>
72
+ )
73
+ }
@@ -0,0 +1,229 @@
1
+ import { z } from 'zod'
2
+ import type { CheckoutContactForm } from './types'
3
+
4
+ export const CHECKOUT_FIELD_IDS = [
5
+ 'email',
6
+ 'firstName',
7
+ 'lastName',
8
+ 'countryCode',
9
+ 'address',
10
+ 'department',
11
+ 'province-geo',
12
+ 'district',
13
+ 'city',
14
+ 'province-region',
15
+ 'phone',
16
+ 'checkout-shipping-method',
17
+ 'checkout-payment-method',
18
+ ] as const
19
+
20
+ export type CheckoutFieldId = (typeof CHECKOUT_FIELD_IDS)[number]
21
+
22
+ export const CHECKOUT_FIELD_FOCUS_ORDER: CheckoutFieldId[] = [
23
+ 'email',
24
+ 'firstName',
25
+ 'lastName',
26
+ 'countryCode',
27
+ 'address',
28
+ 'department',
29
+ 'province-geo',
30
+ 'district',
31
+ 'city',
32
+ 'province-region',
33
+ 'phone',
34
+ 'checkout-shipping-method',
35
+ 'checkout-payment-method',
36
+ ]
37
+
38
+ const zodPathToFieldId: Record<string, CheckoutFieldId> = {
39
+ email: 'email',
40
+ firstName: 'firstName',
41
+ lastName: 'lastName',
42
+ countryCode: 'countryCode',
43
+ address: 'address',
44
+ phone: 'phone',
45
+ departmentCode: 'department',
46
+ provinceCode: 'province-geo',
47
+ districtCode: 'district',
48
+ city: 'city',
49
+ province: 'province-region',
50
+ selectedShippingKey: 'checkout-shipping-method',
51
+ selectedPaymentCode: 'checkout-payment-method',
52
+ }
53
+
54
+ export type CheckoutFormValidationContext = {
55
+ contact: CheckoutContactForm
56
+ selectedShippingKey: string | null
57
+ shippingOptionsCount: number
58
+ selectedPaymentCode: string | null
59
+ paymentMethodsCount: number
60
+ }
61
+
62
+ export function buildCheckoutFormValidationSchema(currency: string) {
63
+ const phoneDigits = currency.toUpperCase() === 'PEN' ? 9 : 10
64
+
65
+ return z
66
+ .object({
67
+ email: z
68
+ .string()
69
+ .trim()
70
+ .min(1, 'El email es requerido')
71
+ .email('Ingresa un email válido'),
72
+ firstName: z
73
+ .string()
74
+ .trim()
75
+ .min(1, 'El nombre es requerido')
76
+ .min(2, 'El nombre debe tener al menos 2 caracteres'),
77
+ lastName: z
78
+ .string()
79
+ .trim()
80
+ .min(1, 'El apellido es requerido')
81
+ .min(2, 'El apellido debe tener al menos 2 caracteres'),
82
+ countryCode: z.string().trim().min(2, 'Selecciona un país').max(2),
83
+ address: z
84
+ .string()
85
+ .trim()
86
+ .min(1, 'La dirección es requerida')
87
+ .min(5, 'La dirección debe tener al menos 5 caracteres'),
88
+ phone: z
89
+ .string()
90
+ .trim()
91
+ .min(1, 'El teléfono es requerido')
92
+ .regex(/^\d+$/, 'El teléfono solo debe contener números')
93
+ .length(phoneDigits, `El teléfono debe tener ${phoneDigits} dígitos`),
94
+ departmentCode: z.string(),
95
+ provinceCode: z.string(),
96
+ districtCode: z.string(),
97
+ city: z.string(),
98
+ province: z.string(),
99
+ selectedShippingKey: z.string().nullable(),
100
+ shippingOptionsCount: z.number().int().nonnegative(),
101
+ selectedPaymentCode: z.string().nullable(),
102
+ paymentMethodsCount: z.number().int().nonnegative(),
103
+ })
104
+ .superRefine((data, ctx) => {
105
+ const country = data.countryCode.toUpperCase()
106
+
107
+ if (country === 'PE') {
108
+ if (!data.departmentCode.trim()) {
109
+ ctx.addIssue({
110
+ code: z.ZodIssueCode.custom,
111
+ path: ['departmentCode'],
112
+ message: 'Selecciona un departamento',
113
+ })
114
+ }
115
+ if (!data.provinceCode.trim()) {
116
+ ctx.addIssue({
117
+ code: z.ZodIssueCode.custom,
118
+ path: ['provinceCode'],
119
+ message: 'Selecciona una provincia',
120
+ })
121
+ }
122
+ if (!data.districtCode.trim()) {
123
+ ctx.addIssue({
124
+ code: z.ZodIssueCode.custom,
125
+ path: ['districtCode'],
126
+ message: 'Selecciona un distrito',
127
+ })
128
+ }
129
+ } else {
130
+ if (!data.city.trim()) {
131
+ ctx.addIssue({
132
+ code: z.ZodIssueCode.custom,
133
+ path: ['city'],
134
+ message: 'La ciudad es requerida',
135
+ })
136
+ }
137
+ if (!data.province.trim()) {
138
+ ctx.addIssue({
139
+ code: z.ZodIssueCode.custom,
140
+ path: ['province'],
141
+ message: 'La región es requerida',
142
+ })
143
+ }
144
+ }
145
+
146
+ if (data.shippingOptionsCount > 0 && !data.selectedShippingKey?.trim()) {
147
+ ctx.addIssue({
148
+ code: z.ZodIssueCode.custom,
149
+ path: ['selectedShippingKey'],
150
+ message: 'Selecciona un método de envío',
151
+ })
152
+ }
153
+
154
+ if (data.paymentMethodsCount > 0 && !data.selectedPaymentCode?.trim()) {
155
+ ctx.addIssue({
156
+ code: z.ZodIssueCode.custom,
157
+ path: ['selectedPaymentCode'],
158
+ message: 'Selecciona un método de pago',
159
+ })
160
+ }
161
+ })
162
+ }
163
+
164
+ export function checkoutValidationInputFromContext(
165
+ ctx: CheckoutFormValidationContext,
166
+ ) {
167
+ const { contact } = ctx
168
+ return {
169
+ email: contact.email,
170
+ firstName: contact.firstName,
171
+ lastName: contact.lastName,
172
+ countryCode: contact.countryCode,
173
+ address: contact.address,
174
+ phone: contact.phone,
175
+ departmentCode: contact.departmentCode,
176
+ provinceCode: contact.provinceCode,
177
+ districtCode: contact.districtCode,
178
+ city: contact.city,
179
+ province: contact.province,
180
+ selectedShippingKey: ctx.selectedShippingKey,
181
+ shippingOptionsCount: ctx.shippingOptionsCount,
182
+ selectedPaymentCode: ctx.selectedPaymentCode,
183
+ paymentMethodsCount: ctx.paymentMethodsCount,
184
+ }
185
+ }
186
+
187
+ export function mapZodIssuesToFieldErrors(
188
+ error: z.ZodError,
189
+ ): Partial<Record<CheckoutFieldId, string>> {
190
+ const out: Partial<Record<CheckoutFieldId, string>> = {}
191
+ for (const issue of error.issues) {
192
+ const key = issue.path[0]
193
+ if (typeof key !== 'string') continue
194
+ const fieldId = zodPathToFieldId[key]
195
+ if (!fieldId || out[fieldId]) continue
196
+ out[fieldId] = issue.message
197
+ }
198
+ return out
199
+ }
200
+
201
+ export function firstInvalidFieldId(
202
+ errors: Partial<Record<CheckoutFieldId, string>>,
203
+ ): CheckoutFieldId | null {
204
+ for (const id of CHECKOUT_FIELD_FOCUS_ORDER) {
205
+ if (errors[id]) return id
206
+ }
207
+ return null
208
+ }
209
+
210
+ export function focusCheckoutField(fieldId: CheckoutFieldId): void {
211
+ if (typeof document === 'undefined') return
212
+ const el = document.getElementById(fieldId)
213
+ if (el) {
214
+ el.focus({ preventScroll: false })
215
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
216
+ return
217
+ }
218
+ if (fieldId === 'checkout-shipping-method') {
219
+ const radio = document.querySelector<HTMLInputElement>('input[name="shipping"]')
220
+ radio?.focus()
221
+ radio?.scrollIntoView({ behavior: 'smooth', block: 'center' })
222
+ return
223
+ }
224
+ if (fieldId === 'checkout-payment-method') {
225
+ const radio = document.querySelector<HTMLInputElement>('input[name="payment"]')
226
+ radio?.focus()
227
+ radio?.scrollIntoView({ behavior: 'smooth', block: 'center' })
228
+ }
229
+ }
@@ -0,0 +1,27 @@
1
+ import type { CartCheckoutResponse } from '@ecomiq/core'
2
+
3
+ export function formatMoney(value: number, language: string, currency: string) {
4
+ return new Intl.NumberFormat(language, {
5
+ style: 'currency',
6
+ currency,
7
+ minimumFractionDigits: 2,
8
+ maximumFractionDigits: 2,
9
+ }).format(value)
10
+ }
11
+
12
+ export function thankYouUrl(cartId: string, transactionId?: string) {
13
+ const q = new URLSearchParams({ cartId })
14
+ if (transactionId) q.set('transactionId', transactionId)
15
+ return `/thank-you?${q.toString()}`
16
+ }
17
+
18
+ /** @deprecated Usa `thankYouUrl`. */
19
+ export function processingUrl(cartId: string, transactionId?: string) {
20
+ return thankYouUrl(cartId, transactionId)
21
+ }
22
+
23
+ export function paymentRedirectUrl(result: CartCheckoutResponse): string | null {
24
+ const actionUrl = result.payment?.action?.url
25
+ if (typeof actionUrl === 'string' && actionUrl.trim()) return actionUrl
26
+ return null
27
+ }