Shop (Supabase)
@plutocms/supabase-shop adds e-commerce features on top of the Supabase layer. A product has a name, a description, a price, a media gallery, a category, and an availability status.
A product in the admin: name, price, stock, and photos
Install
Install the package first. extends does not install it.
npm install @plutocms/supabase-shoppnpm add @plutocms/supabase-shopyarn add @plutocms/supabase-shopbun add @plutocms/supabase-shopdeno add npm:@plutocms/supabase-shopThen extend only this layer:
export default defineNuxtConfig({
extends: ['@plutocms/supabase-shop']
})
The layer extends @plutocms/supabase (which extends @plutocms/core) and @plutocms/shop. Do not add them to extends and do not install them: the package manager installs them with the layer. You need the Supabase environment variables and the setup of the Supabase layer. The server applies the pending migrations at start-up. You can also apply them on /admin/migrations.
Admin views
| Route | Purpose |
|---|---|
/admin/products | List products |
/admin/product/new | Create a product |
/admin/product/edit/:id | Edit a product |
The photos of a product are files of the media library of core (/admin/media). @plutocms/supabase stores them in Supabase Storage. A product can also have a 3D model when the app has the 3D add-on.
The product admin runs on the generic content UI of core. The product content type is declared in the layer. It has a publish workflow: draft, in review, scheduled, and published. See Scheduled publish and review. A removed product goes to the trash for 30 days. See Trash.
The products table
| Column | Meaning |
|---|---|
id | bigint, primary key |
slug | Unique. It forms the public path /product/<slug>. |
name | Required |
description | Optional |
price | bigint, required. Integer cents. |
created_at | Set by the database. There is no updated_at. |
category | Foreign key to product_category. Null when uncategorized. |
is_custom | Boolean. True for a made-to-order (commission) product. |
stock_quantity | Numeric. Meaningful only when availability is "in stock". |
availability | Foreign key to product_availability |
media | jsonb, default []. The ids of the media records, in order. |
status | draft, review, scheduled, or published. Migration 008_product_status.sql. Every product that existed before the migration is published. A new product from the admin is a draft until you publish it. |
published_at | The first publish time, or the planned time of a scheduled product. |
deleted_at | The time the product went to the trash, else null. Migration 007_trash.sql. |
Row level security gates writes on three capabilities: products:manage (insert and update), products:publish (publish and schedule), and products:delete. Migration 008 gives products:publish to every role that holds products:manage. A role with products:manage only can save drafts and send them to review. The public storefront reads the live products only: published, or scheduled with a time that has come. A holder of products:manage reads every product. A fourth capability, shop:manage_taxonomy, gates creating a category inline.
Prices are integer cents
products.price holds integer cents. 4990 means R$ 49,90. The price field in the admin takes an amount with 2 decimals and stores cents.
Do not divide by 100 in your app. Use the auto-imported helpers of the shop add-on, @plutocms/shop, which this layer extends:
formatProductPrice(cents, options?)formats cents as a currency string. The currency is BRL by default. It takes the same options asformatCurrency.centsToAmount(cents)converts cents to an amount (4990to49.9).amountToCents(amount)converts an amount to cents, with rounding (49.9to4990).
Cart
The layer extends @plutocms/shop, so a shop has useCart(). The browser keeps the cart. The cart reads stock_quantity as the stock, and a product with is_custom has no stock limit. The shop template has a cart page.
Field widgets
Five fields use a widget of this layer. They load on demand. The media field uses the media widget of core.
| Widget | Field | Behavior |
|---|---|---|
product-price | price | A cents mask. Each typed digit enters from the right. |
product-is-custom | is_custom | Part of the cross-field rule |
product-availability | availability | Part of the cross-field rule |
product-stock | stock_quantity | Part of the cross-field rule |
product-category | category | A searchable menu with inline "create new category" |
The custom, availability, and stock rule
A custom product is always "commission" availability, with no stock quantity. A non-custom product can have any availability. It shows a stock quantity only while its availability is "in stock".
The three widgets share one piece of state. The widget for availability finds rows by slug (commission, in-stock), not by id, because ids are different in each database.
The media field
media is { type: 'media', multiple: true, accept: ['image/*', 'model/gltf-binary'] }. The core widget shows the files in order, and the picker opens the media library. The upload rules are the rules of core: see Media. Core takes a .glb file only when the app has @plutocms/3d.
A product has a 3D model when one of its records has mimeType: 'model/gltf-binary'. Do not look at the file name.
Public API
A storefront, in Nuxt or in any other stack, reads the shop through the Public API:
| Route | Answer |
|---|---|
GET /api/pluto/v1/content/product | The products |
GET /api/pluto/v1/content/product/<id or slug> | One product |
GET /api/pluto/v1/content/product-category | The categories (label, slug, description) |
GET /api/pluto/v1/content/product-availability | The availability statuses (label, slug) |
A product has id, createdAt, name, slug, description, price (integer cents), is_custom, stock_quantity, and:
categoryandavailability:{ id, label, slug }, ornull.media: a list of media records, in their order:{ id, url, name, mimeType, size, width, height, alt, createdAt }. Core gives them. See Media.
The list of fields is explicit, so a column that you add later stays private until the type lists it. A list loads the categories and the availability with two queries, and the media with one, for every product of the answer at once.
Filter by the id of a relation: filter[category]=3. The answer shows the object. Sort with sort=-createdAt or sort=price.
In Nuxt:
const { products, pages } = await useProducts({ limit: 12, sort: '-createdAt', filter: { category: 3 } })
const { product } = await useProduct(slug)
const { categories } = await useProductCategories()
useProduct shows the error page for a missing product, sets the SEO tags, and shows Edit Product in the admin bar. See Public API.
Show a photo with PlutoImage. It gets the sizes from the image service of the host:
<PlutoImage :media="product.media.find(isMediaImage)" sizes="xs:50vw lg:300px" />
Internal routes
The admin uses these routes. They are not a stable contract. Core blocks a cross-site write to them.
| Route | Purpose |
|---|---|
GET /api/_pluto/shop/categories, POST /api/_pluto/shop/categories | Categories. A new category needs shop:manage_taxonomy. |
GET /api/_pluto/shop/availability | Every availability status |
The admin writes products through the generic routes /api/_pluto/content/product/*.
Types
PublicProductandPublicProductTaxonomy: the answers of the public API. Themediaof a product usesPlutoPublicMediaof core. Auto-imported.
Known gaps
- The list shows the name, the slug, and the price as money. It does not show the category and the creation date.
- The price widget uses the
BRLcurrency.
Translation
The admin is translated to en and pt-BR in the supabase-shop namespace. Category and availability names come from the database and stay as stored.