Configurable products are built from two collections. Reference for both, and for the rules that govern how their values are priced and persisted.
| Collection | Role |
|---|---|
sf_attribute | The reusable definition — the full option list |
sf_product.attributes[] | The per-product reference — this product's subset and its own required decision |
The split exists so one color attribute can serve every product while each product offers only the colours it sells.
sf_attribute
{
"name": "color",
"title": "Colour",
"type": "selection",
"display": "color",
"multiple": false,
"accept": null,
"maxFiles": null,
"minValue": null,
"maxValue": null,
"minIncrement": null,
"options": [
{ "label": "Navy", "value": "navy", "color": "#1e3a8a", "priceDelta": 0 }
]
}| Field | Applies to | Meaning |
|---|---|---|
type | all | selection · number · date · text · file |
display | selection | How the control renders — see below |
multiple | file | Allow more than one file |
accept | file | Comma list of extensions; blank means any |
maxFiles | file | Cap when multiple; blank means unlimited |
minValue, maxValue, minIncrement | number | Input bounds and step |
options[] | selection | The full option list |
Schema sources: sdk/src/models/storefront/sf-attribute.ts, sf-product.ts, file-info.ts.
sf_product.attributes[]
{
"name": "color",
"required": false,
"options": [
{ "label": "Navy", "value": "navy", "price": 0 },
{ "label": "Oak", "value": "oak", "price": 12 }
]
}Two differences from the definition, both easy to miss:
The price field is renamed. priceDelta on the definition; price on the product's copy. Both are deltas added to the base price, never absolutes.
It is a denormalised copy. Editing the shared definition does not update products already referencing it. That prevents a catalogue edit from silently changing live product pricing, at the cost of making a catalogue-wide option change a migration rather than an edit.
Storage
Attributes live at data.attributes. Write with the dot-path:
req("POST", f"/repository/update-partial/sf_product/{sk}", {
"sk": sk, "version": rec["version"],
"data.attributes": attributes,
}, token=t){"data": {...}} replaces the entire data object — price, images, parcel and everything else you did not include. Snapshot with find before any bulk write.
Option presentation
An option carries the stored answer and its presentation separately:
| Field | Purpose |
|---|---|
value | The stored answer — "navy" |
color | Hex swatch, used when display: 'color' |
image | FileInfo array, used when display: 'image' |
Do not put a hex code or URL in value. Renderers read option.color and option.image[0].url, falling back to value only for legacy data. A product whose value is "#1e3a8a" stores that string as the customer's answer, and it appears on the order and the packing slip.
Types and controls
| type · display | Control | Stored as |
|---|---|---|
selection · color | Colour swatches | Option index |
selection · image | Image tiles | Option index |
selection · radio (or blank) | Pill buttons | Option index |
selection · select | Dropdown | Option index |
selection · checkbox | Multi-select | Map of checked indexes |
selection · range-slider / range-input | Slider across options | Option index |
text | Input; textarea when the name matches /instruction/i | String |
number | Number input honouring minValue / maxValue / minIncrement | Number |
date | Date input | String |
file | Upload zone | FileInfo[] |
Storefronts iterate product.attributes and render by type and display. Nothing is hard-coded per product: add an attribute to the record and its control appears.
File attributes
Each upload becomes a FileInfo:
{
"path": "org/artwork/logo.ai",
"url": "https://…",
"contentType": "application/postscript",
"isPublic": true,
"meta": { "width": 0, "height": 0, "size": 284113 }
}Per-file notes go at meta.note. There is no notes field in the schema and none is needed — meta is an open object. This matters for print work, where someone uploading four files has something different to say about each.
Placement: sf_product.preview.views[].zones[] maps a file attribute by name to a box on a mockup — x, y, width, height as percentages, plus rotation and fit — which is what makes front/back compositing possible.
Binaries upload separately:
curl -X POST "$APPMINT_HOST/repository/file/upload" \
-H "orgid: $APPMINT_ORG" -H "Authorization: Bearer $TOKEN" \
-F "location=artwork" -F "[email protected]"location is the folder — the endpoint appends the filename.
The required rule
required lives on the per-product entry, not the shared definition.
null,undefinedandtrueall mean REQUIRED. Only an explicitfalsemeans optional.
The default is required; opting out must be explicit.
| Type | Behaviour |
|---|---|
file, text, number, date | Blocks submit until satisfied |
selection | Always has a value (index 0), so 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. Anything you genuinely cannot fulfil without must also be validated where the order is processed.
Pricing
Deltas reach the server only through options[], with a numeric price:
options: [
{ name: 'color', label: 'Colour', value: 'Oak', price: 12 },
{ name: 'size', label: 'Size', value: 'Large', price: 90 }
]storefront/pricing/calculate-cart counts nothing that arrives any other way. Choices recorded in a freeform attributes object are displayed but not charged.
| Control | Contribution |
|---|---|
| Radio / select / colour / image | The chosen option's price |
| Checkbox | The sum of every checked option |
| Toggle | Option 1's price when on |
| Quantity | Multiplies the line — not a delta |
calculatedPrice on a product is an object — { finalPrice, originalPrice, discount, … }. Read .finalPrice.
recalc() folds the server's figures back after every cart mutation. Render those, never a client-side sum.
Order persistence
storefront/checkout-cart builds the order from productItems and persists options[] = [{ name, label, value, price }]. It drops freeform attributes and design.
Push every choice through options[], including those priced at zero. A fact recorded only in attributes does not reach the order, and the gap surfaces when someone in fulfilment asks which colour was ordered.
Related
- Configurable products — the same model, applied
- Storefront — catalog, cart, checkout
- Files — upload endpoints and FileInfo
- Bulk loading a catalogue — loading products at volume