docs
/
Building on Appmint

Configurable products

The two-layer attribute model behind custom products — a shared definition, a per-product subset, file uploads, the required rule, and how option prices reach the server.

A configurable product is one where the customer chooses something that changes what they get and what they pay: a size, a colour, a wood, an uploaded file, a quantity, a date.

Appmint models this in two layers, and understanding why there are two is most of the work.

Two collections

CollectionWhat it is
sf_attributeThe reusable definition — a color attribute listing every colour you sell
sf_product.attributes[]The per-product reference — this product's subset of those options, and whether it is required here

The reason for the split: you have one color attribute, but a given product might offer four of the twenty colours. The definition is the catalogue; each product picks from it.

// sf_attribute — defined once
{
  "name": "color",
  "title": "Colour",
  "type": "selection",
  "display": "color",
  "options": [
    { "label": "Navy",  "value": "navy",  "color": "#1e3a8a", "priceDelta": 0 },
    { "label": "Oak",   "value": "oak",   "color": "#c9a86a", "priceDelta": 0 },
    /* …every colour you sell… */
  ]
}
// sf_product.attributes[] — this product's subset
{
  "name": "color",
  "required": false,
  "options": [
    { "label": "Navy", "value": "navy", "price": 0 },
    { "label": "Oak",  "value": "oak",  "price": 12 }
  ]
}

The per-product copy is denormalised, and the price field is renamed. The definition uses priceDelta; the product's copy uses price. Both are deltas added to the base price, never absolute.

Because it is a copy, editing the shared definition does not update products already referencing it. That is a deliberate trade — a price change cannot silently alter live products — but it means a catalogue-wide option change is a migration, not an edit.

Presentation is not the answer

An option carries three separate things, and conflating them is the most common mistake here:

FieldWhat it is
valueThe stored answer — "navy"
colorA hex swatch for display: 'color' — "#1e3a8a"
imageA FileInfo array for display: 'image'

Do not overload value with a hex code or a URL. Renderers read option.color and option.image[0].url for presentation and fall back to value only for legacy data. A product whose value is "#1e3a8a" will store "#1e3a8a" as the customer's answer, and that string is what appears on the order, the packing slip and the invoice.

Storage

Attributes live at data.attributes on the product. Write them with the dot-path:

req("POST", f"/repository/update-partial/sf_product/{sk}", {
    "sk": sk, "version": rec["version"],
    "data.attributes": attributes,          # not {"data": {...}}
}, token=t)

{"data": {...}} replaces the entire data object — price, images, description, everything not in your payload is gone. This has destroyed catalogue data in a real migration. Use dot-paths, and snapshot before a bulk write.

The types, and what each renders as

type is one of selection, number, date, text, file. For selection, display decides the control.

type · displayControl
selection · colorColour swatches
selection · imageImage tiles
selection · radio (or blank)Pill buttons
selection · selectDropdown
selection · checkboxMulti-select — sums the price of every checked option
selection · range-slider / range-inputSlider stepping through the options
textInput; a textarea when the name matches /instruction/i
numberNumber input, honouring minValue, maxValue, minIncrement
dateDate input
fileUpload zone — see below

A storefront iterates product.attributes and renders by type and display. Nothing is hard-coded per product: add an attribute to the record and the control appears; remove it and it is gone. That is what makes one customizer serve every product.

A number attribute renders as a plain number input by default. For quantity, a − / input / + stepper reads far better than a dropdown and allows any amount — worth building once and reusing.

File attributes

A type: 'file' attribute is the "array of files" field. Its configuration lives on the attribute:

FieldMeaning
multipleAllow more than one file
acceptComma list — .pdf,.ai,.eps,.svg,.png,.jpg — blank means any
maxFilesCap when multiple; blank means unlimited

Each upload becomes a FileInfo:

{
  "path": "your-org/artwork/logo.ai",
  "url": "https://…/your-org/artwork/logo.ai?…",
  "contentType": "application/postscript",
  "isPublic": true,
  "meta": { "width": 0, "height": 0, "size": 284113 }
}

Per-file notes

There is no notes field in the schema, and there does not need to be one: meta is an open object, so a per-file instruction goes at meta.note.

fileInfo.meta.note = "Print this one at 200% on the back";

This matters more than it sounds. Someone uploading four files for a print job has something different to say about each, and a single order-level comment box loses that.

Placement on a mockup

sf_product.preview.views[].zones[] maps a file attribute by name to a placement box — x, y, width, height as percentages of the mockup, plus rotation and fit — so the UI can composite the artwork onto a product image. That is what makes a front/back toggle possible.

The required rule

required lives on the per-product entry, not the shared definition. The semantics are unusual and worth memorising:

null, undefined and true all mean REQUIRED. Only an explicit false means optional.

The default is required. To make something optional you must say so:

{ "name": "artwork", "required": false, "options": [] }

How it applies:

  • file, text, number, date — blocked until satisfied. A required file attribute blocks submit until at least one file is uploaded.
  • selection — always has a value, because index 0 is the default. It is auto-satisfied, never blocks, and should not show a "required" tag.

Enforcement is client-side, in the storefront's submit guard. The server does not check it. A required attribute is a UX guarantee, not a data guarantee — anything you truly cannot process without must be validated wherever you fulfil the order, too.

Getting the price to the server

This is where configurable products most often go wrong, and the failure is quiet: the customer configures £40 of upgrades and is charged the base price.

The server only counts a delta if it arrives in options[], with a numeric price:

var item = {
  sku: product.sku,
  name: product.name,
  quantity: qty,
  options: [
    { name: 'color', label: 'Colour', value: 'Oak',   price: 12 },
    { name: 'size',  label: 'Size',   value: 'Large', price: 90 }
  ],
  attributes: chosen,     // human-readable, for display
};
await appmint.cart.add(item);

storefront/pricing/calculate-cart adds a delta only when it rides in options[]. Choices recorded anywhere else — a freeform attributes object, a design blob — are not priced.

recalc() folds the server's figures back after every cart mutation. Render those.

calculatedPrice on a product is an object, not a number: { finalPrice, originalPrice, discount, … }. Read .finalPrice. Rendering the object gives you [object Object] in your price field, which at least fails loudly — the worse case is summing the wrong field and being quietly wrong.

Per-type pricing rules:

ControlContribution
Radio / select / colour / imageThe chosen option's price
CheckboxThe sum of every checked option's price
ToggleOption 1's price when on
QuantityMultiplies the line — it is not a delta

What survives to the order

storefront/checkout-cart builds the order from productItems — and drops freeform attributes and design. It persists options[] = [{ name, label, value, price }] and nothing else.

Push every choice through options[], including ones that cost nothing. An option with price: 0 still reaches the order; the same fact in a freeform attributes object does not, and you find out when someone in fulfilment asks which colour it was.

Uploaded binaries go up separately:

curl -X POST "$APPMINT_HOST/repository/file/upload" \
  -H "orgid: $APPMINT_ORG" -H "Authorization: Bearer $TOKEN" \
  -F "location=artwork" -F "[email protected]"

The response's path gives you the FileInfo url, which you attach to the line item's option.

Checklist

  • Shared definition in sf_attribute; per-product subset in data.attributes
  • price on the product copy, priceDelta on the definition — both deltas
  • value holds the answer; color and image hold presentation
  • required: false written explicitly wherever something is optional
  • Every choice in options[] with a numeric price, including zeros
  • .finalPrice read from calculatedPrice, never the object
  • Server totals rendered after recalc(), never a client sum
  • Written with data.attributes, never a nested data object

Next: Bulk loading a catalogue — getting hundreds of these in from JSON.