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
| Collection | What it is |
|---|---|
sf_attribute | The 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:
| Field | What it is |
|---|---|
value | The stored answer — "navy" |
color | A hex swatch for display: 'color' — "#1e3a8a" |
image | A 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 · display | Control |
|---|---|
selection · color | Colour swatches |
selection · image | Image tiles |
selection · radio (or blank) | Pill buttons |
selection · select | Dropdown |
selection · checkbox | Multi-select — sums the price of every checked option |
selection · range-slider / range-input | Slider stepping through the options |
text | Input; a textarea when the name matches /instruction/i |
number | Number input, honouring minValue, maxValue, minIncrement |
date | Date input |
file | Upload 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:
| Field | Meaning |
|---|---|
multiple | Allow more than one file |
accept | Comma list — .pdf,.ai,.eps,.svg,.png,.jpg — blank means any |
maxFiles | Cap 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,undefinedandtrueall mean REQUIRED. Only an explicitfalsemeans 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:
| Control | Contribution |
|---|---|
| Radio / select / colour / image | The chosen option's price |
| Checkbox | The sum of every checked option's price |
| Toggle | Option 1's price when on |
| Quantity | Multiplies 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 indata.attributes -
priceon the product copy,priceDeltaon the definition — both deltas -
valueholds the answer;colorandimagehold presentation -
required: falsewritten explicitly wherever something is optional - Every choice in
options[]with a numericprice, including zeros -
.finalPriceread fromcalculatedPrice, never the object - Server totals rendered after
recalc(), never a client sum - Written with
data.attributes, never a nesteddataobject
Next: Bulk loading a catalogue — getting hundreds of these in from JSON.