@pcreative/commerce-contract 1.3.5 → 1.3.10
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.es.md +75 -2
- package/README.md +73 -2
- package/package.json +1 -1
- package/src/adapters/api.d.ts +8 -0
- package/src/adapters/api.js +312 -5
- package/src/index.d.ts +325 -4
- package/src/index.js +55 -0
- package/src/ip.js +106 -0
package/README.es.md
CHANGED
|
@@ -90,6 +90,78 @@ const licencias = await commerce.getLicenses(id, access) // 403 sin
|
|
|
90
90
|
const dado = await commerce.requestOrderAccess(id, email) // { access, days } | null
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
**Agotada y reservada no son lo mismo.** Mientras alguien paga, la tienda le
|
|
94
|
+
guarda unos minutos lo que lleva. Esas unidades salen en `Variant.held` y ya
|
|
95
|
+
están descontadas de `stock`; el carrito que paga lleva `holdUntil`. Con `stock`
|
|
96
|
+
0 y `held` mayor que 0 la pieza puede volver en unos minutos.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { isVariantHeld, holdSecondsLeft } from "@pcreative/commerce-contract"
|
|
100
|
+
isVariantHeld(variante) // true: «otra persona la está pagando»
|
|
101
|
+
holdSecondsLeft(carrito) // segundos que quedan, o null si no retiene
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Lanzamientos.** Un producto con fecha de salida llega `sealed: true` hasta
|
|
105
|
+
entonces, con `available: false` en el producto y en todas sus variantes, así
|
|
106
|
+
que no entra en un carrito (la tienda contesta 400). Trae `releaseAt` (ISO, en
|
|
107
|
+
UTC), `releaseAtLocal` (`AAAA-MM-DDTHH:mm` en la zona de la tienda) y
|
|
108
|
+
`releaseTimeZone`. El precio se sigue viendo. `releases.notify` (que no envía
|
|
109
|
+
nada si `consent.accepted` no es exactamente `true`) pide un aviso
|
|
110
|
+
por correo (del lanzamiento, o de reposición de una variante agotada con
|
|
111
|
+
`variantId`): manda un correo de confirmación y contesta siempre
|
|
112
|
+
`{ status: "pendiente" }`, estuviera o no ya apuntada esa dirección. Falla con
|
|
113
|
+
409 `avisos.ya_a_la_venta` si no hay nada que esperar, con 400 sin
|
|
114
|
+
consentimiento y con 429 (`rate_limited`) al pasar los límites. `consent.info`
|
|
115
|
+
(opcional, hasta 4000 caracteres) es el texto plano de la información básica
|
|
116
|
+
que se vio junto a la casilla; se guarda con la prueba como `consentInfo`. El correo
|
|
117
|
+
enlaza a `/avisos/confirmar?token=` y `/avisos/baja?token=` del escaparate, que
|
|
118
|
+
llaman a `releases.confirm` (`{ confirmed: false }` si el enlace caducó o ya se
|
|
119
|
+
usó) y a `releases.unsubscribe` desde el servidor, tras un clic, nunca con un
|
|
120
|
+
GET que pueda disparar un escáner de enlaces.
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
await commerce.releases.notify({ productId, variantId, email, consent: { accepted: true, text, version, info }, locale })
|
|
124
|
+
await commerce.releases.confirm(token) // { confirmed: boolean }
|
|
125
|
+
await commerce.releases.unsubscribe(token) // { unsubscribed: true, alreadyWas: boolean }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Ediciones numeradas.** Una variante que se vende como edición numerada lleva
|
|
129
|
+
`Variant.edition: { size, available }`: cuántos ejemplares hay y cuántos
|
|
130
|
+
números quedan libres (coincide con `stock`). En lo demás es `null`. El
|
|
131
|
+
carrito no lleva número: la tienda los asigna al crear el pedido, empezando por
|
|
132
|
+
el más bajo libre. Cada línea del pedido trae entonces `editionNumber` (el más
|
|
133
|
+
bajo), `editionNumbers` (todos, ordenados) y `editionSize`, todo `null` si la
|
|
134
|
+
línea no es una edición, y `editionLabel`: lo mismo ya escrito en el idioma de
|
|
135
|
+
la petición (también en `account.orders`).
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
const v = producto.variants[0]
|
|
139
|
+
if (v.edition) `Edición de ${v.edition.size} · quedan ${v.edition.available}`
|
|
140
|
+
if (linea.editionNumber) `Edición nº ${linea.editionNumbers.join(", ")}/${linea.editionSize}`
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Un tema que llama a la tienda desde su servidor le pasa la IP del
|
|
144
|
+
comprador**, o todos los compradores comparten el límite del tema. El adaptador
|
|
145
|
+
la manda en `X-Forwarded-For` solo en las escrituras y nunca desde el
|
|
146
|
+
navegador, y solo si es una única dirección válida. `clientIpFromHeaders` la
|
|
147
|
+
saca por la derecha de la cabecera que llega, saltándose tus proxies de
|
|
148
|
+
confianza, así que el comprador no la puede elegir. Igual con
|
|
149
|
+
`clientUserAgent`: el navegador del comprador, que va en las escrituras en
|
|
150
|
+
`X-Pcc-Client-User-Agent` (sin caracteres de control, 512 como mucho) como
|
|
151
|
+
prueba del consentimiento; la tienda solo lo acepta de un proxy de confianza.
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
const commerce = createCommerce({
|
|
155
|
+
baseUrl: process.env.COMMERCE_URL,
|
|
156
|
+
clientIp: async () => clientIpFromHeaders(await headers(), {
|
|
157
|
+
trustedProxies: process.env.PCC_PROXIES_DE_CONFIANZA,
|
|
158
|
+
}),
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Un 429 trae `err.retryAfter` en segundos, y
|
|
163
|
+
`tooManyAttemptsMessage(err, "es")` da el texto que hay que enseñar.
|
|
164
|
+
|
|
93
165
|
## Escribir otro adaptador
|
|
94
166
|
|
|
95
167
|
Implementa la interfaz `CommerceClient` de [`src/index.d.ts`](src/index.d.ts) y
|
|
@@ -100,8 +172,9 @@ import { assertCommerceClient } from "@pcreative/commerce-contract"
|
|
|
100
172
|
const faltan = assertCommerceClient(miCliente) // [] si cumple
|
|
101
173
|
```
|
|
102
174
|
|
|
103
|
-
`account
|
|
104
|
-
|
|
175
|
+
`account` (con su libreta `account.addresses`), `wishlist`, `content`,
|
|
176
|
+
`getAuthor` y `support` son opcionales: un backend puede no tener cuentas de
|
|
177
|
+
cliente, ni lista de deseos, ni blog, ni mercado de autores.
|
|
105
178
|
|
|
106
179
|
## Idioma de los mensajes
|
|
107
180
|
|
package/README.md
CHANGED
|
@@ -89,6 +89,76 @@ const licencias = await commerce.getLicenses(id, access) // 403 with
|
|
|
89
89
|
const dado = await commerce.requestOrderAccess(id, email) // { access, days } | null
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
+
**Sold out and held are not the same.** While someone pays, the store holds
|
|
93
|
+
what their cart carries for a few minutes. Those units come in `Variant.held`
|
|
94
|
+
and are already taken out of `stock`; the cart that is paying carries
|
|
95
|
+
`holdUntil`. With `stock` 0 and `held` above 0 the item may come back soon.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
import { isVariantHeld, holdSecondsLeft } from "@pcreative/commerce-contract"
|
|
99
|
+
isVariantHeld(variant) // true: "someone else is paying for it"
|
|
100
|
+
holdSecondsLeft(cart) // seconds left, or null if it holds nothing
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Releases.** A product with a release date comes `sealed: true` until then,
|
|
104
|
+
with `available: false` on the product and on every variant, so it cannot be
|
|
105
|
+
added to a cart (the store answers 400). It carries `releaseAt` (ISO, UTC),
|
|
106
|
+
`releaseAtLocal` (`YYYY-MM-DDTHH:mm` in the store's time zone) and
|
|
107
|
+
`releaseTimeZone`. The price stays visible. `releases.notify` (which sends
|
|
108
|
+
nothing unless `consent.accepted` is literally `true`) asks for an email alert (launch, or back in stock for a sold-out variant with
|
|
109
|
+
`variantId`): it sends a confirmation email and always answers
|
|
110
|
+
`{ status: "pendiente" }`, whether or not the address was already there. It
|
|
111
|
+
fails with 409 `avisos.ya_a_la_venta` when there is nothing to wait for, 400
|
|
112
|
+
without consent and 429 (`rate_limited`) when the limits kick in. `consent.info`
|
|
113
|
+
(optional, up to 4000 characters) is the plain text of the privacy notice shown
|
|
114
|
+
next to the box; it is stored with the proof as `consentInfo`. The email
|
|
115
|
+
links to `/avisos/confirmar?token=` and `/avisos/baja?token=` on the
|
|
116
|
+
storefront, which call `releases.confirm` (`{ confirmed: false }` for an
|
|
117
|
+
expired or used link) and `releases.unsubscribe` from the server, after a
|
|
118
|
+
click, never on a plain GET a link scanner could trigger.
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
await commerce.releases.notify({ productId, variantId, email, consent: { accepted: true, text, version, info }, locale })
|
|
122
|
+
await commerce.releases.confirm(token) // { confirmed: boolean }
|
|
123
|
+
await commerce.releases.unsubscribe(token) // { unsubscribed: true, alreadyWas: boolean }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Numbered editions.** A variant sold as a numbered edition carries
|
|
127
|
+
`Variant.edition: { size, available }`: how many copies there are and how many
|
|
128
|
+
numbers are still free (it matches `stock`). It is `null` for anything else.
|
|
129
|
+
The cart carries no number: the store assigns them when the order is created,
|
|
130
|
+
lowest free first. Each order line then carries `editionNumber` (the lowest),
|
|
131
|
+
`editionNumbers` (all of them, sorted) and `editionSize`, all `null` when the
|
|
132
|
+
line is not an edition, plus `editionLabel`: the same, already written in the
|
|
133
|
+
language of the request (also in `account.orders`).
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
const v = product.variants[0]
|
|
137
|
+
if (v.edition) `Edition of ${v.edition.size} · ${v.edition.available} left`
|
|
138
|
+
if (line.editionNumber) `Edition no. ${line.editionNumbers.join(", ")}/${line.editionSize}`
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**A theme that calls the store from its server passes the buyer's IP**, or
|
|
142
|
+
every buyer shares the theme's rate limit. The adapter sends it in
|
|
143
|
+
`X-Forwarded-For` only on writes and never from the browser, and only if it is
|
|
144
|
+
a single valid address. `clientIpFromHeaders` picks it from the right of the
|
|
145
|
+
incoming header, skipping your trusted proxies, so the buyer cannot choose it.
|
|
146
|
+
The same goes for `clientUserAgent`: the buyer's browser, sent on writes in
|
|
147
|
+
`X-Pcc-Client-User-Agent` (no control characters, 512 at most) as proof of
|
|
148
|
+
consent; the store only accepts it from a trusted proxy.
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
const commerce = createCommerce({
|
|
152
|
+
baseUrl: process.env.COMMERCE_URL,
|
|
153
|
+
clientIp: async () => clientIpFromHeaders(await headers(), {
|
|
154
|
+
trustedProxies: process.env.PCC_PROXIES_DE_CONFIANZA,
|
|
155
|
+
}),
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A 429 comes with `err.retryAfter` in seconds, and
|
|
160
|
+
`tooManyAttemptsMessage(err, "en")` gives the text to show.
|
|
161
|
+
|
|
92
162
|
## Writing another adapter
|
|
93
163
|
|
|
94
164
|
Implement the `CommerceClient` interface from [`src/index.d.ts`](src/index.d.ts)
|
|
@@ -99,8 +169,9 @@ import { assertCommerceClient } from "@pcreative/commerce-contract"
|
|
|
99
169
|
const faltan = assertCommerceClient(miCliente) // [] if it complies
|
|
100
170
|
```
|
|
101
171
|
|
|
102
|
-
`account
|
|
103
|
-
|
|
172
|
+
`account` (with its `account.addresses` book), `wishlist`, `content`,
|
|
173
|
+
`getAuthor` and `support` are optional: a backend may have no customer
|
|
174
|
+
accounts, no wishlist, no blog and no author marketplace.
|
|
104
175
|
|
|
105
176
|
## Language of the messages
|
|
106
177
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pcreative/commerce-contract",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.10",
|
|
4
4
|
"description": "Commerce data contract: the types a theme consumes and the adapters that resolve them. No dependencies and no backend SDK.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
package/src/adapters/api.d.ts
CHANGED
|
@@ -7,6 +7,14 @@ export interface OpcionesCommerce {
|
|
|
7
7
|
locale?: string
|
|
8
8
|
fetch?: typeof globalThis.fetch
|
|
9
9
|
requestInit?: RequestInit
|
|
10
|
+
/**
|
|
11
|
+
* La IP del comprador, para un tema que llama al comercio desde su servidor.
|
|
12
|
+
* Va en `X-Forwarded-For` solo en las escrituras y solo fuera del navegador,
|
|
13
|
+
* y tiene que ser una única dirección válida: cualquier otra cosa se descarta.
|
|
14
|
+
* Sácala con `clientIpFromHeaders`, nunca copiando la cabecera que llega.
|
|
15
|
+
*/
|
|
16
|
+
clientIp?: string | null | (() => string | null | undefined | Promise<string | null | undefined>)
|
|
17
|
+
clientUserAgent?: string | null | (() => string | null | undefined | Promise<string | null | undefined>)
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
export declare function createCommerce(opciones: OpcionesCommerce): CommerceClient
|
package/src/adapters/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { money } from "../index.js"
|
|
1
|
+
import { money, parseRetryAfter } from "../index.js"
|
|
2
|
+
import { normalizeIp } from "../ip.js"
|
|
2
3
|
import { msg } from "../mensajes.js"
|
|
3
4
|
|
|
4
5
|
/** La cabecera con la que se demuestra que un pedido de invitado es tuyo. */
|
|
@@ -20,6 +21,9 @@ function aHilo(h) {
|
|
|
20
21
|
status: h.estado,
|
|
21
22
|
covered: h.con_soporte !== false,
|
|
22
23
|
solved: h.resuelto ?? null,
|
|
24
|
+
mediation: h.mediacion_en
|
|
25
|
+
? { requestedAt: h.mediacion_en, resolvedAt: h.mediacion_resuelta_en ?? null }
|
|
26
|
+
: null,
|
|
23
27
|
messages: (h.mensajes ?? []).map((m) => ({
|
|
24
28
|
from: DE_QUIEN[m.de] ?? "store",
|
|
25
29
|
text: m.texto,
|
|
@@ -30,6 +34,34 @@ function aHilo(h) {
|
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
const CODIGOS_DE_SALDO = new Set([
|
|
38
|
+
"balance.invalid",
|
|
39
|
+
"balance.expired",
|
|
40
|
+
"balance.disabled",
|
|
41
|
+
"balance.currency",
|
|
42
|
+
"balance.empty",
|
|
43
|
+
"rate_limited",
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
const CLAVES_DE_SALDO = {
|
|
47
|
+
"saldo.no_existe": "balance.invalid",
|
|
48
|
+
"saldo.caducado": "balance.expired",
|
|
49
|
+
"saldo.desactivado": "balance.disabled",
|
|
50
|
+
"saldo.otra_moneda": "balance.currency",
|
|
51
|
+
"saldo.sin_saldo": "balance.empty",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function codigoDeSaldo(motivo, codigo) {
|
|
55
|
+
if (typeof codigo === "string") {
|
|
56
|
+
if (CODIGOS_DE_SALDO.has(codigo)) return codigo
|
|
57
|
+
if (CLAVES_DE_SALDO[codigo]) return CLAVES_DE_SALDO[codigo]
|
|
58
|
+
}
|
|
59
|
+
const texto = String(motivo ?? "")
|
|
60
|
+
if (/caduc|expir/i.test(texto)) return "balance.expired"
|
|
61
|
+
if (/saldo|balance|vac[ií]a|empty/i.test(texto)) return "balance.empty"
|
|
62
|
+
return "balance.invalid"
|
|
63
|
+
}
|
|
64
|
+
|
|
33
65
|
export function createCommerce({
|
|
34
66
|
baseUrl,
|
|
35
67
|
publishableKey,
|
|
@@ -37,10 +69,27 @@ export function createCommerce({
|
|
|
37
69
|
locale,
|
|
38
70
|
fetch: fetchImpl,
|
|
39
71
|
requestInit = {},
|
|
72
|
+
clientIp,
|
|
73
|
+
clientUserAgent,
|
|
40
74
|
} = {}) {
|
|
41
75
|
if (!baseUrl) throw new Error(msg("faltaBaseUrl"))
|
|
42
76
|
const f = fetchImpl ?? globalThis.fetch
|
|
43
77
|
const raiz = String(baseUrl).replace(/\/$/, "")
|
|
78
|
+
const enNavegador = typeof window !== "undefined" && typeof document !== "undefined"
|
|
79
|
+
|
|
80
|
+
async function ipDelComprador(method) {
|
|
81
|
+
if (!clientIp || enNavegador || method === "GET" || method === "HEAD") return null
|
|
82
|
+
const valor = typeof clientIp === "function" ? await clientIp() : clientIp
|
|
83
|
+
return normalizeIp(valor)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function navegadorDelComprador(method) {
|
|
87
|
+
if (!clientUserAgent || enNavegador || method === "GET" || method === "HEAD") return null
|
|
88
|
+
const valor = typeof clientUserAgent === "function" ? await clientUserAgent() : clientUserAgent
|
|
89
|
+
if (typeof valor !== "string") return null
|
|
90
|
+
const limpio = valor.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 512)
|
|
91
|
+
return limpio || null
|
|
92
|
+
}
|
|
44
93
|
|
|
45
94
|
async function api(ruta, { method = "GET", body, token, acceso, ...extra } = {}) {
|
|
46
95
|
const cabeceras = { accept: "application/json", ...(extra.headers ?? {}) }
|
|
@@ -52,6 +101,10 @@ export function createCommerce({
|
|
|
52
101
|
// nunca en la direccion: una direccion acaba en el historial, en los
|
|
53
102
|
// registros del servidor y en el `Referer` de cualquier enlace que se pulse.
|
|
54
103
|
if (acceso) cabeceras[CABECERA_DE_ACCESO] = acceso
|
|
104
|
+
const ip = await ipDelComprador(method)
|
|
105
|
+
if (ip) cabeceras["x-forwarded-for"] = ip
|
|
106
|
+
const navegador = await navegadorDelComprador(method)
|
|
107
|
+
if (navegador) cabeceras["x-pcc-client-user-agent"] = navegador
|
|
55
108
|
|
|
56
109
|
const res = await f(`${raiz}${ruta}`, {
|
|
57
110
|
method,
|
|
@@ -63,14 +116,22 @@ export function createCommerce({
|
|
|
63
116
|
|
|
64
117
|
if (!res.ok) {
|
|
65
118
|
let detalle = ""
|
|
119
|
+
let codigoDelCuerpo = ""
|
|
66
120
|
try {
|
|
67
|
-
|
|
121
|
+
const cuerpo = await res.json()
|
|
122
|
+
detalle = cuerpo?.message ?? ""
|
|
123
|
+
codigoDelCuerpo = typeof cuerpo?.code === "string" ? cuerpo.code : typeof cuerpo?.codigo === "string" ? cuerpo.codigo : ""
|
|
68
124
|
} catch {
|
|
69
125
|
detalle = ""
|
|
70
126
|
}
|
|
71
127
|
const err = new Error(`${method} ${ruta} → ${res.status}${detalle ? `: ${detalle}` : ""}`)
|
|
72
128
|
err.status = res.status
|
|
73
129
|
err.detalle = detalle
|
|
130
|
+
if (codigoDelCuerpo) err.code = codigoDelCuerpo
|
|
131
|
+
if (res.status === 429) {
|
|
132
|
+
err.code = codigoDelCuerpo || "rate_limited"
|
|
133
|
+
err.retryAfter = parseRetryAfter(res.headers?.get?.("retry-after"))
|
|
134
|
+
}
|
|
74
135
|
throw err
|
|
75
136
|
}
|
|
76
137
|
return res.status === 204 ? null : res.json()
|
|
@@ -105,6 +166,22 @@ export function createCommerce({
|
|
|
105
166
|
}
|
|
106
167
|
}
|
|
107
168
|
|
|
169
|
+
function mapEdicion(e) {
|
|
170
|
+
const size = Number(e?.size)
|
|
171
|
+
const available = Number(e?.available)
|
|
172
|
+
if (!Number.isInteger(size) || size <= 0 || !Number.isInteger(available) || available < 0) return null
|
|
173
|
+
return { size, available: Math.min(available, size) }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function lineaDePedido(l) {
|
|
177
|
+
const numeros = Array.isArray(l?.editionNumbers) ? l.editionNumbers.map(Number).filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => a - b) : null
|
|
178
|
+
const suelto = Number.isInteger(Number(l?.editionNumber)) && Number(l.editionNumber) > 0 ? Number(l.editionNumber) : null
|
|
179
|
+
const numero = numeros?.length ? numeros[0] : suelto
|
|
180
|
+
const tamano = Number.isInteger(Number(l?.editionSize)) && Number(l.editionSize) > 0 ? Number(l.editionSize) : null
|
|
181
|
+
const etiqueta = typeof l?.editionLabel === "string" && l.editionLabel.trim() ? l.editionLabel.trim() : null
|
|
182
|
+
return { ...l, editionNumber: numero, editionNumbers: numeros?.length ? numeros : null, editionSize: tamano, editionLabel: etiqueta }
|
|
183
|
+
}
|
|
184
|
+
|
|
108
185
|
function mapProducto(p) {
|
|
109
186
|
return {
|
|
110
187
|
id: p.id,
|
|
@@ -128,6 +205,8 @@ export function createCommerce({
|
|
|
128
205
|
price: v.price,
|
|
129
206
|
compareAt: v.compareAt ?? null,
|
|
130
207
|
stock: v.stock ?? null,
|
|
208
|
+
held: v.held ?? null,
|
|
209
|
+
edition: mapEdicion(v.edition),
|
|
131
210
|
available: v.available,
|
|
132
211
|
options: v.options ?? [],
|
|
133
212
|
weight: v.weight ?? null,
|
|
@@ -147,6 +226,10 @@ export function createCommerce({
|
|
|
147
226
|
quantityRules: p.quantityRules ?? null,
|
|
148
227
|
customization: p.customization ?? [],
|
|
149
228
|
available: p.available,
|
|
229
|
+
releaseAt: typeof p.releaseAt === "string" ? p.releaseAt : null,
|
|
230
|
+
releaseAtLocal: typeof p.releaseAtLocal === "string" ? p.releaseAtLocal : null,
|
|
231
|
+
releaseTimeZone: typeof p.releaseTimeZone === "string" ? p.releaseTimeZone : null,
|
|
232
|
+
sealed: p.sealed === true,
|
|
150
233
|
metadata: p.metadata ?? {},
|
|
151
234
|
}
|
|
152
235
|
}
|
|
@@ -217,15 +300,61 @@ export function createCommerce({
|
|
|
217
300
|
billingAddress: mapAddress(c.billingAddress),
|
|
218
301
|
shippingMethods: c.shippingMethods ?? [],
|
|
219
302
|
regionId: c.regionId,
|
|
303
|
+
balanceApplied: c.balanceApplied ?? money(0, c.total?.currency ?? c.currency ?? "eur", c.total?.taxIncluded),
|
|
304
|
+
amountDue: c.amountDue ?? c.total,
|
|
305
|
+
balances: Array.isArray(c.balances) ? c.balances.map(mapSaldo).filter(Boolean) : [],
|
|
306
|
+
...(c.includedTax && typeof c.includedTax === "object" ? { includedTax: c.includedTax } : {}),
|
|
307
|
+
...(typeof c.pricesIncludeTax === "boolean" ? { pricesIncludeTax: c.pricesIncludeTax } : {}),
|
|
220
308
|
promoCodes: c.promoCodes ?? [],
|
|
221
309
|
hasDigital: Boolean(c.hasDigital),
|
|
222
310
|
digitalConsent: c.digitalConsent ?? null,
|
|
223
311
|
pago: c.pago ?? null,
|
|
224
312
|
vatNumber: c.vatNumber ?? null,
|
|
225
313
|
taxExempt: c.taxExempt ?? null,
|
|
314
|
+
holdUntil: c.holdUntil ?? null,
|
|
226
315
|
}
|
|
227
316
|
}
|
|
228
317
|
|
|
318
|
+
function mapSaldo(s) {
|
|
319
|
+
if (!s || typeof s !== "object" || !s.id) return null
|
|
320
|
+
const final = s.last4 ?? s.final ?? null
|
|
321
|
+
const disponible = s.available ?? (s.disponible != null ? money(s.disponible, s.moneda ?? "eur") : null)
|
|
322
|
+
return {
|
|
323
|
+
id: String(s.id),
|
|
324
|
+
last4: final ? String(final).slice(-4) : null,
|
|
325
|
+
applied: s.applied && typeof s.applied === "object" ? s.applied : null,
|
|
326
|
+
available: disponible && typeof disponible === "object" ? disponible : null,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function conSaldos(cart, ids, nuevo) {
|
|
331
|
+
if (!cart) return cart
|
|
332
|
+
const porId = new Map(cart.balances.map((b) => [b.id, b]))
|
|
333
|
+
if (nuevo) porId.set(nuevo.id, { ...nuevo, ...(porId.get(nuevo.id) ?? {}), last4: porId.get(nuevo.id)?.last4 ?? nuevo.last4 })
|
|
334
|
+
const orden = Array.isArray(ids) && ids.length ? ids.map(String) : [...porId.keys()]
|
|
335
|
+
const balances = orden.map((id) => porId.get(id) ?? { id, last4: null, applied: null, available: null })
|
|
336
|
+
return { ...cart, balances }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function mapSavedAddress(a) {
|
|
340
|
+
return {
|
|
341
|
+
...mapAddress(a),
|
|
342
|
+
id: a.id,
|
|
343
|
+
name: a.address_name ?? a.name ?? null,
|
|
344
|
+
isDefaultShipping: Boolean(a.is_default_shipping ?? a.isDefaultShipping),
|
|
345
|
+
isDefaultBilling: Boolean(a.is_default_billing ?? a.isDefaultBilling),
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const aDireccionGuardadaApi = (a = {}) => ({
|
|
350
|
+
...aDireccionApi(a),
|
|
351
|
+
...(a.name !== undefined ? { address_name: a.name } : {}),
|
|
352
|
+
...(a.isDefaultShipping !== undefined ? { is_default_shipping: Boolean(a.isDefaultShipping) } : {}),
|
|
353
|
+
...(a.isDefaultBilling !== undefined ? { is_default_billing: Boolean(a.isDefaultBilling) } : {}),
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const listaDeDeseos = (r) => (Array.isArray(r?.wishlist) ? r.wishlist.filter((x) => typeof x === "string") : [])
|
|
357
|
+
|
|
229
358
|
async function listProducts(params = {}) {
|
|
230
359
|
const limit = params.limit ?? 24
|
|
231
360
|
const offset = params.offset ?? 0
|
|
@@ -234,6 +363,10 @@ export function createCommerce({
|
|
|
234
363
|
if (params.category) qs.set("categoria_handle", params.category)
|
|
235
364
|
if (params.categoryId) qs.set("categoria", params.categoryId)
|
|
236
365
|
if (params.type) qs.set("tipo", params.type)
|
|
366
|
+
for (const [clave, valores] of Object.entries(params.attributes ?? {})) {
|
|
367
|
+
const lista = (Array.isArray(valores) ? valores : [valores]).map(String).filter(Boolean)
|
|
368
|
+
if (lista.length) qs.set(`attr.${clave}`, lista.join(","))
|
|
369
|
+
}
|
|
237
370
|
if (params.ids?.length) qs.set("ids", params.ids.join(","))
|
|
238
371
|
if (ORDEN_DEL_CONTRATO[params.sort]) qs.set("orden", ORDEN_DEL_CONTRATO[params.sort])
|
|
239
372
|
const r = await api(`/tienda/productos?${qs}`)
|
|
@@ -271,6 +404,7 @@ export function createCommerce({
|
|
|
271
404
|
try {
|
|
272
405
|
const { order, acceso_requerido } = await api(`/tienda/pedidos/${encodeURIComponent(id)}`, { acceso })
|
|
273
406
|
if (!order) return null
|
|
407
|
+
if (Array.isArray(order.items)) order.items = order.items.map(lineaDePedido)
|
|
274
408
|
// Sin prueba, el comercio contesta el pedido recortado: sin correo ni
|
|
275
409
|
// direccion. `partial` es lo que le dice al tema que pida el correo en vez
|
|
276
410
|
// de pintar huecos vacios como si el pedido no tuviera datos.
|
|
@@ -385,6 +519,29 @@ export function createCommerce({
|
|
|
385
519
|
const { cart: c } = await api(rutaCarrito(cartId, `/cupon/${encodeURIComponent(code)}`), { method: "DELETE" })
|
|
386
520
|
return mapCart(c)
|
|
387
521
|
},
|
|
522
|
+
async applyBalance(cartId, code) {
|
|
523
|
+
let r
|
|
524
|
+
try {
|
|
525
|
+
r = await api(rutaCarrito(cartId, "/saldo"), { method: "POST", body: { codigo: code } })
|
|
526
|
+
} catch (e) {
|
|
527
|
+
if (e?.status === 400 && !e.code) e.code = codigoDeSaldo(e.detalle)
|
|
528
|
+
throw e
|
|
529
|
+
}
|
|
530
|
+
if (!r?.aplicado) {
|
|
531
|
+
const motivo = r?.motivo ?? ""
|
|
532
|
+
const err = new Error(motivo || msg("codigoNoValido"))
|
|
533
|
+
err.status = 400
|
|
534
|
+
err.detalle = motivo
|
|
535
|
+
err.code = codigoDeSaldo(motivo, r?.code ?? r?.codigo)
|
|
536
|
+
throw err
|
|
537
|
+
}
|
|
538
|
+
return conSaldos(mapCart(r.cart), r.saldos, mapSaldo(r.saldo))
|
|
539
|
+
},
|
|
540
|
+
async removeBalance(cartId, balanceId) {
|
|
541
|
+
const { cart: c } = await api(rutaCarrito(cartId, `/saldo/${encodeURIComponent(balanceId)}`), { method: "DELETE" })
|
|
542
|
+
const carrito = mapCart(c)
|
|
543
|
+
return carrito ? { ...carrito, balances: carrito.balances.filter((b) => b.id !== balanceId) } : carrito
|
|
544
|
+
},
|
|
388
545
|
}
|
|
389
546
|
|
|
390
547
|
const checkout = {
|
|
@@ -530,7 +687,19 @@ export function createCommerce({
|
|
|
530
687
|
},
|
|
531
688
|
async orders(token) {
|
|
532
689
|
const { orders = [] } = await api("/tienda/cuenta/pedidos", { token })
|
|
533
|
-
return orders
|
|
690
|
+
return orders.map((o) => (Array.isArray(o?.items) ? { ...o, items: o.items.map(lineaDePedido) } : o))
|
|
691
|
+
},
|
|
692
|
+
async licenses(token) {
|
|
693
|
+
const { licencias = [] } = await api("/tienda/cuenta/licencias", { token })
|
|
694
|
+
return licencias
|
|
695
|
+
},
|
|
696
|
+
async releaseSite(token, licenseKey, instance) {
|
|
697
|
+
const r = await api("/tienda/cuenta/licencias/soltar", {
|
|
698
|
+
method: "POST",
|
|
699
|
+
token,
|
|
700
|
+
body: { clave: licenseKey, instancia: instance },
|
|
701
|
+
})
|
|
702
|
+
return { released: Boolean(r.soltado), site: r.sitio ?? "" }
|
|
534
703
|
},
|
|
535
704
|
async confirmEmail(sessionToken, token) {
|
|
536
705
|
try {
|
|
@@ -541,6 +710,52 @@ export function createCommerce({
|
|
|
541
710
|
throw e
|
|
542
711
|
}
|
|
543
712
|
},
|
|
713
|
+
addresses: {
|
|
714
|
+
async list(token) {
|
|
715
|
+
try {
|
|
716
|
+
const { direcciones = [] } = await api("/tienda/cuenta/direcciones", { token })
|
|
717
|
+
return direcciones.map(mapSavedAddress)
|
|
718
|
+
} catch (e) {
|
|
719
|
+
if (e.status === 404) return []
|
|
720
|
+
throw e
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
async create(token, direccion) {
|
|
724
|
+
const { direccion: d } = await api("/tienda/cuenta/direcciones", {
|
|
725
|
+
method: "POST",
|
|
726
|
+
token,
|
|
727
|
+
body: aDireccionGuardadaApi(direccion),
|
|
728
|
+
})
|
|
729
|
+
return mapSavedAddress(d)
|
|
730
|
+
},
|
|
731
|
+
async update(token, id, direccion) {
|
|
732
|
+
const { direccion: d } = await api(`/tienda/cuenta/direcciones/${encodeURIComponent(id)}`, {
|
|
733
|
+
method: "POST",
|
|
734
|
+
token,
|
|
735
|
+
body: aDireccionGuardadaApi(direccion),
|
|
736
|
+
})
|
|
737
|
+
return mapSavedAddress(d)
|
|
738
|
+
},
|
|
739
|
+
async remove(token, id) {
|
|
740
|
+
await api(`/tienda/cuenta/direcciones/${encodeURIComponent(id)}`, { method: "DELETE", token })
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const wishlist = {
|
|
746
|
+
async list(token) {
|
|
747
|
+
return listaDeDeseos(await api("/tienda/wishlist", { token }))
|
|
748
|
+
},
|
|
749
|
+
async add(token, productIds) {
|
|
750
|
+
const ids = [...new Set((Array.isArray(productIds) ? productIds : [productIds]).map(String).filter(Boolean))]
|
|
751
|
+
if (!ids.length) return wishlist.list(token)
|
|
752
|
+
const body = ids.length === 1 ? { product_id: ids[0] } : { wishlist: ids }
|
|
753
|
+
return listaDeDeseos(await api("/tienda/wishlist", { method: "POST", token, body }))
|
|
754
|
+
},
|
|
755
|
+
async remove(token, productId) {
|
|
756
|
+
const qs = new URLSearchParams({ product_id: String(productId) })
|
|
757
|
+
return listaDeDeseos(await api(`/tienda/wishlist?${qs}`, { method: "DELETE", token }))
|
|
758
|
+
},
|
|
544
759
|
}
|
|
545
760
|
|
|
546
761
|
const mapPost = (p) => ({
|
|
@@ -666,6 +881,44 @@ export function createCommerce({
|
|
|
666
881
|
)
|
|
667
882
|
return codigo ?? null
|
|
668
883
|
},
|
|
884
|
+
releases: {
|
|
885
|
+
async notify({ productId, variantId = null, email, consent, locale } = {}) {
|
|
886
|
+
if (consent?.accepted !== true || !consent?.text || !consent?.version) {
|
|
887
|
+
const err = new Error("POST /tienda/avisos: sin consentimiento expreso no se envía")
|
|
888
|
+
err.status = 400
|
|
889
|
+
err.code = "avisos.falta_consentimiento"
|
|
890
|
+
err.detalle = ""
|
|
891
|
+
throw err
|
|
892
|
+
}
|
|
893
|
+
await api("/tienda/avisos", {
|
|
894
|
+
method: "POST",
|
|
895
|
+
body: {
|
|
896
|
+
producto_id: productId,
|
|
897
|
+
variante_id: variantId ?? null,
|
|
898
|
+
email,
|
|
899
|
+
consentimiento: true,
|
|
900
|
+
texto: consent?.text,
|
|
901
|
+
version: consent?.version,
|
|
902
|
+
...(typeof consent?.info === "string" && consent.info.trim() ? { consentInfo: consent.info.trim().slice(0, 4000) } : {}),
|
|
903
|
+
...(locale ? { idioma: locale } : {}),
|
|
904
|
+
},
|
|
905
|
+
})
|
|
906
|
+
return { status: "pendiente" }
|
|
907
|
+
},
|
|
908
|
+
async confirm(token) {
|
|
909
|
+
try {
|
|
910
|
+
await api("/tienda/avisos/confirmar", { method: "POST", body: { token } })
|
|
911
|
+
return { confirmed: true }
|
|
912
|
+
} catch (e) {
|
|
913
|
+
if (e.status === 400) return { confirmed: false }
|
|
914
|
+
throw e
|
|
915
|
+
}
|
|
916
|
+
},
|
|
917
|
+
async unsubscribe(token) {
|
|
918
|
+
const r = await api("/tienda/avisos/baja", { method: "POST", body: { token } })
|
|
919
|
+
return { unsubscribed: true, alreadyWas: Boolean(r?.yaEstaba) }
|
|
920
|
+
},
|
|
921
|
+
},
|
|
669
922
|
newsletter: {
|
|
670
923
|
async subscribe(email, consent = {}) {
|
|
671
924
|
await api("/tienda/boletin", {
|
|
@@ -697,14 +950,14 @@ export function createCommerce({
|
|
|
697
950
|
},
|
|
698
951
|
},
|
|
699
952
|
async applyAsAuthor(a) {
|
|
700
|
-
const vende = [...new Set((a.sells ?? []).filter(
|
|
953
|
+
const vende = [...new Set((a.sells ?? []).map(String).filter(Boolean))]
|
|
701
954
|
const r = await api("/tienda/autores/solicitudes", {
|
|
702
955
|
method: "POST",
|
|
703
956
|
body: {
|
|
704
957
|
nombre: a.name,
|
|
705
958
|
email: a.email,
|
|
706
959
|
portafolio: a.portfolio,
|
|
707
|
-
vende
|
|
960
|
+
vende,
|
|
708
961
|
slug: a.handle,
|
|
709
962
|
pais: a.country,
|
|
710
963
|
web: a.website,
|
|
@@ -714,6 +967,56 @@ export function createCommerce({
|
|
|
714
967
|
})
|
|
715
968
|
return { id: r.id }
|
|
716
969
|
},
|
|
970
|
+
forum: {
|
|
971
|
+
async get() {
|
|
972
|
+
const { forum } = await api("/tienda/foro")
|
|
973
|
+
return forum?.url ? { url: forum.url } : null
|
|
974
|
+
},
|
|
975
|
+
async signIn(token, sso, sig) {
|
|
976
|
+
const { url } = await api("/tienda/foro/entrar", { method: "POST", token, body: { sso, sig } })
|
|
977
|
+
return { url }
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
async listReviews(productId, params = {}) {
|
|
981
|
+
const q = new URLSearchParams({ product_id: productId })
|
|
982
|
+
if (params.limit != null) q.set("limit", String(params.limit))
|
|
983
|
+
if (params.offset != null) q.set("offset", String(params.offset))
|
|
984
|
+
const r = await api(`/tienda/reviews?${q}`)
|
|
985
|
+
return {
|
|
986
|
+
reviews: r.reviews ?? [],
|
|
987
|
+
count: r.count ?? 0,
|
|
988
|
+
average: r.average ?? null,
|
|
989
|
+
...(r.histogram ? { histogram: r.histogram } : {}),
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
async submitReview(productId, review, token) {
|
|
993
|
+
const { review: r } = await api("/tienda/reviews", {
|
|
994
|
+
method: "POST",
|
|
995
|
+
token,
|
|
996
|
+
body: { product_id: productId, rating: review.rating, title: review.title, content: review.content },
|
|
997
|
+
})
|
|
998
|
+
return { id: r.id, status: r.status }
|
|
999
|
+
},
|
|
1000
|
+
async getFounders() {
|
|
1001
|
+
const { founders } = await api("/tienda/mercado/fundadores")
|
|
1002
|
+
return founders ?? null
|
|
1003
|
+
},
|
|
1004
|
+
async listMarketTypes() {
|
|
1005
|
+
const { tipos } = await api("/tienda/mercado/tipos")
|
|
1006
|
+
return (tipos ?? []).map((t) => ({
|
|
1007
|
+
key: t.clave,
|
|
1008
|
+
name: t.nombre ?? {},
|
|
1009
|
+
description: t.descripcion ?? {},
|
|
1010
|
+
order: t.orden ?? 0,
|
|
1011
|
+
attributes: (t.atributos ?? []).map((a) => ({
|
|
1012
|
+
key: a.clave,
|
|
1013
|
+
name: a.nombre ?? {},
|
|
1014
|
+
multiple: a.multiple === true,
|
|
1015
|
+
required: a.obligatorio === true,
|
|
1016
|
+
options: (a.opciones ?? []).map((o) => ({ value: o.valor, name: o.nombre ?? {} })),
|
|
1017
|
+
})),
|
|
1018
|
+
}))
|
|
1019
|
+
},
|
|
717
1020
|
async getAuthor(handle) {
|
|
718
1021
|
try {
|
|
719
1022
|
const { autor } = await api(`/tienda/autores/${encodeURIComponent(handle)}`)
|
|
@@ -769,6 +1072,9 @@ export function createCommerce({
|
|
|
769
1072
|
async reply(id, email, message) {
|
|
770
1073
|
await api(`/tienda/soporte/${encodeURIComponent(id)}`, { method: "POST", body: { email, texto: message } })
|
|
771
1074
|
},
|
|
1075
|
+
async requestMediation(id, email, reason) {
|
|
1076
|
+
await api(`/tienda/soporte/${encodeURIComponent(id)}`, { method: "POST", body: { email, mediacion: reason } })
|
|
1077
|
+
},
|
|
772
1078
|
async rate(id, email, solved) {
|
|
773
1079
|
await api(`/tienda/soporte/${encodeURIComponent(id)}`, {
|
|
774
1080
|
method: "POST",
|
|
@@ -779,6 +1085,7 @@ export function createCommerce({
|
|
|
779
1085
|
cart,
|
|
780
1086
|
checkout,
|
|
781
1087
|
account,
|
|
1088
|
+
wishlist,
|
|
782
1089
|
content,
|
|
783
1090
|
async getStore() {
|
|
784
1091
|
try {
|
package/src/index.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface Variant {
|
|
|
45
45
|
compareAt?: Money | null
|
|
46
46
|
/** null = el backend no lleva stock de esta variante. */
|
|
47
47
|
stock: number | null
|
|
48
|
+
held?: number | null
|
|
49
|
+
edition?: { size: number; available: number } | null
|
|
48
50
|
available: boolean
|
|
49
51
|
options: OptionValue[]
|
|
50
52
|
weight?: number | null
|
|
@@ -121,6 +123,33 @@ export interface License {
|
|
|
121
123
|
supportedUntil: string | null
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
/** Un sitio donde una licencia esta puesta ahora mismo. */
|
|
127
|
+
export interface LicenseSite {
|
|
128
|
+
/** Lo que identifica esa instalacion: es lo que se manda para desactivarla. */
|
|
129
|
+
instance: string
|
|
130
|
+
/** El dominio, ya normalizado (sin esquema, sin www, sin puerto). */
|
|
131
|
+
site: string
|
|
132
|
+
/** Nombre que le puso la instalacion, si lo mando. */
|
|
133
|
+
label: string | null
|
|
134
|
+
activatedAt: string | null
|
|
135
|
+
/** Ultima vez que esa instalacion comprobo la licencia. */
|
|
136
|
+
lastSeenAt: string | null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Una licencia vista desde la cuenta de quien la compro: lo mismo que
|
|
141
|
+
* `License` mas los sitios, porque desde aqui se desactivan.
|
|
142
|
+
*/
|
|
143
|
+
export interface OwnedLicense {
|
|
144
|
+
key: string
|
|
145
|
+
status: "activa" | "caducada" | "anulada"
|
|
146
|
+
title: string | null
|
|
147
|
+
orderId: string
|
|
148
|
+
limit: number
|
|
149
|
+
supportedUntil: string | null
|
|
150
|
+
sites: LicenseSite[]
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
export interface ProductCode {
|
|
125
154
|
id: string
|
|
126
155
|
lineId: string
|
|
@@ -147,8 +176,10 @@ export interface ProductCode {
|
|
|
147
176
|
* y se rellenan en `getProduct`, que es donde se enseñan.
|
|
148
177
|
*/
|
|
149
178
|
export interface MarketInfo {
|
|
150
|
-
type:
|
|
179
|
+
type: string
|
|
180
|
+
attributes?: Record<string, string[]>
|
|
151
181
|
author: { name: string; handle: string }
|
|
182
|
+
contributors?: { name: string; handle: string }[]
|
|
152
183
|
version: string | null
|
|
153
184
|
updatedAt: string | null
|
|
154
185
|
stack: string | null
|
|
@@ -162,6 +193,40 @@ export interface MarketInfo {
|
|
|
162
193
|
* versiones nuevas no tienen fecha: le llegan al comprador siempre.
|
|
163
194
|
*/
|
|
164
195
|
supportMonths?: number
|
|
196
|
+
sales?: number
|
|
197
|
+
downloads?: number
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ReviewReply {
|
|
201
|
+
content: string
|
|
202
|
+
created_at: string | null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface Review {
|
|
206
|
+
id: string
|
|
207
|
+
product_id: string
|
|
208
|
+
rating: number
|
|
209
|
+
title: string | null
|
|
210
|
+
content: string
|
|
211
|
+
author_name: string
|
|
212
|
+
created_at: string
|
|
213
|
+
updated_at?: string
|
|
214
|
+
version?: string | null
|
|
215
|
+
verified?: boolean
|
|
216
|
+
reply?: ReviewReply | null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ReviewList {
|
|
220
|
+
reviews: Review[]
|
|
221
|
+
count: number
|
|
222
|
+
average: number | null
|
|
223
|
+
histogram?: Record<"1" | "2" | "3" | "4" | "5", number>
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface ReviewDraft {
|
|
227
|
+
rating: number
|
|
228
|
+
content: string
|
|
229
|
+
title?: string
|
|
165
230
|
}
|
|
166
231
|
|
|
167
232
|
export interface Download {
|
|
@@ -234,6 +299,10 @@ export interface Product {
|
|
|
234
299
|
rating?: number | null
|
|
235
300
|
reviewCount?: number
|
|
236
301
|
available: boolean
|
|
302
|
+
releaseAt?: string | null
|
|
303
|
+
releaseAtLocal?: string | null
|
|
304
|
+
releaseTimeZone?: string | null
|
|
305
|
+
sealed?: boolean
|
|
237
306
|
quantityRules?: QuantityRules | null
|
|
238
307
|
customization?: CustomField[]
|
|
239
308
|
metadata?: Record<string, unknown>
|
|
@@ -266,6 +335,19 @@ export interface Address {
|
|
|
266
335
|
phone?: string
|
|
267
336
|
}
|
|
268
337
|
|
|
338
|
+
export interface SavedAddress extends Address {
|
|
339
|
+
id: string
|
|
340
|
+
name?: string | null
|
|
341
|
+
isDefaultShipping: boolean
|
|
342
|
+
isDefaultBilling: boolean
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface SavedAddressInput extends Address {
|
|
346
|
+
name?: string | null
|
|
347
|
+
isDefaultShipping?: boolean
|
|
348
|
+
isDefaultBilling?: boolean
|
|
349
|
+
}
|
|
350
|
+
|
|
269
351
|
export interface LineItem {
|
|
270
352
|
id: string
|
|
271
353
|
productId: string
|
|
@@ -291,6 +373,10 @@ export interface LineItem {
|
|
|
291
373
|
personalized?: boolean
|
|
292
374
|
digital?: boolean
|
|
293
375
|
bundle?: { title: string; quantity: number }[] | null
|
|
376
|
+
editionNumber?: number | null
|
|
377
|
+
editionNumbers?: number[] | null
|
|
378
|
+
editionSize?: number | null
|
|
379
|
+
editionLabel?: string | null
|
|
294
380
|
}
|
|
295
381
|
|
|
296
382
|
/** Un sitio del que sale mercancía: un paquete, un porte. */
|
|
@@ -419,6 +505,51 @@ export type SiguientePago =
|
|
|
419
505
|
proveedor?: string
|
|
420
506
|
}
|
|
421
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Una tarjeta regalo o un saldo puesto en el carrito.
|
|
510
|
+
*
|
|
511
|
+
* El código entero no vuelve nunca: con los cuatro últimos caracteres el
|
|
512
|
+
* comprador reconoce su tarjeta, y quien mire la pantalla no se la lleva.
|
|
513
|
+
*/
|
|
514
|
+
export interface AppliedBalance {
|
|
515
|
+
id: string
|
|
516
|
+
/** Los cuatro últimos caracteres del código; `null` en un saldo de cuenta sin código. */
|
|
517
|
+
last4: string | null
|
|
518
|
+
/** Lo que esta tarjeta paga de este carrito. `null` si no se sabe. */
|
|
519
|
+
applied: Money | null
|
|
520
|
+
/** Lo que le queda a la tarjeta. `null` si no se sabe. */
|
|
521
|
+
available: Money | null
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Por qué no se ha podido poner un saldo. Llega en `err.code`; el texto del
|
|
526
|
+
* backend sigue en `err.detalle` por si el tema quiere enseñarlo tal cual.
|
|
527
|
+
*/
|
|
528
|
+
export type BalanceErrorCode =
|
|
529
|
+
/** El código no existe, está desactivado o es de otra persona. */
|
|
530
|
+
| "balance.invalid"
|
|
531
|
+
/** El código existió pero ya ha caducado. */
|
|
532
|
+
| "balance.expired"
|
|
533
|
+
/** La tienda ha desactivado la tarjeta. */
|
|
534
|
+
| "balance.disabled"
|
|
535
|
+
/** La tarjeta es de otra moneda que el carrito. */
|
|
536
|
+
| "balance.currency"
|
|
537
|
+
/** La tarjeta existe pero no le queda nada. */
|
|
538
|
+
| "balance.empty"
|
|
539
|
+
/** Demasiados intentos: `err.retryAfter` dice cuántos segundos esperar. */
|
|
540
|
+
| "rate_limited"
|
|
541
|
+
|
|
542
|
+
/** El error que lanza el cliente cuando el comercio contesta con un fallo. */
|
|
543
|
+
export interface CommerceError extends Error {
|
|
544
|
+
status: number
|
|
545
|
+
/** Código estable para elegir el texto; sin definir si el comercio no da ninguno. */
|
|
546
|
+
code?: BalanceErrorCode | (string & {})
|
|
547
|
+
/** El motivo tal y como lo escribió el backend, ya en el idioma de la petición. */
|
|
548
|
+
detalle: string
|
|
549
|
+
/** Segundos que pide esperar un 429, si los dice. */
|
|
550
|
+
retryAfter?: number | null
|
|
551
|
+
}
|
|
552
|
+
|
|
422
553
|
export interface Cart {
|
|
423
554
|
id: string
|
|
424
555
|
items: LineItem[]
|
|
@@ -427,6 +558,33 @@ export interface Cart {
|
|
|
427
558
|
discount: Money
|
|
428
559
|
tax: Money
|
|
429
560
|
total: Money
|
|
561
|
+
/**
|
|
562
|
+
* Lo que pagan las tarjetas regalo y el saldo puestos en el carrito. Ya está
|
|
563
|
+
* dentro de `total`: el pedido se factura por `total` y el saldo es una forma
|
|
564
|
+
* de pago, no un descuento.
|
|
565
|
+
*/
|
|
566
|
+
balanceApplied: Money
|
|
567
|
+
/**
|
|
568
|
+
* Lo que queda por pagar con una pasarela: `total` menos `balanceApplied`.
|
|
569
|
+
* Con 0, la caja cierra con `checkout.complete` sin elegir forma de pago.
|
|
570
|
+
*/
|
|
571
|
+
amountDue: Money
|
|
572
|
+
/**
|
|
573
|
+
* Las tarjetas y saldos puestos, para enseñarlos y poder quitarlos con
|
|
574
|
+
* `removeBalance`. El código entero no viene nunca.
|
|
575
|
+
*/
|
|
576
|
+
balances?: AppliedBalance[]
|
|
577
|
+
/**
|
|
578
|
+
* El impuesto que ya va DENTRO de los precios. La cuenta cuadra siempre:
|
|
579
|
+
* `subtotal - discount + shipping + (tax - includedTax) = total`.
|
|
580
|
+
*/
|
|
581
|
+
includedTax?: Money
|
|
582
|
+
/**
|
|
583
|
+
* `true` si los precios de las líneas llevan el impuesto dentro. El total lo
|
|
584
|
+
* lleva siempre: bajo el total se pinta «IVA incluido (tax)». Sin definir si
|
|
585
|
+
* el backend no lo dice.
|
|
586
|
+
*/
|
|
587
|
+
pricesIncludeTax?: boolean
|
|
430
588
|
itemCount: number
|
|
431
589
|
email?: string | null
|
|
432
590
|
shippingAddress?: Address | null
|
|
@@ -443,6 +601,7 @@ export interface Cart {
|
|
|
443
601
|
* métodos sin pasarela: ahí pagar es confirmar el pedido y ya está.
|
|
444
602
|
*/
|
|
445
603
|
pago?: SiguientePago | null
|
|
604
|
+
holdUntil?: string | null
|
|
446
605
|
}
|
|
447
606
|
|
|
448
607
|
export interface Order {
|
|
@@ -450,11 +609,36 @@ export interface Order {
|
|
|
450
609
|
displayId?: number
|
|
451
610
|
email?: string
|
|
452
611
|
status: string
|
|
612
|
+
paymentStatus?: "not_paid" | "authorized" | "partially_authorized" | "captured" | "partially_captured" | "refunded" | "partially_refunded" | "canceled"
|
|
453
613
|
items: LineItem[]
|
|
454
614
|
subtotal: Money
|
|
455
615
|
shipping: Money
|
|
616
|
+
/** El descuento APLICADO al pedido, no el que prometía el cupón. */
|
|
456
617
|
discount: Money
|
|
457
618
|
tax: Money
|
|
619
|
+
/**
|
|
620
|
+
* El impuesto que ya va DENTRO de los precios. La cuenta cuadra siempre:
|
|
621
|
+
* `subtotal - discount + shipping + (tax - includedTax) = total`.
|
|
622
|
+
*/
|
|
623
|
+
includedTax?: Money
|
|
624
|
+
/**
|
|
625
|
+
* `true` si los precios de las líneas llevan el impuesto dentro. El total lo
|
|
626
|
+
* lleva siempre: bajo el total se pinta «IVA incluido (tax)». Sin definir si
|
|
627
|
+
* el backend no lo dice.
|
|
628
|
+
*/
|
|
629
|
+
pricesIncludeTax?: boolean
|
|
630
|
+
/**
|
|
631
|
+
* Lo que se pagó con tarjetas regalo o saldo. Ya está dentro de `total`: lo
|
|
632
|
+
* cobrado por la pasarela es `total - balanceApplied`. Sin definir si la
|
|
633
|
+
* tienda no lo dice o no se usó saldo.
|
|
634
|
+
*/
|
|
635
|
+
balanceApplied?: Money
|
|
636
|
+
/**
|
|
637
|
+
* La forma de pago del resto, tal y como la llama la tienda: `pp_offline_transfer`,
|
|
638
|
+
* `pp_offline_bizum`, `pp_offline_cod` o la de una pasarela. Sin definir si la
|
|
639
|
+
* tienda no lo dice: el tema no debe adivinarla.
|
|
640
|
+
*/
|
|
641
|
+
paymentProvider?: string | null
|
|
458
642
|
total: Money
|
|
459
643
|
shippingAddress?: Address | null
|
|
460
644
|
createdAt?: string
|
|
@@ -521,7 +705,8 @@ export interface ListProductsParams {
|
|
|
521
705
|
* Solo piezas del mercado de ese tipo. Sin él salen todos los productos,
|
|
522
706
|
* sean o no del mercado. Un adaptador sin mercado lo ignora.
|
|
523
707
|
*/
|
|
524
|
-
type?:
|
|
708
|
+
type?: string
|
|
709
|
+
attributes?: Record<string, string[]>
|
|
525
710
|
limit?: number
|
|
526
711
|
offset?: number
|
|
527
712
|
sort?: SortProductos
|
|
@@ -538,11 +723,37 @@ export interface Page<T> {
|
|
|
538
723
|
// ── El cliente ────────────────────────────────────────────────────────────
|
|
539
724
|
|
|
540
725
|
|
|
726
|
+
export type LocalizedText = Record<string, string>
|
|
727
|
+
|
|
728
|
+
export interface MarketTypeAttribute {
|
|
729
|
+
key: string
|
|
730
|
+
name: LocalizedText
|
|
731
|
+
multiple: boolean
|
|
732
|
+
required: boolean
|
|
733
|
+
options: { value: string; name: LocalizedText }[]
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export interface FounderProgram {
|
|
737
|
+
slots: number
|
|
738
|
+
taken: number
|
|
739
|
+
left: number
|
|
740
|
+
commissionPct: number
|
|
741
|
+
months: number
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export interface MarketType {
|
|
745
|
+
key: string
|
|
746
|
+
name: LocalizedText
|
|
747
|
+
description: LocalizedText
|
|
748
|
+
attributes: MarketTypeAttribute[]
|
|
749
|
+
order: number
|
|
750
|
+
}
|
|
751
|
+
|
|
541
752
|
/** Una pieza del autor tal y como se lista en su ficha. */
|
|
542
753
|
export interface AuthorItem {
|
|
543
754
|
handle: string
|
|
544
755
|
title: string
|
|
545
|
-
type:
|
|
756
|
+
type: string
|
|
546
757
|
summary: string | null
|
|
547
758
|
price: Money
|
|
548
759
|
cover: string | null
|
|
@@ -588,7 +799,7 @@ export interface AuthorApplication {
|
|
|
588
799
|
email: string
|
|
589
800
|
/** Enlace a algo que haya hecho: sin eso no hay nada que revisar. */
|
|
590
801
|
portfolio: string
|
|
591
|
-
sells:
|
|
802
|
+
sells: string[]
|
|
592
803
|
/** Identificador público; si falta, sale del nombre. */
|
|
593
804
|
handle?: string
|
|
594
805
|
/** Código de dos letras. */
|
|
@@ -613,11 +824,27 @@ export interface SupportThread {
|
|
|
613
824
|
covered: boolean
|
|
614
825
|
/** Lo que contestó al cerrar. null mientras no se le ha preguntado. */
|
|
615
826
|
solved: boolean | null
|
|
827
|
+
mediation?: { requestedAt: string; resolvedAt: string | null } | null
|
|
616
828
|
messages: SupportMessage[]
|
|
617
829
|
createdAt: string
|
|
618
830
|
updatedAt: string
|
|
619
831
|
}
|
|
620
832
|
|
|
833
|
+
export interface ReleaseNotifyInput {
|
|
834
|
+
productId: string
|
|
835
|
+
variantId?: string | null
|
|
836
|
+
email: string
|
|
837
|
+
consent: { accepted: true; text: string; version: string; info?: string }
|
|
838
|
+
locale?: string
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export type ReleaseErrorCode =
|
|
842
|
+
| "avisos.ya_a_la_venta"
|
|
843
|
+
| "avisos.correo_no_valido"
|
|
844
|
+
| "avisos.falta_consentimiento"
|
|
845
|
+
| "avisos.producto_no_existe"
|
|
846
|
+
| "rate_limited"
|
|
847
|
+
|
|
621
848
|
export interface CommerceClient {
|
|
622
849
|
/** Identificador del adaptador: "pcreative", "static"… */
|
|
623
850
|
readonly adapter: string
|
|
@@ -675,6 +902,14 @@ export interface CommerceClient {
|
|
|
675
902
|
* `err.status` 400 y el motivo en `err.detalle`.
|
|
676
903
|
*/
|
|
677
904
|
applyAsAuthor?(application: AuthorApplication): Promise<{ id: string }>
|
|
905
|
+
listMarketTypes?(): Promise<MarketType[]>
|
|
906
|
+
getFounders?(): Promise<FounderProgram | null>
|
|
907
|
+
forum?: {
|
|
908
|
+
get(): Promise<{ url: string } | null>
|
|
909
|
+
signIn(token: string, sso: string, sig: string): Promise<{ url: string }>
|
|
910
|
+
}
|
|
911
|
+
listReviews?(productId: string, params?: { limit?: number; offset?: number }): Promise<ReviewList>
|
|
912
|
+
submitReview?(productId: string, review: ReviewDraft, token?: string): Promise<{ id: string; status: string }>
|
|
678
913
|
/**
|
|
679
914
|
* Opcional: el boletín, con doble confirmación. `subscribe` NO apunta a nadie:
|
|
680
915
|
* manda un correo con un enlace, y solo al pulsarlo (`confirm`) queda dentro.
|
|
@@ -697,6 +932,11 @@ export interface CommerceClient {
|
|
|
697
932
|
confirm(token: string): Promise<boolean>
|
|
698
933
|
unsubscribe(token: string): Promise<boolean>
|
|
699
934
|
}
|
|
935
|
+
releases?: {
|
|
936
|
+
notify(input: ReleaseNotifyInput): Promise<{ status: "pendiente" }>
|
|
937
|
+
confirm(token: string): Promise<{ confirmed: boolean }>
|
|
938
|
+
unsubscribe(token: string): Promise<{ unsubscribed: true; alreadyWas: boolean }>
|
|
939
|
+
}
|
|
700
940
|
/** Opcional: la ficha pública de un autor del mercado. */
|
|
701
941
|
getAuthor?(handle: string): Promise<Author | null>
|
|
702
942
|
|
|
@@ -713,6 +953,7 @@ export interface CommerceClient {
|
|
|
713
953
|
reply(id: string, email: string, message: string): Promise<void>
|
|
714
954
|
/** Una sola pregunta al cerrar: si le sirvió. Alimenta el dato público del autor. */
|
|
715
955
|
rate(id: string, email: string, solved: boolean): Promise<void>
|
|
956
|
+
requestMediation?(id: string, email: string, reason: string): Promise<void>
|
|
716
957
|
}
|
|
717
958
|
|
|
718
959
|
cart: {
|
|
@@ -731,6 +972,17 @@ export interface CommerceClient {
|
|
|
731
972
|
consentirDigital?(cartId: string, aceptado: boolean): Promise<Cart>
|
|
732
973
|
applyPromo(cartId: string, code: string): Promise<Cart>
|
|
733
974
|
removePromo(cartId: string, code: string): Promise<Cart>
|
|
975
|
+
/**
|
|
976
|
+
* Opcional: pone una tarjeta regalo o un saldo por su código. El carrito
|
|
977
|
+
* vuelve con `balanceApplied`, `amountDue` y la tarjeta en `balances`.
|
|
978
|
+
*
|
|
979
|
+
* Si no se puede, rechaza con un `CommerceError` cuyo `code` es un
|
|
980
|
+
* `BalanceErrorCode`: `status` 400 para los códigos que no sirven y 429
|
|
981
|
+
* para demasiados intentos.
|
|
982
|
+
*/
|
|
983
|
+
applyBalance?(cartId: string, code: string): Promise<Cart>
|
|
984
|
+
/** Opcional: quita del carrito la tarjeta o el saldo con ese `id`. */
|
|
985
|
+
removeBalance?(cartId: string, balanceId: string): Promise<Cart>
|
|
734
986
|
}
|
|
735
987
|
|
|
736
988
|
checkout: {
|
|
@@ -787,6 +1039,29 @@ export interface CommerceClient {
|
|
|
787
1039
|
me(token: string): Promise<Customer | null>
|
|
788
1040
|
orders(token: string): Promise<Order[]>
|
|
789
1041
|
confirmEmail?(sessionToken: string, token: string): Promise<{ adopted: number } | null>
|
|
1042
|
+
/**
|
|
1043
|
+
* Opcional: las licencias que ha comprado, con los sitios donde están
|
|
1044
|
+
* puestas ahora mismo. Vacio si el adaptador no vende licencias.
|
|
1045
|
+
*/
|
|
1046
|
+
licenses?(token: string): Promise<OwnedLicense[]>
|
|
1047
|
+
/**
|
|
1048
|
+
* Opcional: desactiva uno de SUS sitios, que es lo que hace quien se muda
|
|
1049
|
+
* de dominio. Rechaza con `status` 400 si ese sitio ya no esta activo o la
|
|
1050
|
+
* licencia no es suya, y 429 si se ha pasado del tope.
|
|
1051
|
+
*/
|
|
1052
|
+
releaseSite?(token: string, licenseKey: string, instance: string): Promise<{ released: boolean; site: string }>
|
|
1053
|
+
addresses?: {
|
|
1054
|
+
list(token: string): Promise<SavedAddress[]>
|
|
1055
|
+
create(token: string, address: SavedAddressInput): Promise<SavedAddress>
|
|
1056
|
+
update(token: string, id: string, address: SavedAddressInput): Promise<SavedAddress>
|
|
1057
|
+
remove(token: string, id: string): Promise<void>
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
wishlist?: {
|
|
1062
|
+
list(token: string): Promise<string[]>
|
|
1063
|
+
add(token: string, productIds: string | string[]): Promise<string[]>
|
|
1064
|
+
remove(token: string, productId: string): Promise<string[]>
|
|
790
1065
|
}
|
|
791
1066
|
|
|
792
1067
|
/** Opcional: nombre y datos legales de la tienda, tal y como los tiene el panel. */
|
|
@@ -826,5 +1101,51 @@ export declare function sumMoney(...importes: Money[]): Money
|
|
|
826
1101
|
/** Construye un Money, con 0 como valor por defecto. */
|
|
827
1102
|
export declare function money(amount: number | null | undefined, currency: string, taxIncluded?: boolean): Money
|
|
828
1103
|
|
|
1104
|
+
export declare function isVariantHeld(variant: Pick<Variant, "stock" | "held"> | null | undefined): boolean
|
|
1105
|
+
|
|
1106
|
+
export declare function holdSecondsLeft(cart: Pick<Cart, "holdUntil"> | null | undefined, now?: number | Date): number | null
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* `true` si las tarjetas y el saldo cubren el carrito entero: no queda nada
|
|
1110
|
+
* que cobrar y la caja se cierra con `checkout.complete` sin pasarela.
|
|
1111
|
+
*/
|
|
1112
|
+
export declare function isCoveredByBalance(cart: Pick<Cart, "balanceApplied" | "amountDue"> | null | undefined): boolean
|
|
1113
|
+
|
|
829
1114
|
/** Comprueba que un objeto implementa el contrato; devuelve lo que falta. */
|
|
830
1115
|
export declare function assertCommerceClient(cliente: unknown): string[]
|
|
1116
|
+
|
|
1117
|
+
/** Una dirección IPv4 o IPv6 en su forma limpia, o `null` si no lo es. */
|
|
1118
|
+
export declare function normalizeIp(valor: unknown): string | null
|
|
1119
|
+
|
|
1120
|
+
export interface TrustedNetwork {
|
|
1121
|
+
bytes: number[]
|
|
1122
|
+
prefijo: number
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Lee una lista de proxies de confianza: `"127.0.0.1, 10.0.0.0/8, ::1"`. */
|
|
1126
|
+
export declare function parseTrustedProxies(lista: string | string[] | null | undefined): TrustedNetwork[]
|
|
1127
|
+
|
|
1128
|
+
export declare function isTrustedProxy(ip: string, redes: TrustedNetwork[]): boolean
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* La IP del comprador según `X-Forwarded-For`, leída de derecha a izquierda y
|
|
1132
|
+
* saltando los proxies de confianza: la primera que no lo es la añadió un proxy
|
|
1133
|
+
* propio y no el navegador. Lo que el comprador escriba a la izquierda no se
|
|
1134
|
+
* usa nunca.
|
|
1135
|
+
*/
|
|
1136
|
+
export declare function clientIpFromHeaders(
|
|
1137
|
+
cabeceras: Headers | Record<string, string | string[] | undefined> | null | undefined,
|
|
1138
|
+
opciones?: { trustedProxies?: string | string[] | TrustedNetwork[] | null }
|
|
1139
|
+
): string | null
|
|
1140
|
+
|
|
1141
|
+
/** Segundos de `Retry-After`, venga en segundos o como fecha HTTP. */
|
|
1142
|
+
export declare function parseRetryAfter(valor: string | null | undefined, now?: number | Date): number | null
|
|
1143
|
+
|
|
1144
|
+
/** `true` si el error es un 429 del comercio. */
|
|
1145
|
+
export declare function isRateLimited(err: unknown): boolean
|
|
1146
|
+
|
|
1147
|
+
/** «Demasiados intentos…», con la espera si se sabe. Acepta el error o los segundos. */
|
|
1148
|
+
export declare function tooManyAttemptsMessage(
|
|
1149
|
+
espera: number | null | undefined | { retryAfter?: number | null },
|
|
1150
|
+
locale?: string
|
|
1151
|
+
): string
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,24 @@ export function sumMoney(...importes) {
|
|
|
37
37
|
)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function isVariantHeld(variant) {
|
|
41
|
+
if (!variant) return false
|
|
42
|
+
return variant.stock === 0 && Number(variant.held ?? 0) > 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function holdSecondsLeft(cart, now = Date.now()) {
|
|
46
|
+
const hasta = cart?.holdUntil ? Date.parse(cart.holdUntil) : NaN
|
|
47
|
+
if (!Number.isFinite(hasta)) return null
|
|
48
|
+
const ahora = now instanceof Date ? now.getTime() : Number(now)
|
|
49
|
+
return Math.max(0, Math.ceil((hasta - ahora) / 1000))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isCoveredByBalance(cart) {
|
|
53
|
+
const aplicado = Math.round(Number(cart?.balanceApplied?.amount ?? 0) * 100)
|
|
54
|
+
const queda = Math.round(Number(cart?.amountDue?.amount ?? NaN) * 100)
|
|
55
|
+
return aplicado > 0 && queda === 0
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
const REQUERIDO = {
|
|
41
59
|
raiz: ["listProducts", "getProduct", "getProductsByIds", "listCategories", "getCategory", "search", "getOrder"],
|
|
42
60
|
cart: ["get", "create", "addItem", "updateItem", "removeItem"],
|
|
@@ -60,3 +78,40 @@ export function assertCommerceClient(cliente) {
|
|
|
60
78
|
}
|
|
61
79
|
return faltan
|
|
62
80
|
}
|
|
81
|
+
|
|
82
|
+
export { clientIpFromHeaders, isTrustedProxy, normalizeIp, parseTrustedProxies } from "./ip.js"
|
|
83
|
+
|
|
84
|
+
export function parseRetryAfter(valor, now = Date.now()) {
|
|
85
|
+
const texto = String(valor ?? "").trim()
|
|
86
|
+
if (!texto) return null
|
|
87
|
+
if (/^\d+$/.test(texto)) return Number(texto)
|
|
88
|
+
const fecha = Date.parse(texto)
|
|
89
|
+
if (Number.isNaN(fecha)) return null
|
|
90
|
+
return Math.max(0, Math.ceil((fecha - (now instanceof Date ? now.getTime() : now)) / 1000))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isRateLimited(err) {
|
|
94
|
+
return Number(err?.status) === 429
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const TEXTOS_DEMASIADOS = {
|
|
98
|
+
en: {
|
|
99
|
+
sin: "Too many attempts. Wait a moment and try again.",
|
|
100
|
+
segundos: (n) => `Too many attempts. Wait ${n} ${n === 1 ? "second" : "seconds"} and try again.`,
|
|
101
|
+
minutos: (n) => `Too many attempts. Wait ${n} ${n === 1 ? "minute" : "minutes"} and try again.`,
|
|
102
|
+
},
|
|
103
|
+
es: {
|
|
104
|
+
sin: "Demasiados intentos. Espera un momento y vuelve a probar.",
|
|
105
|
+
segundos: (n) => `Demasiados intentos. Espera ${n} ${n === 1 ? "segundo" : "segundos"} y vuelve a probar.`,
|
|
106
|
+
minutos: (n) => `Demasiados intentos. Espera ${n} ${n === 1 ? "minuto" : "minutos"} y vuelve a probar.`,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function tooManyAttemptsMessage(espera, locale = "en") {
|
|
111
|
+
const textos = TEXTOS_DEMASIADOS[String(locale).toLowerCase().startsWith("es") ? "es" : "en"]
|
|
112
|
+
const segundos = typeof espera === "object" && espera !== null ? espera.retryAfter : espera
|
|
113
|
+
const n = Number(segundos)
|
|
114
|
+
if (!Number.isFinite(n) || n <= 0) return textos.sin
|
|
115
|
+
if (n < 90) return textos.segundos(Math.ceil(n))
|
|
116
|
+
return textos.minutos(Math.ceil(n / 60))
|
|
117
|
+
}
|
package/src/ip.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/
|
|
2
|
+
|
|
3
|
+
function bytesV4(texto) {
|
|
4
|
+
if (!IPV4.test(texto)) return null
|
|
5
|
+
return texto.split(".").map(Number)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function bytesV6(texto) {
|
|
9
|
+
if (!/^[0-9a-fA-F:.]+$/.test(texto) || texto.length > 45) return null
|
|
10
|
+
let cola = []
|
|
11
|
+
let cuerpo = texto
|
|
12
|
+
const ultimo = texto.lastIndexOf(":")
|
|
13
|
+
if (texto.includes(".")) {
|
|
14
|
+
const v4 = bytesV4(texto.slice(ultimo + 1))
|
|
15
|
+
if (!v4) return null
|
|
16
|
+
cola = v4
|
|
17
|
+
cuerpo = texto.slice(0, ultimo + 1) + "0:0"
|
|
18
|
+
}
|
|
19
|
+
const partes = cuerpo.split("::")
|
|
20
|
+
if (partes.length > 2) return null
|
|
21
|
+
const trozos = (s) => (s === "" ? [] : s.split(":"))
|
|
22
|
+
const izquierda = trozos(partes[0])
|
|
23
|
+
const derecha = partes.length === 2 ? trozos(partes[1]) : []
|
|
24
|
+
const faltan = 8 - izquierda.length - derecha.length
|
|
25
|
+
if (partes.length === 1 ? faltan !== 0 : faltan < 1) return null
|
|
26
|
+
const grupos = [...izquierda, ...Array(partes.length === 2 ? faltan : 0).fill("0"), ...derecha]
|
|
27
|
+
if (grupos.length !== 8 || grupos.some((g) => !/^[0-9a-fA-F]{1,4}$/.test(g))) return null
|
|
28
|
+
const bytes = grupos.flatMap((g) => {
|
|
29
|
+
const n = parseInt(g, 16)
|
|
30
|
+
return [n >> 8, n & 255]
|
|
31
|
+
})
|
|
32
|
+
if (cola.length) bytes.splice(12, 4, ...cola)
|
|
33
|
+
return bytes
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeIp(valor) {
|
|
37
|
+
let texto = String(valor ?? "").trim()
|
|
38
|
+
if (texto.startsWith("[") && texto.endsWith("]")) texto = texto.slice(1, -1)
|
|
39
|
+
if (/^::ffff:/i.test(texto) && bytesV4(texto.slice(7))) texto = texto.slice(7)
|
|
40
|
+
if (bytesV4(texto)) return texto
|
|
41
|
+
if (bytesV6(texto)) return texto.toLowerCase()
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bytesDe(ip) {
|
|
46
|
+
return bytesV4(ip) ?? bytesV6(ip)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dentro(bytes, red, prefijo) {
|
|
50
|
+
if (bytes.length !== red.length) return false
|
|
51
|
+
let resto = prefijo
|
|
52
|
+
for (let i = 0; i < bytes.length && resto > 0; i++) {
|
|
53
|
+
const bits = Math.min(8, resto)
|
|
54
|
+
const mascara = (0xff << (8 - bits)) & 0xff
|
|
55
|
+
if ((bytes[i] & mascara) !== (red[i] & mascara)) return false
|
|
56
|
+
resto -= bits
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseTrustedProxies(lista) {
|
|
62
|
+
const trozos = Array.isArray(lista) ? lista : String(lista ?? "").split(",")
|
|
63
|
+
const redes = []
|
|
64
|
+
for (const trozo of trozos.map((t) => String(t).trim()).filter(Boolean)) {
|
|
65
|
+
const [direccion, prefijoTexto] = trozo.split("/")
|
|
66
|
+
const ip = normalizeIp(direccion)
|
|
67
|
+
if (!ip) continue
|
|
68
|
+
const bytes = bytesDe(ip)
|
|
69
|
+
const maximo = bytes.length * 8
|
|
70
|
+
const prefijo = prefijoTexto === undefined ? maximo : Number(prefijoTexto)
|
|
71
|
+
if (!Number.isInteger(prefijo) || prefijo < 0 || prefijo > maximo) continue
|
|
72
|
+
redes.push({ bytes, prefijo })
|
|
73
|
+
}
|
|
74
|
+
return redes
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isTrustedProxy(ip, redes) {
|
|
78
|
+
const limpia = normalizeIp(ip)
|
|
79
|
+
if (!limpia) return false
|
|
80
|
+
const bytes = bytesDe(limpia)
|
|
81
|
+
return redes.some((r) => dentro(bytes, r.bytes, r.prefijo))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function leerCabecera(cabeceras, nombre) {
|
|
85
|
+
if (!cabeceras) return ""
|
|
86
|
+
if (typeof cabeceras.get === "function") return cabeceras.get(nombre) ?? ""
|
|
87
|
+
const encontrada = Object.keys(cabeceras).find((k) => k.toLowerCase() === nombre)
|
|
88
|
+
const valor = encontrada ? cabeceras[encontrada] : ""
|
|
89
|
+
return Array.isArray(valor) ? valor.join(",") : String(valor ?? "")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function clientIpFromHeaders(cabeceras, { trustedProxies } = {}) {
|
|
93
|
+
const redes = Array.isArray(trustedProxies) && trustedProxies[0]?.bytes ? trustedProxies : parseTrustedProxies(trustedProxies)
|
|
94
|
+
const saltos = leerCabecera(cabeceras, "x-forwarded-for")
|
|
95
|
+
.split(",")
|
|
96
|
+
.map((s) => s.trim())
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
let ultimaDeConfianza = null
|
|
99
|
+
for (let i = saltos.length - 1; i >= 0; i--) {
|
|
100
|
+
const ip = normalizeIp(saltos[i])
|
|
101
|
+
if (!ip) return ultimaDeConfianza
|
|
102
|
+
if (!isTrustedProxy(ip, redes)) return ip
|
|
103
|
+
ultimaDeConfianza ??= ip
|
|
104
|
+
}
|
|
105
|
+
return ultimaDeConfianza
|
|
106
|
+
}
|