@pcreative/commerce-contract 1.3.5 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.es.md 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.5",
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,19 @@ export function createCommerce({
530
687
  },
531
688
  async orders(token) {
532
689
  const { orders = [] } = await api("/tienda/cuenta/pedidos", { token })
533
- return orders
690
+ return orders.map((o) => (Array.isArray(o?.items) ? { ...o, items: o.items.map(lineaDePedido) } : o))
691
+ },
692
+ async licenses(token) {
693
+ const { licencias = [] } = await api("/tienda/cuenta/licencias", { token })
694
+ return licencias
695
+ },
696
+ async releaseSite(token, licenseKey, instance) {
697
+ const r = await api("/tienda/cuenta/licencias/soltar", {
698
+ method: "POST",
699
+ token,
700
+ body: { clave: licenseKey, instancia: instance },
701
+ })
702
+ return { released: Boolean(r.soltado), site: r.sitio ?? "" }
534
703
  },
535
704
  async confirmEmail(sessionToken, token) {
536
705
  try {
@@ -541,6 +710,52 @@ export function createCommerce({
541
710
  throw e
542
711
  }
543
712
  },
713
+ addresses: {
714
+ async list(token) {
715
+ try {
716
+ const { direcciones = [] } = await api("/tienda/cuenta/direcciones", { token })
717
+ return direcciones.map(mapSavedAddress)
718
+ } catch (e) {
719
+ if (e.status === 404) return []
720
+ throw e
721
+ }
722
+ },
723
+ async create(token, direccion) {
724
+ const { direccion: d } = await api("/tienda/cuenta/direcciones", {
725
+ method: "POST",
726
+ token,
727
+ body: aDireccionGuardadaApi(direccion),
728
+ })
729
+ return mapSavedAddress(d)
730
+ },
731
+ async update(token, id, direccion) {
732
+ const { direccion: d } = await api(`/tienda/cuenta/direcciones/${encodeURIComponent(id)}`, {
733
+ method: "POST",
734
+ token,
735
+ body: aDireccionGuardadaApi(direccion),
736
+ })
737
+ return mapSavedAddress(d)
738
+ },
739
+ async remove(token, id) {
740
+ await api(`/tienda/cuenta/direcciones/${encodeURIComponent(id)}`, { method: "DELETE", token })
741
+ },
742
+ },
743
+ }
744
+
745
+ const wishlist = {
746
+ async list(token) {
747
+ return listaDeDeseos(await api("/tienda/wishlist", { token }))
748
+ },
749
+ async add(token, productIds) {
750
+ const ids = [...new Set((Array.isArray(productIds) ? productIds : [productIds]).map(String).filter(Boolean))]
751
+ if (!ids.length) return wishlist.list(token)
752
+ const body = ids.length === 1 ? { product_id: ids[0] } : { wishlist: ids }
753
+ return listaDeDeseos(await api("/tienda/wishlist", { method: "POST", token, body }))
754
+ },
755
+ async remove(token, productId) {
756
+ const qs = new URLSearchParams({ product_id: String(productId) })
757
+ return listaDeDeseos(await api(`/tienda/wishlist?${qs}`, { method: "DELETE", token }))
758
+ },
544
759
  }
545
760
 
546
761
  const mapPost = (p) => ({
@@ -666,6 +881,44 @@ export function createCommerce({
666
881
  )
667
882
  return codigo ?? null
668
883
  },
884
+ releases: {
885
+ async notify({ productId, variantId = null, email, consent, locale } = {}) {
886
+ if (consent?.accepted !== true || !consent?.text || !consent?.version) {
887
+ const err = new Error("POST /tienda/avisos: sin consentimiento expreso no se envía")
888
+ err.status = 400
889
+ err.code = "avisos.falta_consentimiento"
890
+ err.detalle = ""
891
+ throw err
892
+ }
893
+ await api("/tienda/avisos", {
894
+ method: "POST",
895
+ body: {
896
+ producto_id: productId,
897
+ variante_id: variantId ?? null,
898
+ email,
899
+ consentimiento: true,
900
+ texto: consent?.text,
901
+ version: consent?.version,
902
+ ...(typeof consent?.info === "string" && consent.info.trim() ? { consentInfo: consent.info.trim().slice(0, 4000) } : {}),
903
+ ...(locale ? { idioma: locale } : {}),
904
+ },
905
+ })
906
+ return { status: "pendiente" }
907
+ },
908
+ async confirm(token) {
909
+ try {
910
+ await api("/tienda/avisos/confirmar", { method: "POST", body: { token } })
911
+ return { confirmed: true }
912
+ } catch (e) {
913
+ if (e.status === 400) return { confirmed: false }
914
+ throw e
915
+ }
916
+ },
917
+ async unsubscribe(token) {
918
+ const r = await api("/tienda/avisos/baja", { method: "POST", body: { token } })
919
+ return { unsubscribed: true, alreadyWas: Boolean(r?.yaEstaba) }
920
+ },
921
+ },
669
922
  newsletter: {
670
923
  async subscribe(email, consent = {}) {
671
924
  await api("/tienda/boletin", {
@@ -697,14 +950,14 @@ export function createCommerce({
697
950
  },
698
951
  },
699
952
  async applyAsAuthor(a) {
700
- const vende = [...new Set((a.sells ?? []).filter((x) => x === "tema" || x === "extension"))]
953
+ const vende = [...new Set((a.sells ?? []).map(String).filter(Boolean))]
701
954
  const r = await api("/tienda/autores/solicitudes", {
702
955
  method: "POST",
703
956
  body: {
704
957
  nombre: a.name,
705
958
  email: a.email,
706
959
  portafolio: a.portfolio,
707
- vende: vende.length === 2 ? ["los-dos"] : vende,
960
+ vende,
708
961
  slug: a.handle,
709
962
  pais: a.country,
710
963
  web: a.website,
@@ -714,6 +967,56 @@ export function createCommerce({
714
967
  })
715
968
  return { id: r.id }
716
969
  },
970
+ forum: {
971
+ async get() {
972
+ const { forum } = await api("/tienda/foro")
973
+ return forum?.url ? { url: forum.url } : null
974
+ },
975
+ async signIn(token, sso, sig) {
976
+ const { url } = await api("/tienda/foro/entrar", { method: "POST", token, body: { sso, sig } })
977
+ return { url }
978
+ },
979
+ },
980
+ async listReviews(productId, params = {}) {
981
+ const q = new URLSearchParams({ product_id: productId })
982
+ if (params.limit != null) q.set("limit", String(params.limit))
983
+ if (params.offset != null) q.set("offset", String(params.offset))
984
+ const r = await api(`/tienda/reviews?${q}`)
985
+ return {
986
+ reviews: r.reviews ?? [],
987
+ count: r.count ?? 0,
988
+ average: r.average ?? null,
989
+ ...(r.histogram ? { histogram: r.histogram } : {}),
990
+ }
991
+ },
992
+ async submitReview(productId, review, token) {
993
+ const { review: r } = await api("/tienda/reviews", {
994
+ method: "POST",
995
+ token,
996
+ body: { product_id: productId, rating: review.rating, title: review.title, content: review.content },
997
+ })
998
+ return { id: r.id, status: r.status }
999
+ },
1000
+ async getFounders() {
1001
+ const { founders } = await api("/tienda/mercado/fundadores")
1002
+ return founders ?? null
1003
+ },
1004
+ async listMarketTypes() {
1005
+ const { tipos } = await api("/tienda/mercado/tipos")
1006
+ return (tipos ?? []).map((t) => ({
1007
+ key: t.clave,
1008
+ name: t.nombre ?? {},
1009
+ description: t.descripcion ?? {},
1010
+ order: t.orden ?? 0,
1011
+ attributes: (t.atributos ?? []).map((a) => ({
1012
+ key: a.clave,
1013
+ name: a.nombre ?? {},
1014
+ multiple: a.multiple === true,
1015
+ required: a.obligatorio === true,
1016
+ options: (a.opciones ?? []).map((o) => ({ value: o.valor, name: o.nombre ?? {} })),
1017
+ })),
1018
+ }))
1019
+ },
717
1020
  async getAuthor(handle) {
718
1021
  try {
719
1022
  const { autor } = await api(`/tienda/autores/${encodeURIComponent(handle)}`)
@@ -769,6 +1072,9 @@ export function createCommerce({
769
1072
  async reply(id, email, message) {
770
1073
  await api(`/tienda/soporte/${encodeURIComponent(id)}`, { method: "POST", body: { email, texto: message } })
771
1074
  },
1075
+ async requestMediation(id, email, reason) {
1076
+ await api(`/tienda/soporte/${encodeURIComponent(id)}`, { method: "POST", body: { email, mediacion: reason } })
1077
+ },
772
1078
  async rate(id, email, solved) {
773
1079
  await api(`/tienda/soporte/${encodeURIComponent(id)}`, {
774
1080
  method: "POST",
@@ -779,6 +1085,7 @@ export function createCommerce({
779
1085
  cart,
780
1086
  checkout,
781
1087
  account,
1088
+ wishlist,
782
1089
  content,
783
1090
  async getStore() {
784
1091
  try {
package/src/index.d.ts CHANGED
@@ -45,6 +45,8 @@ export interface Variant {
45
45
  compareAt?: Money | null
46
46
  /** null = el backend no lleva stock de esta variante. */
47
47
  stock: number | null
48
+ held?: number | null
49
+ edition?: { size: number; available: number } | null
48
50
  available: boolean
49
51
  options: OptionValue[]
50
52
  weight?: number | null
@@ -121,6 +123,33 @@ export interface License {
121
123
  supportedUntil: string | null
122
124
  }
123
125
 
126
+ /** Un sitio donde una licencia esta puesta ahora mismo. */
127
+ export interface LicenseSite {
128
+ /** Lo que identifica esa instalacion: es lo que se manda para desactivarla. */
129
+ instance: string
130
+ /** El dominio, ya normalizado (sin esquema, sin www, sin puerto). */
131
+ site: string
132
+ /** Nombre que le puso la instalacion, si lo mando. */
133
+ label: string | null
134
+ activatedAt: string | null
135
+ /** Ultima vez que esa instalacion comprobo la licencia. */
136
+ lastSeenAt: string | null
137
+ }
138
+
139
+ /**
140
+ * Una licencia vista desde la cuenta de quien la compro: lo mismo que
141
+ * `License` mas los sitios, porque desde aqui se desactivan.
142
+ */
143
+ export interface OwnedLicense {
144
+ key: string
145
+ status: "activa" | "caducada" | "anulada"
146
+ title: string | null
147
+ orderId: string
148
+ limit: number
149
+ supportedUntil: string | null
150
+ sites: LicenseSite[]
151
+ }
152
+
124
153
  export interface ProductCode {
125
154
  id: string
126
155
  lineId: string
@@ -147,8 +176,10 @@ export interface ProductCode {
147
176
  * y se rellenan en `getProduct`, que es donde se enseñan.
148
177
  */
149
178
  export interface MarketInfo {
150
- type: "tema" | "extension"
179
+ type: string
180
+ attributes?: Record<string, string[]>
151
181
  author: { name: string; handle: string }
182
+ contributors?: { name: string; handle: string }[]
152
183
  version: string | null
153
184
  updatedAt: string | null
154
185
  stack: string | null
@@ -162,6 +193,40 @@ export interface MarketInfo {
162
193
  * versiones nuevas no tienen fecha: le llegan al comprador siempre.
163
194
  */
164
195
  supportMonths?: number
196
+ sales?: number
197
+ downloads?: number
198
+ }
199
+
200
+ export interface ReviewReply {
201
+ content: string
202
+ created_at: string | null
203
+ }
204
+
205
+ export interface Review {
206
+ id: string
207
+ product_id: string
208
+ rating: number
209
+ title: string | null
210
+ content: string
211
+ author_name: string
212
+ created_at: string
213
+ updated_at?: string
214
+ version?: string | null
215
+ verified?: boolean
216
+ reply?: ReviewReply | null
217
+ }
218
+
219
+ export interface ReviewList {
220
+ reviews: Review[]
221
+ count: number
222
+ average: number | null
223
+ histogram?: Record<"1" | "2" | "3" | "4" | "5", number>
224
+ }
225
+
226
+ export interface ReviewDraft {
227
+ rating: number
228
+ content: string
229
+ title?: string
165
230
  }
166
231
 
167
232
  export interface Download {
@@ -234,6 +299,10 @@ export interface Product {
234
299
  rating?: number | null
235
300
  reviewCount?: number
236
301
  available: boolean
302
+ releaseAt?: string | null
303
+ releaseAtLocal?: string | null
304
+ releaseTimeZone?: string | null
305
+ sealed?: boolean
237
306
  quantityRules?: QuantityRules | null
238
307
  customization?: CustomField[]
239
308
  metadata?: Record<string, unknown>
@@ -266,6 +335,19 @@ export interface Address {
266
335
  phone?: string
267
336
  }
268
337
 
338
+ export interface SavedAddress extends Address {
339
+ id: string
340
+ name?: string | null
341
+ isDefaultShipping: boolean
342
+ isDefaultBilling: boolean
343
+ }
344
+
345
+ export interface SavedAddressInput extends Address {
346
+ name?: string | null
347
+ isDefaultShipping?: boolean
348
+ isDefaultBilling?: boolean
349
+ }
350
+
269
351
  export interface LineItem {
270
352
  id: string
271
353
  productId: string
@@ -291,6 +373,10 @@ export interface LineItem {
291
373
  personalized?: boolean
292
374
  digital?: boolean
293
375
  bundle?: { title: string; quantity: number }[] | null
376
+ editionNumber?: number | null
377
+ editionNumbers?: number[] | null
378
+ editionSize?: number | null
379
+ editionLabel?: string | null
294
380
  }
295
381
 
296
382
  /** Un sitio del que sale mercancía: un paquete, un porte. */
@@ -419,6 +505,51 @@ export type SiguientePago =
419
505
  proveedor?: string
420
506
  }
421
507
 
508
+ /**
509
+ * Una tarjeta regalo o un saldo puesto en el carrito.
510
+ *
511
+ * El código entero no vuelve nunca: con los cuatro últimos caracteres el
512
+ * comprador reconoce su tarjeta, y quien mire la pantalla no se la lleva.
513
+ */
514
+ export interface AppliedBalance {
515
+ id: string
516
+ /** Los cuatro últimos caracteres del código; `null` en un saldo de cuenta sin código. */
517
+ last4: string | null
518
+ /** Lo que esta tarjeta paga de este carrito. `null` si no se sabe. */
519
+ applied: Money | null
520
+ /** Lo que le queda a la tarjeta. `null` si no se sabe. */
521
+ available: Money | null
522
+ }
523
+
524
+ /**
525
+ * Por qué no se ha podido poner un saldo. Llega en `err.code`; el texto del
526
+ * backend sigue en `err.detalle` por si el tema quiere enseñarlo tal cual.
527
+ */
528
+ export type BalanceErrorCode =
529
+ /** El código no existe, está desactivado o es de otra persona. */
530
+ | "balance.invalid"
531
+ /** El código existió pero ya ha caducado. */
532
+ | "balance.expired"
533
+ /** La tienda ha desactivado la tarjeta. */
534
+ | "balance.disabled"
535
+ /** La tarjeta es de otra moneda que el carrito. */
536
+ | "balance.currency"
537
+ /** La tarjeta existe pero no le queda nada. */
538
+ | "balance.empty"
539
+ /** Demasiados intentos: `err.retryAfter` dice cuántos segundos esperar. */
540
+ | "rate_limited"
541
+
542
+ /** El error que lanza el cliente cuando el comercio contesta con un fallo. */
543
+ export interface CommerceError extends Error {
544
+ status: number
545
+ /** Código estable para elegir el texto; sin definir si el comercio no da ninguno. */
546
+ code?: BalanceErrorCode | (string & {})
547
+ /** El motivo tal y como lo escribió el backend, ya en el idioma de la petición. */
548
+ detalle: string
549
+ /** Segundos que pide esperar un 429, si los dice. */
550
+ retryAfter?: number | null
551
+ }
552
+
422
553
  export interface Cart {
423
554
  id: string
424
555
  items: LineItem[]
@@ -427,6 +558,33 @@ export interface Cart {
427
558
  discount: Money
428
559
  tax: Money
429
560
  total: Money
561
+ /**
562
+ * Lo que pagan las tarjetas regalo y el saldo puestos en el carrito. Ya está
563
+ * dentro de `total`: el pedido se factura por `total` y el saldo es una forma
564
+ * de pago, no un descuento.
565
+ */
566
+ balanceApplied: Money
567
+ /**
568
+ * Lo que queda por pagar con una pasarela: `total` menos `balanceApplied`.
569
+ * Con 0, la caja cierra con `checkout.complete` sin elegir forma de pago.
570
+ */
571
+ amountDue: Money
572
+ /**
573
+ * Las tarjetas y saldos puestos, para enseñarlos y poder quitarlos con
574
+ * `removeBalance`. El código entero no viene nunca.
575
+ */
576
+ balances?: AppliedBalance[]
577
+ /**
578
+ * El impuesto que ya va DENTRO de los precios. La cuenta cuadra siempre:
579
+ * `subtotal - discount + shipping + (tax - includedTax) = total`.
580
+ */
581
+ includedTax?: Money
582
+ /**
583
+ * `true` si los precios de las líneas llevan el impuesto dentro. El total lo
584
+ * lleva siempre: bajo el total se pinta «IVA incluido (tax)». Sin definir si
585
+ * el backend no lo dice.
586
+ */
587
+ pricesIncludeTax?: boolean
430
588
  itemCount: number
431
589
  email?: string | null
432
590
  shippingAddress?: Address | null
@@ -443,6 +601,7 @@ export interface Cart {
443
601
  * métodos sin pasarela: ahí pagar es confirmar el pedido y ya está.
444
602
  */
445
603
  pago?: SiguientePago | null
604
+ holdUntil?: string | null
446
605
  }
447
606
 
448
607
  export interface Order {
@@ -450,11 +609,36 @@ export interface Order {
450
609
  displayId?: number
451
610
  email?: string
452
611
  status: string
612
+ paymentStatus?: "not_paid" | "authorized" | "partially_authorized" | "captured" | "partially_captured" | "refunded" | "partially_refunded" | "canceled"
453
613
  items: LineItem[]
454
614
  subtotal: Money
455
615
  shipping: Money
616
+ /** El descuento APLICADO al pedido, no el que prometía el cupón. */
456
617
  discount: Money
457
618
  tax: Money
619
+ /**
620
+ * El impuesto que ya va DENTRO de los precios. La cuenta cuadra siempre:
621
+ * `subtotal - discount + shipping + (tax - includedTax) = total`.
622
+ */
623
+ includedTax?: Money
624
+ /**
625
+ * `true` si los precios de las líneas llevan el impuesto dentro. El total lo
626
+ * lleva siempre: bajo el total se pinta «IVA incluido (tax)». Sin definir si
627
+ * el backend no lo dice.
628
+ */
629
+ pricesIncludeTax?: boolean
630
+ /**
631
+ * Lo que se pagó con tarjetas regalo o saldo. Ya está dentro de `total`: lo
632
+ * cobrado por la pasarela es `total - balanceApplied`. Sin definir si la
633
+ * tienda no lo dice o no se usó saldo.
634
+ */
635
+ balanceApplied?: Money
636
+ /**
637
+ * La forma de pago del resto, tal y como la llama la tienda: `pp_offline_transfer`,
638
+ * `pp_offline_bizum`, `pp_offline_cod` o la de una pasarela. Sin definir si la
639
+ * tienda no lo dice: el tema no debe adivinarla.
640
+ */
641
+ paymentProvider?: string | null
458
642
  total: Money
459
643
  shippingAddress?: Address | null
460
644
  createdAt?: string
@@ -521,7 +705,8 @@ export interface ListProductsParams {
521
705
  * Solo piezas del mercado de ese tipo. Sin él salen todos los productos,
522
706
  * sean o no del mercado. Un adaptador sin mercado lo ignora.
523
707
  */
524
- type?: "tema" | "extension"
708
+ type?: string
709
+ attributes?: Record<string, string[]>
525
710
  limit?: number
526
711
  offset?: number
527
712
  sort?: SortProductos
@@ -538,11 +723,37 @@ export interface Page<T> {
538
723
  // ── El cliente ────────────────────────────────────────────────────────────
539
724
 
540
725
 
726
+ export type LocalizedText = Record<string, string>
727
+
728
+ export interface MarketTypeAttribute {
729
+ key: string
730
+ name: LocalizedText
731
+ multiple: boolean
732
+ required: boolean
733
+ options: { value: string; name: LocalizedText }[]
734
+ }
735
+
736
+ export interface FounderProgram {
737
+ slots: number
738
+ taken: number
739
+ left: number
740
+ commissionPct: number
741
+ months: number
742
+ }
743
+
744
+ export interface MarketType {
745
+ key: string
746
+ name: LocalizedText
747
+ description: LocalizedText
748
+ attributes: MarketTypeAttribute[]
749
+ order: number
750
+ }
751
+
541
752
  /** Una pieza del autor tal y como se lista en su ficha. */
542
753
  export interface AuthorItem {
543
754
  handle: string
544
755
  title: string
545
- type: "tema" | "extension"
756
+ type: string
546
757
  summary: string | null
547
758
  price: Money
548
759
  cover: string | null
@@ -588,7 +799,7 @@ export interface AuthorApplication {
588
799
  email: string
589
800
  /** Enlace a algo que haya hecho: sin eso no hay nada que revisar. */
590
801
  portfolio: string
591
- sells: ("tema" | "extension")[]
802
+ sells: string[]
592
803
  /** Identificador público; si falta, sale del nombre. */
593
804
  handle?: string
594
805
  /** Código de dos letras. */
@@ -613,11 +824,27 @@ export interface SupportThread {
613
824
  covered: boolean
614
825
  /** Lo que contestó al cerrar. null mientras no se le ha preguntado. */
615
826
  solved: boolean | null
827
+ mediation?: { requestedAt: string; resolvedAt: string | null } | null
616
828
  messages: SupportMessage[]
617
829
  createdAt: string
618
830
  updatedAt: string
619
831
  }
620
832
 
833
+ export interface ReleaseNotifyInput {
834
+ productId: string
835
+ variantId?: string | null
836
+ email: string
837
+ consent: { accepted: true; text: string; version: string; info?: string }
838
+ locale?: string
839
+ }
840
+
841
+ export type ReleaseErrorCode =
842
+ | "avisos.ya_a_la_venta"
843
+ | "avisos.correo_no_valido"
844
+ | "avisos.falta_consentimiento"
845
+ | "avisos.producto_no_existe"
846
+ | "rate_limited"
847
+
621
848
  export interface CommerceClient {
622
849
  /** Identificador del adaptador: "pcreative", "static"… */
623
850
  readonly adapter: string
@@ -675,6 +902,14 @@ export interface CommerceClient {
675
902
  * `err.status` 400 y el motivo en `err.detalle`.
676
903
  */
677
904
  applyAsAuthor?(application: AuthorApplication): Promise<{ id: string }>
905
+ listMarketTypes?(): Promise<MarketType[]>
906
+ getFounders?(): Promise<FounderProgram | null>
907
+ forum?: {
908
+ get(): Promise<{ url: string } | null>
909
+ signIn(token: string, sso: string, sig: string): Promise<{ url: string }>
910
+ }
911
+ listReviews?(productId: string, params?: { limit?: number; offset?: number }): Promise<ReviewList>
912
+ submitReview?(productId: string, review: ReviewDraft, token?: string): Promise<{ id: string; status: string }>
678
913
  /**
679
914
  * Opcional: el boletín, con doble confirmación. `subscribe` NO apunta a nadie:
680
915
  * manda un correo con un enlace, y solo al pulsarlo (`confirm`) queda dentro.
@@ -697,6 +932,11 @@ export interface CommerceClient {
697
932
  confirm(token: string): Promise<boolean>
698
933
  unsubscribe(token: string): Promise<boolean>
699
934
  }
935
+ releases?: {
936
+ notify(input: ReleaseNotifyInput): Promise<{ status: "pendiente" }>
937
+ confirm(token: string): Promise<{ confirmed: boolean }>
938
+ unsubscribe(token: string): Promise<{ unsubscribed: true; alreadyWas: boolean }>
939
+ }
700
940
  /** Opcional: la ficha pública de un autor del mercado. */
701
941
  getAuthor?(handle: string): Promise<Author | null>
702
942
 
@@ -713,6 +953,7 @@ export interface CommerceClient {
713
953
  reply(id: string, email: string, message: string): Promise<void>
714
954
  /** Una sola pregunta al cerrar: si le sirvió. Alimenta el dato público del autor. */
715
955
  rate(id: string, email: string, solved: boolean): Promise<void>
956
+ requestMediation?(id: string, email: string, reason: string): Promise<void>
716
957
  }
717
958
 
718
959
  cart: {
@@ -731,6 +972,17 @@ export interface CommerceClient {
731
972
  consentirDigital?(cartId: string, aceptado: boolean): Promise<Cart>
732
973
  applyPromo(cartId: string, code: string): Promise<Cart>
733
974
  removePromo(cartId: string, code: string): Promise<Cart>
975
+ /**
976
+ * Opcional: pone una tarjeta regalo o un saldo por su código. El carrito
977
+ * vuelve con `balanceApplied`, `amountDue` y la tarjeta en `balances`.
978
+ *
979
+ * Si no se puede, rechaza con un `CommerceError` cuyo `code` es un
980
+ * `BalanceErrorCode`: `status` 400 para los códigos que no sirven y 429
981
+ * para demasiados intentos.
982
+ */
983
+ applyBalance?(cartId: string, code: string): Promise<Cart>
984
+ /** Opcional: quita del carrito la tarjeta o el saldo con ese `id`. */
985
+ removeBalance?(cartId: string, balanceId: string): Promise<Cart>
734
986
  }
735
987
 
736
988
  checkout: {
@@ -787,6 +1039,29 @@ export interface CommerceClient {
787
1039
  me(token: string): Promise<Customer | null>
788
1040
  orders(token: string): Promise<Order[]>
789
1041
  confirmEmail?(sessionToken: string, token: string): Promise<{ adopted: number } | null>
1042
+ /**
1043
+ * Opcional: las licencias que ha comprado, con los sitios donde están
1044
+ * puestas ahora mismo. Vacio si el adaptador no vende licencias.
1045
+ */
1046
+ licenses?(token: string): Promise<OwnedLicense[]>
1047
+ /**
1048
+ * Opcional: desactiva uno de SUS sitios, que es lo que hace quien se muda
1049
+ * de dominio. Rechaza con `status` 400 si ese sitio ya no esta activo o la
1050
+ * licencia no es suya, y 429 si se ha pasado del tope.
1051
+ */
1052
+ releaseSite?(token: string, licenseKey: string, instance: string): Promise<{ released: boolean; site: string }>
1053
+ addresses?: {
1054
+ list(token: string): Promise<SavedAddress[]>
1055
+ create(token: string, address: SavedAddressInput): Promise<SavedAddress>
1056
+ update(token: string, id: string, address: SavedAddressInput): Promise<SavedAddress>
1057
+ remove(token: string, id: string): Promise<void>
1058
+ }
1059
+ }
1060
+
1061
+ wishlist?: {
1062
+ list(token: string): Promise<string[]>
1063
+ add(token: string, productIds: string | string[]): Promise<string[]>
1064
+ remove(token: string, productId: string): Promise<string[]>
790
1065
  }
791
1066
 
792
1067
  /** Opcional: nombre y datos legales de la tienda, tal y como los tiene el panel. */
@@ -826,5 +1101,51 @@ export declare function sumMoney(...importes: Money[]): Money
826
1101
  /** Construye un Money, con 0 como valor por defecto. */
827
1102
  export declare function money(amount: number | null | undefined, currency: string, taxIncluded?: boolean): Money
828
1103
 
1104
+ export declare function isVariantHeld(variant: Pick<Variant, "stock" | "held"> | null | undefined): boolean
1105
+
1106
+ export declare function holdSecondsLeft(cart: Pick<Cart, "holdUntil"> | null | undefined, now?: number | Date): number | null
1107
+
1108
+ /**
1109
+ * `true` si las tarjetas y el saldo cubren el carrito entero: no queda nada
1110
+ * que cobrar y la caja se cierra con `checkout.complete` sin pasarela.
1111
+ */
1112
+ export declare function isCoveredByBalance(cart: Pick<Cart, "balanceApplied" | "amountDue"> | null | undefined): boolean
1113
+
829
1114
  /** Comprueba que un objeto implementa el contrato; devuelve lo que falta. */
830
1115
  export declare function assertCommerceClient(cliente: unknown): string[]
1116
+
1117
+ /** Una dirección IPv4 o IPv6 en su forma limpia, o `null` si no lo es. */
1118
+ export declare function normalizeIp(valor: unknown): string | null
1119
+
1120
+ export interface TrustedNetwork {
1121
+ bytes: number[]
1122
+ prefijo: number
1123
+ }
1124
+
1125
+ /** Lee una lista de proxies de confianza: `"127.0.0.1, 10.0.0.0/8, ::1"`. */
1126
+ export declare function parseTrustedProxies(lista: string | string[] | null | undefined): TrustedNetwork[]
1127
+
1128
+ export declare function isTrustedProxy(ip: string, redes: TrustedNetwork[]): boolean
1129
+
1130
+ /**
1131
+ * La IP del comprador según `X-Forwarded-For`, leída de derecha a izquierda y
1132
+ * saltando los proxies de confianza: la primera que no lo es la añadió un proxy
1133
+ * propio y no el navegador. Lo que el comprador escriba a la izquierda no se
1134
+ * usa nunca.
1135
+ */
1136
+ export declare function clientIpFromHeaders(
1137
+ cabeceras: Headers | Record<string, string | string[] | undefined> | null | undefined,
1138
+ opciones?: { trustedProxies?: string | string[] | TrustedNetwork[] | null }
1139
+ ): string | null
1140
+
1141
+ /** Segundos de `Retry-After`, venga en segundos o como fecha HTTP. */
1142
+ export declare function parseRetryAfter(valor: string | null | undefined, now?: number | Date): number | null
1143
+
1144
+ /** `true` si el error es un 429 del comercio. */
1145
+ export declare function isRateLimited(err: unknown): boolean
1146
+
1147
+ /** «Demasiados intentos…», con la espera si se sabe. Acepta el error o los segundos. */
1148
+ export declare function tooManyAttemptsMessage(
1149
+ espera: number | null | undefined | { retryAfter?: number | null },
1150
+ locale?: string
1151
+ ): string
package/src/index.js CHANGED
@@ -37,6 +37,24 @@ export function sumMoney(...importes) {
37
37
  )
38
38
  }
39
39
 
40
+ export function isVariantHeld(variant) {
41
+ if (!variant) return false
42
+ return variant.stock === 0 && Number(variant.held ?? 0) > 0
43
+ }
44
+
45
+ export function holdSecondsLeft(cart, now = Date.now()) {
46
+ const hasta = cart?.holdUntil ? Date.parse(cart.holdUntil) : NaN
47
+ if (!Number.isFinite(hasta)) return null
48
+ const ahora = now instanceof Date ? now.getTime() : Number(now)
49
+ return Math.max(0, Math.ceil((hasta - ahora) / 1000))
50
+ }
51
+
52
+ export function isCoveredByBalance(cart) {
53
+ const aplicado = Math.round(Number(cart?.balanceApplied?.amount ?? 0) * 100)
54
+ const queda = Math.round(Number(cart?.amountDue?.amount ?? NaN) * 100)
55
+ return aplicado > 0 && queda === 0
56
+ }
57
+
40
58
  const REQUERIDO = {
41
59
  raiz: ["listProducts", "getProduct", "getProductsByIds", "listCategories", "getCategory", "search", "getOrder"],
42
60
  cart: ["get", "create", "addItem", "updateItem", "removeItem"],
@@ -60,3 +78,40 @@ export function assertCommerceClient(cliente) {
60
78
  }
61
79
  return faltan
62
80
  }
81
+
82
+ export { clientIpFromHeaders, isTrustedProxy, normalizeIp, parseTrustedProxies } from "./ip.js"
83
+
84
+ export function parseRetryAfter(valor, now = Date.now()) {
85
+ const texto = String(valor ?? "").trim()
86
+ if (!texto) return null
87
+ if (/^\d+$/.test(texto)) return Number(texto)
88
+ const fecha = Date.parse(texto)
89
+ if (Number.isNaN(fecha)) return null
90
+ return Math.max(0, Math.ceil((fecha - (now instanceof Date ? now.getTime() : now)) / 1000))
91
+ }
92
+
93
+ export function isRateLimited(err) {
94
+ return Number(err?.status) === 429
95
+ }
96
+
97
+ const TEXTOS_DEMASIADOS = {
98
+ en: {
99
+ sin: "Too many attempts. Wait a moment and try again.",
100
+ segundos: (n) => `Too many attempts. Wait ${n} ${n === 1 ? "second" : "seconds"} and try again.`,
101
+ minutos: (n) => `Too many attempts. Wait ${n} ${n === 1 ? "minute" : "minutes"} and try again.`,
102
+ },
103
+ es: {
104
+ sin: "Demasiados intentos. Espera un momento y vuelve a probar.",
105
+ segundos: (n) => `Demasiados intentos. Espera ${n} ${n === 1 ? "segundo" : "segundos"} y vuelve a probar.`,
106
+ minutos: (n) => `Demasiados intentos. Espera ${n} ${n === 1 ? "minuto" : "minutos"} y vuelve a probar.`,
107
+ },
108
+ }
109
+
110
+ export function tooManyAttemptsMessage(espera, locale = "en") {
111
+ const textos = TEXTOS_DEMASIADOS[String(locale).toLowerCase().startsWith("es") ? "es" : "en"]
112
+ const segundos = typeof espera === "object" && espera !== null ? espera.retryAfter : espera
113
+ const n = Number(segundos)
114
+ if (!Number.isFinite(n) || n <= 0) return textos.sin
115
+ if (n < 90) return textos.segundos(Math.ceil(n))
116
+ return textos.minutos(Math.ceil(n / 60))
117
+ }
package/src/ip.js ADDED
@@ -0,0 +1,106 @@
1
+ const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/
2
+
3
+ function bytesV4(texto) {
4
+ if (!IPV4.test(texto)) return null
5
+ return texto.split(".").map(Number)
6
+ }
7
+
8
+ function bytesV6(texto) {
9
+ if (!/^[0-9a-fA-F:.]+$/.test(texto) || texto.length > 45) return null
10
+ let cola = []
11
+ let cuerpo = texto
12
+ const ultimo = texto.lastIndexOf(":")
13
+ if (texto.includes(".")) {
14
+ const v4 = bytesV4(texto.slice(ultimo + 1))
15
+ if (!v4) return null
16
+ cola = v4
17
+ cuerpo = texto.slice(0, ultimo + 1) + "0:0"
18
+ }
19
+ const partes = cuerpo.split("::")
20
+ if (partes.length > 2) return null
21
+ const trozos = (s) => (s === "" ? [] : s.split(":"))
22
+ const izquierda = trozos(partes[0])
23
+ const derecha = partes.length === 2 ? trozos(partes[1]) : []
24
+ const faltan = 8 - izquierda.length - derecha.length
25
+ if (partes.length === 1 ? faltan !== 0 : faltan < 1) return null
26
+ const grupos = [...izquierda, ...Array(partes.length === 2 ? faltan : 0).fill("0"), ...derecha]
27
+ if (grupos.length !== 8 || grupos.some((g) => !/^[0-9a-fA-F]{1,4}$/.test(g))) return null
28
+ const bytes = grupos.flatMap((g) => {
29
+ const n = parseInt(g, 16)
30
+ return [n >> 8, n & 255]
31
+ })
32
+ if (cola.length) bytes.splice(12, 4, ...cola)
33
+ return bytes
34
+ }
35
+
36
+ export function normalizeIp(valor) {
37
+ let texto = String(valor ?? "").trim()
38
+ if (texto.startsWith("[") && texto.endsWith("]")) texto = texto.slice(1, -1)
39
+ if (/^::ffff:/i.test(texto) && bytesV4(texto.slice(7))) texto = texto.slice(7)
40
+ if (bytesV4(texto)) return texto
41
+ if (bytesV6(texto)) return texto.toLowerCase()
42
+ return null
43
+ }
44
+
45
+ function bytesDe(ip) {
46
+ return bytesV4(ip) ?? bytesV6(ip)
47
+ }
48
+
49
+ function dentro(bytes, red, prefijo) {
50
+ if (bytes.length !== red.length) return false
51
+ let resto = prefijo
52
+ for (let i = 0; i < bytes.length && resto > 0; i++) {
53
+ const bits = Math.min(8, resto)
54
+ const mascara = (0xff << (8 - bits)) & 0xff
55
+ if ((bytes[i] & mascara) !== (red[i] & mascara)) return false
56
+ resto -= bits
57
+ }
58
+ return true
59
+ }
60
+
61
+ export function parseTrustedProxies(lista) {
62
+ const trozos = Array.isArray(lista) ? lista : String(lista ?? "").split(",")
63
+ const redes = []
64
+ for (const trozo of trozos.map((t) => String(t).trim()).filter(Boolean)) {
65
+ const [direccion, prefijoTexto] = trozo.split("/")
66
+ const ip = normalizeIp(direccion)
67
+ if (!ip) continue
68
+ const bytes = bytesDe(ip)
69
+ const maximo = bytes.length * 8
70
+ const prefijo = prefijoTexto === undefined ? maximo : Number(prefijoTexto)
71
+ if (!Number.isInteger(prefijo) || prefijo < 0 || prefijo > maximo) continue
72
+ redes.push({ bytes, prefijo })
73
+ }
74
+ return redes
75
+ }
76
+
77
+ export function isTrustedProxy(ip, redes) {
78
+ const limpia = normalizeIp(ip)
79
+ if (!limpia) return false
80
+ const bytes = bytesDe(limpia)
81
+ return redes.some((r) => dentro(bytes, r.bytes, r.prefijo))
82
+ }
83
+
84
+ function leerCabecera(cabeceras, nombre) {
85
+ if (!cabeceras) return ""
86
+ if (typeof cabeceras.get === "function") return cabeceras.get(nombre) ?? ""
87
+ const encontrada = Object.keys(cabeceras).find((k) => k.toLowerCase() === nombre)
88
+ const valor = encontrada ? cabeceras[encontrada] : ""
89
+ return Array.isArray(valor) ? valor.join(",") : String(valor ?? "")
90
+ }
91
+
92
+ export function clientIpFromHeaders(cabeceras, { trustedProxies } = {}) {
93
+ const redes = Array.isArray(trustedProxies) && trustedProxies[0]?.bytes ? trustedProxies : parseTrustedProxies(trustedProxies)
94
+ const saltos = leerCabecera(cabeceras, "x-forwarded-for")
95
+ .split(",")
96
+ .map((s) => s.trim())
97
+ .filter(Boolean)
98
+ let ultimaDeConfianza = null
99
+ for (let i = saltos.length - 1; i >= 0; i--) {
100
+ const ip = normalizeIp(saltos[i])
101
+ if (!ip) return ultimaDeConfianza
102
+ if (!isTrustedProxy(ip, redes)) return ip
103
+ ultimaDeConfianza ??= ip
104
+ }
105
+ return ultimaDeConfianza
106
+ }