Contrato de comercio — pcreative Commerce
Es lo que un tema puede pedirle a la tienda: productos, carrito, caja, cuenta, descargas, soporte del autor… Un tema no importa el SDK de ningún backend:…
Paquete: @pcreative/commerce-contract
· versión 1.3.5 · contrato de datos 1.0 (CONTRACT_VERSION).
Es lo que un tema puede pedirle a la tienda: productos, carrito, caja, cuenta, descargas, soporte del autor… Un tema no importa el SDK de ningún backend: llama a este cliente, y el adaptador traduce a la API de la tienda.
De ahí salen dos cosas. El mismo tema sirve mañana con otro backend, con otro adaptador. Y la tienda no queda casada con temas de un solo framework.
El paquete no tiene dependencias y pide Node 18 o superior.
Crear el cliente
import { createCommerce } from "@pcreative/commerce-contract/api"
const commerce = createCommerce({
baseUrl: process.env.NEXT_PUBLIC_COMMERCE_URL,
publishableKey: process.env.NEXT_PUBLIC_COMMERCE_KEY,
countryCode: "es",
requestInit: { next: { revalidate: 300 } },
})| Opción | Obligatoria | Qué hace |
|---|---|---|
baseUrl | sí | La dirección de la tienda. Sin ella, createCommerce lanza un error. |
publishableKey | no | La clave publicable. Viaja en la cabecera x-pcc-clave. |
countryCode | no | Elige la región (y con ella la moneda) cuyo país coincide. Sin él, la primera región. |
locale | no | El idioma. Viaja en la cabecera x-pcc-locale. |
fetch | no | Otro fetch. Sirve para probar sin tienda. |
requestInit | no | Se mezcla en cada petición. Así el framework pone su caché sin que el adaptador sepa de frameworks. |
El adaptador se llama "pcreative" (commerce.adapter).
Reglas del contrato
Los importes van en unidades mayores, con su moneda al lado: 19.99, no
1999. Un Money es { amount, currency, taxIncluded? }, con la moneda ISO
4217 en minúsculas. Así ningún tema tiene que adivinar la escala, que es de
donde sale la mitad de los errores de precio.
import { formatMoney, sumMoney, money } from "@pcreative/commerce-contract"
formatMoney({ amount: 19.99, currency: "eur" }) // "19,99 €"
sumMoney(a, b) // lanza si mezclas monedas
money(19.99, "eur", true) // construye un MoneyformatMoney usa es-ES si no le pasas otro idioma.
Los errores llevan el código HTTP dentro: err.status, y el mensaje de la
tienda en err.detalle. Un 404 de carrito caducado y un 401 de clave mal puesta
piden cosas distintas al tema.
Lo que no existe devuelve null, no una excepción: un carrito caducado, un
producto que no está, un pedido ajeno. Las listas que no existen devuelven [].
checkout.complete() no lanza si el cobro falla. Devuelve { order } o
{ cart, error }, y en el segundo caso el carrito vuelve intacto con el motivo.
Lo que no tiene dato viene en null, nunca en cero. En la ficha del autor,
las ventas y el soporte sin datos suficientes vienen en null, para que el tema
enseñe «autor nuevo» en vez de un 0 % que espanta.
El correo del comprador nunca va en la dirección. En el soporte viaja siempre en el cuerpo de la petición: una dirección acaba en registros y en el historial del navegador.
Métodos obligatorios
Son los que comprueba assertCommerceClient. Un adaptador sin alguno de ellos
no cumple el contrato.
Catálogo
| Método | Devuelve |
|---|---|
listProducts(params?) | Page<Product>: { items, count, limit, offset }. count es el total, no el de esta página. |
getProduct(handle) | Product o null. |
getProductsByIds(ids) | Product[] en el mismo orden que ids, sin los que no existan. |
listCategories() | Category[]: solo las de primer nivel. |
getCategory(handle) | Category o null. |
search(q, limit?) | Product[]. Por defecto, 24. |
getOrder(id) | Order o null. Es para la página de confirmación tras pagar. |
listProducts acepta q, category (handle), categoryId, ids, tags,
type ("tema" o "extension", solo piezas del mercado), limit (24 por
defecto), offset y sort.
sort admite relevance, price_asc, price_desc, newest y rating. Este
adaptador solo traduce newest, price_asc y price_desc; con los otros dos
la tienda usa su propio orden, y tags no se envía.
cart
| Método | Devuelve |
|---|---|
cart.get(id) | Cart o null si ya no existe. |
cart.create() | Cart nuevo, en la región de countryCode. |
cart.addItem(cartId, variantId, quantity, personalizacion?, pack?) | Cart. pack elige los componentes de un pack: { grupo: [variantes] }. |
cart.updateItem(cartId, lineId, quantity) | Cart. Con 0 o menos, quita la línea. |
cart.removeItem(cartId, lineId) | Cart. |
checkout
| Método | Devuelve |
|---|---|
checkout.setEmail(cartId, email) | Cart. |
checkout.setAddresses(cartId, shipping, billing?) | Cart. Sin billing, se usa la de envío. |
checkout.listShippingOptions(cartId) | ShippingOption[]. |
checkout.setShippingMethod(cartId, optionId) | Cart. |
checkout.listPaymentMethods(cartId) | PaymentMethod[]. |
checkout.selectPaymentMethod(cartId, provider, datos?) | Cart. Si el método necesita un paso más, llega en cart.pago. |
checkout.complete(cartId) | { order } o { cart, error }. |
El recargo de una forma de pago (surcharge) es para enseñarlo. Lo cobra la
tienda, leyéndolo de su configuración: si viniera del escaparate, cualquiera
podría pedir un recargo de cero.
cart.pago dice cómo sigue el comprador: { tipo: "redirigir", url } para
mandarlo a la pasarela, o { tipo: "confirmar_en_cliente", pasarela, datos }
para confirmar en el navegador con el SDK de la pasarela. Es null con los
métodos sin pasarela.
Métodos opcionales
Un adaptador puede no tenerlos. Compruébalo con typeof antes de usarlos. El
adaptador "pcreative" los trae todos.
Carrito y caja
| Método | Devuelve |
|---|---|
cart.applyPromo(cartId, code) | Cart. Si el código no vale, lanza con status 400 y el motivo. |
cart.removePromo(cartId, code) | Cart. |
cart.consentirDigital(cartId, aceptado) | Cart. El consentimiento para recibir contenido digital ya (UE, Reino Unido). |
checkout.listCountries() | string[]: los países donde vende la tienda, en minúsculas. |
checkout.listProvinces(country) | { code, name }[]. Lista vacía: ese país no usa provincia y el campo queda libre. |
checkout.setVatNumber(cartId, vatNumber) | Cart, con vatNumber y, si aplica, taxExempt. null borra el número. |
checkout.listShippingGroups(cartId) | { grupos, moneda }: un grupo por sitio del que sale mercancía (marketplace). null si no hay. |
checkout.setShippingMethods(cartId, elecciones) | nada. Fija el porte de todos los grupos a la vez. |
applyPromo y removePromo están en el tipo de cart, pero
assertCommerceClient no los exige.
setShippingMethods pide el conjunto entero a propósito: en la tienda, mandar
un porte suelto borra los demás sin avisar.
vatNumber es { number, valid, checked, name }. taxExempt es
{ reason: "intra_eu", articles } cuando el carrito no paga IVA por ser una
empresa de otro país de la UE.
Cuenta: account
| Método | Devuelve |
|---|---|
account.login(email, password, cartId?) | { token }. |
account.register({ email, password, firstName?, lastName? }) | Customer. |
account.me(token) | Customer o null si el token ya no vale. |
account.orders(token) | Order[]. |
account.confirmEmail(sessionToken, token) | { adopted } o null si el enlace no vale. |
Contenido: content
| Método | Devuelve |
|---|---|
content.listPosts(limit?) | Post[]. Por defecto, 10. |
content.getPost(handle) | Post o null. |
content.listPages() | PaginaSuelta[], sin su contenido: es lo que pinta el pie. |
content.getPage(handle) | PaginaSuelta con su contenido, o null. |
Si la tienda no contesta, estos cuatro devuelven lista vacía o null en vez de
lanzar: un blog caído no tumba la página.
Una página suelta no es una entrada de blog. Una entrada tiene fecha; una página
tiene un orden (order) que decide quien la escribe.
Tienda y contacto
| Método | Devuelve |
|---|---|
getStore() | StoreInfo o null: nombre, razón social, NIF, dirección, correo, teléfono, país, cómo enseñar los precios y los avisos legales. |
sendContact({ name?, email, subject?, message }) | nada. Lanza con status 400 si faltan datos, 429 si hay demasiados envíos y 503 si la tienda no tiene a dónde mandarlo. |
StoreInfo.priceDisplay dice cómo quiere la tienda los precios: show es
"with", "without" o "both"; storedWithTax, si el precio que llega ya
lleva el impuesto; y defaultRate, la tasa del país de la tienda, para calcular
el otro importe antes de que el cliente diga dónde vive.
Después de comprar
| Método | Devuelve |
|---|---|
getDownloads(orderId) | Download[]: las descargas de un pedido con contenido digital. Cada url ya lleva la dirección de la tienda delante. |
getLicenses(orderId) | License[]: las licencias de las piezas del mercado compradas en ese pedido, con la clave entera. |
getCodes(orderId) | ProductCode[]: los códigos del pedido, tapados. Se ve hint (los últimos caracteres). |
revealCode(orderId, codeId) | string: el código entero. Queda apuntado quién y cuándo, y cuenta como entregado. |
getWithdrawal(orderId) | WithdrawalState o null: si se puede desistir, cuántos días, hasta cuándo y qué líneas quedan fuera. |
requestWithdrawal(orderId, { lineas?, motivo? }) | { solicitud, aceptadas }. Sin lineas, todas. |
subirFichero(archivo) | { id, nombre, tipo, bytes, firma }: sube un fichero que el cliente adjunta a una personalización. |
getDownloads, getLicenses y getCodes devuelven [] si el pedido no existe;
getWithdrawal, null.
Mercado de autores
| Método | Devuelve |
|---|---|
getAuthor(handle) | Author o null: la ficha pública de quien vende, con sus piezas, sus ventas y su soporte. |
applyAsAuthor(application) | { id } de la solicitud. La cuenta se crea cuando alguien la acepta. Si ese correo ya pidió, lanza con status 400 y el motivo en err.detalle. |
support.open({ licenseKey, subject, message, email? }) | { id, covered }. |
support.get(id, email) | SupportThread o null. |
support.reply(id, email, message) | nada. |
support.rate(id, email, solved) | nada. La única pregunta al cerrar: si le sirvió. |
Para abrir soporte hace falta la clave de licencia: solo escribe quien ha
comprado. Si el soporte incluido ya caducó, covered viene en false. Se
avisa, no se bloquea.
En Author, sales y support vienen en null mientras no hay datos.
support.resolvedPct es el porcentaje de consultas resueltas,
firstReplyHours la mediana de horas hasta la primera respuesta y ratings
cuántas valoraciones lo sostienen. Lo calcula la plataforma, no lo escribe el
autor.
Boletín: newsletter
| Método | Devuelve |
|---|---|
newsletter.subscribe(email, consent) | { status: "pendiente" }. |
newsletter.confirm(token) | true, o false si el enlace no vale. |
newsletter.unsubscribe(token) | true. |
subscribe no apunta a nadie: manda un correo con un enlace, y solo al pulsarlo
(confirm) la persona queda dentro. Contesta siempre lo mismo, para que no sirva
para averiguar quién está apuntado.
consent lleva text (el texto exacto que vio la persona junto a la
casilla, que es la prueba) y version, y puede llevar locale, source, ip y
userAgent.
Escribir otro adaptador
Implementa la interfaz CommerceClient del paquete y compruébala:
import { assertCommerceClient } from "@pcreative/commerce-contract"
const faltan = assertCommerceClient(miCliente) // [] si cumpleDevuelve la lista de lo que falta: los métodos de la raíz por su nombre
(getOrder) y los de los grupos con su grupo delante (cart.addItem). Si falta
un grupo entero, sale el grupo (checkout).
Solo mira lo obligatorio. Todo lo opcional puede faltar: un backend puede no tener cuentas de cliente, ni blog, ni mercado de autores.
Ver también
- Contrato de tema: el tema declara en
theme.jsonqué versión del contrato de comercio usa (commerce.contract). - Crear un tema.