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.
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). 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 sets status: false, so a product has no publish workflow. See Content model.
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. |
Row level security gates writes on two capabilities: products:manage (insert and update) and products:delete. Reading a product needs no capability. The public storefront reads every product. A third 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:
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).
Migrate from a version before cents
- Update
@plutocms/supabase-shop. - Open
/admin/migrationsand apply004_price_in_cents.sql. It multiplies every stored price by 100. Apply it once only. The migration ledger prevents a second run. - In your storefront, replace
formatCurrency(product.price)withformatProductPrice(product.price). Replace any price sum or sort so it works on cents. - Clear old carts that your app stored in the browser. They hold prices in reais.
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 { data } = await usePlutoPublicList<PublicProduct>('product', { limit: 12, sort: '-createdAt' })
const { data: one } = await usePlutoPublicItem<PublicProduct>('product', slug)
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.