@pcreative/commerce-contract 1.3.6 → 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 +300 -5
- package/src/index.d.ts +287 -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,7 @@ 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))
|
|
534
691
|
},
|
|
535
692
|
async licenses(token) {
|
|
536
693
|
const { licencias = [] } = await api("/tienda/cuenta/licencias", { token })
|
|
@@ -553,6 +710,52 @@ export function createCommerce({
|
|
|
553
710
|
throw e
|
|
554
711
|
}
|
|
555
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
|
+
},
|
|
556
759
|
}
|
|
557
760
|
|
|
558
761
|
const mapPost = (p) => ({
|
|
@@ -678,6 +881,44 @@ export function createCommerce({
|
|
|
678
881
|
)
|
|
679
882
|
return codigo ?? null
|
|
680
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
|
+
},
|
|
681
922
|
newsletter: {
|
|
682
923
|
async subscribe(email, consent = {}) {
|
|
683
924
|
await api("/tienda/boletin", {
|
|
@@ -709,14 +950,14 @@ export function createCommerce({
|
|
|
709
950
|
},
|
|
710
951
|
},
|
|
711
952
|
async applyAsAuthor(a) {
|
|
712
|
-
const vende = [...new Set((a.sells ?? []).filter(
|
|
953
|
+
const vende = [...new Set((a.sells ?? []).map(String).filter(Boolean))]
|
|
713
954
|
const r = await api("/tienda/autores/solicitudes", {
|
|
714
955
|
method: "POST",
|
|
715
956
|
body: {
|
|
716
957
|
nombre: a.name,
|
|
717
958
|
email: a.email,
|
|
718
959
|
portafolio: a.portfolio,
|
|
719
|
-
vende
|
|
960
|
+
vende,
|
|
720
961
|
slug: a.handle,
|
|
721
962
|
pais: a.country,
|
|
722
963
|
web: a.website,
|
|
@@ -726,6 +967,56 @@ export function createCommerce({
|
|
|
726
967
|
})
|
|
727
968
|
return { id: r.id }
|
|
728
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
|
+
},
|
|
729
1020
|
async getAuthor(handle) {
|
|
730
1021
|
try {
|
|
731
1022
|
const { autor } = await api(`/tienda/autores/${encodeURIComponent(handle)}`)
|
|
@@ -781,6 +1072,9 @@ export function createCommerce({
|
|
|
781
1072
|
async reply(id, email, message) {
|
|
782
1073
|
await api(`/tienda/soporte/${encodeURIComponent(id)}`, { method: "POST", body: { email, texto: message } })
|
|
783
1074
|
},
|
|
1075
|
+
async requestMediation(id, email, reason) {
|
|
1076
|
+
await api(`/tienda/soporte/${encodeURIComponent(id)}`, { method: "POST", body: { email, mediacion: reason } })
|
|
1077
|
+
},
|
|
784
1078
|
async rate(id, email, solved) {
|
|
785
1079
|
await api(`/tienda/soporte/${encodeURIComponent(id)}`, {
|
|
786
1080
|
method: "POST",
|
|
@@ -791,6 +1085,7 @@ export function createCommerce({
|
|
|
791
1085
|
cart,
|
|
792
1086
|
checkout,
|
|
793
1087
|
account,
|
|
1088
|
+
wishlist,
|
|
794
1089
|
content,
|
|
795
1090
|
async getStore() {
|
|
796
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
|
|
@@ -174,8 +176,10 @@ export interface ProductCode {
|
|
|
174
176
|
* y se rellenan en `getProduct`, que es donde se enseñan.
|
|
175
177
|
*/
|
|
176
178
|
export interface MarketInfo {
|
|
177
|
-
type:
|
|
179
|
+
type: string
|
|
180
|
+
attributes?: Record<string, string[]>
|
|
178
181
|
author: { name: string; handle: string }
|
|
182
|
+
contributors?: { name: string; handle: string }[]
|
|
179
183
|
version: string | null
|
|
180
184
|
updatedAt: string | null
|
|
181
185
|
stack: string | null
|
|
@@ -189,6 +193,40 @@ export interface MarketInfo {
|
|
|
189
193
|
* versiones nuevas no tienen fecha: le llegan al comprador siempre.
|
|
190
194
|
*/
|
|
191
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
|
|
192
230
|
}
|
|
193
231
|
|
|
194
232
|
export interface Download {
|
|
@@ -261,6 +299,10 @@ export interface Product {
|
|
|
261
299
|
rating?: number | null
|
|
262
300
|
reviewCount?: number
|
|
263
301
|
available: boolean
|
|
302
|
+
releaseAt?: string | null
|
|
303
|
+
releaseAtLocal?: string | null
|
|
304
|
+
releaseTimeZone?: string | null
|
|
305
|
+
sealed?: boolean
|
|
264
306
|
quantityRules?: QuantityRules | null
|
|
265
307
|
customization?: CustomField[]
|
|
266
308
|
metadata?: Record<string, unknown>
|
|
@@ -293,6 +335,19 @@ export interface Address {
|
|
|
293
335
|
phone?: string
|
|
294
336
|
}
|
|
295
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
|
+
|
|
296
351
|
export interface LineItem {
|
|
297
352
|
id: string
|
|
298
353
|
productId: string
|
|
@@ -318,6 +373,10 @@ export interface LineItem {
|
|
|
318
373
|
personalized?: boolean
|
|
319
374
|
digital?: boolean
|
|
320
375
|
bundle?: { title: string; quantity: number }[] | null
|
|
376
|
+
editionNumber?: number | null
|
|
377
|
+
editionNumbers?: number[] | null
|
|
378
|
+
editionSize?: number | null
|
|
379
|
+
editionLabel?: string | null
|
|
321
380
|
}
|
|
322
381
|
|
|
323
382
|
/** Un sitio del que sale mercancía: un paquete, un porte. */
|
|
@@ -446,6 +505,51 @@ export type SiguientePago =
|
|
|
446
505
|
proveedor?: string
|
|
447
506
|
}
|
|
448
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
|
+
|
|
449
553
|
export interface Cart {
|
|
450
554
|
id: string
|
|
451
555
|
items: LineItem[]
|
|
@@ -454,6 +558,33 @@ export interface Cart {
|
|
|
454
558
|
discount: Money
|
|
455
559
|
tax: Money
|
|
456
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
|
|
457
588
|
itemCount: number
|
|
458
589
|
email?: string | null
|
|
459
590
|
shippingAddress?: Address | null
|
|
@@ -470,6 +601,7 @@ export interface Cart {
|
|
|
470
601
|
* métodos sin pasarela: ahí pagar es confirmar el pedido y ya está.
|
|
471
602
|
*/
|
|
472
603
|
pago?: SiguientePago | null
|
|
604
|
+
holdUntil?: string | null
|
|
473
605
|
}
|
|
474
606
|
|
|
475
607
|
export interface Order {
|
|
@@ -477,11 +609,36 @@ export interface Order {
|
|
|
477
609
|
displayId?: number
|
|
478
610
|
email?: string
|
|
479
611
|
status: string
|
|
612
|
+
paymentStatus?: "not_paid" | "authorized" | "partially_authorized" | "captured" | "partially_captured" | "refunded" | "partially_refunded" | "canceled"
|
|
480
613
|
items: LineItem[]
|
|
481
614
|
subtotal: Money
|
|
482
615
|
shipping: Money
|
|
616
|
+
/** El descuento APLICADO al pedido, no el que prometía el cupón. */
|
|
483
617
|
discount: Money
|
|
484
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
|
|
485
642
|
total: Money
|
|
486
643
|
shippingAddress?: Address | null
|
|
487
644
|
createdAt?: string
|
|
@@ -548,7 +705,8 @@ export interface ListProductsParams {
|
|
|
548
705
|
* Solo piezas del mercado de ese tipo. Sin él salen todos los productos,
|
|
549
706
|
* sean o no del mercado. Un adaptador sin mercado lo ignora.
|
|
550
707
|
*/
|
|
551
|
-
type?:
|
|
708
|
+
type?: string
|
|
709
|
+
attributes?: Record<string, string[]>
|
|
552
710
|
limit?: number
|
|
553
711
|
offset?: number
|
|
554
712
|
sort?: SortProductos
|
|
@@ -565,11 +723,37 @@ export interface Page<T> {
|
|
|
565
723
|
// ── El cliente ────────────────────────────────────────────────────────────
|
|
566
724
|
|
|
567
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
|
+
|
|
568
752
|
/** Una pieza del autor tal y como se lista en su ficha. */
|
|
569
753
|
export interface AuthorItem {
|
|
570
754
|
handle: string
|
|
571
755
|
title: string
|
|
572
|
-
type:
|
|
756
|
+
type: string
|
|
573
757
|
summary: string | null
|
|
574
758
|
price: Money
|
|
575
759
|
cover: string | null
|
|
@@ -615,7 +799,7 @@ export interface AuthorApplication {
|
|
|
615
799
|
email: string
|
|
616
800
|
/** Enlace a algo que haya hecho: sin eso no hay nada que revisar. */
|
|
617
801
|
portfolio: string
|
|
618
|
-
sells:
|
|
802
|
+
sells: string[]
|
|
619
803
|
/** Identificador público; si falta, sale del nombre. */
|
|
620
804
|
handle?: string
|
|
621
805
|
/** Código de dos letras. */
|
|
@@ -640,11 +824,27 @@ export interface SupportThread {
|
|
|
640
824
|
covered: boolean
|
|
641
825
|
/** Lo que contestó al cerrar. null mientras no se le ha preguntado. */
|
|
642
826
|
solved: boolean | null
|
|
827
|
+
mediation?: { requestedAt: string; resolvedAt: string | null } | null
|
|
643
828
|
messages: SupportMessage[]
|
|
644
829
|
createdAt: string
|
|
645
830
|
updatedAt: string
|
|
646
831
|
}
|
|
647
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
|
+
|
|
648
848
|
export interface CommerceClient {
|
|
649
849
|
/** Identificador del adaptador: "pcreative", "static"… */
|
|
650
850
|
readonly adapter: string
|
|
@@ -702,6 +902,14 @@ export interface CommerceClient {
|
|
|
702
902
|
* `err.status` 400 y el motivo en `err.detalle`.
|
|
703
903
|
*/
|
|
704
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 }>
|
|
705
913
|
/**
|
|
706
914
|
* Opcional: el boletín, con doble confirmación. `subscribe` NO apunta a nadie:
|
|
707
915
|
* manda un correo con un enlace, y solo al pulsarlo (`confirm`) queda dentro.
|
|
@@ -724,6 +932,11 @@ export interface CommerceClient {
|
|
|
724
932
|
confirm(token: string): Promise<boolean>
|
|
725
933
|
unsubscribe(token: string): Promise<boolean>
|
|
726
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
|
+
}
|
|
727
940
|
/** Opcional: la ficha pública de un autor del mercado. */
|
|
728
941
|
getAuthor?(handle: string): Promise<Author | null>
|
|
729
942
|
|
|
@@ -740,6 +953,7 @@ export interface CommerceClient {
|
|
|
740
953
|
reply(id: string, email: string, message: string): Promise<void>
|
|
741
954
|
/** Una sola pregunta al cerrar: si le sirvió. Alimenta el dato público del autor. */
|
|
742
955
|
rate(id: string, email: string, solved: boolean): Promise<void>
|
|
956
|
+
requestMediation?(id: string, email: string, reason: string): Promise<void>
|
|
743
957
|
}
|
|
744
958
|
|
|
745
959
|
cart: {
|
|
@@ -758,6 +972,17 @@ export interface CommerceClient {
|
|
|
758
972
|
consentirDigital?(cartId: string, aceptado: boolean): Promise<Cart>
|
|
759
973
|
applyPromo(cartId: string, code: string): Promise<Cart>
|
|
760
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>
|
|
761
986
|
}
|
|
762
987
|
|
|
763
988
|
checkout: {
|
|
@@ -825,6 +1050,18 @@ export interface CommerceClient {
|
|
|
825
1050
|
* licencia no es suya, y 429 si se ha pasado del tope.
|
|
826
1051
|
*/
|
|
827
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[]>
|
|
828
1065
|
}
|
|
829
1066
|
|
|
830
1067
|
/** Opcional: nombre y datos legales de la tienda, tal y como los tiene el panel. */
|
|
@@ -864,5 +1101,51 @@ export declare function sumMoney(...importes: Money[]): Money
|
|
|
864
1101
|
/** Construye un Money, con 0 como valor por defecto. */
|
|
865
1102
|
export declare function money(amount: number | null | undefined, currency: string, taxIncluded?: boolean): Money
|
|
866
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
|
+
|
|
867
1114
|
/** Comprueba que un objeto implementa el contrato; devuelve lo que falta. */
|
|
868
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
|
+
}
|