@ecomiq/storefront 1.1.9 → 2.0.1
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/README.md +30 -415
- package/bin/ecomiq.mjs +165 -0
- package/package.json +72 -94
- package/src/api-proxy.ts +194 -0
- package/src/auth/create-storefront-auth.ts +35 -0
- package/src/auth/ecomiq-oauth-password-client.ts +26 -0
- package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
- package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
- package/src/auth/ecomiq-oauth-password-types.ts +25 -0
- package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
- package/src/auth/env.ts +7 -0
- package/src/cart-session.ts +241 -0
- package/src/client.ts +48 -0
- package/src/codegen.mjs +704 -0
- package/src/components/checkout/cart-sync.ts +143 -0
- package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
- package/src/components/checkout/checkout-form-controls.tsx +139 -0
- package/src/components/checkout/checkout-line-items.tsx +43 -0
- package/src/components/checkout/checkout-order-summary.tsx +156 -0
- package/src/components/checkout/checkout-payment-section.tsx +215 -0
- package/src/components/checkout/checkout-session-view.tsx +240 -0
- package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
- package/src/components/checkout/checkout-shipping-section.tsx +90 -0
- package/src/components/checkout/checkout-states.tsx +73 -0
- package/src/components/checkout/form-schema.ts +229 -0
- package/src/components/checkout/format.ts +27 -0
- package/src/components/checkout/index.ts +51 -0
- package/src/components/checkout/types.ts +43 -0
- package/src/components/checkout/use-checkout-geo.ts +50 -0
- package/src/components/checkout/use-checkout-session.ts +583 -0
- package/src/components/hero.tsx +291 -0
- package/src/config.d.ts +69 -0
- package/src/config.mjs +165 -0
- package/src/index.ts +274 -0
- package/src/link.tsx +2 -0
- package/src/pages/checkout.$checkoutId.tsx +60 -0
- package/src/pages/checkout.tsx +31 -0
- package/src/pages/thank-you.tsx +53 -0
- package/src/providers/auth.tsx +69 -0
- package/src/providers/cart.tsx +275 -0
- package/src/providers/ecomiq.ts +137 -0
- package/src/providers/index.ts +26 -0
- package/src/public-config.ts +44 -0
- package/src/review-submission.ts +89 -0
- package/src/runtime/context.ts +1 -0
- package/src/runtime/default-layout.tsx +80 -0
- package/src/runtime/head.ts +126 -0
- package/src/runtime/page.tsx +82 -0
- package/src/runtime/postcss-browser-stub.mjs +5 -0
- package/src/server-config-store.ts +26 -0
- package/src/server.ts +279 -0
- package/src/styles.css +76 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +31 -0
- package/src/ui/card.tsx +31 -0
- package/src/ui/carousel.tsx +218 -0
- package/src/ui/cn.ts +6 -0
- package/src/ui/index.ts +8 -0
- package/src/ui/separator.tsx +6 -0
- package/src/ui/skeleton.tsx +6 -0
- package/src/ui/storefront-error.tsx +49 -0
- package/src/utils.ts +36 -0
- package/src/vite-inline.mjs +135 -0
- package/templates/__root.tsx +77 -0
- package/templates/router.tsx +19 -0
- package/dist/chunk-64GLOWMJ.js +0 -216
- package/dist/chunk-64GLOWMJ.js.map +0 -1
- package/dist/chunk-AKD5IDEB.js +0 -177
- package/dist/chunk-AKD5IDEB.js.map +0 -1
- package/dist/chunk-EFEVJJWF.js +0 -1345
- package/dist/chunk-EFEVJJWF.js.map +0 -1
- package/dist/chunk-NAJQB5DN.js +0 -155
- package/dist/chunk-NAJQB5DN.js.map +0 -1
- package/dist/chunk-OABDGTBI.js +0 -253
- package/dist/chunk-OABDGTBI.js.map +0 -1
- package/dist/chunk-Q4U5OEUT.js +0 -21
- package/dist/chunk-Q4U5OEUT.js.map +0 -1
- package/dist/chunk-S7CPZ4UE.js +0 -6
- package/dist/chunk-S7CPZ4UE.js.map +0 -1
- package/dist/chunks/client.css +0 -984
- package/dist/chunks/client.css.map +0 -1
- package/dist/chunks/client.d.ts +0 -10759
- package/dist/chunks/client.js +0 -5398
- package/dist/chunks/client.js.map +0 -1
- package/dist/chunks/config.d.ts +0 -31
- package/dist/chunks/config.js +0 -5
- package/dist/chunks/config.js.map +0 -1
- package/dist/chunks/core-public.d.ts +0 -130
- package/dist/chunks/core-public.js +0 -92
- package/dist/chunks/core-public.js.map +0 -1
- package/dist/chunks/server-nextjs.d.ts +0 -33
- package/dist/chunks/server-nextjs.js +0 -59
- package/dist/chunks/server-nextjs.js.map +0 -1
- package/dist/chunks/server-tanstack.d.ts +0 -22
- package/dist/chunks/server-tanstack.js +0 -40
- package/dist/chunks/server-tanstack.js.map +0 -1
- package/dist/collection-DU7gREXS.d.ts +0 -4062
- package/dist/ecomiq-oauth-password-plugin-B_gMdGuc.d.ts +0 -3631
- package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
- package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.server.d.ts +0 -9
- package/dist/index.server.js +0 -8
- package/dist/index.server.js.map +0 -1
- package/dist/nextjs/api.d.ts +0 -45
- package/dist/nextjs/api.js +0 -32
- package/dist/nextjs/api.js.map +0 -1
- package/dist/nextjs/globals.css +0 -13
- package/dist/nextjs.d.ts +0 -2
- package/dist/nextjs.js +0 -2
- package/dist/nextjs.js.map +0 -1
- package/dist/nextjs.server.d.ts +0 -18
- package/dist/nextjs.server.js +0 -1
- package/dist/nextjs.server.js.map +0 -1
- package/dist/postcss.config.mjs +0 -6
- package/dist/react/checkout-page.css +0 -855
- package/dist/react/client.css +0 -984
- package/dist/react/payment-provider.css +0 -91
- package/dist/react/thank-you-page.css +0 -105
- package/dist/react.d.ts +0 -1
- package/dist/react.js +0 -2
- package/dist/react.js.map +0 -1
- package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
- package/dist/tanstack/api.d.ts +0 -41
- package/dist/tanstack/api.js +0 -30
- package/dist/tanstack/api.js.map +0 -1
- package/dist/tanstack.d.ts +0 -1
- package/dist/tanstack.js +0 -2
- package/dist/tanstack.js.map +0 -1
- package/dist/tanstack.server.d.ts +0 -18
- package/dist/tanstack.server.js +0 -1
- 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
|
+
}
|