@pcreative/commerce-contract 1.3.4 → 1.3.6
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 +118 -0
- package/README.md +64 -37
- package/package.json +3 -2
- package/src/adapters/api.js +73 -21
- package/src/index.d.ts +85 -10
- package/src/index.js +4 -2
- package/src/mensajes.js +31 -0
package/README.es.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @pcreative/commerce-contract
|
|
2
|
+
|
|
3
|
+
[English](README.md) · **Español**
|
|
4
|
+
|
|
5
|
+
Contrato de datos de commerce (v1.0): las entidades que consume un tema y los
|
|
6
|
+
adaptadores que las resuelven. **Sin dependencias y sin el SDK del backend.**
|
|
7
|
+
|
|
8
|
+
Un tema no importa ningún SDK de nadie: consume este contrato.
|
|
9
|
+
De ahí salen las dos propiedades que sostienen el producto — el mismo tema sirve
|
|
10
|
+
para otro backend mañana, y el backend no queda casado con temas de un solo
|
|
11
|
+
framework.
|
|
12
|
+
|
|
13
|
+
## Uso
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { createCommerce } from "@pcreative/commerce-contract/api"
|
|
17
|
+
|
|
18
|
+
const commerce = createCommerce({
|
|
19
|
+
baseUrl: process.env.NEXT_PUBLIC_COMMERCE_URL,
|
|
20
|
+
publishableKey: process.env.NEXT_PUBLIC_COMMERCE_KEY,
|
|
21
|
+
countryCode: "es",
|
|
22
|
+
// El framework aporta su caché sin que el adaptador sepa de frameworks:
|
|
23
|
+
requestInit: { next: { revalidate: 300 } },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const { items, count } = await commerce.listProducts({ category: "iluminacion", limit: 24 })
|
|
27
|
+
const carrito = await commerce.cart.addItem(cartId, variantId, 1)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Reglas del contrato
|
|
31
|
+
|
|
32
|
+
**Los importes son decimales en unidades mayores** (`19.99`, no `1999`) y llevan
|
|
33
|
+
su moneda al lado. Ningún tema tiene que adivinar la escala, que es de donde sale
|
|
34
|
+
la mitad de los errores de precio en una tienda.
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { formatMoney, sumMoney } from "@pcreative/commerce-contract"
|
|
38
|
+
formatMoney({ amount: 19.99, currency: "eur" }) // "19,99 €"
|
|
39
|
+
sumMoney(a, b) // lanza si mezclas monedas
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Los errores llevan el código HTTP dentro** (`err.status`), porque un 404 de
|
|
43
|
+
carrito caducado y un 401 por clave publicable mal puesta piden cosas distintas
|
|
44
|
+
al tema. Un carrito que ya no existe devuelve `null`, no una excepción.
|
|
45
|
+
|
|
46
|
+
**`checkout.complete()` puede devolver `{ order }` o `{ cart, error }`** — las
|
|
47
|
+
dos con HTTP 200. Si el cobro falla, el carrito vuelve intacto y con el motivo.
|
|
48
|
+
|
|
49
|
+
**El autor de una pieza y su soporte van juntos.** `getAuthor(handle)` trae la
|
|
50
|
+
ficha pública de quien vende en el mercado, con sus piezas y con lo que calcula
|
|
51
|
+
la plataforma: cuántas consultas resuelve y cuánto tarda en contestar. Lo que
|
|
52
|
+
todavía no tiene dato viene en `null` —nunca en cero— para que el tema pueda
|
|
53
|
+
enseñar «autor nuevo» en vez de un 0 % que espanta.
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const autor = await commerce.getAuthor("estudio-demo")
|
|
57
|
+
autor.support?.resolvedPct // 80, o null si aún no hay tres valoraciones
|
|
58
|
+
autor.sales // 12, o null si todavía no ha vendido
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Para escribir al autor hace falta la clave de licencia**: solo da soporte a
|
|
62
|
+
quien ha comprado. El hilo es privado y el correo del comprador viaja siempre en
|
|
63
|
+
el cuerpo, nunca en la dirección.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
const { id, covered } = await commerce.support.open({
|
|
67
|
+
licenseKey, subject: "No instala", message: "Me da un error", email,
|
|
68
|
+
})
|
|
69
|
+
// covered === false: su soporte incluido ya caducó. Se avisa, no se bloquea.
|
|
70
|
+
await commerce.support.get(id, email)
|
|
71
|
+
await commerce.support.rate(id, email, true) // una sola pregunta al cerrar
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**El id de un pedido no es una contraseña.** El pedido de quien compró sin
|
|
75
|
+
cuenta solo vuelve entero con la prueba de que es suyo. Sin ella, `getOrder`
|
|
76
|
+
devuelve el pedido marcado con `partial: true` y sin correo ni dirección, y las
|
|
77
|
+
licencias, los códigos y las descargas contestan 403. La prueba le llega al tema
|
|
78
|
+
en el enlace que llevan los correos (`?acceso=`), en la respuesta de
|
|
79
|
+
`checkout.complete`, o pidiéndola con el correo de la compra: y viaja en una
|
|
80
|
+
cabecera, nunca en la dirección.
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
const { order, access } = await commerce.checkout.complete(cartId) // guarda `access`
|
|
84
|
+
const pedido = await commerce.getOrder(id, access) // entero
|
|
85
|
+
const licencias = await commerce.getLicenses(id, access) // 403 sin ella
|
|
86
|
+
|
|
87
|
+
// ¿Sin prueba? Se pide. Devuelve null cuando el correo no es el del pedido:
|
|
88
|
+
// exactamente lo mismo que devuelve un pedido que no existe, para que la
|
|
89
|
+
// respuesta no pueda decir cuál de las dos cosas ha pasado.
|
|
90
|
+
const dado = await commerce.requestOrderAccess(id, email) // { access, days } | null
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Escribir otro adaptador
|
|
94
|
+
|
|
95
|
+
Implementa la interfaz `CommerceClient` de [`src/index.d.ts`](src/index.d.ts) y
|
|
96
|
+
compruébalo:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { assertCommerceClient } from "@pcreative/commerce-contract"
|
|
100
|
+
const faltan = assertCommerceClient(miCliente) // [] si cumple
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`account`, `content`, `getAuthor` y `support` son opcionales: un backend puede
|
|
104
|
+
no tener cuentas de cliente, ni blog, ni mercado de autores.
|
|
105
|
+
|
|
106
|
+
## Idioma de los mensajes
|
|
107
|
+
|
|
108
|
+
Los errores que lanza el paquete salen en inglés. Salen en español cuando
|
|
109
|
+
`LC_ALL`, `LC_MESSAGES` o `LANG` empiezan por `es` (por ejemplo
|
|
110
|
+
`LANG=es_ES.UTF-8`). Donde no hay entorno (un navegador), en inglés.
|
|
111
|
+
|
|
112
|
+
## Tests
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
node --test "test/*.test.js"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Los tests inyectan un `fetch` de mentira, así que corren sin backend.
|
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# @pcreative/commerce-contract
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
adaptadores que las resuelven. **Sin dependencias y sin el SDK del backend.**
|
|
3
|
+
**English** · [Español](README.es.md)
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
Commerce data contract (v1.0): the entities a theme consumes and the adapters
|
|
6
|
+
that resolve them. **No dependencies and no backend SDK.**
|
|
7
|
+
|
|
8
|
+
A theme does not import anybody's SDK: it consumes this contract. That gives
|
|
9
|
+
the two properties the product stands on — the same theme can serve another
|
|
10
|
+
backend tomorrow, and the backend is not married to themes from a single
|
|
9
11
|
framework.
|
|
10
12
|
|
|
11
|
-
##
|
|
13
|
+
## Usage
|
|
12
14
|
|
|
13
15
|
```js
|
|
14
16
|
import { createCommerce } from "@pcreative/commerce-contract/api"
|
|
@@ -17,7 +19,7 @@ const commerce = createCommerce({
|
|
|
17
19
|
baseUrl: process.env.NEXT_PUBLIC_COMMERCE_URL,
|
|
18
20
|
publishableKey: process.env.NEXT_PUBLIC_COMMERCE_KEY,
|
|
19
21
|
countryCode: "es",
|
|
20
|
-
//
|
|
22
|
+
// The framework brings its own cache without the adapter knowing about frameworks:
|
|
21
23
|
requestInit: { next: { revalidate: 300 } },
|
|
22
24
|
})
|
|
23
25
|
|
|
@@ -25,62 +27,87 @@ const { items, count } = await commerce.listProducts({ category: "iluminacion",
|
|
|
25
27
|
const carrito = await commerce.cart.addItem(cartId, variantId, 1)
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
##
|
|
30
|
+
## Contract rules
|
|
29
31
|
|
|
30
|
-
**
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
**Amounts are decimals in major units** (`19.99`, not `1999`) and carry their
|
|
33
|
+
currency next to them. No theme has to guess the scale, which is where half of
|
|
34
|
+
the price bugs in a store come from.
|
|
33
35
|
|
|
34
36
|
```js
|
|
35
37
|
import { formatMoney, sumMoney } from "@pcreative/commerce-contract"
|
|
36
38
|
formatMoney({ amount: 19.99, currency: "eur" }) // "19,99 €"
|
|
37
|
-
sumMoney(a, b) //
|
|
39
|
+
sumMoney(a, b) // throws if you mix currencies
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
**
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
**Errors carry the HTTP status inside** (`err.status`), because a 404 for an
|
|
43
|
+
expired cart and a 401 for a misconfigured publishable key ask different things
|
|
44
|
+
of the theme. A cart that no longer exists returns `null`, not an exception.
|
|
43
45
|
|
|
44
|
-
**`checkout.complete()`
|
|
45
|
-
|
|
46
|
+
**`checkout.complete()` can return `{ order }` or `{ cart, error }`** — both
|
|
47
|
+
with HTTP 200. If the charge fails, the cart comes back intact with the reason.
|
|
46
48
|
|
|
47
|
-
**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
**An item's author and its support go together.** `getAuthor(handle)` returns
|
|
50
|
+
the public profile of whoever sells on the marketplace, with their items and
|
|
51
|
+
what the platform computes: how many requests they resolve and how long they
|
|
52
|
+
take to answer. Anything without data yet comes as `null` — never zero — so the
|
|
53
|
+
theme can show "new author" instead of a 0 % that scares people away.
|
|
52
54
|
|
|
53
55
|
```js
|
|
54
56
|
const autor = await commerce.getAuthor("estudio-demo")
|
|
55
|
-
autor.support?.resolvedPct // 80,
|
|
56
|
-
autor.sales // 12,
|
|
57
|
+
autor.support?.resolvedPct // 80, or null until there are three ratings
|
|
58
|
+
autor.sales // 12, or null if they have not sold yet
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
**
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
**Writing to the author requires the license key**: support is only for those
|
|
62
|
+
who bought. The thread is private and the buyer's email always travels in the
|
|
63
|
+
body, never in the URL.
|
|
62
64
|
|
|
63
65
|
```js
|
|
64
66
|
const { id, covered } = await commerce.support.open({
|
|
65
|
-
licenseKey, subject: "
|
|
67
|
+
licenseKey, subject: "Will not install", message: "I get an error", email,
|
|
66
68
|
})
|
|
67
|
-
// covered === false:
|
|
69
|
+
// covered === false: the included support has expired. It warns, it does not block.
|
|
68
70
|
await commerce.support.get(id, email)
|
|
69
|
-
await commerce.support.rate(id, email, true) //
|
|
71
|
+
await commerce.support.rate(id, email, true) // a single question when closing
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
**An order id is not a password.** A guest order only comes back whole with
|
|
75
|
+
proof that it belongs to whoever is asking. Without it, `getOrder` returns the
|
|
76
|
+
order marked `partial: true` and with no email or address, and licences, codes
|
|
77
|
+
and downloads answer 403. The proof reaches the theme in the link the emails
|
|
78
|
+
carry (`?acceso=`), in the answer to `checkout.complete`, or by asking for it
|
|
79
|
+
with the email of the purchase — and it travels in a header, never in the URL.
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
const { order, access } = await commerce.checkout.complete(cartId) // keep `access`
|
|
83
|
+
const pedido = await commerce.getOrder(id, access) // whole
|
|
84
|
+
const licencias = await commerce.getLicenses(id, access) // 403 without it
|
|
85
|
+
|
|
86
|
+
// No proof? Ask for it. null when the email is not the one on the order —
|
|
87
|
+
// exactly what an order that does not exist returns, so the answer cannot say
|
|
88
|
+
// which of the two happened.
|
|
89
|
+
const dado = await commerce.requestOrderAccess(id, email) // { access, days } | null
|
|
90
|
+
```
|
|
73
91
|
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
## Writing another adapter
|
|
93
|
+
|
|
94
|
+
Implement the `CommerceClient` interface from [`src/index.d.ts`](src/index.d.ts)
|
|
95
|
+
and check it:
|
|
76
96
|
|
|
77
97
|
```js
|
|
78
98
|
import { assertCommerceClient } from "@pcreative/commerce-contract"
|
|
79
|
-
const faltan = assertCommerceClient(miCliente) // []
|
|
99
|
+
const faltan = assertCommerceClient(miCliente) // [] if it complies
|
|
80
100
|
```
|
|
81
101
|
|
|
82
|
-
`account`, `content`, `getAuthor`
|
|
83
|
-
no
|
|
102
|
+
`account`, `content`, `getAuthor` and `support` are optional: a backend may have
|
|
103
|
+
no customer accounts, no blog and no author marketplace.
|
|
104
|
+
|
|
105
|
+
## Language of the messages
|
|
106
|
+
|
|
107
|
+
Errors thrown by the package are in English. They come out in Spanish when
|
|
108
|
+
`LC_ALL`, `LC_MESSAGES` or `LANG` starts with `es` (for example
|
|
109
|
+
`LANG=es_ES.UTF-8`). Where there is no environment (a browser), English is
|
|
110
|
+
used.
|
|
84
111
|
|
|
85
112
|
## Tests
|
|
86
113
|
|
|
@@ -88,4 +115,4 @@ no tener cuentas de cliente, ni blog, ni mercado de autores.
|
|
|
88
115
|
node --test "test/*.test.js"
|
|
89
116
|
```
|
|
90
117
|
|
|
91
|
-
|
|
118
|
+
The tests inject a fake `fetch`, so they run without a backend.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pcreative/commerce-contract",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.6",
|
|
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
7
|
"main": "./src/index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"README.md",
|
|
22
|
+
"README.es.md",
|
|
22
23
|
"LICENSE"
|
|
23
24
|
],
|
|
24
25
|
"engines": {
|
package/src/adapters/api.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { money } from "../index.js"
|
|
2
|
+
import { msg } from "../mensajes.js"
|
|
3
|
+
|
|
4
|
+
/** La cabecera con la que se demuestra que un pedido de invitado es tuyo. */
|
|
5
|
+
const CABECERA_DE_ACCESO = "x-pcc-pedido"
|
|
2
6
|
|
|
3
7
|
const ORDEN_DEL_CONTRATO = {
|
|
4
8
|
newest: "novedades",
|
|
@@ -34,16 +38,20 @@ export function createCommerce({
|
|
|
34
38
|
fetch: fetchImpl,
|
|
35
39
|
requestInit = {},
|
|
36
40
|
} = {}) {
|
|
37
|
-
if (!baseUrl) throw new Error("
|
|
41
|
+
if (!baseUrl) throw new Error(msg("faltaBaseUrl"))
|
|
38
42
|
const f = fetchImpl ?? globalThis.fetch
|
|
39
43
|
const raiz = String(baseUrl).replace(/\/$/, "")
|
|
40
44
|
|
|
41
|
-
async function api(ruta, { method = "GET", body, token, ...extra } = {}) {
|
|
45
|
+
async function api(ruta, { method = "GET", body, token, acceso, ...extra } = {}) {
|
|
42
46
|
const cabeceras = { accept: "application/json", ...(extra.headers ?? {}) }
|
|
43
47
|
if (publishableKey) cabeceras["x-pcc-clave"] = publishableKey
|
|
44
48
|
if (locale) cabeceras["x-pcc-locale"] = locale
|
|
45
49
|
if (body !== undefined) cabeceras["content-type"] = "application/json"
|
|
46
50
|
if (token) cabeceras.authorization = `Bearer ${token}`
|
|
51
|
+
// La prueba de que el pedido es de quien pregunta va SIEMPRE en cabecera y
|
|
52
|
+
// nunca en la direccion: una direccion acaba en el historial, en los
|
|
53
|
+
// registros del servidor y en el `Referer` de cualquier enlace que se pulse.
|
|
54
|
+
if (acceso) cabeceras[CABECERA_DE_ACCESO] = acceso
|
|
47
55
|
|
|
48
56
|
const res = await f(`${raiz}${ruta}`, {
|
|
49
57
|
method,
|
|
@@ -259,16 +267,39 @@ export function createCommerce({
|
|
|
259
267
|
return product_categories[0] ? mapCategory(product_categories[0]) : null
|
|
260
268
|
}
|
|
261
269
|
|
|
262
|
-
async function getOrder(id) {
|
|
270
|
+
async function getOrder(id, acceso) {
|
|
263
271
|
try {
|
|
264
|
-
const { order } = await api(`/tienda/pedidos/${encodeURIComponent(id)}
|
|
265
|
-
|
|
272
|
+
const { order, acceso_requerido } = await api(`/tienda/pedidos/${encodeURIComponent(id)}`, { acceso })
|
|
273
|
+
if (!order) return null
|
|
274
|
+
// Sin prueba, el comercio contesta el pedido recortado: sin correo ni
|
|
275
|
+
// direccion. `partial` es lo que le dice al tema que pida el correo en vez
|
|
276
|
+
// de pintar huecos vacios como si el pedido no tuviera datos.
|
|
277
|
+
return acceso_requerido ? { ...order, partial: true } : order
|
|
266
278
|
} catch (e) {
|
|
267
279
|
if (e.status === 404 || e.status === 401) return null
|
|
268
280
|
throw e
|
|
269
281
|
}
|
|
270
282
|
}
|
|
271
283
|
|
|
284
|
+
/**
|
|
285
|
+
* La segunda puerta: quien llega a `/pedido/<id>` sin la prueba del correo la
|
|
286
|
+
* pide con el correo de la compra. Devuelve null cuando no coincide, que es
|
|
287
|
+
* la MISMA respuesta que da un pedido que no existe: si no, la respuesta
|
|
288
|
+
* diria si ese pedido existe.
|
|
289
|
+
*/
|
|
290
|
+
async function requestOrderAccess(orderId, email) {
|
|
291
|
+
try {
|
|
292
|
+
const r = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/acceso`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
body: { email },
|
|
295
|
+
})
|
|
296
|
+
return r?.acceso ? { access: r.acceso, days: Number(r.dias ?? 0) } : null
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (e.status === 404 || e.status === 400) return null
|
|
299
|
+
throw e
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
272
303
|
async function search(q, limit = 24) {
|
|
273
304
|
const { items } = await listProducts({ q, limit })
|
|
274
305
|
return items
|
|
@@ -295,7 +326,7 @@ export function createCommerce({
|
|
|
295
326
|
body: cuerpo,
|
|
296
327
|
})
|
|
297
328
|
const datos = await r.json().catch(() => ({}))
|
|
298
|
-
if (!r.ok) throw new Error(datos?.message || "
|
|
329
|
+
if (!r.ok) throw new Error(datos?.message || msg("ficheroNoSubido"))
|
|
299
330
|
return datos.fichero
|
|
300
331
|
}
|
|
301
332
|
|
|
@@ -343,7 +374,7 @@ export function createCommerce({
|
|
|
343
374
|
async applyPromo(cartId, code) {
|
|
344
375
|
const r = await api(rutaCarrito(cartId, "/cupon"), { method: "POST", body: { codigo: code } })
|
|
345
376
|
if (!r.aplicado) {
|
|
346
|
-
const err = new Error(r.motivo ?? "
|
|
377
|
+
const err = new Error(r.motivo ?? msg("codigoNoValido"))
|
|
347
378
|
err.status = 400
|
|
348
379
|
err.detalle = r.motivo ?? ""
|
|
349
380
|
throw err
|
|
@@ -443,7 +474,11 @@ export function createCommerce({
|
|
|
443
474
|
async complete(cartId) {
|
|
444
475
|
try {
|
|
445
476
|
const r = await api(rutaCarrito(cartId, "/completar"), { method: "POST", body: {} })
|
|
446
|
-
|
|
477
|
+
// La prueba de acceso vuelve con el pedido recién cerrado: la página de
|
|
478
|
+
// gracias enseña claves y descargas sin pedirle el correo a quien acaba
|
|
479
|
+
// de escribirlo dos pantallas antes.
|
|
480
|
+
const acceso = r.acceso ?? undefined
|
|
481
|
+
const pedido = (await getOrder(r.order.id, acceso)) ?? {
|
|
447
482
|
id: r.order.id,
|
|
448
483
|
status: "pending",
|
|
449
484
|
items: [],
|
|
@@ -453,7 +488,7 @@ export function createCommerce({
|
|
|
453
488
|
tax: money(0, r.order.currency_code ?? "eur", true),
|
|
454
489
|
total: money(r.order.total, r.order.currency_code ?? "eur", true),
|
|
455
490
|
}
|
|
456
|
-
return { order: pedido }
|
|
491
|
+
return acceso ? { order: pedido, access: acceso } : { order: pedido }
|
|
457
492
|
} catch (e) {
|
|
458
493
|
if (e.status !== 409 && e.status !== 400) throw e
|
|
459
494
|
return { cart: await getCart(cartId), error: e.detalle || "No se pudo completar el pedido" }
|
|
@@ -497,6 +532,18 @@ export function createCommerce({
|
|
|
497
532
|
const { orders = [] } = await api("/tienda/cuenta/pedidos", { token })
|
|
498
533
|
return orders
|
|
499
534
|
},
|
|
535
|
+
async licenses(token) {
|
|
536
|
+
const { licencias = [] } = await api("/tienda/cuenta/licencias", { token })
|
|
537
|
+
return licencias
|
|
538
|
+
},
|
|
539
|
+
async releaseSite(token, licenseKey, instance) {
|
|
540
|
+
const r = await api("/tienda/cuenta/licencias/soltar", {
|
|
541
|
+
method: "POST",
|
|
542
|
+
token,
|
|
543
|
+
body: { clave: licenseKey, instancia: instance },
|
|
544
|
+
})
|
|
545
|
+
return { released: Boolean(r.soltado), site: r.sitio ?? "" }
|
|
546
|
+
},
|
|
500
547
|
async confirmEmail(sessionToken, token) {
|
|
501
548
|
try {
|
|
502
549
|
const r = await api("/tienda/cuenta/confirmar", { method: "POST", token: sessionToken, body: { token } })
|
|
@@ -572,31 +619,36 @@ export function createCommerce({
|
|
|
572
619
|
getCategory,
|
|
573
620
|
search,
|
|
574
621
|
getOrder,
|
|
622
|
+
requestOrderAccess,
|
|
575
623
|
subirFichero: subirFicheroCliente,
|
|
576
|
-
async getWithdrawal(orderId) {
|
|
624
|
+
async getWithdrawal(orderId, acceso) {
|
|
577
625
|
try {
|
|
578
|
-
const { desistimiento } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento
|
|
626
|
+
const { desistimiento } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, { acceso })
|
|
579
627
|
return desistimiento ?? null
|
|
580
628
|
} catch (e) {
|
|
581
629
|
if (e.status === 404) return null
|
|
582
630
|
throw e
|
|
583
631
|
}
|
|
584
632
|
},
|
|
585
|
-
async requestWithdrawal(orderId, datos = {}) {
|
|
586
|
-
return await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, {
|
|
633
|
+
async requestWithdrawal(orderId, datos = {}, acceso) {
|
|
634
|
+
return await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, {
|
|
635
|
+
method: "POST",
|
|
636
|
+
body: datos,
|
|
637
|
+
acceso,
|
|
638
|
+
})
|
|
587
639
|
},
|
|
588
|
-
async getDownloads(orderId) {
|
|
640
|
+
async getDownloads(orderId, acceso) {
|
|
589
641
|
try {
|
|
590
|
-
const { descargas } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/descargas
|
|
642
|
+
const { descargas } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/descargas`, { acceso })
|
|
591
643
|
return (descargas ?? []).map((d) => ({ ...d, url: d.url ? `${raiz}${d.url}` : null }))
|
|
592
644
|
} catch (e) {
|
|
593
645
|
if (e.status === 404) return []
|
|
594
646
|
throw e
|
|
595
647
|
}
|
|
596
648
|
},
|
|
597
|
-
async getLicenses(orderId) {
|
|
649
|
+
async getLicenses(orderId, acceso) {
|
|
598
650
|
try {
|
|
599
|
-
const { licencias } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/licencias
|
|
651
|
+
const { licencias } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/licencias`, { acceso })
|
|
600
652
|
return (licencias ?? []).map((l) => ({
|
|
601
653
|
key: l.key,
|
|
602
654
|
status: l.status,
|
|
@@ -610,19 +662,19 @@ export function createCommerce({
|
|
|
610
662
|
throw e
|
|
611
663
|
}
|
|
612
664
|
},
|
|
613
|
-
async getCodes(orderId) {
|
|
665
|
+
async getCodes(orderId, acceso) {
|
|
614
666
|
try {
|
|
615
|
-
const { codigos } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos
|
|
667
|
+
const { codigos } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos`, { acceso })
|
|
616
668
|
return codigos ?? []
|
|
617
669
|
} catch (e) {
|
|
618
670
|
if (e.status === 404) return []
|
|
619
671
|
throw e
|
|
620
672
|
}
|
|
621
673
|
},
|
|
622
|
-
async revealCode(orderId, codeId) {
|
|
674
|
+
async revealCode(orderId, codeId, acceso) {
|
|
623
675
|
const { codigo } = await api(
|
|
624
676
|
`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos/${encodeURIComponent(codeId)}/revelar`,
|
|
625
|
-
{ method: "POST", body: {} },
|
|
677
|
+
{ method: "POST", body: {}, acceso },
|
|
626
678
|
)
|
|
627
679
|
return codigo ?? null
|
|
628
680
|
},
|
package/src/index.d.ts
CHANGED
|
@@ -121,6 +121,33 @@ export interface License {
|
|
|
121
121
|
supportedUntil: string | null
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** Un sitio donde una licencia esta puesta ahora mismo. */
|
|
125
|
+
export interface LicenseSite {
|
|
126
|
+
/** Lo que identifica esa instalacion: es lo que se manda para desactivarla. */
|
|
127
|
+
instance: string
|
|
128
|
+
/** El dominio, ya normalizado (sin esquema, sin www, sin puerto). */
|
|
129
|
+
site: string
|
|
130
|
+
/** Nombre que le puso la instalacion, si lo mando. */
|
|
131
|
+
label: string | null
|
|
132
|
+
activatedAt: string | null
|
|
133
|
+
/** Ultima vez que esa instalacion comprobo la licencia. */
|
|
134
|
+
lastSeenAt: string | null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Una licencia vista desde la cuenta de quien la compro: lo mismo que
|
|
139
|
+
* `License` mas los sitios, porque desde aqui se desactivan.
|
|
140
|
+
*/
|
|
141
|
+
export interface OwnedLicense {
|
|
142
|
+
key: string
|
|
143
|
+
status: "activa" | "caducada" | "anulada"
|
|
144
|
+
title: string | null
|
|
145
|
+
orderId: string
|
|
146
|
+
limit: number
|
|
147
|
+
supportedUntil: string | null
|
|
148
|
+
sites: LicenseSite[]
|
|
149
|
+
}
|
|
150
|
+
|
|
124
151
|
export interface ProductCode {
|
|
125
152
|
id: string
|
|
126
153
|
lineId: string
|
|
@@ -458,6 +485,13 @@ export interface Order {
|
|
|
458
485
|
total: Money
|
|
459
486
|
shippingAddress?: Address | null
|
|
460
487
|
createdAt?: string
|
|
488
|
+
/**
|
|
489
|
+
* El pedido llega RECORTADO: sin correo ni dirección, porque quien lo pide no
|
|
490
|
+
* ha demostrado todavía que es suyo. Un tema que lo ignore pinta huecos
|
|
491
|
+
* vacíos como si el pedido no tuviera esos datos; lo que toca es pedir el
|
|
492
|
+
* correo con `requestOrderAccess`.
|
|
493
|
+
*/
|
|
494
|
+
partial?: boolean
|
|
461
495
|
}
|
|
462
496
|
|
|
463
497
|
export interface Customer {
|
|
@@ -621,22 +655,47 @@ export interface CommerceClient {
|
|
|
621
655
|
listCategories(): Promise<Category[]>
|
|
622
656
|
getCategory(handle: string): Promise<Category | null>
|
|
623
657
|
search(q: string, limit?: number): Promise<Product[]>
|
|
624
|
-
/**
|
|
625
|
-
|
|
658
|
+
/**
|
|
659
|
+
* Pedido por id, para la página de confirmación tras el checkout.
|
|
660
|
+
*
|
|
661
|
+
* Un id NO es una credencial: el pedido de quien compró sin cuenta solo sale
|
|
662
|
+
* entero con la prueba de `access`, que llega en el enlace del correo
|
|
663
|
+
* (`?acceso=`), la devuelve `checkout.complete` o la da `requestOrderAccess`.
|
|
664
|
+
* Sin ella vuelve con `partial: true` y sin datos personales.
|
|
665
|
+
*/
|
|
666
|
+
getOrder(id: string, access?: string): Promise<Order | null>
|
|
667
|
+
/**
|
|
668
|
+
* Opcional: pedir la prueba de acceso a un pedido con el correo de la compra.
|
|
669
|
+
* Devuelve null si no coincide, que es exactamente lo mismo que devuelve un
|
|
670
|
+
* pedido que no existe: la respuesta no puede decir cuál de las dos cosas
|
|
671
|
+
* pasó. Está limitada por número de intentos, así que el tema tiene que
|
|
672
|
+
* contar con un fallo por exceso.
|
|
673
|
+
*/
|
|
674
|
+
requestOrderAccess?(orderId: string, email: string): Promise<{ access: string; days: number } | null>
|
|
626
675
|
/** Sube un fichero que el cliente adjunta a una personalización. */
|
|
627
676
|
subirFichero?(archivo: File): Promise<{ id: string; nombre: string; tipo: string; bytes: number; firma: string }>
|
|
628
|
-
/**
|
|
629
|
-
|
|
677
|
+
/**
|
|
678
|
+
* Opcional: las descargas de un pedido con contenido digital.
|
|
679
|
+
*
|
|
680
|
+
* Estas cuatro NO tienen versión recortada: sin `access` el comercio contesta
|
|
681
|
+
* 403, porque un fichero, una clave de licencia y un código son la compra
|
|
682
|
+
* misma. El tema las llama con la prueba o no las llama.
|
|
683
|
+
*/
|
|
684
|
+
getDownloads?(orderId: string, access?: string): Promise<Download[]>
|
|
630
685
|
/** Opcional: las licencias de las piezas del mercado compradas en un pedido. */
|
|
631
|
-
getLicenses?(orderId: string): Promise<License[]>
|
|
686
|
+
getLicenses?(orderId: string, access?: string): Promise<License[]>
|
|
632
687
|
/** Los codigos de un pedido, tapados. */
|
|
633
|
-
getCodes?(orderId: string): Promise<ProductCode[]>
|
|
688
|
+
getCodes?(orderId: string, access?: string): Promise<ProductCode[]>
|
|
634
689
|
/** Destapa uno y devuelve el codigo entero. Queda apuntado quien y cuando. */
|
|
635
|
-
revealCode?(orderId: string, codeId: string): Promise<string>
|
|
690
|
+
revealCode?(orderId: string, codeId: string, access?: string): Promise<string>
|
|
636
691
|
/** Opcional: estado del derecho de desistimiento de un pedido. */
|
|
637
|
-
getWithdrawal?(orderId: string): Promise<WithdrawalState | null>
|
|
692
|
+
getWithdrawal?(orderId: string, access?: string): Promise<WithdrawalState | null>
|
|
638
693
|
/** Opcional: solicita el desistimiento de un pedido (botón obligatorio en la UE). */
|
|
639
|
-
requestWithdrawal?(
|
|
694
|
+
requestWithdrawal?(
|
|
695
|
+
orderId: string,
|
|
696
|
+
datos?: { lineas?: string[]; motivo?: string },
|
|
697
|
+
access?: string,
|
|
698
|
+
): Promise<{ solicitud: string; aceptadas: number }>
|
|
640
699
|
/**
|
|
641
700
|
* Opcional: pedir vender en el mercado. Devuelve el id de la solicitud; la
|
|
642
701
|
* cuenta se crea cuando alguien la acepta. Si el correo ya pidió, falla con
|
|
@@ -740,7 +799,12 @@ export interface CommerceClient {
|
|
|
740
799
|
* backend. Se usa para la ruta de vuelta, que depende del idioma.
|
|
741
800
|
*/
|
|
742
801
|
selectPaymentMethod(cartId: string, provider: string, datos?: Record<string, unknown>): Promise<Cart>
|
|
743
|
-
|
|
802
|
+
/**
|
|
803
|
+
* `access` es la prueba de que ese pedido es de quien acaba de pagarlo: la
|
|
804
|
+
* página de gracias enseña claves y descargas sin volver a pedirle el
|
|
805
|
+
* correo a quien lo escribió dos pantallas antes. Guárdala.
|
|
806
|
+
*/
|
|
807
|
+
complete(cartId: string): Promise<{ order: Order; access?: string } | { cart: Cart; error: string }>
|
|
744
808
|
}
|
|
745
809
|
|
|
746
810
|
/** Opcional: un adaptador puede no soportar cuentas de cliente. */
|
|
@@ -750,6 +814,17 @@ export interface CommerceClient {
|
|
|
750
814
|
me(token: string): Promise<Customer | null>
|
|
751
815
|
orders(token: string): Promise<Order[]>
|
|
752
816
|
confirmEmail?(sessionToken: string, token: string): Promise<{ adopted: number } | null>
|
|
817
|
+
/**
|
|
818
|
+
* Opcional: las licencias que ha comprado, con los sitios donde están
|
|
819
|
+
* puestas ahora mismo. Vacio si el adaptador no vende licencias.
|
|
820
|
+
*/
|
|
821
|
+
licenses?(token: string): Promise<OwnedLicense[]>
|
|
822
|
+
/**
|
|
823
|
+
* Opcional: desactiva uno de SUS sitios, que es lo que hace quien se muda
|
|
824
|
+
* de dominio. Rechaza con `status` 400 si ese sitio ya no esta activo o la
|
|
825
|
+
* licencia no es suya, y 429 si se ha pasado del tope.
|
|
826
|
+
*/
|
|
827
|
+
releaseSite?(token: string, licenseKey: string, instance: string): Promise<{ released: boolean; site: string }>
|
|
753
828
|
}
|
|
754
829
|
|
|
755
830
|
/** Opcional: nombre y datos legales de la tienda, tal y como los tiene el panel. */
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
|
|
2
|
+
import { msg } from "./mensajes.js"
|
|
3
|
+
|
|
2
4
|
export const CONTRACT_VERSION = "1.0"
|
|
3
5
|
|
|
4
6
|
export function money(amount, currency, taxIncluded) {
|
|
@@ -25,7 +27,7 @@ export function sumMoney(...importes) {
|
|
|
25
27
|
const currency = validos[0].currency
|
|
26
28
|
for (const m of validos) {
|
|
27
29
|
if (m.currency !== currency) {
|
|
28
|
-
throw new Error(
|
|
30
|
+
throw new Error(msg("monedasDistintas", currency, m.currency))
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
return money(
|
|
@@ -43,7 +45,7 @@ const REQUERIDO = {
|
|
|
43
45
|
|
|
44
46
|
export function assertCommerceClient(cliente) {
|
|
45
47
|
const faltan = []
|
|
46
|
-
if (!cliente || typeof cliente !== "object") return ["
|
|
48
|
+
if (!cliente || typeof cliente !== "object") return [msg("clienteNoObjeto")]
|
|
47
49
|
for (const m of REQUERIDO.raiz) {
|
|
48
50
|
if (typeof cliente[m] !== "function") faltan.push(m)
|
|
49
51
|
}
|
package/src/mensajes.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function idioma(entorno) {
|
|
2
|
+
const env = entorno ?? (typeof process !== "undefined" && process?.env ? process.env : {})
|
|
3
|
+
for (const v of [env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
|
|
4
|
+
if (v) return /^es([_.@-]|$)/i.test(String(v)) ? "es" : "en"
|
|
5
|
+
}
|
|
6
|
+
return "en"
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const en = {
|
|
10
|
+
monedasDistintas: (a, b) => `cannot add amounts in ${a} and in ${b}`,
|
|
11
|
+
clienteNoObjeto: () => "the client is not an object",
|
|
12
|
+
faltaBaseUrl: () => "createCommerce: baseUrl is missing",
|
|
13
|
+
ficheroNoSubido: () => "The file could not be uploaded.",
|
|
14
|
+
codigoNoValido: () => "That code cannot be used.",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const es = {
|
|
18
|
+
monedasDistintas: (a, b) => `no se pueden sumar importes en ${a} y en ${b}`,
|
|
19
|
+
clienteNoObjeto: () => "el cliente no es un objeto",
|
|
20
|
+
faltaBaseUrl: () => "createCommerce: falta baseUrl",
|
|
21
|
+
ficheroNoSubido: () => "No se pudo subir el fichero.",
|
|
22
|
+
codigoNoValido: () => "Ese código no se puede usar.",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MENSAJES = { en, es }
|
|
26
|
+
|
|
27
|
+
export function msg(clave, ...args) {
|
|
28
|
+
const f = MENSAJES[idioma()][clave] ?? en[clave]
|
|
29
|
+
if (typeof f !== "function") throw new Error(`message without text: ${clave}`)
|
|
30
|
+
return f(...args)
|
|
31
|
+
}
|