inmake DOCS
Docs / Generative / Commerce products

Commerce products

The "commerce-products" rule fragment — composed into the CRM & Studio agents' prompts and served as drop-core://rules/commerce-products.

COMMERCE PRODUCTS: a sellable product is TWO documents that share one id — never a plain data collection. The BASE product products/{id} (store-level, NOT under data/) is the commerce source of truth: it is what the admin Products view lists, what orders/checkout price against, and what the stock system tracks. The PER-LOCALE content documents data/{locale}/products/{id} are the storefront face: the localized name, summary, description, images and any custom schema fields. Writing only the locale documents (the classic mistake) produces products that render on the site but are invisible in the admin Products view, unpriceable at checkout and untracked in stock.

BASE PRODUCT (products/{id}) fields:

  • name — the fallback display name (each locale document carries its own localized name; use the store default locale's wording here).
  • type — "product" (physical, stock-tracked; the default), "service" (no stock), or "subscription".
  • status — "public" | "private" | "hidden", same vocabulary as data documents.
  • availability — {COUNTRY: {price, currency, vat?, price_original?}}, the price list per market. COUNTRY keys are the YY half of the store's locale tags (cs_CZ → CZ, en_GB → GB); a country no locale matches never reaches a storefront. Include every market the store's locales serve.
  • template — the shared schemas/{schemaId} (created with base:"product") whose fields extend the per-locale content form. Set it when a custom product schema exists.
  • Optional: code (SKU), tags (KEYS of the store's tag registry — see TAXONOMY), categories (category PATHS such as recipe:summer:tomato — see TAXONOMY), variants / additions (registries keyed by PRODUCT ID — see VARIANTS and ADDITIONS below), parent (variant products), package.
  • supply is NOT yours to write — see STOCK below.

PROPAGATION — the platform owns these keys on locale documents. On every base-product write a trigger mirrors price (computed per country from availability as a {price, currency, price_exvat?, price_original?} OBJECT), supply, supplies, variants, additions, package, tags, categories, type, parent and — since the built-in listing keys — the base product's status and timestamp onto every matching locale content document, so a product never opened in the content editor is still listable and its status_* keys exist. NEVER write any of those keys on data/{locale}/products/{id} — the next base write silently overwrites them. In templates a product's price is therefore always the object: bind {{price.price}} / {{price.currency}}, never {{price}} (renders [object Object]).

TAXONOMY — a product's tags and categories are KEYS, never display text. Their labels live in two reserved translation groups the admin's Tags / Categories pages own: data/{locale}/translations/tag is {sale: "Akce", new: "Novinka"}, and data/{locale}/translations/category is keyed by the FULL path with the LEAF's label ({"recipe": "Recepty", "recipe:summer": "Léto", "recipe:summer:tomato": "Rajčata"}) — the tree is implied by the key set, and every ancestor path must exist. A key is [a-z0-9][a-z0-9_-]* (no dots — a dot nests the document; never id/ref/schema/timestamp/seo/slugs); a category path joins keys with : (max 6 levels). Store the CHOSEN NODE only on the product (categories: ["recipe:summer:tomato"]), not its ancestors. Before putting a new key on a product, label it in the group document for the default locale at least (read the doc first; merge-write it — other locales fall back to the default locale on the site). Templates print {{t "tag" this}} / {{t "category" this}} (positional args join with a dot — {{#each tags}}{{t "tag" this}}{{/each}}); filter a listing with query: {tags: {"array-contains": "sale"}} / {categories: {"array-contains": "recipe:summer"}}, and list a PARENT category's whole subtree with {categories: {"array-contains-any": [<the node + every descendant path>]}} (30 values max — Firestore's cap). Never invent free-text tags: values that are not registry keys show as unregistered in the admin.

DANGER — removing a COUNTRY from availability DELETES that country's locale content documents for the product (the propagation trigger evicts them wholesale). Never rewrite availability without carrying over every country you do not mean to withdraw, and treat withdrawing one as a destructive act to confirm with the user.

STOCK: supply belongs to the stock ledger, not to any product document. Creating a type:"product" base document auto-creates its stock/{id} ledger doc (at supply 0, owner = this store); the ledger's supply syncs onto the base product and from there onto the locale mirrors. To stock a product, append a ledger RECORD: write stock/{id}/records/{recordId} with {count: N, type: "manual", note: "..."} (count is a DELTA — positive receives, negative removes; a settled order writes its own type:"order" records). Writing supply directly on any product document is futile — the next ledger sync overwrites it. Read stock/{id} to see the current supply and stock/{id}/records for the movement history.

CREATION ORDER for a catalog: 1) writeSchema with base:"product" for the custom content fields (summary, gallery, materials, …); 2) the base documents products/{id} (type, status, availability, template); 3) the locale content documents data/{locale}/products/{id} — stamp schema: "<schemaId>" and write ONLY content fields (name, summary, images, custom fields), never the propagated keys; 4) stock records if the user gives quantities. NEVER registerCollection("products") — the key is reserved because products already have their own admin view: base documents list under Products, and the per-locale content is reached from there (the Products locale filter) or through the product editor's locale tabs.

READING: listStoreCollection("products") (store-level path, no data/ prefix) is the commerce state — prices, supply, status; data/{locale}/products is the content. Product detail pages render the locale document (with the mirrored price/supply available), get their URL from seo.slug like any document, and list from the base or locale collection depending on whether you need commerce fields or localized text — for a paged storefront listing follow readRule("load-more") against the locale collection, with the shapes under LISTINGS.

LISTINGS (the locale mirror data/{locale}/products) need NO declared key for the common shapes — a storefront listing is status == public (plus a category or tag filter) with a sort. BUILT-IN KEYS on products (platform-fixed, their own budget, never re-declare them): status_price [status, price.price], status_name [status, name], status_timestamp [status, timestamp] — public products by price (asc or desc), A–Z, newest. CATEGORY and TAG pages: categories and tags are arrays, so "category X by price" is array-contains + order_by, a shape no key expresses — the platform serves it from sixteen composite indexes on products shared by every store: for each of categories / tags × sort by price.price asc, price.price desc, name asc, timestamp desc, plain and with status == public. So query: {status: "public", categories: {"array-contains": "recipe:summer"}} + order_by: {"price.price": "asc"} just works, and array-contains-any (a multi-select, a category subtree) rides the same index. ONE array filter per query: Firestore allows a single array-contains, so "category X AND tag Y" is not one index — filter the second array in memory over the category page, or give the product a scalar field (primary_category) and declare a key on it. Beyond these (a sort by supply, services by price = type_price) the store declares its own keys in the products collection row (setup/data.collections[key = "products"].keys) (readRule("data-collections") QUERY KEYS for the shape, the limits and the null disable); query_key_required names the fields. Variants are presented through their parent product page (VARIANTS below), never filtered out of a listing.

VARIANTS — a product sold in several variants (colors, sizes, grades) is ONE PARENT + ONE CHILD PRODUCT PER VARIANT, all ordinary two-document products:

  • The PARENT is type:"group" when it is not itself sellable (the usual case): no stock ledger, its own SKU is DENIED at checkout, storefronts render it as the variant chooser. A sellable parent stays type:"product" and its variants are optional alternates.
  • Each CHILD is a normal products/{id} with parent: "<parentId>". Its stock ledger registers under the parent (the parent's read-only supplies.{childId} map mirrors every child's supply). A child WITHOUT its own availability inherits the parent's prices — and its locale content documents are auto-seeded from the parent's country set, so give the parent a full availability first. A child WITH its own availability prices independently (checkout resolves child country entry, else parent's).
  • The platform auto-registers children in the parent's variants map ({name, type, tags} synced on every child write, removed on delete) — do NOT hand-write child-keyed entries there. On the propagated locale mirrors each child entry is ENRICHED per country/locale with the resolved price, the child's supply and its seo.slug; children inherit the whole registry at read time, so the SAME detail template renders the picker on parent and child pages.
  • CONTENT FALLBACK is resolved at read time: any field a child's locale document leaves unfilled (empty string/array/object) renders with the parent's value — fill only what differs per variant; clearing a child field means "inherit again". Identity fields (seo, schema, id, parent) never inherit — every child needs its own seo.slug.
  • Deleting a parent CASCADES: children, their stock ledgers/records and all locale documents go with it.
  • CART KEYS are product ids: a child variant is added to the cart by ITS OWN id. Only an inline (hand-written, non-product) variant key is ordered as the composite {pid}-{variantKey}; any other suffix is DENIED at checkout and has no price.
  • PICKER MARKUP (detail template): render {{#each variants}} as links carrying data-variant="{{ @key }}", href {{#if this.slug}}/{{ this.slug }}{{else}}/ref/products/{{ @key }}{{/if}}, and mark the current page's own entry via {{#ifEq @key ../id}}. Bind {{ this.price.price }} {{ this.price.currency }} per chip. Suppress the stock badge on {{#ifEq type "group"}} pages (the chooser); leaf pages badge from supply.
  • LISTINGS never filter on parent: the former "grouped" mode (query: {parent: null} + a [parent, <sort>] key) is retired. A listing is status == public (+ a category/tag filter) with a sort, served by the built-in keys and the platform composites (LISTINGS above); variants are presented through the parent's page — the chooser — not filtered out of the listing.
  • IN-PLACE SWAP (optional progressive enhancement; slug links stay the no-JS fallback): POST ${DROP_BASE}/variant/${DROP_LOCALE} with {path: location.pathname, variant: chip.dataset.variant} — the server verifies variant against THAT page's registry (a key it doesn't carry is rejected), and returns {id, document, slug?, href?} where document is the variant's locale doc WITH the parent fallback already applied and href the variant page's public URL (its slug, routed segment and language prefix). Swap name/price/supply-badge/description in place, retarget the current chip, and history.replaceState(null, '', window.DROP_BASE + href) (fallback when href is absent: /+slug) so the URL stays canonical — never build it from slug yourself (a store may serve languages under /en/…). document.cover here is the RAW image value — build the URL as /assets/${document.cover.path} (render-context URL resolution doesn't apply to this endpoint).

ADDITIONS — add-ons offered WITH a product (gift packaging, insurance, engraving, extended warranty, …). An addition is an ORDINARY PRODUCT of the store — typically type:"service" (no stock) — that the host product's additions map references BY ID; one addition product is usually shared by many hosts:

  • Create the addition first as a normal two-document product: products/gift-wrap (type:"service", its OWN availability — an add-on is priced on its own, never from the host) + its locale content documents. Then put additions: {"gift-wrap": {name: "Gift wrapping"}} on every host product (merge-write; {name, type, tags} is a registry stub the platform overwrites from the addition product — the key is what matters; an optional params map is host-specific free-form config the sync preserves, e.g. {max_chars: 30} for engraving).
  • The platform keeps every host's entry in sync on every write of the addition (name/type/tags) and unlinks it on delete. On the propagated locale mirrors each product-backed entry is ENRICHED per country/locale with the addition's OWN price (absent when the addition isn't sold in that country — hide it then), its supply (stock-tracked types only; services carry none) and its seo.slug. Keys naming no product stay plain labels (legacy) — never rely on those for anything sellable.
  • Variant children with no additions of their own inherit the parent's at read time (same fallback as content), so the SAME detail template renders the add-on chooser on parent and child pages.
  • CHOOSER MARKUP (detail template): {{#each additions}} → one checkbox/chip per entry carrying data-addition="{{ @key }}", label {{ this.name }}, price {{#if this.price}}+ {{ this.price.price }} {{ this.price.currency }}{{/if}} (skip entries without price); link the addition's own page via this.slug when present. this.params drives any per-addition input (an engraving text field, …).
  • CART: an addition is added as its OWN SKU — the cart's items map gains {"gift-wrap": 1} next to the host product's line (readRule("commerce-cart")); the server prices it from the addition product. There is no line-item link between a host and its add-on: quantity semantics (one per order vs one per host unit) are the storefront's choice — mirror the host quantity when the add-on is per unit. Per-item text (an engraving) goes on the order note or a form record, not on the cart item.
  • Never list additions as products of their own in catalog listings unless intended — a service add-on usually stays status:"hidden" (hidden products still sell at checkout and still render on the host's mirror).

CART / CHECKOUT UI: building add-to-cart, a cart page or checkout against these products is its own contract — readRule("commerce-cart") BEFORE that work (server-side carts, base-SKU items, checkout by cart id).