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.

A product in the admin: name, price, stock, and photos

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) 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

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

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.
statusdraft, 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_atThe first publish time, or the planned time of a scheduled product.
deleted_atThe 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 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).

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.

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

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.