Saltar al contenido
pcreative Commerce

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ónObligatoriaQué hace
baseUrlsíLa dirección de la tienda. Sin ella, createCommerce lanza un error.
publishableKeynoLa clave publicable. Viaja en la cabecera x-pcc-clave.
countryCodenoElige la región (y con ella la moneda) cuyo país coincide. Sin él, la primera región.
localenoEl idioma. Viaja en la cabecera x-pcc-locale.
fetchnoOtro fetch. Sirve para probar sin tienda.
requestInitnoSe 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 Money

formatMoney 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.

MétodoDevuelve
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étodoDevuelve
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étodoDevuelve
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étodoDevuelve
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étodoDevuelve
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étodoDevuelve
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étodoDevuelve
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étodoDevuelve
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étodoDevuelve
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étodoDevuelve
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 cumple

Devuelve 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