@pcreative/commerce-contract 1.3.4 → 1.3.5
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 +61 -21
- package/src/index.d.ts +47 -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.5",
|
|
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" }
|
|
@@ -572,31 +607,36 @@ export function createCommerce({
|
|
|
572
607
|
getCategory,
|
|
573
608
|
search,
|
|
574
609
|
getOrder,
|
|
610
|
+
requestOrderAccess,
|
|
575
611
|
subirFichero: subirFicheroCliente,
|
|
576
|
-
async getWithdrawal(orderId) {
|
|
612
|
+
async getWithdrawal(orderId, acceso) {
|
|
577
613
|
try {
|
|
578
|
-
const { desistimiento } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento
|
|
614
|
+
const { desistimiento } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, { acceso })
|
|
579
615
|
return desistimiento ?? null
|
|
580
616
|
} catch (e) {
|
|
581
617
|
if (e.status === 404) return null
|
|
582
618
|
throw e
|
|
583
619
|
}
|
|
584
620
|
},
|
|
585
|
-
async requestWithdrawal(orderId, datos = {}) {
|
|
586
|
-
return await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, {
|
|
621
|
+
async requestWithdrawal(orderId, datos = {}, acceso) {
|
|
622
|
+
return await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/desistimiento`, {
|
|
623
|
+
method: "POST",
|
|
624
|
+
body: datos,
|
|
625
|
+
acceso,
|
|
626
|
+
})
|
|
587
627
|
},
|
|
588
|
-
async getDownloads(orderId) {
|
|
628
|
+
async getDownloads(orderId, acceso) {
|
|
589
629
|
try {
|
|
590
|
-
const { descargas } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/descargas
|
|
630
|
+
const { descargas } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/descargas`, { acceso })
|
|
591
631
|
return (descargas ?? []).map((d) => ({ ...d, url: d.url ? `${raiz}${d.url}` : null }))
|
|
592
632
|
} catch (e) {
|
|
593
633
|
if (e.status === 404) return []
|
|
594
634
|
throw e
|
|
595
635
|
}
|
|
596
636
|
},
|
|
597
|
-
async getLicenses(orderId) {
|
|
637
|
+
async getLicenses(orderId, acceso) {
|
|
598
638
|
try {
|
|
599
|
-
const { licencias } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/licencias
|
|
639
|
+
const { licencias } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/licencias`, { acceso })
|
|
600
640
|
return (licencias ?? []).map((l) => ({
|
|
601
641
|
key: l.key,
|
|
602
642
|
status: l.status,
|
|
@@ -610,19 +650,19 @@ export function createCommerce({
|
|
|
610
650
|
throw e
|
|
611
651
|
}
|
|
612
652
|
},
|
|
613
|
-
async getCodes(orderId) {
|
|
653
|
+
async getCodes(orderId, acceso) {
|
|
614
654
|
try {
|
|
615
|
-
const { codigos } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos
|
|
655
|
+
const { codigos } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos`, { acceso })
|
|
616
656
|
return codigos ?? []
|
|
617
657
|
} catch (e) {
|
|
618
658
|
if (e.status === 404) return []
|
|
619
659
|
throw e
|
|
620
660
|
}
|
|
621
661
|
},
|
|
622
|
-
async revealCode(orderId, codeId) {
|
|
662
|
+
async revealCode(orderId, codeId, acceso) {
|
|
623
663
|
const { codigo } = await api(
|
|
624
664
|
`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos/${encodeURIComponent(codeId)}/revelar`,
|
|
625
|
-
{ method: "POST", body: {} },
|
|
665
|
+
{ method: "POST", body: {}, acceso },
|
|
626
666
|
)
|
|
627
667
|
return codigo ?? null
|
|
628
668
|
},
|
package/src/index.d.ts
CHANGED
|
@@ -458,6 +458,13 @@ export interface Order {
|
|
|
458
458
|
total: Money
|
|
459
459
|
shippingAddress?: Address | null
|
|
460
460
|
createdAt?: string
|
|
461
|
+
/**
|
|
462
|
+
* El pedido llega RECORTADO: sin correo ni dirección, porque quien lo pide no
|
|
463
|
+
* ha demostrado todavía que es suyo. Un tema que lo ignore pinta huecos
|
|
464
|
+
* vacíos como si el pedido no tuviera esos datos; lo que toca es pedir el
|
|
465
|
+
* correo con `requestOrderAccess`.
|
|
466
|
+
*/
|
|
467
|
+
partial?: boolean
|
|
461
468
|
}
|
|
462
469
|
|
|
463
470
|
export interface Customer {
|
|
@@ -621,22 +628,47 @@ export interface CommerceClient {
|
|
|
621
628
|
listCategories(): Promise<Category[]>
|
|
622
629
|
getCategory(handle: string): Promise<Category | null>
|
|
623
630
|
search(q: string, limit?: number): Promise<Product[]>
|
|
624
|
-
/**
|
|
625
|
-
|
|
631
|
+
/**
|
|
632
|
+
* Pedido por id, para la página de confirmación tras el checkout.
|
|
633
|
+
*
|
|
634
|
+
* Un id NO es una credencial: el pedido de quien compró sin cuenta solo sale
|
|
635
|
+
* entero con la prueba de `access`, que llega en el enlace del correo
|
|
636
|
+
* (`?acceso=`), la devuelve `checkout.complete` o la da `requestOrderAccess`.
|
|
637
|
+
* Sin ella vuelve con `partial: true` y sin datos personales.
|
|
638
|
+
*/
|
|
639
|
+
getOrder(id: string, access?: string): Promise<Order | null>
|
|
640
|
+
/**
|
|
641
|
+
* Opcional: pedir la prueba de acceso a un pedido con el correo de la compra.
|
|
642
|
+
* Devuelve null si no coincide, que es exactamente lo mismo que devuelve un
|
|
643
|
+
* pedido que no existe: la respuesta no puede decir cuál de las dos cosas
|
|
644
|
+
* pasó. Está limitada por número de intentos, así que el tema tiene que
|
|
645
|
+
* contar con un fallo por exceso.
|
|
646
|
+
*/
|
|
647
|
+
requestOrderAccess?(orderId: string, email: string): Promise<{ access: string; days: number } | null>
|
|
626
648
|
/** Sube un fichero que el cliente adjunta a una personalización. */
|
|
627
649
|
subirFichero?(archivo: File): Promise<{ id: string; nombre: string; tipo: string; bytes: number; firma: string }>
|
|
628
|
-
/**
|
|
629
|
-
|
|
650
|
+
/**
|
|
651
|
+
* Opcional: las descargas de un pedido con contenido digital.
|
|
652
|
+
*
|
|
653
|
+
* Estas cuatro NO tienen versión recortada: sin `access` el comercio contesta
|
|
654
|
+
* 403, porque un fichero, una clave de licencia y un código son la compra
|
|
655
|
+
* misma. El tema las llama con la prueba o no las llama.
|
|
656
|
+
*/
|
|
657
|
+
getDownloads?(orderId: string, access?: string): Promise<Download[]>
|
|
630
658
|
/** Opcional: las licencias de las piezas del mercado compradas en un pedido. */
|
|
631
|
-
getLicenses?(orderId: string): Promise<License[]>
|
|
659
|
+
getLicenses?(orderId: string, access?: string): Promise<License[]>
|
|
632
660
|
/** Los codigos de un pedido, tapados. */
|
|
633
|
-
getCodes?(orderId: string): Promise<ProductCode[]>
|
|
661
|
+
getCodes?(orderId: string, access?: string): Promise<ProductCode[]>
|
|
634
662
|
/** Destapa uno y devuelve el codigo entero. Queda apuntado quien y cuando. */
|
|
635
|
-
revealCode?(orderId: string, codeId: string): Promise<string>
|
|
663
|
+
revealCode?(orderId: string, codeId: string, access?: string): Promise<string>
|
|
636
664
|
/** Opcional: estado del derecho de desistimiento de un pedido. */
|
|
637
|
-
getWithdrawal?(orderId: string): Promise<WithdrawalState | null>
|
|
665
|
+
getWithdrawal?(orderId: string, access?: string): Promise<WithdrawalState | null>
|
|
638
666
|
/** Opcional: solicita el desistimiento de un pedido (botón obligatorio en la UE). */
|
|
639
|
-
requestWithdrawal?(
|
|
667
|
+
requestWithdrawal?(
|
|
668
|
+
orderId: string,
|
|
669
|
+
datos?: { lineas?: string[]; motivo?: string },
|
|
670
|
+
access?: string,
|
|
671
|
+
): Promise<{ solicitud: string; aceptadas: number }>
|
|
640
672
|
/**
|
|
641
673
|
* Opcional: pedir vender en el mercado. Devuelve el id de la solicitud; la
|
|
642
674
|
* cuenta se crea cuando alguien la acepta. Si el correo ya pidió, falla con
|
|
@@ -740,7 +772,12 @@ export interface CommerceClient {
|
|
|
740
772
|
* backend. Se usa para la ruta de vuelta, que depende del idioma.
|
|
741
773
|
*/
|
|
742
774
|
selectPaymentMethod(cartId: string, provider: string, datos?: Record<string, unknown>): Promise<Cart>
|
|
743
|
-
|
|
775
|
+
/**
|
|
776
|
+
* `access` es la prueba de que ese pedido es de quien acaba de pagarlo: la
|
|
777
|
+
* página de gracias enseña claves y descargas sin volver a pedirle el
|
|
778
|
+
* correo a quien lo escribió dos pantallas antes. Guárdala.
|
|
779
|
+
*/
|
|
780
|
+
complete(cartId: string): Promise<{ order: Order; access?: string } | { cart: Cart; error: string }>
|
|
744
781
|
}
|
|
745
782
|
|
|
746
783
|
/** Opcional: un adaptador puede no soportar cuentas de cliente. */
|
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
|
+
}
|