@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 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`, `content`, `getAuthor` y `support` son opcionales: un backend puede
104
- no tener cuentas de cliente, ni blog, ni mercado de autores.
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`, `content`, `getAuthor` and `support` are optional: a backend may have
103
- no customer accounts, no blog and no author marketplace.
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.6",
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",
@@ -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
@@ -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
- detalle = (await res.json())?.message ?? ""
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((x) => x === "tema" || x === "extension"))]
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: vende.length === 2 ? ["los-dos"] : 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: "tema" | "extension"
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?: "tema" | "extension"
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: "tema" | "extension"
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: ("tema" | "extension")[]
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
+ }