Layers

Shop (Supabase)

@plutocms/supabase-shop adds a product admin and a media gallery.

@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-shop

Then extend only this layer:

nuxt.config.ts
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

RoutePurpose
/admin/productsList products
/admin/product/newCreate a product
/admin/product/edit/:idEdit 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

ColumnMeaning
idbigint, primary key
slugUnique. It forms the public path /product/<slug>.
nameRequired
descriptionOptional
pricebigint, required. Integer cents.
created_atSet by the database. There is no updated_at.
categoryForeign key to product_category. Null when uncategorized.
is_customBoolean. True for a made-to-order (commission) product.
stock_quantityNumeric. Meaningful only when availability is "in stock".
availabilityForeign key to product_availability
mediajsonb, 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 as formatCurrency.
  • centsToAmount(cents) converts cents to an amount (4990 to 49.9).
  • amountToCents(amount) converts an amount to cents, with rounding (49.9 to 4990).

Migrate from a version before cents

  1. Update @plutocms/supabase-shop.
  2. Open /admin/migrations and apply 004_price_in_cents.sql. It multiplies every stored price by 100. Apply it once only. The migration ledger prevents a second run.
  3. In your storefront, replace formatCurrency(product.price) with formatProductPrice(product.price). Replace any price sum or sort so it works on cents.
  4. 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.

WidgetFieldBehavior
product-pricepriceA cents mask. Each typed digit enters from the right.
product-is-customis_customPart of the cross-field rule
product-availabilityavailabilityPart of the cross-field rule
product-stockstock_quantityPart of the cross-field rule
product-categorycategoryA 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:

RouteAnswer
GET /api/pluto/v1/content/productThe products
GET /api/pluto/v1/content/product/<id or slug>One product
GET /api/pluto/v1/content/product-categoryThe categories (label, slug, description)
GET /api/pluto/v1/content/product-availabilityThe availability statuses (label, slug)

A product has id, createdAt, name, slug, description, price (integer cents), is_custom, stock_quantity, and:

  • category and availability: { id, label, slug }, or null.
  • 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.

RoutePurpose
GET /api/_pluto/shop/categories, POST /api/_pluto/shop/categoriesCategories. A new category needs shop:manage_taxonomy.
GET /api/_pluto/shop/availabilityEvery availability status

The admin writes products through the generic routes /api/_pluto/content/product/*.

Types

  • PublicProduct and PublicProductTaxonomy: the answers of the public API. The media of a product uses PlutoPublicMedia of 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 BRL currency.

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.