Commerce contract — pcreative Commerce
It is what a theme can ask the store for: products, cart, checkout, account, downloads, author support… A theme does not import any backend's SDK: it calls…
Package: @pcreative/commerce-contract
· version 1.3.5 · data contract 1.0 (CONTRACT_VERSION).
It is what a theme can ask the store for: products, cart, checkout, account, downloads, author support… A theme does not import any backend's SDK: it calls this client, and the adapter translates to the store's API.
Two things follow from that. The same theme works tomorrow with another backend, through another adapter. And the store is not married to themes from a single framework.
The package has no dependencies and needs Node 18 or later.
Creating the client
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 } },
})| Option | Required | What it does |
|---|---|---|
baseUrl | yes | The store's address. Without it, createCommerce throws. |
publishableKey | no | The publishable key. Sent in the x-pcc-clave header. |
countryCode | no | Picks the region (and with it the currency) whose country matches. Without it, the first region. |
locale | no | The language. Sent in the x-pcc-locale header. |
fetch | no | A different fetch. Useful for testing without a store. |
requestInit | no | Merged into every request. That way the framework adds its cache without the adapter knowing about frameworks. |
The adapter is called "pcreative" (commerce.adapter).
Contract rules
Amounts are in major units, with their currency alongside: 19.99, not
1999. A Money is { amount, currency, taxIncluded? }, with the ISO 4217
currency in lowercase. So no theme has to guess the scale, which is where half
of all price bugs come from.
import { formatMoney, sumMoney, money } from "@pcreative/commerce-contract"
formatMoney({ amount: 19.99, currency: "eur" }) // "19,99 €"
sumMoney(a, b) // throws if you mix currencies
money(19.99, "eur", true) // builds a MoneyformatMoney uses es-ES unless you pass another locale.
Errors carry the HTTP status inside: err.status, and the store's message
in err.detalle. A 404 for an expired cart and a 401 for a wrong key ask
different things of the theme.
What does not exist returns null, not an exception: an expired cart, a
product that is not there, someone else's order. Lists that do not exist return
[].
checkout.complete() does not throw when payment fails. It returns
{ order } or { cart, error }, and in the second case the cart comes back
intact with the reason.
What has no data comes as null, never as zero. On the author's page, sales
and support without enough data come as null, so the theme shows "new author"
instead of an off-putting 0%.
The buyer's email never goes in the URL. In support it always travels in the request body: a URL ends up in logs and in browser history.
Required methods
These are the ones assertCommerceClient checks. An adapter missing any of
them does not meet the contract.
Catalogue
| Method | Returns |
|---|---|
listProducts(params?) | Page<Product>: { items, count, limit, offset }. count is the total, not this page's. |
getProduct(handle) | Product or null. |
getProductsByIds(ids) | Product[] in the same order as ids, without the ones that do not exist. |
listCategories() | Category[]: top level only. |
getCategory(handle) | Category or null. |
search(q, limit?) | Product[]. 24 by default. |
getOrder(id) | Order or null. It is for the confirmation page after paying. |
listProducts accepts q, category (handle), categoryId, ids, tags,
type ("tema" or "extension", marketplace items only), limit (24 by
default), offset and sort.
sort accepts relevance, price_asc, price_desc, newest and rating.
This adapter only translates newest, price_asc and price_desc; with the
other two the store uses its own order, and tags is not sent.
cart
| Method | Returns |
|---|---|
cart.get(id) | Cart, or null if it no longer exists. |
cart.create() | A new Cart, in the countryCode region. |
cart.addItem(cartId, variantId, quantity, personalizacion?, pack?) | Cart. pack picks a bundle's components: { group: [variants] }. |
cart.updateItem(cartId, lineId, quantity) | Cart. With 0 or less, it removes the line. |
cart.removeItem(cartId, lineId) | Cart. |
checkout
| Method | Returns |
|---|---|
checkout.setEmail(cartId, email) | Cart. |
checkout.setAddresses(cartId, shipping, billing?) | Cart. Without billing, the shipping one is used. |
checkout.listShippingOptions(cartId) | ShippingOption[]. |
checkout.setShippingMethod(cartId, optionId) | Cart. |
checkout.listPaymentMethods(cartId) | PaymentMethod[]. |
checkout.selectPaymentMethod(cartId, provider, datos?) | Cart. If the method needs one more step, it arrives in cart.pago. |
checkout.complete(cartId) | { order } or { cart, error }. |
A payment method's surcharge (surcharge) is there to be shown. The store
charges it, reading it from its own settings: if it came from the storefront,
anyone could ask for a zero surcharge.
cart.pago says how the buyer carries on: { tipo: "redirigir", url } to send
them to the gateway, or { tipo: "confirmar_en_cliente", pasarela, datos } to
confirm in the browser with the gateway's SDK. It is null for methods with no
gateway.
Optional methods
An adapter may not have them. Check with typeof before using them. The
"pcreative" adapter has them all.
Cart and checkout
| Method | Returns |
|---|---|
cart.applyPromo(cartId, code) | Cart. If the code is not valid, throws with status 400 and the reason. |
cart.removePromo(cartId, code) | Cart. |
cart.consentirDigital(cartId, aceptado) | Cart. Consent to receive digital content right away (EU, UK). |
checkout.listCountries() | string[]: the countries the store sells to, lowercase. |
checkout.listProvinces(country) | { code, name }[]. Empty list: that country uses no province and the field stays free. |
checkout.setVatNumber(cartId, vatNumber) | Cart, with vatNumber and, when it applies, taxExempt. null clears the number. |
checkout.listShippingGroups(cartId) | { grupos, moneda }: one group per place goods ship from (marketplace). null if there is none. |
checkout.setShippingMethods(cartId, elecciones) | nothing. Sets the shipping of all groups at once. |
applyPromo and removePromo are in the cart type, but
assertCommerceClient does not require them.
setShippingMethods takes the whole set on purpose: in the store, sending one
shipping method on its own deletes the others without warning.
vatNumber is { number, valid, checked, name }. taxExempt is
{ reason: "intra_eu", articles } when the cart pays no VAT because it is a
business in another EU country.
Account: account
| Method | Returns |
|---|---|
account.login(email, password, cartId?) | { token }. |
account.register({ email, password, firstName?, lastName? }) | Customer. |
account.me(token) | Customer, or null if the token is no longer valid. |
account.orders(token) | Order[]. |
account.confirmEmail(sessionToken, token) | { adopted }, or null if the link is not valid. |
Content: content
| Method | Returns |
|---|---|
content.listPosts(limit?) | Post[]. 10 by default. |
content.getPost(handle) | Post or null. |
content.listPages() | PaginaSuelta[], without their content: it is what the footer draws. |
content.getPage(handle) | PaginaSuelta with its content, or null. |
If the store does not answer, these four return an empty list or null instead
of throwing: a broken blog does not take the page down.
A standalone page is not a blog post. A post has a date; a page has an order
decided by whoever writes it.
Store and contact
| Method | Returns |
|---|---|
getStore() | StoreInfo or null: name, legal name, tax ID, address, email, phone, country, how to show prices and the legal notices. |
sendContact({ name?, email, subject?, message }) | nothing. Throws with status 400 if data is missing, 429 if there are too many messages and 503 if the store has nowhere to send it. |
StoreInfo.priceDisplay says how the store wants its prices: show is
"with", "without" or "both"; storedWithTax, whether the incoming price
already includes tax; and defaultRate, the rate of the store's country, to
work out the other amount before the customer says where they live.
After buying
| Method | Returns |
|---|---|
getDownloads(orderId) | Download[]: the downloads of an order with digital content. Each url already has the store's address in front. |
getLicenses(orderId) | License[]: the licences of the marketplace items bought in that order, with the full key. |
getCodes(orderId) | ProductCode[]: the order's codes, masked. You see hint (the last characters). |
revealCode(orderId, codeId) | string: the full code. Who and when is recorded, and it counts as delivered. |
getWithdrawal(orderId) | WithdrawalState or null: whether withdrawal is possible, how many days, until when and which lines are left out. |
requestWithdrawal(orderId, { lineas?, motivo? }) | { solicitud, aceptadas }. Without lineas, all of them. |
subirFichero(archivo) | { id, nombre, tipo, bytes, firma }: uploads a file the customer attaches to a customisation. |
getDownloads, getLicenses and getCodes return [] if the order does not
exist; getWithdrawal, null.
Author marketplace
| Method | Returns |
|---|---|
getAuthor(handle) | Author or null: the public page of whoever sells, with their items, their sales and their support. |
applyAsAuthor(application) | { id } of the application. The account is created when someone accepts it. If that email already applied, throws with status 400 and the reason in err.detalle. |
support.open({ licenseKey, subject, message, email? }) | { id, covered }. |
support.get(id, email) | SupportThread or null. |
support.reply(id, email, message) | nothing. |
support.rate(id, email, solved) | nothing. The only question at closing: did it help. |
Opening support needs the licence key: only someone who bought can write.
If the included support has expired, covered comes as false. It warns, it
does not block.
In Author, sales and support come as null while there is no data.
support.resolvedPct is the percentage of resolved queries, firstReplyHours
the median hours to the first reply and ratings how many ratings back it. The
platform calculates it; the author does not write it.
Newsletter: newsletter
| Method | Returns |
|---|---|
newsletter.subscribe(email, consent) | { status: "pendiente" }. |
newsletter.confirm(token) | true, or false if the link is not valid. |
newsletter.unsubscribe(token) | true. |
subscribe signs nobody up: it sends an email with a link, and only when it is
clicked (confirm) is the person in. It always answers the same, so it cannot
be used to find out who is subscribed.
consent carries text (the exact text the person saw next to the
checkbox, which is the proof) and version, and may carry locale, source,
ip and userAgent.
Writing another adapter
Implement the package's CommerceClient interface and check it:
import { assertCommerceClient } from "@pcreative/commerce-contract"
const missing = assertCommerceClient(myClient) // [] if it compliesIt returns the list of what is missing: root methods by name (getOrder) and
group methods with their group in front (cart.addItem). If a whole group is
missing, the group comes out (checkout).
It only looks at what is required. Everything optional may be missing: a backend may have no customer accounts, no blog and no author marketplace.
See also
- Theme contract: the theme declares in
theme.jsonwhich commerce contract version it uses (commerce.contract). - Create a theme.